<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://gophertrunk.org/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gophertrunk.org/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-06-29T12:05:38-05:00</updated><id>https://gophertrunk.org/feed.xml</id><title type="html">GopherTrunk</title><subtitle>Pure-Go digital-trunking RTL-SDR scanner engine. P25, DMR, TETRA, NXDN, Motorola, EDACS, LTR, MPT 1327, dPMR, D-STAR, YSF — single static binary.</subtitle><author><name>Matt Cheramie</name></author><entry><title type="html">Build in the Open, Part 12: Optimizing &amp;amp; Securing Your Repository</title><link href="https://gophertrunk.org/blog/tutorials/build-in-the-open-12-optimizing-securing-your-repository/" rel="alternate" type="text/html" title="Build in the Open, Part 12: Optimizing &amp;amp; Securing Your Repository" /><published>2026-06-29T00:00:00-05:00</published><updated>2026-06-29T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/tutorials/build-in-the-open-12-optimizing-securing-your-repository</id><content type="html" xml:base="https://gophertrunk.org/blog/tutorials/build-in-the-open-12-optimizing-securing-your-repository/"><![CDATA[<blockquote>
  <p><strong>TL;DR:</strong> A good public repo does two jobs — it <em>sells itself</em> and it
<em>protects itself</em>. Optimize discoverability with a filled-in About box, topics,
a social-preview image, and honest status badges. Harden it with branch
protection / required checks, secret scanning + push protection, automated
dependency-CVE scanning (Dependabot / SCA), a <code class="language-plaintext highlighter-rouge">SECURITY.md</code> disclosure policy,
checksummed releases, and least-privilege <code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code> permissions in your
Actions. Most of this is free and takes an afternoon.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li>The About box, topics, and a social-preview image are the cheapest SEO and
first-impression wins you’ll ever get.</li>
  <li>Badges should be <em>honest signals</em> (CI passing, latest release, license), not
decoration.</li>
  <li>Branch protection with required status checks stops broken code from reaching
<code class="language-plaintext highlighter-rouge">main</code>.</li>
  <li>Turn on secret scanning + push protection so credentials can’t be committed in
the first place.</li>
  <li>Scan dependencies for known CVEs automatically; a <code class="language-plaintext highlighter-rouge">SECURITY.md</code> tells people
how to report what you missed.</li>
  <li>Give Actions the <em>minimum</em> token permissions they need — default to
read-only.</li>
</ul>

<p><em>This is Part 12 of <strong>Build in the Open</strong>, a 14-part series on taking a software
project from a blank idea to a public release using GitHub and Claude Code. Each
post teaches a technique you can apply to any project in any language, then shows
how the open-source <a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a>
scanner does it for real.</em></p>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>Optimizing for discovery</strong> — the About box, topics, social preview, badges,
and a tidy repo.</li>
  <li><strong>Securing the front door</strong> — branch protection and required status checks.</li>
  <li><strong>Stopping secrets at the source</strong> — secret scanning and push protection.</li>
  <li><strong>Watching your dependencies</strong> — Dependabot, SCA, and CVE scanning.</li>
  <li><strong>A disclosure policy</strong> — what <code class="language-plaintext highlighter-rouge">SECURITY.md</code> should say.</li>
  <li><strong>Least-privilege Actions</strong> — locking down <code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code>.</li>
  <li><strong>How GopherTrunk does it</strong>, with its real rulesets and CI checks.</li>
</ul>

<h2 id="half-one-optimize-for-discovery">Half one: optimize for discovery</h2>

<p>A repo nobody can find or understand is a tree falling in an empty forest. These
are low-effort, high-leverage fixes.</p>

<h3 id="fill-in-the-about-box">Fill in the About box</h3>

<p>The little gear next to “About” on your repo’s homepage controls three things
that GitHub search and Google both index:</p>

<ul>
  <li><strong>Description</strong> — one sentence, what it <em>is</em> and what makes it different.</li>
  <li><strong>Website</strong> — your docs or landing page (the one you built in
<a href="/blog/tutorials/build-in-the-open-10-websites-support-pages-github-pages/">Part 10</a>).</li>
  <li><strong>Topics</strong> — tags like <code class="language-plaintext highlighter-rouge">golang</code>, <code class="language-plaintext highlighter-rouge">sdr</code>, <code class="language-plaintext highlighter-rouge">cli</code>. These power GitHub topic pages
and “explore” discovery. Add 5–10 relevant ones.</li>
</ul>

<h3 id="add-a-social-preview-image">Add a social-preview image</h3>

<p>Settings → General → Social preview. Upload an image and every time your repo is
shared on social media, Slack, or Discord it renders a rich card instead of a
bare URL. This is a one-time upload that pays off forever.</p>

<h3 id="use-badges-as-honest-signals">Use badges as honest signals</h3>

<p>Badges at the top of your README communicate health at a glance: is CI passing,
what’s the latest release, what license, what language version. Keep them
<em>truthful</em> — a green CI badge that’s actually broken is worse than no badge.
Good additions include a code-quality/report-card badge and a docs link.</p>

<h3 id="keep-the-repo-tidy">Keep the repo tidy</h3>

<p>Discoverability is also legibility. A clear directory structure, the standard
“repository health” files (README, LICENSE, CONTRIBUTING, CODE_OF_CONDUCT,
SECURITY, issue/PR templates), and a <code class="language-plaintext highlighter-rouge">.gitignore</code> that keeps build junk out all
make the repo feel maintained — which is itself a signal to potential users and
contributors.</p>

<h2 id="half-two-secure-the-repository">Half two: secure the repository</h2>

<p>Optimization gets people in the door. Security keeps the door from being kicked
off its hinges.</p>

<h3 id="branch-protection-and-required-checks">Branch protection and required checks</h3>

<p>Protect <code class="language-plaintext highlighter-rouge">main</code> so nobody (including you on a bad day) can push broken code or
force-push history away. The essentials:</p>

<ul>
  <li>Require changes to come through a pull request.</li>
  <li>Require your CI status checks to pass before merge.</li>
  <li>Block force-pushes (non-fast-forward) and branch deletion.</li>
</ul>

<p>On GitHub this is “branch protection rules” or the newer, JSON-defined
<strong>rulesets</strong> — which have the advantage that you can commit them to the repo and
review changes to your protection policy like any other code.</p>

<h3 id="secret-scanning--push-protection">Secret scanning + push protection</h3>

<p>Secret scanning watches for committed credentials (API keys, tokens) and alerts
you. <strong>Push protection</strong> goes further — it <em>blocks the push</em> before the secret
ever lands in history. Turn both on (free for public repos). The best secret is
the one that never gets committed; the second best is one you’re told about
within seconds.</p>

<h3 id="dependabot-and-dependency-cve-scanning-sca">Dependabot and dependency CVE scanning (SCA)</h3>

<p>Your code is only as secure as its dependencies. <strong>Software Composition Analysis
(SCA)</strong> means scanning your dependency tree for known CVEs. Two complementary
tools:</p>

<ul>
  <li><strong>Dependabot</strong> — opens PRs to bump dependencies with known vulnerabilities,
and can keep all deps current on a schedule.</li>
  <li><strong>A CVE scanner in CI</strong> — fails the build if a vulnerable dependency is in the
graph, so you find out at PR time, not in production.</li>
</ul>

<h3 id="a-securitymd-disclosure-policy">A SECURITY.md disclosure policy</h3>

<p>When someone finds a vulnerability, they need a <em>private</em> way to tell you.
<code class="language-plaintext highlighter-rouge">SECURITY.md</code> documents that path. Good ones include: which versions are
supported, how to report privately (GitHub’s private security advisories are the
standard), what’s in and out of scope, and rough response timelines. This turns
a scary surprise into a managed process.</p>

<h3 id="least-privilege-actions-tokens">Least-privilege Actions tokens</h3>

<p>Every workflow run gets a <code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code>. By default it can be broad. Set
<code class="language-plaintext highlighter-rouge">permissions:</code> to the minimum each workflow needs — read-only for CI, <code class="language-plaintext highlighter-rouge">contents:
write</code> only for the job that publishes releases. A leaked or exploited workflow
with read-only scope can do far less damage than one with write-everything.</p>

<h2 id="how-gophertrunk-does-it">How GopherTrunk does it</h2>

<p><a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a> treats both halves as
first-class. Here’s the real configuration.</p>

<p><strong>Honest badges.</strong> The README header carries six: a live CI status badge
(<code class="language-plaintext highlighter-rouge">actions/workflows/ci.yml/badge.svg</code>), a release badge
(<code class="language-plaintext highlighter-rouge">shields.io/github/v/release</code> with <code class="language-plaintext highlighter-rouge">include_prereleases&amp;sort=semver</code> — so it
honestly shows the <code class="language-plaintext highlighter-rouge">0.x</code> pre-release state), an Apache-2.0 license badge, a
Go-version badge pulled from <code class="language-plaintext highlighter-rouge">go.mod</code>, a <strong>Go Report Card</strong> code-quality badge,
and a docs badge linking to gophertrunk.org. Every one is a live signal, not a
static image.</p>

<p><strong>Protection as committed rulesets.</strong> Branch and tag protection live in the repo
as JSON, reviewable like code:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">.github/rulesets/main-branch-protection.json</code> targets the default branch and
requires pull requests, resolves review threads
(<code class="language-plaintext highlighter-rouge">required_review_thread_resolution: true</code>), blocks <code class="language-plaintext highlighter-rouge">deletion</code> and
<code class="language-plaintext highlighter-rouge">non_fast_forward</code>, and — crucially — requires three status checks to pass
before merge: <code class="language-plaintext highlighter-rouge">build-test</code>, <code class="language-plaintext highlighter-rouge">usb-windows</code>, and <code class="language-plaintext highlighter-rouge">usb-macos</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">.github/rulesets/release-tags-protection.json</code> (from
<a href="/blog/tutorials/build-in-the-open-11-releases-prerelease-semver-changelogs/">Part 11</a>)
matches <code class="language-plaintext highlighter-rouge">refs/tags/v*</code> and blocks both <code class="language-plaintext highlighter-rouge">deletion</code> and <code class="language-plaintext highlighter-rouge">update</code>, so published
release tags are immutable.</li>
</ul>

<p><strong>CVE scanning is a required check.</strong> The CI workflow has a dedicated <code class="language-plaintext highlighter-rouge">vulncheck</code>
job that installs and runs <code class="language-plaintext highlighter-rouge">govulncheck ./...</code> — Go’s official vulnerability
scanner — enumerating known CVEs across the direct and transitive dependency
graph. Because <code class="language-plaintext highlighter-rouge">build-test</code> and the USB jobs are required, a failing security
scan keeps a PR from merging. There’s also a <code class="language-plaintext highlighter-rouge">licenses</code> job that regenerates the
third-party license inventory with <code class="language-plaintext highlighter-rouge">go-licenses</code>, backstopping the hand-curated
table.</p>

<p><strong>A real disclosure policy.</strong> <code class="language-plaintext highlighter-rouge">SECURITY.md</code> is not boilerplate — it opens with a
<em>threat model</em> (an operator running the daemon on a private network or single
host they own), lists supported versions, and routes reports through <strong>GitHub’s
private security advisory</strong> workflow with explicit “do not open a public issue”
guidance. It enumerates what’s in scope (auth bypass, path traversal, SQL
injection, DoS, memory-safety bugs) and out (operator misconfiguration,
<code class="language-plaintext highlighter-rouge">auth.mode: disabled</code>), and publishes a severity-based response timeline
(critical: ≤7 days initial, ≤30 days fix).</p>

<p><strong>Crypto and secrets done right.</strong> The API uses bearer-token auth with
<em>constant-time comparison</em> (<code class="language-plaintext highlighter-rouge">crypto/subtle.ConstantTimeCompare</code>), so
token-comparison side channels are out of scope by construction, per
<code class="language-plaintext highlighter-rouge">SECURITY.md</code>. And the one real build secret — the RadioReference app key — is
never committed; it’s injected at build time via ldflags
(<code class="language-plaintext highlighter-rouge">-X ...radioreference.DefaultAppKey=${RR_APP_KEY}</code>) from a GitHub Actions
secret. The <code class="language-plaintext highlighter-rouge">release.yml</code> workflow also declares <code class="language-plaintext highlighter-rouge">permissions: contents: write</code>
explicitly rather than inheriting broad defaults, and ships SHA-256 checksums
with every release.</p>

<p>The portable lesson: commit your protection rules, make a CVE scan a required
check, write a real <code class="language-plaintext highlighter-rouge">SECURITY.md</code>, keep secrets out of source, and scope your
tokens tight.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Are these features free?</strong>
For public repositories, yes — branch protection, rulesets, secret scanning,
push protection, and Dependabot are all free on GitHub. CVE scanners like
<code class="language-plaintext highlighter-rouge">govulncheck</code> are free open-source tools you run in CI.</p>

<p><strong>What’s the difference between Dependabot and a CVE scanner like govulncheck?</strong>
Dependabot proactively opens PRs to <em>update</em> vulnerable or outdated
dependencies. A scanner like <code class="language-plaintext highlighter-rouge">govulncheck</code> runs in CI and <em>fails the build</em> when
a known-vulnerable dependency is present. They’re complementary: one fixes,
one gates.</p>

<p><strong>Why use rulesets instead of classic branch protection?</strong>
Rulesets can be exported as JSON and committed to the repo, so changes to your
protection policy are reviewable and versioned like any other code. GopherTrunk
keeps both <code class="language-plaintext highlighter-rouge">main</code> and tag protection under <code class="language-plaintext highlighter-rouge">.github/rulesets/</code>.</p>

<p><strong>What belongs in SECURITY.md?</strong>
Supported versions, a private reporting channel (GitHub advisories are
standard), what’s in and out of scope, and rough response timelines. A short
threat model up front helps reporters know what you actually care about.</p>

<p><strong>How do I keep secrets out of my repo?</strong>
Never commit them. Store them as GitHub Actions secrets and inject them at build
or runtime, turn on push protection to block accidental commits, and prefer
build-time injection (like GopherTrunk’s ldflags app-key) over checked-in config.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 12 of 14</strong> · ←
<a href="/blog/tutorials/build-in-the-open-11-releases-prerelease-semver-changelogs/">Part 11: Releases — SemVer &amp; Changelogs</a>
· Next →
<a href="/blog/tutorials/build-in-the-open-13-advanced-git-and-github-features/">Part 13: Advanced Git &amp; GitHub Features</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="tutorials" /><category term="github" /><category term="security" /><category term="repository" /><category term="badges" /><category term="dependabot" /><category term="claude-code" /><summary type="html"><![CDATA[How to make your GitHub repo discoverable and safe — About box, badges, branch protection, secret scanning, Dependabot, SECURITY.md, and least-privilege tokens.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RF Front End, Part 12: The SDR Pool &amp;amp; USB Hotplug Watchdog</title><link href="https://gophertrunk.org/blog/deep-dives/rf-front-end-12-sdr-pool-usb-watchdog/" rel="alternate" type="text/html" title="RF Front End, Part 12: The SDR Pool &amp;amp; USB Hotplug Watchdog" /><published>2026-06-29T00:00:00-05:00</published><updated>2026-06-29T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/rf-front-end-12-sdr-pool-usb-watchdog</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/rf-front-end-12-sdr-pool-usb-watchdog/"><![CDATA[<p><em>Part 12 of <strong>RF Front End</strong>. We’ve spent eleven posts inside a single dongle —
USB transport, the RTL2832U register dance, tuners, sample conversion. Now we
zoom out to the fleet: how the daemon holds several radios at once, gives each a
job, and keeps them alive across the USB drops that real hardware throws at you.</em></p>

<blockquote>
  <p><strong>TL;DR</strong> — The SDR pool owns every opened dongle behind one interface, assigning each a role (control, voice, wideband) and keeping the fleet alive through USB hotplug via a 30-second watchdog that reacquires devices under new bus addresses. A scanner retune that raced stream teardown (issue #686) is fixed by serializing re-open behind an idempotent stop.</p>
</blockquote>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li>The <strong>pool</strong> (<code class="language-plaintext highlighter-rouge">internal/sdr/pool.go</code>) — a fleet of opened devices, each with a
<strong>role</strong>: control, voice, or wideband.</li>
  <li><strong>Strict / allowlist mode</strong> and <strong>serial-alias matching</strong> so an operator’s
<code class="language-plaintext highlighter-rouge">config.yaml</code> selects exactly the dongles they named.</li>
  <li>The <strong>USB watchdog</strong> that re-enumerates every ~30 s, publishes
<code class="language-plaintext highlighter-rouge">KindSDRAttached</code> / <code class="language-plaintext highlighter-rouge">KindSDRDetached</code>, and <strong>reacquires</strong> a device after the
kernel re-enumerates it with a new address.</li>
  <li><strong>The re-open race (issue #686)</strong>: a scanner retune raced USB stream teardown,
and how serializing behind an idempotent stop fixed it.</li>
</ul>

<h2 id="what-the-pool-does">What the pool does</h2>

<p>A single dongle is one <code class="language-plaintext highlighter-rouge">Device</code>. A trunked system needs more than one: a radio
camped on the control channel decoding grants, and one or more radios that follow
those grants onto voice frequencies. A wideband Airspy might cover a whole site’s
worth of channels at once. The pool is the thing that owns all of them.</p>

<p>Its job is narrow but load-bearing. At boot it enumerates every registered
driver, opens the devices the operator selected, programs a known-good sample
rate on each, and assigns each one a <strong>role</strong>. After boot it answers a single
question for the rest of the engine — “give me the device with role X” — and it
keeps that fleet healthy while USB does what USB does: drop a stick mid-stream,
re-enumerate it under a new device number, and expect the software to cope.</p>

<p>Roles matter because the engine never reaches for a <em>specific</em> dongle. The
control-channel decoder asks for <code class="language-plaintext highlighter-rouge">RoleControl</code>; the voice composer asks the pool
to find a <code class="language-plaintext highlighter-rouge">RoleVoice</code> device by serial when the engine binds a call. That
indirection is what lets the same code run on a one-stick hobby setup and a
four-stick site without a branch anywhere in the engine.</p>

<h2 id="how-gophertrunk-implements-it-in-go">How GopherTrunk implements it in Go</h2>

<p>A <code class="language-plaintext highlighter-rouge">Pool</code> is a slice of opened entries behind a mutex, plus an optional event bus:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/pool.go</span>
<span class="k">type</span> <span class="n">Pool</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">mu</span>      <span class="n">sync</span><span class="o">.</span><span class="n">RWMutex</span>
    <span class="n">entries</span> <span class="p">[]</span><span class="o">*</span><span class="n">PoolEntry</span>
    <span class="n">log</span>     <span class="o">*</span><span class="n">slog</span><span class="o">.</span><span class="n">Logger</span>
    <span class="n">bus</span>     <span class="o">*</span><span class="n">events</span><span class="o">.</span><span class="n">Bus</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">PoolEntry</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Driver</span> <span class="n">Driver</span>
    <span class="n">Device</span> <span class="n">Device</span>
    <span class="n">Info</span>   <span class="n">Info</span>
    <span class="n">Role</span>   <span class="n">Role</span>
    <span class="n">Hint</span>   <span class="n">Hint</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">OpenWith</code> is the heart of bring-up. It sweeps every registered driver, opens the
selected devices, programs the IQ rate, and assigns roles.</strong> Role assignment is one
simple rule: the first opened device that isn’t otherwise claimed takes
<code class="language-plaintext highlighter-rouge">RoleControl</code>; everything after it defaults to <code class="language-plaintext highlighter-rouge">RoleVoice</code>. A <code class="language-plaintext highlighter-rouge">Hint</code> can override
that per serial.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/pool.go (shape)</span>
<span class="n">role</span> <span class="o">:=</span> <span class="n">RoleAuto</span>
<span class="k">if</span> <span class="n">hinted</span> <span class="p">{</span>
    <span class="n">role</span> <span class="o">=</span> <span class="n">hint</span><span class="o">.</span><span class="n">Role</span>
<span class="p">}</span>
<span class="k">if</span> <span class="n">role</span> <span class="o">==</span> <span class="n">RoleAuto</span> <span class="p">{</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">controlClaimed</span> <span class="p">{</span>
        <span class="n">role</span> <span class="o">=</span> <span class="n">RoleControl</span>
        <span class="n">controlClaimed</span> <span class="o">=</span> <span class="no">true</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="n">role</span> <span class="o">=</span> <span class="n">RoleVoice</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Programming the sample rate at open time isn’t optional housekeeping — it’s a
fix for issue #275. Without an explicit <code class="language-plaintext highlighter-rouge">SetSampleRate</code>, the chip streams at
whatever rate its resampler powered up at, while the decoder runs its
symbol-timing math against the <em>configured</em> rate. The result is a silent failure
to lock, the worst kind of bug in a radio. So a device whose <code class="language-plaintext highlighter-rouge">SetSampleRate</code>
fails is closed and skipped: a wrong-rate radio is worse than no radio at all.</p>

<h3 id="strict-mode-and-serial-aliases">Strict mode and serial aliases</h3>

<p>By default the pool opens every dongle it finds. The moment an operator lists
specific devices in config, that’s their signal that they want <em>only</em> those —
so the daemon engages <strong>strict mode</strong>, where <code class="language-plaintext highlighter-rouge">Hints</code> becomes an allowlist:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/pool.go (shape)</span>
<span class="k">if</span> <span class="n">opts</span><span class="o">.</span><span class="n">Strict</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">hinted</span> <span class="p">{</span>
    <span class="n">p</span><span class="o">.</span><span class="n">log</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"skipping non-configured SDR; add its serial to sdr.devices to use it"</span><span class="p">,</span>
        <span class="s">"driver"</span><span class="p">,</span> <span class="n">d</span><span class="o">.</span><span class="n">drv</span><span class="o">.</span><span class="n">Name</span><span class="p">(),</span> <span class="s">"serial"</span><span class="p">,</span> <span class="n">d</span><span class="o">.</span><span class="n">info</span><span class="o">.</span><span class="n">Serial</span><span class="p">)</span>
    <span class="k">continue</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Matching a hint to a device means matching serials, and serials aren’t always
clean. Airspy reports a legacy form — <code class="language-plaintext highlighter-rouge">AIRSPY SN:35ac63dc2d701c4f</code> — that an
operator might write a dozen ways. <code class="language-plaintext highlighter-rouge">serialKey</code> normalizes them so the config and
the wire agree:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/pool.go</span>
<span class="k">func</span> <span class="n">serialKey</span><span class="p">(</span><span class="n">s</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
    <span class="n">s</span> <span class="o">=</span> <span class="n">strings</span><span class="o">.</span><span class="n">TrimSpace</span><span class="p">(</span><span class="n">s</span><span class="p">)</span>
    <span class="n">s</span> <span class="o">=</span> <span class="n">strings</span><span class="o">.</span><span class="n">ToLower</span><span class="p">(</span><span class="n">s</span><span class="p">)</span>
    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">strings</span><span class="o">.</span><span class="n">HasPrefix</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="s">"airspy sn:"</span><span class="p">)</span><span class="o">:</span>
        <span class="k">return</span> <span class="n">strings</span><span class="o">.</span><span class="n">TrimPrefix</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="s">"airspy sn:"</span><span class="p">)</span>
    <span class="k">case</span> <span class="n">strings</span><span class="o">.</span><span class="n">HasPrefix</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="s">"airspy_sn:"</span><span class="p">)</span><span class="o">:</span>
        <span class="k">return</span> <span class="n">strings</span><span class="o">.</span><span class="n">TrimPrefix</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="s">"airspy_sn:"</span><span class="p">)</span>
    <span class="k">default</span><span class="o">:</span>
        <span class="k">return</span> <span class="n">s</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">TestPoolMatchesAirspySerialAliases</code> pins this: a hint written
<code class="language-plaintext highlighter-rouge">AIRSPY SN:35ac63dc2d701c4f</code> opens the device whose raw serial is
<code class="language-plaintext highlighter-rouge">35AC63DC2D701C4F</code>, and <code class="language-plaintext highlighter-rouge">FindBySerial</code> resolves all three spellings to the same
entry.</p>

<h3 id="the-usb-watchdog">The USB watchdog</h3>

<p>The pool also runs a supervisor loop. <code class="language-plaintext highlighter-rouge">RunWatchdog</code> ticks every interval — 30
seconds by default — re-enumerates every driver, and acts only on <em>transitions</em>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/watchdog.go</span>
<span class="k">const</span> <span class="n">DefaultWatchdogInterval</span> <span class="o">=</span> <span class="m">30</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Second</span>

<span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">Pool</span><span class="p">)</span> <span class="n">RunWatchdog</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">interval</span> <span class="n">time</span><span class="o">.</span><span class="n">Duration</span><span class="p">,</span> <span class="n">sampleRateHz</span> <span class="kt">uint32</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">interval</span> <span class="o">&lt;=</span> <span class="m">0</span> <span class="p">{</span>
        <span class="o">&lt;-</span><span class="n">ctx</span><span class="o">.</span><span class="n">Done</span><span class="p">()</span>
        <span class="k">return</span> <span class="n">ctx</span><span class="o">.</span><span class="n">Err</span><span class="p">()</span>
    <span class="p">}</span>
    <span class="n">tick</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">NewTicker</span><span class="p">(</span><span class="n">interval</span><span class="p">)</span>
    <span class="k">defer</span> <span class="n">tick</span><span class="o">.</span><span class="n">Stop</span><span class="p">()</span>

    <span class="n">missing</span> <span class="o">:=</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">bool</span><span class="p">{}</span>
    <span class="k">for</span> <span class="p">{</span>
        <span class="k">select</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">&lt;-</span><span class="n">ctx</span><span class="o">.</span><span class="n">Done</span><span class="p">()</span><span class="o">:</span>
            <span class="k">return</span> <span class="n">ctx</span><span class="o">.</span><span class="n">Err</span><span class="p">()</span>
        <span class="k">case</span> <span class="o">&lt;-</span><span class="n">tick</span><span class="o">.</span><span class="n">C</span><span class="o">:</span>
            <span class="n">p</span><span class="o">.</span><span class="n">watchdogTick</span><span class="p">(</span><span class="n">missing</span><span class="p">,</span> <span class="n">sampleRateHz</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">missing</code> map is the state machine. A pool serial that the enumerate stops
seeing flips to missing and emits one <code class="language-plaintext highlighter-rouge">KindSDRDetached</code> — the API, TUI, and web
snapshot all show the gap. When that same serial reappears in a later enumerate,
the watchdog deletes it from <code class="language-plaintext highlighter-rouge">missing</code> and calls <code class="language-plaintext highlighter-rouge">Reacquire</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/watchdog.go (shape)</span>
<span class="k">if</span> <span class="n">missing</span><span class="p">[</span><span class="n">serial</span><span class="p">]</span> <span class="p">{</span>
    <span class="nb">delete</span><span class="p">(</span><span class="n">missing</span><span class="p">,</span> <span class="n">serial</span><span class="p">)</span>
    <span class="n">p</span><span class="o">.</span><span class="n">log</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"sdr: watchdog: device reappeared; reacquiring"</span><span class="p">,</span> <span class="s">"serial"</span><span class="p">,</span> <span class="n">serial</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">Reacquire</span><span class="p">(</span><span class="n">serial</span><span class="p">,</span> <span class="n">sampleRateHz</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">p</span><span class="o">.</span><span class="n">log</span><span class="o">.</span><span class="n">Warn</span><span class="p">(</span><span class="s">"sdr: watchdog: reacquire failed"</span><span class="p">,</span> <span class="s">"serial"</span><span class="p">,</span> <span class="n">serial</span><span class="p">,</span> <span class="s">"err"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Reacquire</code> is where the hotplug story gets real. When a dongle browns out and
comes back, the kernel assigns it a new device number — but it reports the same
serial. So <code class="language-plaintext highlighter-rouge">Reacquire</code> closes the (likely dead) handle best-effort, re-enumerates
the driver, finds the serial under its <em>new</em> index, opens a fresh handle,
re-programs the rate, and re-applies the original <code class="language-plaintext highlighter-rouge">Hint</code> (PPM, gain, bias-tee).
Crucially it swaps the <code class="language-plaintext highlighter-rouge">Device</code> <strong>in place</strong> on the existing <code class="language-plaintext highlighter-rouge">PoolEntry</code> —
<code class="language-plaintext highlighter-rouge">Role</code>, serial identity, and any pointer a consumer is holding all survive; only
<code class="language-plaintext highlighter-rouge">Info.Index</code> updates to the new enumeration. <code class="language-plaintext highlighter-rouge">TestPoolReacquireSwapsDeviceHandleInPlace</code>
asserts exactly that: same <code class="language-plaintext highlighter-rouge">PoolEntry</code>, new <code class="language-plaintext highlighter-rouge">*fakeDevice</code>, stale handle closed,
bias-tee re-applied, index refreshed to 7.</p>

<h2 id="the-problem-we-hit-the-retune-vs-teardown-re-open-race-issue-686">The problem we hit: the retune-vs-teardown re-open race (issue #686)</h2>

<p>The watchdog handles the <em>idle</em> case — a device nobody is streaming. The in-use
case is harder, and it bit us in scanner mode.</p>

<p><strong>Symptom.</strong> In scanner mode a fast retune cancels the IQ stream’s context and immediately
re-opens it on the new frequency. But USB drivers don’t tear a stream down
synchronously — the bulk-IN reaper goroutine runs <code class="language-plaintext highlighter-rouge">cancelStream</code> asynchronously,
draining URBs and closing the consumer channel on its own schedule. So the
sequence that should have been “stop, then start” became “start while the
previous stop is still in flight.” The second <code class="language-plaintext highlighter-rouge">StreamIQ</code> found the bulk-IN
endpoint still claimed and failed with <code class="language-plaintext highlighter-rouge">stream already active</code> — surfacing to the
operator as <code class="language-plaintext highlighter-rouge">conv: StreamIQ failed</code> and a dead capture.</p>

<p><strong>Root cause.</strong> The race was structural, not a missing lock. The teardown path is idempotent via
a <code class="language-plaintext highlighter-rouge">sync.Once</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/device.go</span>
<span class="k">func</span> <span class="p">(</span><span class="n">d</span> <span class="o">*</span><span class="n">Device</span><span class="p">)</span> <span class="n">cancelStream</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">d</span><span class="o">.</span><span class="n">stopOnce</span><span class="o">.</span><span class="n">Do</span><span class="p">(</span><span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">_</span> <span class="o">=</span> <span class="n">d</span><span class="o">.</span><span class="n">transport</span><span class="o">.</span><span class="n">StopBulkIn</span><span class="p">()</span>
        <span class="c">// ...close the consumer channel</span>
    <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">stopOnce</code> guarantees teardown runs exactly once — but it didn’t guarantee the
<em>next</em> <code class="language-plaintext highlighter-rouge">StreamIQ</code> waited for it. <strong>The fix</strong> was to make re-open serialize behind the
in-flight teardown: a new stream resets <code class="language-plaintext highlighter-rouge">stopOnce</code> only after the previous stop
has actually completed, so a retune can never out-run the reaper.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go (shape)</span>
<span class="n">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="p">[]</span><span class="kt">complex64</span><span class="p">,</span> <span class="n">streamChanDepth</span><span class="p">)</span>
<span class="n">d</span><span class="o">.</span><span class="n">out</span> <span class="o">=</span> <span class="n">out</span>
<span class="n">d</span><span class="o">.</span><span class="n">stopOnce</span> <span class="o">=</span> <span class="n">sync</span><span class="o">.</span><span class="n">Once</span><span class="p">{}</span> <span class="c">// only reachable once the prior teardown finished</span>
</code></pre></div></div>

<p>The lesson is a recurring one in this series: with USB, “stop” is a <em>request</em>,
not an event. Anything that re-opens has to wait on the teardown completing, not
on having asked for it.</p>

<h2 id="the-design-principle-supervisor--observer">The design principle: supervisor + observer</h2>

<p>Two patterns share the load here. The pool is a <strong>supervisor</strong> (a fleet manager):
it owns the lifecycle of every device, restarts the ones that fail, and presents
the survivors as a roster the engine can query by role. The watchdog is the
supervisor’s health check, and <code class="language-plaintext highlighter-rouge">Reacquire</code> is its restart strategy.</p>

<p>The second pattern is <strong>observer</strong>. <strong>The pool never calls into the daemon, the
API, or the TUI.</strong> It <code class="language-plaintext highlighter-rouge">Publish</code>es <code class="language-plaintext highlighter-rouge">KindSDRAttached</code> / <code class="language-plaintext highlighter-rouge">KindSDRDetached</code> to an
optional bus and lets whoever cares subscribe:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/pool.go</span>
<span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">Pool</span><span class="p">)</span> <span class="n">publish</span><span class="p">(</span><span class="n">kind</span> <span class="n">events</span><span class="o">.</span><span class="n">Kind</span><span class="p">,</span> <span class="n">payload</span> <span class="n">any</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">bus</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span>
    <span class="p">}</span>
    <span class="n">p</span><span class="o">.</span><span class="n">bus</span><span class="o">.</span><span class="n">Publish</span><span class="p">(</span><span class="n">events</span><span class="o">.</span><span class="n">Event</span><span class="p">{</span><span class="n">Kind</span><span class="o">:</span> <span class="n">kind</span><span class="p">,</span> <span class="n">Payload</span><span class="o">:</span> <span class="n">payload</span><span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="how-that-principle-shaped-the-go-code">How that principle shaped the Go code</h3>

<ul>
  <li><strong>The bus is optional.</strong> <code class="language-plaintext highlighter-rouge">NewPool</code> takes only a logger; <code class="language-plaintext highlighter-rouge">SetBus</code> is a separate,
idempotent step. The <code class="language-plaintext highlighter-rouge">gophertrunk sdr list</code> CLI and every unit test run the
pool with <code class="language-plaintext highlighter-rouge">bus == nil</code>, and <code class="language-plaintext highlighter-rouge">publish</code> short-circuits — the same fleet code, no
daemon required.</li>
  <li><strong>State lives in one goroutine.</strong> The watchdog’s <code class="language-plaintext highlighter-rouge">missing</code> map is owned solely
by the watchdog goroutine and passed in by value-reference, so attach/detach
transitions need no extra lock. Only the pool’s <code class="language-plaintext highlighter-rouge">entries</code> slice is shared, and
that’s behind <code class="language-plaintext highlighter-rouge">sync.RWMutex</code>.</li>
  <li><strong>Identity is stable across reacquisition.</strong> Because <code class="language-plaintext highlighter-rouge">Reacquire</code> swaps the
<code class="language-plaintext highlighter-rouge">Device</code> inside an existing <code class="language-plaintext highlighter-rouge">PoolEntry</code> rather than replacing the entry,
consumers that cached a <code class="language-plaintext highlighter-rouge">*PoolEntry</code> keep working across a USB cycle. Role and
serial are the identity; the handle is just an attribute.</li>
  <li><strong>Recovery is best-effort and idempotent.</strong> Closing a dead handle may error;
re-enumerate may miss the serial; the in-stream retry loop may beat the
watchdog to it. Every path logs and moves on, because the next tick — or the
next consumer — will try again.</li>
</ul>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>The pool assigns roles and keeps devices alive, but we’ve leaned on tests
throughout this post — <code class="language-plaintext highlighter-rouge">TestPoolReacquireSwapsDeviceHandleInPlace</code>,
<code class="language-plaintext highlighter-rouge">TestPoolMatchesAirspySerialAliases</code> — without explaining how you test a fleet of
radios in CI where there are no radios at all. That’s
<a href="/blog/deep-dives/rf-front-end-13-testing-radios-without-radios/">Part 13</a>:
replaying captured USB control-transfer sequences, bit-identical conversion
golden masters, and an opt-in real-hardware tier.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Why poll every 30 seconds instead of listening for kernel hotplug events?</strong>
Polling is portable. The same re-enumerate loop works on Linux USBDEVFS, Windows
WinUSB, and macOS IOKit without three platform-specific hotplug listeners. 30 s
is short enough to recover a transient drop inside one failure cycle and long
enough not to load a slow hub.</p>

<p><strong>What happens to an in-use device that drops?</strong> The watchdog owns the <em>idle</em>
case. A device that’s actively streaming surfaces its death through the stream
itself — the reaper closes the channel, the consumer (ccdecoder retry loop,
<code class="language-plaintext highlighter-rouge">VoicePool.Bind</code>) sees EOF and drives its own <code class="language-plaintext highlighter-rouge">Reacquire</code>. The watchdog is the
backstop for radios nobody is currently touching.</p>

<p><strong>Why does strict mode skip a device that’s physically present?</strong> Because an
allowlist is an allowlist, not a preference. If you named your control stick in
config and an unrelated dongle is on the bus, opening that dongle could let it
win <code class="language-plaintext highlighter-rouge">RoleControl</code> and bind the decoder to a radio that never got your PPM
correction — the original issue #264 failure. Strict mode refuses to guess.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 12 of 14</strong> · ← <a href="/blog/deep-dives/rf-front-end-11-hackrf-one/">Part 11</a> · Next → <a href="/blog/deep-dives/rf-front-end-13-testing-radios-without-radios/">Part 13: Testing radios without radios</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="sdr" /><category term="go" /><category term="usb" /><category term="concurrency" /><category term="software-design" /><summary type="html"><![CDATA[How GopherTrunk manages a fleet of opened dongles behind one pool — assigning control, voice, and wideband roles, running a USB watchdog that re-enumerates every 30 seconds to catch hotplug, and reacquiring a device after the kernel hands it a new bus address. Plus the re-open race that taught us to serialize retune behind stream teardown.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Build in the Open, Part 11: Releases — Cadence, Pre-Release vs. Release, SemVer &amp;amp; Changelogs</title><link href="https://gophertrunk.org/blog/tutorials/build-in-the-open-11-releases-prerelease-semver-changelogs/" rel="alternate" type="text/html" title="Build in the Open, Part 11: Releases — Cadence, Pre-Release vs. Release, SemVer &amp;amp; Changelogs" /><published>2026-06-28T00:00:00-05:00</published><updated>2026-06-28T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/tutorials/build-in-the-open-11-releases-prerelease-semver-changelogs</id><content type="html" xml:base="https://gophertrunk.org/blog/tutorials/build-in-the-open-11-releases-prerelease-semver-changelogs/"><![CDATA[<blockquote>
  <p><strong>TL;DR:</strong> A release is just a <em>tagged, named, downloadable snapshot</em> of your
project plus notes that tell people what changed. Use Semantic Versioning
(<code class="language-plaintext highlighter-rouge">MAJOR.MINOR.PATCH</code>) so the number means something, release often instead of
hoarding changes, ship pre-releases (<code class="language-plaintext highlighter-rouge">alpha</code>/<code class="language-plaintext highlighter-rouge">beta</code>/<code class="language-plaintext highlighter-rouge">rc</code>, or any <code class="language-plaintext highlighter-rouge">0.x</code> while
you’re still stabilizing) to set expectations, keep a human-readable changelog
in the <a href="https://keepachangelog.com/">Keep a Changelog</a> format, and automate
the build so a single <code class="language-plaintext highlighter-rouge">git tag</code> produces signed, checksummed artifacts.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li>SemVer encodes a <em>promise</em>: bump MAJOR for breaking changes, MINOR for new
features, PATCH for fixes.</li>
  <li>Anything below <code class="language-plaintext highlighter-rouge">1.0.0</code> is implicitly a pre-release — the public API may still
change. Ship a <code class="language-plaintext highlighter-rouge">0.x</code> or <code class="language-plaintext highlighter-rouge">-rc</code> build before you commit to <code class="language-plaintext highlighter-rouge">v1.0.0</code>.</li>
  <li>Maintain a <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> with an <code class="language-plaintext highlighter-rouge">## [Unreleased]</code> section you fill in as you
go, so cutting a release is just renaming a heading.</li>
  <li>Let a tag trigger the build. Publishing artifacts by hand is how you ship the
wrong binary at 2 a.m.</li>
  <li>Always ship checksums (<code class="language-plaintext highlighter-rouge">SHA256SUMS</code>) so users can verify what they downloaded.</li>
</ul>

<p><em>This is Part 11 of <strong>Build in the Open</strong>, a 14-part series on taking a software
project from a blank idea to a public release using GitHub and Claude Code. Each
post teaches a technique you can apply to any project in any language, then shows
how the open-source <a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a>
scanner does it for real.</em></p>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>What a release actually is</strong> — and why “tag, notes, artifacts” is the whole
game.</li>
  <li><strong>Semantic Versioning</strong> — what each number promises, and how to bump it.</li>
  <li><strong>Pre-releases</strong> — alpha/beta/rc, the <code class="language-plaintext highlighter-rouge">0.x</code> convention, and why you want one
before <code class="language-plaintext highlighter-rouge">v1.0.0</code>.</li>
  <li><strong>Release cadence</strong> — why shipping small and often beats the big-bang release.</li>
  <li><strong>Writing changelogs and release notes</strong> people will actually read.</li>
  <li><strong>Automating the build</strong> so a tag produces checksummed artifacts.</li>
  <li><strong>How GopherTrunk does it</strong>, end to end, with real commands.</li>
</ul>

<h2 id="what-is-a-release-really">What is a release, really?</h2>

<p>Strip away the ceremony and a release is three things:</p>

<ol>
  <li><strong>A tag</strong> — an immutable git pointer (<code class="language-plaintext highlighter-rouge">v0.4.5</code>) to one exact commit.</li>
  <li><strong>A name and notes</strong> — a human-readable summary of what changed.</li>
  <li><strong>Artifacts</strong> — the built binaries, archives, or packages users download,
plus checksums so they can verify integrity.</li>
</ol>

<p>Everything else — version policy, changelogs, automation — exists to make those
three things trustworthy and repeatable. If you get the tag right and the build
is reproducible from it, you can always reconstruct exactly what a user is
running.</p>

<h2 id="semantic-versioning-make-the-number-mean-something">Semantic Versioning: make the number mean something</h2>

<p><a href="https://semver.org/">Semantic Versioning</a> (SemVer) is a contract written into
the version number <code class="language-plaintext highlighter-rouge">MAJOR.MINOR.PATCH</code>:</p>

<ul>
  <li><strong>MAJOR</strong> (<code class="language-plaintext highlighter-rouge">1.x.x</code> → <code class="language-plaintext highlighter-rouge">2.0.0</code>): you broke backward compatibility. Users must
read the notes before upgrading.</li>
  <li><strong>MINOR</strong> (<code class="language-plaintext highlighter-rouge">1.2.x</code> → <code class="language-plaintext highlighter-rouge">1.3.0</code>): you added functionality, backward-compatibly.
Safe to upgrade.</li>
  <li><strong>PATCH</strong> (<code class="language-plaintext highlighter-rouge">1.2.3</code> → <code class="language-plaintext highlighter-rouge">1.2.4</code>): you fixed a bug, no API change. Always safe.</li>
</ul>

<p>The discipline is what makes this useful. If a “patch” quietly changes behavior,
the number is lying and your users stop trusting it. When in doubt about whether
something is a feature or a fix, ask: <em>could this surprise someone who upgrades
without reading?</em> If yes, it’s at least a MINOR.</p>

<p>A subtle but important rule: <strong>everything below <code class="language-plaintext highlighter-rouge">1.0.0</code> is a free-for-all.</strong>
SemVer §4 says the public API should not be considered stable until <code class="language-plaintext highlighter-rouge">1.0.0</code>. A
<code class="language-plaintext highlighter-rouge">0.x</code> version is itself a signal — “I’m still figuring out the shape of this,
expect change.” That’s not a cop-out; it’s an honest label.</p>

<h2 id="pre-releases-alpha-beta-rc--and-the-0x-convention">Pre-releases: alpha, beta, rc — and the 0.x convention</h2>

<p>A <strong>pre-release</strong> is a version you ship knowing it’s not the final cut. SemVer
gives you a suffix for this (<code class="language-plaintext highlighter-rouge">1.0.0-alpha</code>, <code class="language-plaintext highlighter-rouge">1.0.0-beta.2</code>, <code class="language-plaintext highlighter-rouge">1.0.0-rc.1</code>), and
the convention is a rough confidence ladder:</p>

<ul>
  <li><strong>alpha</strong> — feature-incomplete, expect bugs, for early testers.</li>
  <li><strong>beta</strong> — feature-complete, hunting for bugs.</li>
  <li><strong>rc</strong> (release candidate) — believed shippable; if nothing breaks, this
<em>becomes</em> the release.</li>
</ul>

<p>Two reasons to bother. First, it sets expectations honestly. Second — and this
is the one people skip — <strong>a pre-release exercises your release machinery on
real infrastructure before it matters.</strong> The first time you push a tag and watch
the build run is exactly when you find out your version-injection is broken or
your artifact paths are wrong. Far better to discover that on <code class="language-plaintext highlighter-rouge">v0.99.0</code> than on
<code class="language-plaintext highlighter-rouge">v1.0.0</code>.</p>

<h2 id="release-often-in-small-bites">Release often, in small bites</h2>

<p>The instinct to batch up “enough” changes into a big release is a trap. Large
releases are riskier (more surface area to regress), harder to write notes for,
and slower to get feedback on. Shipping small and often means each release is
easy to reason about, easy to roll back, and keeps users in the habit of
upgrading. Your cadence doesn’t have to be fixed — “whenever there’s something
worth shipping” is a perfectly good policy for a side project.</p>

<h2 id="write-a-changelog-humans-will-read">Write a changelog humans will read</h2>

<p>A <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> is the project’s release diary. The
<a href="https://keepachangelog.com/">Keep a Changelog</a> format is the de-facto standard
and it’s dead simple:</p>

<ul>
  <li>Newest version at the top.</li>
  <li>An <code class="language-plaintext highlighter-rouge">## [Unreleased]</code> section you append to <em>as you merge changes</em> — so the work
is already done when you cut a release.</li>
  <li>Changes grouped under <code class="language-plaintext highlighter-rouge">### Added</code>, <code class="language-plaintext highlighter-rouge">### Changed</code>, <code class="language-plaintext highlighter-rouge">### Fixed</code>, <code class="language-plaintext highlighter-rouge">### Removed</code>,
<code class="language-plaintext highlighter-rouge">### Deprecated</code>, <code class="language-plaintext highlighter-rouge">### Security</code>.</li>
</ul>

<p>Write entries for <em>users</em>, not for git. “Fixed null pointer in handler” is a
commit message; “Scanner no longer crashes when a talkgroup has no name” is a
changelog entry. When you cut a release, you rename <code class="language-plaintext highlighter-rouge">[Unreleased]</code> to the new
version with a date and open a fresh <code class="language-plaintext highlighter-rouge">[Unreleased]</code>. That’s it.</p>

<p>GitHub Releases can also auto-generate notes from merged PR titles, which is a
great complement to a hand-written changelog: the changelog tells the <em>story</em>,
the auto-notes give the <em>complete</em> list.</p>

<h2 id="automate-the-build-off-the-tag">Automate the build off the tag</h2>

<p>Manual release builds are where mistakes live. The pattern that scales:</p>

<ol>
  <li>You push a tag matching a pattern (e.g. <code class="language-plaintext highlighter-rouge">v*.*.*</code>).</li>
  <li>A CI workflow triggers on that tag, builds every target, injects the version
into the binary, computes checksums, and creates the GitHub Release with the
artifacts attached.</li>
</ol>

<p>Inject the version at build time rather than hard-coding it in source — that way
the binary can report exactly which release and commit it came from. In Go this
is <code class="language-plaintext highlighter-rouge">-ldflags "-X pkg.Version=..."</code>; other ecosystems have equivalents. And always
publish a <code class="language-plaintext highlighter-rouge">SHA256SUMS</code> file so anyone can verify the download wasn’t corrupted or
tampered with.</p>

<h2 id="how-gophertrunk-does-it">How GopherTrunk does it</h2>

<p><a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a> is on <strong>SemVer
<code class="language-plaintext highlighter-rouge">v0.4.5</code></strong> — squarely in the honest <code class="language-plaintext highlighter-rouge">0.x</code> “still stabilizing” range, with the
public API and config schema free to evolve until <code class="language-plaintext highlighter-rouge">v1.0.0</code>. Here’s the full
release machinery, all real:</p>

<p><strong>The changelog is the source of truth.</strong> <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> opens by declaring it
follows Keep a Changelog and Semantic Versioning, and the top of the file is
always an <code class="language-plaintext highlighter-rouge">## [Unreleased]</code> section. Contributors add a line there as part of
every PR — <code class="language-plaintext highlighter-rouge">CONTRIBUTING.md</code> literally lists “Add a line to <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> under
<code class="language-plaintext highlighter-rouge">## [Unreleased]</code>” as step 3 of sending a pull request. Cutting <code class="language-plaintext highlighter-rouge">v0.4.5</code> was just
promoting that section to a dated heading.</p>

<p><strong>A tag triggers everything.</strong> <code class="language-plaintext highlighter-rouge">.github/workflows/release.yml</code> is wired to
<code class="language-plaintext highlighter-rouge">on: push: tags: ["v*.*.*"]</code> (plus a manual <code class="language-plaintext highlighter-rouge">workflow_dispatch</code> button). Pushing
<code class="language-plaintext highlighter-rouge">vX.Y.Z</code> builds Windows, Linux, and macOS artifacts for both <code class="language-plaintext highlighter-rouge">amd64</code> and <code class="language-plaintext highlighter-rouge">arm64</code>
in parallel, then a final <code class="language-plaintext highlighter-rouge">release</code> job flattens them and publishes the GitHub
Release.</p>

<p><strong>Version is injected at link time.</strong> Each build computes the version, short
commit SHA, and UTC build time and bakes them in via ldflags:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-ldflags "-X github.com/MattCheramie/GopherTrunk/internal/version.Version=${VERSION} \
          -X github.com/MattCheramie/GopherTrunk/internal/version.Commit=${COMMIT} \
          -X github.com/MattCheramie/GopherTrunk/internal/version.BuildTime=${BUILD_TIME}"
</code></pre></div></div>

<p>So <code class="language-plaintext highlighter-rouge">gophertrunk version</code> always reports exactly which release and commit you’re
running.</p>

<p><strong>Rehearse before you tag.</strong> <code class="language-plaintext highlighter-rouge">CONTRIBUTING.md</code>’s “Cutting a release” section
tells you to dry-run first:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make release-dry-run <span class="nv">VERSION</span><span class="o">=</span>v0.99.0
</code></pre></div></div>

<p>That builds <code class="language-plaintext highlighter-rouge">dist/dry-run/gophertrunk</code> with the same ldflags the real workflow
uses, runs <code class="language-plaintext highlighter-rouge">./gophertrunk version</code> against it, and writes a <code class="language-plaintext highlighter-rouge">SHA256SUMS</code> file —
so packaging or ldflags breakage surfaces <em>before</em> a tag is cut.</p>

<p><strong>Pre-release before v1.0.0.</strong> The repo’s stated policy is that “the first
production release should be a prerelease (e.g. <code class="language-plaintext highlighter-rouge">v0.99.0</code>)” so the full workflow
runs end-to-end against live GitHub Actions before <code class="language-plaintext highlighter-rouge">v1.0.0</code> ships. And the
release job encodes the SemVer pre-release rule in code: any <code class="language-plaintext highlighter-rouge">v0.x</code> tag <em>or</em> any
tag with a hyphen suffix (<code class="language-plaintext highlighter-rouge">v1.0.0-rc.1</code>) is published with <code class="language-plaintext highlighter-rouge">prerelease: true</code>;
only a clean <code class="language-plaintext highlighter-rouge">v1.0.0+</code> lands as the “latest” stable release.</p>

<p><strong>Checksums and tag protection.</strong> The final job runs <code class="language-plaintext highlighter-rouge">sha256sum * &gt; SHA256SUMS</code>
over every artifact, and every release ends with the line “All artifacts are
SHA-256-checksummed in <code class="language-plaintext highlighter-rouge">SHA256SUMS</code>.” Tags themselves are protected: the
<code class="language-plaintext highlighter-rouge">.github/rulesets/release-tags-protection.json</code> ruleset matches <code class="language-plaintext highlighter-rouge">refs/tags/v*</code>
and blocks both <code class="language-plaintext highlighter-rouge">deletion</code> and <code class="language-plaintext highlighter-rouge">update</code>, so a published release tag can never be
silently moved to point at different code.</p>

<p>You don’t need a multi-OS matrix to copy this. The portable moves are: pick
SemVer, keep an <code class="language-plaintext highlighter-rouge">[Unreleased]</code> changelog, trigger the build off a tag, inject the
version, ship checksums, and rehearse with a pre-release.</p>

<h2 id="faq">FAQ</h2>

<p><strong>When should I release v1.0.0?</strong>
When you’re willing to promise API stability — that you won’t make breaking
changes without bumping to <code class="language-plaintext highlighter-rouge">2.0.0</code>. If you’re still reshaping the public
interface, stay on <code class="language-plaintext highlighter-rouge">0.x</code>. There’s no rush; plenty of widely-used software lives
happily at <code class="language-plaintext highlighter-rouge">0.x</code> for years.</p>

<p><strong>Do I need a changelog if GitHub auto-generates release notes?</strong>
They serve different purposes. Auto-generated notes list every merged PR — great
for completeness. A hand-written <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> curates the <em>user-facing story</em>
and groups changes by type. The best setup uses both, which is exactly what
GopherTrunk does (<code class="language-plaintext highlighter-rouge">generate_release_notes: true</code> alongside <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code>).</p>

<p><strong>What’s the difference between a tag and a release?</strong>
A tag is a git concept: an immutable pointer to a commit. A “release” is a
GitHub feature layered on top — a tag plus a title, notes, and downloadable
artifacts. You can have tags with no release, but not a release without a tag.</p>

<p><strong>Why inject the version instead of writing it in a file?</strong>
A version file can drift from reality — someone forgets to bump it, or a local
build reports the wrong number. Injecting <code class="language-plaintext highlighter-rouge">Version</code>/<code class="language-plaintext highlighter-rouge">Commit</code>/<code class="language-plaintext highlighter-rouge">BuildTime</code> at link
time means the binary always tells the truth about exactly what produced it.</p>

<p><strong>Should pre-releases show up as the “latest” download?</strong>
No. Mark them <code class="language-plaintext highlighter-rouge">prerelease: true</code> so users who just want the stable build aren’t
handed a release candidate. GopherTrunk’s release job does this automatically for
any <code class="language-plaintext highlighter-rouge">0.x</code> or hyphen-suffixed tag.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 11 of 14</strong> · ←
<a href="/blog/tutorials/build-in-the-open-10-websites-support-pages-github-pages/">Part 10: Websites, Support Pages &amp; GitHub Pages</a>
· Next →
<a href="/blog/tutorials/build-in-the-open-12-optimizing-securing-your-repository/">Part 12: Optimizing &amp; Securing Your Repository</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="tutorials" /><category term="github" /><category term="releases" /><category term="semver" /><category term="changelog" /><category term="ci-cd" /><category term="claude-code" /><summary type="html"><![CDATA[How to ship software releases the right way — Semantic Versioning, pre-releases, git tags, Keep a Changelog notes, and automated, checksummed builds.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RF Front End, Part 11: HackRF One — Signed 8-Bit IQ End to End</title><link href="https://gophertrunk.org/blog/deep-dives/rf-front-end-11-hackrf-one/" rel="alternate" type="text/html" title="RF Front End, Part 11: HackRF One — Signed 8-Bit IQ End to End" /><published>2026-06-28T00:00:00-05:00</published><updated>2026-06-28T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/rf-front-end-11-hackrf-one</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/rf-front-end-11-hackrf-one/"><![CDATA[<p><em>Part 11 of <strong>RF Front End</strong>. After the Airspy’s host-side DSP, the
<a href="/reference/hackrf/">HackRF One</a> is a palate cleanser: the
simplest wire format of the three. The interesting part isn’t the samples — it’s
identity. The driver guesses the model from a USB PID, then asks the firmware who
it really is, and that open-time handshake is where a probe-gains bug lived.</em></p>

<blockquote>
  <p><strong>TL;DR</strong> — The HackRF One driver decodes the simplest of the three wire formats: signed 8-bit interleaved IQ scaled to <code class="language-plaintext highlighter-rouge">complex64</code>. Identity is the tricky part — the driver guesses the model from a USB PID, then lets the firmware’s board-ID readback override it. A probe-gains bug (<code class="language-plaintext highlighter-rouge">sdr list --probe</code> showed empty gains) is fixed by stamping the full identity, ladder included, onto the device at open time.</p>
</blockquote>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li>Decoding the HackRF’s <strong>signed 8-bit interleaved IQ</strong> into <code class="language-plaintext highlighter-rouge">complex64</code> in <code class="language-plaintext highlighter-rouge">[-1, 1]</code>.</li>
  <li>Enumeration across the <strong>One / Jawbreaker / Rad1o</strong> PIDs on VID <code class="language-plaintext highlighter-rouge">0x1d50</code>, and
why the <strong>board-ID read at open</strong> takes precedence over the PID guess.</li>
  <li>The <strong>SET_TRANSCEIVER_MODE</strong> state machine (0 off / 1 receive) and the default
gain ladder (LNA 16 dB, VGA 20 dB).</li>
  <li>The bug: <code class="language-plaintext highlighter-rouge">sdr list --probe</code> showed <strong>empty gains</strong> because we read them before
the device was fully open — and the open-time fix the tests pin down.</li>
</ul>

<h2 id="what-the-hackrf-driver-does">What the HackRF driver does</h2>

<p>The <a href="/reference/hackrf/">HackRF</a> One is a half-duplex
transceiver from Great Scott Gadgets covering 1 MHz–6 GHz. GopherTrunk only
receives, so the driver’s surface is small: enumerate, claim, tune, set rate and
gain, flip into receive mode, and reap bulk-IN URBs into <code class="language-plaintext highlighter-rouge">complex64</code>. Like every
other backend it speaks the vendor protocol — here libhackrf’s — directly over
the shared pure-Go USB transport, so no CGO and no libhackrf land in the build.</p>

<p>Its sample format is the friendliest of the bunch. The firmware delivers
<strong>signed 8-bit interleaved IQ</strong> — <code class="language-plaintext highlighter-rouge">I, Q, I, Q, …</code> — on bulk endpoint <code class="language-plaintext highlighter-rouge">0x81</code> once
the transceiver is in receive mode. There’s no real-to-complex synthesis (that
was the Airspy R2/Mini in
<a href="/blog/deep-dives/rf-front-end-10-airspy-real-to-complex/">Part 10</a>)
and no 16-bit unpacking; just two signed bytes per complex sample.</p>

<h2 id="how-gophertrunk-implements-it-in-go">How GopherTrunk implements it in Go</h2>

<h3 id="decoding-the-samples">Decoding the samples</h3>

<p>The decode is a tight loop over <code class="language-plaintext highlighter-rouge">internal/sdr/hackrf/hackrf.go</code>: read a signed
byte for I, one for Q, normalize each to roughly <code class="language-plaintext highlighter-rouge">[-1, 1]</code> by dividing by 128:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf.go</span>
<span class="c">// decodeInt8IQ converts a HackRF bulk-IN payload (signed 8-bit</span>
<span class="c">// interleaved IQ) into normalised complex64 samples in [-1, 1].</span>
<span class="k">func</span> <span class="n">decodeInt8IQ</span><span class="p">(</span><span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">[]</span><span class="kt">complex64</span> <span class="p">{</span>
    <span class="n">n</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="o">/</span> <span class="m">2</span>
    <span class="n">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">complex64</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">iv</span> <span class="o">:=</span> <span class="kt">float32</span><span class="p">(</span><span class="kt">int8</span><span class="p">(</span><span class="n">buf</span><span class="p">[</span><span class="m">2</span><span class="o">*</span><span class="n">i</span><span class="p">]))</span> <span class="o">/</span> <span class="m">128</span>
        <span class="n">qv</span> <span class="o">:=</span> <span class="kt">float32</span><span class="p">(</span><span class="kt">int8</span><span class="p">(</span><span class="n">buf</span><span class="p">[</span><span class="m">2</span><span class="o">*</span><span class="n">i</span><span class="o">+</span><span class="m">1</span><span class="p">]))</span> <span class="o">/</span> <span class="m">128</span>
        <span class="n">out</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="nb">complex</span><span class="p">(</span><span class="n">iv</span><span class="p">,</span> <span class="n">qv</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">out</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>The <code class="language-plaintext highlighter-rouge">int8</code> conversion is load-bearing: the bytes arrive as <code class="language-plaintext highlighter-rouge">uint8</code> in the slice,
and reinterpreting them as <code class="language-plaintext highlighter-rouge">int8</code> is what makes <code class="language-plaintext highlighter-rouge">0x80</code> read as <code class="language-plaintext highlighter-rouge">-128</code> rather than
<code class="language-plaintext highlighter-rouge">+128</code>.</strong> The in-package test pins that down with a <code class="language-plaintext highlighter-rouge">(-128, +64)</code> sample expected
near <code class="language-plaintext highlighter-rouge">(-1, +0.5)</code>.</p>

<h3 id="the-transceiver-state-machine">The transceiver state machine</h3>

<p>The HackRF won’t stream until you tell it to receive, and you must put it back to
off when you stop. That’s a two-value state machine over <code class="language-plaintext highlighter-rouge">SET_TRANSCEIVER_MODE</code>
(<code class="language-plaintext highlighter-rouge">reqSetTransceiverMode</code>):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf.go</span>
<span class="k">const</span> <span class="p">(</span>
    <span class="n">transceiverModeOff</span>     <span class="kt">uint16</span> <span class="o">=</span> <span class="m">0</span>
    <span class="n">transceiverModeReceive</span> <span class="kt">uint16</span> <span class="o">=</span> <span class="m">1</span>
<span class="p">)</span>

<span class="k">func</span> <span class="p">(</span><span class="n">d</span> <span class="o">*</span><span class="n">Device</span><span class="p">)</span> <span class="n">setMode</span><span class="p">(</span><span class="n">mode</span> <span class="kt">uint16</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">d</span><span class="o">.</span><span class="n">t</span><span class="o">.</span><span class="n">ControlOut</span><span class="p">(</span><span class="n">reqSetTransceiverMode</span><span class="p">,</span> <span class="n">mode</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="no">nil</span><span class="p">,</span> <span class="n">controlTimeoutMs</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">StreamIQ</code> flips to receive, starts the bulk-IN reaper, and a detached goroutine
flips back to off on either context cancellation or an unrecoverable URB death —
the same teardown shape every driver in this series shares.</p>

<h3 id="tuning-and-the-tracking-baseband-filter">Tuning and the tracking baseband filter</h3>

<p><code class="language-plaintext highlighter-rouge">SetCenterFreq</code> is the one place the HackRF’s wire encoding is slightly unusual:
libhackrf splits the frequency into an MHz part and an Hz remainder, two
little-endian <code class="language-plaintext highlighter-rouge">uint32</code>s in the data stage:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf.go</span>
<span class="n">payload</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="m">8</span><span class="p">)</span>
<span class="n">binary</span><span class="o">.</span><span class="n">LittleEndian</span><span class="o">.</span><span class="n">PutUint32</span><span class="p">(</span><span class="n">payload</span><span class="p">[</span><span class="m">0</span><span class="o">:</span><span class="m">4</span><span class="p">],</span> <span class="n">hz</span><span class="o">/</span><span class="m">1</span><span class="n">_000_000</span><span class="p">)</span>
<span class="n">binary</span><span class="o">.</span><span class="n">LittleEndian</span><span class="o">.</span><span class="n">PutUint32</span><span class="p">(</span><span class="n">payload</span><span class="p">[</span><span class="m">4</span><span class="o">:</span><span class="m">8</span><span class="p">],</span> <span class="n">hz</span><span class="o">%</span><span class="m">1</span><span class="n">_000_000</span><span class="p">)</span>
<span class="k">return</span> <span class="n">d</span><span class="o">.</span><span class="n">t</span><span class="o">.</span><span class="n">ControlOut</span><span class="p">(</span><span class="n">reqSetFreq</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">payload</span><span class="p">,</span> <span class="n">controlTimeoutMs</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">SetSampleRate</code> does a little extra work: the rate goes out as a
numerator/divider pair (the driver always uses divider 1, so the host sees
exactly the requested rate), and then the <strong>baseband filter cutoff tracks the
rate</strong> — set to ~75 % of it to keep the passband flat while rejecting alias
energy near the band edge. The filter program is fire-and-forget; the rate
program is the one that must land:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf.go  (shape)</span>
<span class="c">// rate = (hz, divider 1); then baseband filter ≈ 0.75 × rate</span>
<span class="n">bw</span> <span class="o">:=</span> <span class="kt">uint32</span><span class="p">(</span><span class="kt">float64</span><span class="p">(</span><span class="n">hz</span><span class="p">)</span> <span class="o">*</span> <span class="m">0.75</span><span class="p">)</span>
<span class="n">_</span> <span class="o">=</span> <span class="n">d</span><span class="o">.</span><span class="n">t</span><span class="o">.</span><span class="n">ControlOut</span><span class="p">(</span><span class="n">reqBasebandFilterBwSet</span><span class="p">,</span>
    <span class="kt">uint16</span><span class="p">(</span><span class="n">bw</span><span class="o">&amp;</span><span class="m">0xFFFF</span><span class="p">),</span> <span class="kt">uint16</span><span class="p">(</span><span class="n">bw</span><span class="o">&gt;&gt;</span><span class="m">16</span><span class="p">),</span> <span class="no">nil</span><span class="p">,</span> <span class="n">controlTimeoutMs</span><span class="p">)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">TestSetSampleRateProgramsFilter</code> test scripts both transfers, asserting the
filter program follows the rate program with the 0.75 cutoff.</p>

<h3 id="the-gain-ladder">The gain ladder</h3>

<p>The HackRF has no true AGC. Its gain is three independent stages: an RF amp
(on/off), an LNA (0–40 dB in 8 dB steps), and a VGA (0–62 dB in 2 dB steps). The
driver maps a single tenth-dB target onto that triple, and maps “auto” (a
negative value) onto a sane fixed preset rather than a non-existent AGC:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf.go</span>
<span class="c">// splitGain maps an SDR-interface tenth-dB target to the HackRF's</span>
<span class="c">// (LNA, VGA, AMP-on) triple. LNA must be a multiple of 8; VGA a multiple of 2.</span>
<span class="k">func</span> <span class="n">splitGain</span><span class="p">(</span><span class="n">tenthDB</span> <span class="kt">int</span><span class="p">)</span> <span class="p">(</span><span class="n">lna</span><span class="p">,</span> <span class="n">vga</span> <span class="kt">int</span><span class="p">,</span> <span class="n">amp</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">tenthDB</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">defaultLNAGainDB</span><span class="p">,</span> <span class="n">defaultVGAGainDB</span><span class="p">,</span> <span class="no">false</span> <span class="c">// 16 dB LNA, 20 dB VGA</span>
    <span class="p">}</span>
    <span class="n">target</span> <span class="o">:=</span> <span class="n">tenthDB</span> <span class="o">/</span> <span class="m">10</span>
    <span class="n">lna</span> <span class="o">=</span> <span class="p">(</span><span class="n">target</span> <span class="o">/</span> <span class="m">8</span><span class="p">)</span> <span class="o">*</span> <span class="m">8</span>
    <span class="k">if</span> <span class="n">lna</span> <span class="o">&gt;</span> <span class="m">40</span> <span class="p">{</span>
        <span class="n">lna</span> <span class="o">=</span> <span class="m">40</span>
    <span class="p">}</span>
    <span class="n">rem</span> <span class="o">:=</span> <span class="n">target</span> <span class="o">-</span> <span class="n">lna</span>
    <span class="n">vga</span> <span class="o">=</span> <span class="p">(</span><span class="n">rem</span> <span class="o">/</span> <span class="m">2</span><span class="p">)</span> <span class="o">*</span> <span class="m">2</span>
    <span class="k">if</span> <span class="n">vga</span> <span class="o">&gt;</span> <span class="m">62</span> <span class="p">{</span>
        <span class="n">vga</span> <span class="o">=</span> <span class="m">62</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">lna</span><span class="p">,</span> <span class="n">vga</span><span class="p">,</span> <span class="no">false</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The default preset — <strong>LNA 16 dB, VGA 20 dB, amp off</strong> — is the
hardware-friendly mid-band split the test ladder asserts on (<code class="language-plaintext highlighter-rouge">splitGain(-1) ==
(16, 20, false)</code>, and rungs like <code class="language-plaintext highlighter-rouge">300 → (24, 6)</code>).</p>

<h3 id="identity-pid-guess-then-ask-the-board">Identity: PID guess, then ask the board</h3>

<p>Three HackRF variants share VID <code class="language-plaintext highlighter-rouge">0x1d50</code> under different PIDs — <strong>One</strong> (<code class="language-plaintext highlighter-rouge">0x6089</code>),
the <strong>Jawbreaker</strong> prototype (<code class="language-plaintext highlighter-rouge">0x604b</code>), and the <strong>Rad1o</strong> badge (<code class="language-plaintext highlighter-rouge">0xcc15</code>). Unlike
the Airspy (one PID) or RTL-SDR, enumeration here has to <strong>scan every known PID</strong>
and concatenate the results into one descriptor list:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf.go  (shape)</span>
<span class="n">descs</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="n">usb</span><span class="o">.</span><span class="n">Descriptor</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span>
<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">pid</span> <span class="o">:=</span> <span class="k">range</span> <span class="p">[]</span><span class="kt">uint16</span><span class="p">{</span><span class="n">pidHackRFOne</span><span class="p">,</span> <span class="n">pidHackRFJawbrk</span><span class="p">,</span> <span class="n">pidHackRFRad1o</span><span class="p">}</span> <span class="p">{</span>
    <span class="n">found</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">d</span><span class="o">.</span><span class="n">enum</span><span class="o">.</span><span class="n">List</span><span class="p">(</span><span class="n">vidHackRF</span><span class="p">,</span> <span class="n">pid</span><span class="p">)</span>
    <span class="c">// ...append found to descs...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Enumeration then maps each PID to a canonical product name, since USB descriptor
strings vary by vendor and firmware while the PID is stable:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf.go</span>
<span class="k">var</span> <span class="n">pidProductNames</span> <span class="o">=</span> <span class="k">map</span><span class="p">[</span><span class="kt">uint16</span><span class="p">]</span><span class="kt">string</span><span class="p">{</span>
    <span class="n">pidHackRFOne</span><span class="o">:</span>    <span class="s">"HackRF One"</span><span class="p">,</span>
    <span class="n">pidHackRFJawbrk</span><span class="o">:</span> <span class="s">"HackRF Jawbreaker"</span><span class="p">,</span>
    <span class="n">pidHackRFRad1o</span><span class="o">:</span>  <span class="s">"Rad1o"</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>But the PID is only a <em>guess</em> about what the box is — a unit flashed with the
wrong firmware will enumerate under one PID while actually running another
board’s image. So at <strong>open</strong> time the driver asks the firmware directly, via
<code class="language-plaintext highlighter-rouge">BOARD_ID_READ</code>, and that answer <strong>takes precedence</strong> over the PID:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf.go</span>
<span class="n">product</span> <span class="o">:=</span> <span class="n">productForPID</span><span class="p">(</span><span class="n">desc</span><span class="o">.</span><span class="n">PID</span><span class="p">,</span> <span class="n">desc</span><span class="o">.</span><span class="n">Product</span><span class="p">)</span>
<span class="k">if</span> <span class="n">bid</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">readBoardID</span><span class="p">(</span><span class="n">t</span><span class="p">);</span> <span class="n">err</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">name</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="n">boardIDNames</span><span class="p">[</span><span class="n">bid</span><span class="p">];</span> <span class="n">ok</span> <span class="o">&amp;&amp;</span> <span class="n">name</span> <span class="o">!=</span> <span class="s">""</span> <span class="p">{</span>
        <span class="n">product</span> <span class="o">=</span> <span class="n">name</span> <span class="c">// firmware self-report wins over the PID guess</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">boardIDNames</code> mirrors libhackrf’s <code class="language-plaintext highlighter-rouge">hackrf_board_id</code> enum (<code class="language-plaintext highlighter-rouge">2</code> → “HackRF One”,
<code class="language-plaintext highlighter-rouge">3</code> → “Rad1o”, …). The same open also reads <code class="language-plaintext highlighter-rouge">VERSION_STRING_READ</code> to suffix the
tuner name with firmware (and to tag PortaPack/Mayhem builds). Both readbacks are
best-effort: older firmware that doesn’t implement them just falls through to the
PID-derived name and a plain tuner string — pinned by
<code class="language-plaintext highlighter-rouge">TestReadVersionStringIgnoredOnError</code> in <code class="language-plaintext highlighter-rouge">hackrf_test.go</code>.</p>

<h2 id="the-problem-we-hit---probe-reported-empty-gains">The problem we hit: <code class="language-plaintext highlighter-rouge">--probe</code> reported empty gains</h2>

<p><strong>Symptom.</strong> <code class="language-plaintext highlighter-rouge">sdr list --probe</code> — which actually opens each device and reports
<code class="language-plaintext highlighter-rouge">dev.Info()</code> — listed HackRFs (and Airspys) with an <strong>empty gain list</strong>. The
non-probing <code class="language-plaintext highlighter-rouge">sdr list</code>, which only enumerates, showed the ladder fine. Same
hardware, two different answers, depending on whether the device had been opened.</p>

<p><strong>Root cause.</strong> The gain ladder was attached to the <code class="language-plaintext highlighter-rouge">sdr.Info</code> produced by
<code class="language-plaintext highlighter-rouge">Enumerate</code>, but the <code class="language-plaintext highlighter-rouge">Info</code> carried by an <strong>opened</strong> <code class="language-plaintext highlighter-rouge">Device</code> was built
separately at open time and never got the <code class="language-plaintext highlighter-rouge">Gains</code> field. So the moment a probe
opened the device and read <code class="language-plaintext highlighter-rouge">dev.Info().Gains</code>, it got back <code class="language-plaintext highlighter-rouge">nil</code>. The information
existed; it just wasn’t established on the device-side <code class="language-plaintext highlighter-rouge">Info</code> that <code class="language-plaintext highlighter-rouge">--probe</code>
reads. (The same gap bit the Airspy driver, and is called out in #454’s
probe-gains fix.)</p>

<p><strong>The Go fix.</strong> Establish the full identity — board-ID, version string, <strong>and</strong>
the gain ladder — at open time, on the same <code class="language-plaintext highlighter-rouge">Info</code> the opened device hands back.
The ladder is shared between <code class="language-plaintext highlighter-rouge">Enumerate</code> and <code class="language-plaintext highlighter-rouge">Open</code> so a probed device reports
exactly what an enumerated one does:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf.go</span>
<span class="c">// gainPresetsTenthDB ... Shared by Enumerate and Open so a probed</span>
<span class="c">// (opened) device reports the same ladder an enumerated one does —</span>
<span class="c">// otherwise `--probe` showed an empty list.</span>
<span class="k">var</span> <span class="n">gainPresetsTenthDB</span> <span class="o">=</span> <span class="p">[]</span><span class="kt">int</span><span class="p">{</span><span class="m">0</span><span class="p">,</span> <span class="m">80</span><span class="p">,</span> <span class="m">160</span><span class="p">,</span> <span class="m">240</span><span class="p">,</span> <span class="m">320</span><span class="p">,</span> <span class="m">400</span><span class="p">,</span> <span class="m">480</span><span class="p">,</span> <span class="m">560</span><span class="p">}</span>

<span class="k">return</span> <span class="o">&amp;</span><span class="n">Device</span><span class="p">{</span>
    <span class="n">t</span><span class="o">:</span> <span class="n">t</span><span class="p">,</span>
    <span class="n">info</span><span class="o">:</span> <span class="n">sdr</span><span class="o">.</span><span class="n">Info</span><span class="p">{</span>
        <span class="c">// ...board-ID-resolved Product, fw-suffixed TunerName...</span>
        <span class="c">// Carry the gain ladder onto the opened device so dev.Info()</span>
        <span class="c">// (used by `sdr list --probe`) reports it, not an empty list.</span>
        <span class="n">Gains</span><span class="o">:</span> <span class="n">gainPresetsTenthDB</span><span class="p">,</span>
    <span class="p">},</span>
<span class="p">},</span> <span class="no">nil</span>
</code></pre></div></div>

<p>The fix is pinned by a test in <code class="language-plaintext highlighter-rouge">hackrf_test.go</code> that opens a device through a
scripted mock and asserts the opened <code class="language-plaintext highlighter-rouge">Info</code> carries the ladder:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/hackrf/hackrf_test.go</span>
<span class="c">// The opened device must carry the gain ladder so `sdr list --probe`</span>
<span class="c">// (which reads dev.Info() post-open) doesn't report an empty list.</span>
<span class="k">if</span> <span class="n">got</span> <span class="o">:=</span> <span class="n">dev</span><span class="o">.</span><span class="n">Info</span><span class="p">()</span><span class="o">.</span><span class="n">Gains</span><span class="p">;</span> <span class="nb">len</span><span class="p">(</span><span class="n">got</span><span class="p">)</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
    <span class="n">t</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"opened device Info().Gains is empty; want the gain ladder"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That same test (<code class="language-plaintext highlighter-rouge">TestDriverEnumerateAndOpen</code>) scripts the <code class="language-plaintext highlighter-rouge">BOARD_ID_READ</code> and
<code class="language-plaintext highlighter-rouge">VERSION_STRING_READ</code> exchanges the open now issues — proof that the board-id and
version readbacks, the firmware suffix, and the gain ladder all get established as
one atomic open-time step rather than lazily on first use.</p>

<p>It’s worth dwelling on why this class of bug is easy to introduce. <code class="language-plaintext highlighter-rouge">Enumerate</code>
and <code class="language-plaintext highlighter-rouge">Open</code> each build an <code class="language-plaintext highlighter-rouge">sdr.Info</code> independently — they have to, because an
opened device knows things an enumerated one can’t (the board-ID, the firmware
string). The trap is that the two <code class="language-plaintext highlighter-rouge">Info</code> constructions drift: a field added to
one is forgotten on the other, and nothing in the type system flags it. The fix
isn’t just “stamp the ladder on at open” — it’s making the <em>shared</em> facts (the
<code class="language-plaintext highlighter-rouge">gainPresetsTenthDB</code> ladder, the canonical product name via <code class="language-plaintext highlighter-rouge">productForPID</code>) come
from one source that both paths read, so they can’t disagree. The open-only facts
(board-ID override, firmware suffix) layer on top.</p>

<h2 id="the-design-principle-identity-by-capability-established-at-open">The design principle: identity by capability, established at open</h2>

<p>The thread tying the decode loop to the probe-gains fix is one principle:
<strong>a device’s identity and capabilities are established when it’s opened, by
asking the hardware — not guessed lazily and not deferred until first use.</strong></p>

<h3 id="how-that-principle-shaped-the-go-code">How that principle shaped the Go code</h3>

<ul>
  <li><strong>The board reports itself; the PID is a fallback.</strong> <code class="language-plaintext highlighter-rouge">BOARD_ID_READ</code> at open
overrides the PID-derived name, so a mis-flashed unit reports what’s actually
running. The PID guess only survives when the firmware can’t answer.</li>
  <li><strong>Open is the single point of truth for <code class="language-plaintext highlighter-rouge">Info</code>.</strong> Product, tuner/firmware
string, and the gain ladder are all stamped onto the device’s <code class="language-plaintext highlighter-rouge">Info</code> in <code class="language-plaintext highlighter-rouge">Open</code>.
Nothing downstream has to re-derive or re-probe them — <code class="language-plaintext highlighter-rouge">dev.Info()</code> is complete
the instant <code class="language-plaintext highlighter-rouge">Open</code> returns, which is exactly what <code class="language-plaintext highlighter-rouge">--probe</code> depends on.</li>
  <li><strong>Best-effort readbacks degrade, they don’t fail.</strong> A board-ID or
version-string NAK on old firmware is swallowed; the device still opens with a
sensible PID-derived identity. Capability discovery never blocks bring-up.</li>
  <li><strong>The mock scripts the open handshake.</strong> Because identity is established through
ordinary control transfers, the tests drive the whole open — board-ID, version,
gain ladder — against a <code class="language-plaintext highlighter-rouge">MockTransport</code> with no hardware, so the
identity-by-capability contract is regression-tested.</li>
</ul>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>That’s all four wire formats and all four open-time identity strategies covered:
RTL-SDR, Airspy R2/Mini, Airspy HF+, and HackRF. The remaining question is what
sits <em>above</em> the drivers — the pool that owns opened devices, dispatches retunes,
and survives a USB unplug-and-replug mid-stream. That’s
<a href="/blog/deep-dives/rf-front-end-12-sdr-pool-usb-watchdog/">Part 12</a>:
the SDR pool and the USB hotplug watchdog.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Why divide the 8-bit samples by 128 and not 127?</strong>
Dividing by 128 maps the full signed range cleanly: <code class="language-plaintext highlighter-rouge">-128 → -1.0</code> exactly, and
<code class="language-plaintext highlighter-rouge">+127 → ~0.992</code>. It keeps the scale a power of two and guarantees the result
never exceeds <code class="language-plaintext highlighter-rouge">[-1, 1)</code>, which downstream DSP assumes.</p>

<p><strong>Why does board-ID override the PID if both are available?</strong>
The PID is what the device <em>enumerated as</em>; the board-ID is what the firmware
<em>reports it is</em>. A unit flashed with another board’s image enumerates under one
PID but runs another board — the firmware’s self-report is the ground truth, so it
wins.</p>

<p><strong>Why did probe show empty gains but plain list didn’t?</strong>
<code class="language-plaintext highlighter-rouge">sdr list</code> only enumerates, reading the <code class="language-plaintext highlighter-rouge">Info</code> from <code class="language-plaintext highlighter-rouge">Enumerate</code> (which had the
ladder). <code class="language-plaintext highlighter-rouge">sdr list --probe</code> opens each device and reads <code class="language-plaintext highlighter-rouge">dev.Info()</code>, which was
built separately at open and lacked the <code class="language-plaintext highlighter-rouge">Gains</code> field until we stamped the ladder
on at open time.</p>

<p><strong>Does the HackRF have AGC?</strong>
No. It has three manual stages (amp, LNA, VGA). The driver maps a negative
(“auto”) target to a fixed hardware-friendly preset — amp off, LNA 16 dB, VGA
20 dB — rather than pretending an AGC exists.</p>

<p><strong>Why scan multiple PIDs at enumerate instead of one?</strong>
Because the HackRF family ships under three PIDs on the same VID. A single
<code class="language-plaintext highlighter-rouge">List(vid, pid)</code> would miss Jawbreakers and Rad1os, so enumeration loops over all
three known PIDs and concatenates the descriptors into one ordered list that
<code class="language-plaintext highlighter-rouge">Open</code> later indexes into.</p>

<p><strong>What’s the bias-tee for, and is it on by default?</strong>
<code class="language-plaintext highlighter-rouge">SetBiasTee</code> toggles the +3.3 V antenna-port bias (<code class="language-plaintext highlighter-rouge">ANTENNA_ENABLE</code>) for powering
an external LNA up the coax. It’s off unless explicitly enabled — the test
round-trips it on then off — so you never accidentally feed DC into a passive
antenna.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 11 of 14</strong> · ←
<a href="/blog/deep-dives/rf-front-end-10-airspy-real-to-complex/">Part 10</a>
· Next →
<a href="/blog/deep-dives/rf-front-end-12-sdr-pool-usb-watchdog/">Part 12: The SDR pool &amp; USB hotplug watchdog</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="sdr" /><category term="go" /><category term="hackrf" /><category term="usb" /><category term="software-design" /><summary type="html"><![CDATA[The HackRF One driver end to end — the simplest IQ format of the three, signed 8-bit interleaved I,Q scaled to complex64; enumeration across One/Jawbreaker/Rad1o PIDs; and the open-time board-ID readback that fixed empty probe gains by establishing identity and the gain ladder before streaming.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Build in the Open, Part 10: Websites, Support Pages &amp;amp; GitHub Pages</title><link href="https://gophertrunk.org/blog/tutorials/build-in-the-open-10-websites-support-pages-github-pages/" rel="alternate" type="text/html" title="Build in the Open, Part 10: Websites, Support Pages &amp;amp; GitHub Pages" /><published>2026-06-27T00:00:00-05:00</published><updated>2026-06-27T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/tutorials/build-in-the-open-10-websites-support-pages-github-pages</id><content type="html" xml:base="https://gophertrunk.org/blog/tutorials/build-in-the-open-10-websites-support-pages-github-pages/"><![CDATA[<blockquote>
  <p><strong>TL;DR:</strong> Your project can have a real website for free. Turn on GitHub Pages,
point it at a static-site generator like Jekyll, and you get a fast, secure,
CDN-backed site built straight from your repo. Add a custom domain with a
one-line <code class="language-plaintext highlighter-rouge">CNAME</code> file, build a landing page that sells the project, add
support and sponsor pages so people can get help and chip in, and run a blog —
including scheduled, drip-released posts. The whole thing lives in the same
repo as your code and deploys on every push.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li>GitHub Pages hosts a static website from your repo, free, with HTTPS and a CDN.</li>
  <li>A static-site generator (Jekyll) turns Markdown into a real site — no servers.</li>
  <li>A custom domain is a one-line <code class="language-plaintext highlighter-rouge">CNAME</code> file plus a DNS record.</li>
  <li>Support and sponsor pages let users get help and fund the work.</li>
  <li>Future-dated posts plus a daily build = a blog that drip-releases itself.</li>
</ul>

<p><em>This is Part 10 of <strong>Build in the Open</strong>, a 14-part series on taking a software
project from a blank idea to a public release using GitHub and Claude Code. Each
post teaches a technique you can apply to any project in any language, then shows
how the open-source <a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a>
scanner does it for real.</em></p>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>What GitHub Pages is</strong> and why it’s the easy default.</li>
  <li><strong>Static-site generators</strong> — what Jekyll buys you over hand-written HTML.</li>
  <li><strong>Custom domains</strong> with a <code class="language-plaintext highlighter-rouge">CNAME</code> file.</li>
  <li><strong>Landing, support, and sponsor pages</strong> — the pages a project actually needs.</li>
  <li><strong>Running a blog</strong> — including scheduled, drip-released posts.</li>
  <li><strong>How GopherTrunk does it</strong>, as a concrete example you can copy.</li>
</ul>

<h2 id="what-is-github-pages">What is GitHub Pages?</h2>

<p>GitHub Pages is free static-site hosting built into GitHub. You enable it in
<strong>Settings → Pages</strong>, choose a source (a branch, a <code class="language-plaintext highlighter-rouge">/docs</code> folder, or — better —
GitHub Actions), and GitHub serves the result over HTTPS from a CDN at
<code class="language-plaintext highlighter-rouge">username.github.io/repo</code>. Because the site is <em>static</em> — plain HTML, CSS, and
JavaScript — there’s nothing to run, nothing to patch, and nothing to pay for.</p>

<p>“Static” sounds limiting but rarely is. Docs, landing pages, blogs, and
marketing sites are all static by nature, and a static site is the fastest and
most secure thing you can ship.</p>

<h2 id="why-use-a-static-site-generator">Why use a static-site generator?</h2>

<p>Writing raw HTML for every page gets old fast. A <strong>static-site generator</strong> lets
you write content in Markdown, share layouts and navigation across pages, and
build the whole site with one command. <strong>Jekyll</strong> is the generator GitHub Pages
supports natively — Pages can build a Jekyll site for you with no extra setup.</p>

<p>What a generator buys you:</p>

<ul>
  <li><strong>Markdown instead of HTML.</strong> Write content; the generator wraps it in your
layout.</li>
  <li><strong>Shared layouts and includes.</strong> Change the header once, every page updates.</li>
  <li><strong>Plugins.</strong> SEO tags, sitemaps, and RSS feeds for free.</li>
  <li><strong>A blog engine.</strong> Drop a file in a posts folder and it becomes a post.</li>
</ul>

<p>Other generators (Hugo, Eleventy, Astro, MkDocs) work on Pages too, usually via
a GitHub Actions build. Jekyll is just the path of least resistance.</p>

<h2 id="custom-domains-with-a-cname">Custom domains with a CNAME</h2>

<p>A <code class="language-plaintext highlighter-rouge">username.github.io</code> URL works, but a real project wants a real domain. Pages
makes this a two-step affair:</p>

<ol>
  <li>Add a file named <strong><code class="language-plaintext highlighter-rouge">CNAME</code></strong> to your site source containing just your domain,
e.g. <code class="language-plaintext highlighter-rouge">example.org</code>.</li>
  <li>At your DNS provider, point the domain at GitHub Pages (a <code class="language-plaintext highlighter-rouge">CNAME</code> record to
<code class="language-plaintext highlighter-rouge">username.github.io</code>, or <code class="language-plaintext highlighter-rouge">A</code>/<code class="language-plaintext highlighter-rouge">AAAA</code> records to GitHub’s IPs for an apex
domain).</li>
</ol>

<p>GitHub then provisions a free TLS certificate, and your site serves over HTTPS
on your own domain. One file, one DNS record, done.</p>

<h2 id="the-pages-a-project-actually-needs">The pages a project actually needs</h2>

<p>Beyond the docs, a few pages do real work:</p>

<ul>
  <li><strong>A landing page.</strong> The pitch: what the project is, who it’s for, and a
prominent path to download or get started. This is where you convert a curious
visitor into a user.</li>
  <li><strong>A support / community page.</strong> Where to ask questions and report problems —
links to issues, a Discord or forum, a FAQ. Reduces the “how do I get help?”
friction to zero.</li>
  <li><strong>A sponsor / funding page.</strong> If you want support for the work, make it easy.
GitHub reads a <code class="language-plaintext highlighter-rouge">.github/FUNDING.yml</code> file and renders a <strong>Sponsor</strong> button on
your repo automatically; you can list GitHub Sponsors, Ko-fi, Patreon, and
more.</li>
  <li><strong>A blog.</strong> For release notes, tutorials, and build-in-the-open posts like this
one — which doubles as SEO and a reason for people to come back.</li>
</ul>

<h2 id="running-a-blog-and-drip-releasing-posts">Running a blog (and drip-releasing posts)</h2>

<p>A static blog is just dated files in a posts folder. The clever part is
<em>scheduling</em>. Most generators, Jekyll included, will <strong>exclude a post dated in
the future</strong> from the build. Pair that with a <strong>daily scheduled rebuild</strong> and you
get a powerful pattern:</p>

<ol>
  <li>Write a whole series at once, dating the posts on consecutive future days.</li>
  <li>Commit them all in one pull request.</li>
  <li>A daily build job rebuilds the site; each day, the posts that have “come due”
appear automatically.</li>
</ol>

<p>No draft branches, no daily commits, no manual publishing — the calendar does
the releasing for you. (This very series is published exactly that way.)</p>

<h2 id="how-gophertrunk-does-it">How GopherTrunk does it</h2>

<p><a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a>’s entire website —
<a href="https://gophertrunk.org">gophertrunk.org</a> — is a Jekyll site on GitHub Pages,
built from the repo’s <code class="language-plaintext highlighter-rouge">docs/</code> folder. The pieces line up with everything above:</p>

<ul>
  <li><strong>Custom domain via <code class="language-plaintext highlighter-rouge">CNAME</code>.</strong> <code class="language-plaintext highlighter-rouge">docs/CNAME</code> contains exactly one line —
<code class="language-plaintext highlighter-rouge">gophertrunk.org</code> — and that’s what serves the site on its own domain over
HTTPS.</li>
  <li><strong>The landing page is synthesized from the README at build time.</strong> The Pages
workflow (<code class="language-plaintext highlighter-rouge">.github/workflows/pages.yml</code>) has a “Synthesize landing page from
README.md” step that transforms <code class="language-plaintext highlighter-rouge">README.md</code> into <code class="language-plaintext highlighter-rouge">docs/index.md</code> during the
build — rewriting asset paths and cross-links and stripping the duplicate hero.
The homepage and the README can never drift apart, because there’s only one
source (the doc-organization payoff from Part 9).</li>
  <li><strong>SEO comes from plugins.</strong> <code class="language-plaintext highlighter-rouge">docs/_config.yml</code> enables <code class="language-plaintext highlighter-rouge">jekyll-seo-tag</code>
(titles, meta descriptions, Open Graph/Twitter cards), <code class="language-plaintext highlighter-rouge">jekyll-sitemap</code>
(<code class="language-plaintext highlighter-rouge">sitemap.xml</code>), and <code class="language-plaintext highlighter-rouge">jekyll-feed</code> (an Atom feed at <code class="language-plaintext highlighter-rouge">/feed.xml</code>) — so search
engines and readers get structured metadata for free.</li>
  <li><strong>Support and sponsor links are real files.</strong> There’s a <code class="language-plaintext highlighter-rouge">docs/support.md</code>
page, and <code class="language-plaintext highlighter-rouge">.github/FUNDING.yml</code> lists <strong>GitHub Sponsors</strong> (<code class="language-plaintext highlighter-rouge">github: MattCheramie</code>)
and <strong>Ko-fi</strong> (<code class="language-plaintext highlighter-rouge">ko_fi: Mrcheramie</code>), which GitHub turns into the repo’s Sponsor
button.</li>
  <li><strong>The blog drip-releases itself.</strong> <code class="language-plaintext highlighter-rouge">docs/_config.yml</code> sets <code class="language-plaintext highlighter-rouge">future: false</code> (a
future-dated post stays out of the build until its date), and
<code class="language-plaintext highlighter-rouge">pages.yml</code> runs a daily <code class="language-plaintext highlighter-rouge">cron: "0 16 * * *"</code> rebuild. A whole series is
committed at once on consecutive future dates and goes live one post per day —
with a <code class="language-plaintext highlighter-rouge">scripts/schedule-series.py</code> helper to assign the dates. That’s the
mechanism publishing the post you’re reading now.</li>
</ul>

<p>You don’t need a radio project to copy this. Any repo can flip on Pages, drop a
<code class="language-plaintext highlighter-rouge">CNAME</code>, point Jekyll at a <code class="language-plaintext highlighter-rouge">/docs</code> folder, add a <code class="language-plaintext highlighter-rouge">FUNDING.yml</code>, and schedule a
blog — all for the price of a push.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Is GitHub Pages really free?</strong>
Yes, for public repositories, including HTTPS and CDN delivery, within generous
soft bandwidth and size limits. For a project site, docs, or blog you will almost
certainly never hit them.</p>

<p><strong>Do I have to use Jekyll?</strong>
No. Jekyll is the generator Pages builds natively with zero config, but you can
deploy any static site — Hugo, Eleventy, Astro, plain HTML — by building it in a
GitHub Actions workflow and publishing the output. Jekyll is just the lowest-
friction option.</p>

<p><strong>How do I add a custom domain to GitHub Pages?</strong>
Add a <code class="language-plaintext highlighter-rouge">CNAME</code> file containing your domain to the site source, then create a DNS
record at your registrar pointing the domain at GitHub Pages. GitHub provisions
a free TLS certificate automatically once DNS resolves.</p>

<p><strong>How do I schedule blog posts to publish on a future date?</strong>
Date the post in the future and configure your generator to exclude future-dated
content (<code class="language-plaintext highlighter-rouge">future: false</code> in Jekyll), then run a daily scheduled build. Each
build reveals the posts whose date has arrived — so you can write a series now and
have it release one post per day.</p>

<p><strong>How do I add a Sponsor button to my repo?</strong>
Add a <code class="language-plaintext highlighter-rouge">.github/FUNDING.yml</code> listing your funding platforms (GitHub Sponsors,
Ko-fi, Patreon, etc.). GitHub reads it and shows a Sponsor button on the repo
automatically — no other setup required.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 10 of 14</strong> · ←
<a href="/blog/tutorials/build-in-the-open-09-documentation-done-right/">Part 9</a>
· Next →
<a href="/blog/tutorials/build-in-the-open-11-releases-prerelease-semver-changelogs/">Part 11: Releases — Pre-release, SemVer &amp; Changelogs</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="tutorials" /><category term="github" /><category term="claude-code" /><category term="github-pages" /><category term="jekyll" /><category term="website" /><category term="seo" /><summary type="html"><![CDATA[How to give a project a free website — enabling GitHub Pages, a Jekyll static site, a custom domain via CNAME, support and sponsor pages, and a drip-released blog.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RF Front End, Part 10: Airspy R2/Mini &amp;amp; HF+ — Real Samples to Complex Baseband</title><link href="https://gophertrunk.org/blog/deep-dives/rf-front-end-10-airspy-real-to-complex/" rel="alternate" type="text/html" title="RF Front End, Part 10: Airspy R2/Mini &amp;amp; HF+ — Real Samples to Complex Baseband" /><published>2026-06-27T00:00:00-05:00</published><updated>2026-06-27T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/rf-front-end-10-airspy-real-to-complex</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/rf-front-end-10-airspy-real-to-complex/"><![CDATA[<p><em>Part 10 of <strong>RF Front End</strong>. The last three posts moved bytes off the wire.
This one does signal processing in the driver: the Airspy R2/Mini hands us bare
real ADC samples, not IQ, so the host has to synthesize complex baseband from
scratch — DC removal, an Fs/4 translation, and a half-band Hilbert pair — and
get the filter state to survive USB packet boundaries.</em></p>

<blockquote>
  <p><strong>TL;DR</strong> — The Airspy R2/Mini stream bare real ADC samples, not IQ, so the
host synthesizes complex baseband: a leaky DC blocker, a multiplier-free Fs/4
mix, and a half-band Hilbert pair. The headline bug (#454): the converter must
carry filter state across USB packets, or you get ~78° quadrature imbalance and
no image rejection — fixed by threading one stateful converter through the
whole stream. The HF+ needs none of it: it delivers native int16 IQ.</p>
</blockquote>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li>Why the <a href="/reference/airspy/">Airspy</a> R2/Mini stream
<strong>bare real samples</strong> (uint16, 12-bit, DC at 2048) at <strong>twice</strong> the IQ rate,
unlike RTL-SDR’s interleaved IQ.</li>
  <li>The four-stage <strong>real-to-complex pipeline</strong> in <code class="language-plaintext highlighter-rouge">iqconverter.go</code>: leaky-HPF DC
removal → multiplier-free <strong>Fs/4</strong> mix → polyphase <strong>half-band</strong> split →
decimate by two.</li>
  <li>Why the converter must be <strong>stateful across packets</strong>, and the bug (#454) we
hit getting that filter memory right.</li>
  <li>The Airspy <strong>HF+</strong>, which delivers native interleaved <strong>int16 IQ</strong> and needs
none of this — plus the shared-VID:PID-disambiguated-by-product-string wrinkle.</li>
</ul>

<h2 id="what-a-real-sampling-front-end-is">What a real-sampling front end is</h2>

<p>Most cheap SDRs hand the host quadrature IQ: two streams, in-phase and
quadrature, already mixed to baseband by the tuner. RTL-SDR does this (unsigned
8-bit I,Q); the HackRF does this (signed 8-bit I,Q); the Airspy HF+ does this
(signed 16-bit I,Q). You decode a pair of integers into one complex sample and
you are done.</p>

<p>The Airspy R2 and Airspy Mini do not. They are <strong>real-sampling</strong> receivers: a
single fast ADC digitizes a real intermediate-frequency signal, and the
firmware streams the bare ADC output — unpacked little-endian <code class="language-plaintext highlighter-rouge">uint16</code>, 12-bit
resolution, DC sitting at 2048 — at <strong>twice</strong> the configured IQ rate. There is
no quadrature channel coming over USB. Producing complex baseband is a host-side
job, and it is the same job an analog superheterodyne would hand to a pair of
mixers and a phasing network: take a real signal, reject its mirror image, and
land it at zero IF as <code class="language-plaintext highlighter-rouge">I + jQ</code>.</p>

<p>That <code class="language-plaintext highlighter-rouge">2×</code> is not incidental. A real signal sampled at <code class="language-plaintext highlighter-rouge">Fs</code> carries usable
bandwidth up to <code class="language-plaintext highlighter-rouge">Fs/2</code>; a complex signal at <code class="language-plaintext highlighter-rouge">Fs/2</code> carries the same bandwidth
across <code class="language-plaintext highlighter-rouge">±Fs/4</code>. So <code class="language-plaintext highlighter-rouge">N</code> real input samples become <code class="language-plaintext highlighter-rouge">N/2</code> complex outputs at half
the rate — which is exactly why, in the driver, a requested IQ rate of 3 MHz
selects the <strong>6 MSPS</strong> device mode. We covered that rate-doubling foot-gun in
the <a href="/blog/deep-dives/rf-front-end-02-the-device-contract/">Part 2</a>
device contract; here we build the converter that justifies it.</p>

<h2 id="how-gophertrunk-implements-it-in-go">How GopherTrunk implements it in Go</h2>

<p><strong>The whole conversion lives in <code class="language-plaintext highlighter-rouge">internal/sdr/airspy/iqconverter.go</code>, driven from
one method per USB packet. Its job, top to bottom: decode <code class="language-plaintext highlighter-rouge">uint16</code> reals,
remove DC, mix by <code class="language-plaintext highlighter-rouge">Fs/4</code>, run the half-band pair, emit <code class="language-plaintext highlighter-rouge">complex64</code>.</strong></p>

<h3 id="stage-1--leaky-hpf-dc-removal">Stage 1 — leaky-HPF DC removal</h3>

<p>The ADC parks DC at 2048 and the front end adds its own slow bias drift. Left
in, that DC becomes a fat spike at the center of every spectrum. We strip it
with a one-pole leaky high-pass — a running estimate of the mean that we
subtract before anything else touches the sample:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/airspy/iqconverter.go</span>
<span class="n">x</span> <span class="o">:=</span> <span class="p">(</span><span class="kt">float32</span><span class="p">(</span><span class="n">binary</span><span class="o">.</span><span class="n">LittleEndian</span><span class="o">.</span><span class="n">Uint16</span><span class="p">(</span><span class="n">buf</span><span class="p">[</span><span class="m">2</span><span class="o">*</span><span class="n">i</span><span class="o">:</span><span class="p">]))</span> <span class="o">-</span> <span class="n">dcBias</span><span class="p">)</span> <span class="o">/</span> <span class="n">dcFull</span>

<span class="c">// Leaky DC blocker: removes the residual ADC bias before the mix.</span>
<span class="n">x</span> <span class="o">-=</span> <span class="n">c</span><span class="o">.</span><span class="n">avg</span>
<span class="n">c</span><span class="o">.</span><span class="n">avg</span> <span class="o">+=</span> <span class="n">dcLeak</span> <span class="o">*</span> <span class="n">x</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">dcBias</code> is 2048, <code class="language-plaintext highlighter-rouge">dcFull</code> normalizes to roughly <code class="language-plaintext highlighter-rouge">[-1, 1)</code>, and <code class="language-plaintext highlighter-rouge">dcLeak</code> is
<code class="language-plaintext highlighter-rouge">0.01</code> — a slow tracker that follows bias drift without eating low-frequency
signal. The accumulator <code class="language-plaintext highlighter-rouge">c.avg</code> is <strong>state</strong>: it carries from one packet to the
next so the DC estimate doesn’t reset (and re-spike) every 6 ms.</p>

<h3 id="stage-2--the-fs4-mix-for-free">Stage 2 — the Fs/4 mix, for free</h3>

<p>To get to baseband we need to multiply the real stream by a complex sinusoid.
Pick that sinusoid at exactly <code class="language-plaintext highlighter-rouge">Fs/4</code> and the multiplications collapse into
nothing: <code class="language-plaintext highlighter-rouge">e^{-j(π/2)n}</code> walks the unit circle in steps of 90°, so its samples
are just <code class="language-plaintext highlighter-rouge">1, -j, -1, +j, …</code>. No multiplies, no sine table — only a sign flip and
a branch on which polyphase lane the sample falls in. That is the <code class="language-plaintext highlighter-rouge">phase</code>
counter, cycling <code class="language-plaintext highlighter-rouge">0..3</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/airspy/iqconverter.go</span>
<span class="k">switch</span> <span class="n">c</span><span class="o">.</span><span class="n">phase</span> <span class="p">{</span>
<span class="k">case</span> <span class="m">0</span><span class="p">,</span> <span class="m">2</span><span class="o">:</span>
    <span class="c">// In-phase branch. The Fs/4 mix alternates the sign of the</span>
    <span class="c">// even samples; push into the FIR and recompute its output.</span>
    <span class="k">if</span> <span class="n">c</span><span class="o">.</span><span class="n">phase</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="n">x</span> <span class="o">=</span> <span class="o">-</span><span class="n">x</span>
    <span class="p">}</span>
    <span class="n">c</span><span class="o">.</span><span class="n">pushI</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
    <span class="n">c</span><span class="o">.</span><span class="n">lastI</span> <span class="o">=</span> <span class="n">c</span><span class="o">.</span><span class="n">firI</span><span class="p">()</span>
<span class="k">case</span> <span class="m">1</span><span class="p">,</span> <span class="m">3</span><span class="o">:</span>
    <span class="c">// Quadrature branch. The mix alternates sign and folds in the</span>
    <span class="c">// 0.5 half-band centre tap; the matched delay aligns it with</span>
    <span class="c">// the in-phase FIR's group delay, then we emit one sample.</span>
    <span class="n">q</span> <span class="o">:=</span> <span class="m">0.5</span> <span class="o">*</span> <span class="n">x</span>
    <span class="k">if</span> <span class="n">c</span><span class="o">.</span><span class="n">phase</span> <span class="o">==</span> <span class="m">1</span> <span class="p">{</span>
        <span class="n">q</span> <span class="o">=</span> <span class="o">-</span><span class="n">q</span>
    <span class="p">}</span>
    <span class="c">// ...delay line, then emit complex(c.lastI, qOut)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">phase</code> is also state — it persists across packets so the <code class="language-plaintext highlighter-rouge">(-j)^n</code> rotation
never glitches at a buffer boundary.</p>

<h3 id="stage-3--the-half-band-hilbert-pair">Stage 3 — the half-band Hilbert pair</h3>

<p>Mixing by <code class="language-plaintext highlighter-rouge">Fs/4</code> puts the wanted signal at baseband but also folds the mirror
image right on top of it. Rejecting that image is the whole game, and it falls
out of a <strong>47-tap half-band</strong> filter, split into its two polyphase lanes:</p>

<ul>
  <li><strong>In-phase lane</strong> (even samples) runs a symmetric low-pass FIR. A half-band’s
odd taps are zero except the center, so only <strong>24 non-trivial taps</strong>
participate — half the multiplies of a general FIR for the same response.</li>
  <li><strong>Quadrature lane</strong> (odd samples) is a <strong>matched integer delay</strong>. The half-band
center tap is exactly <code class="language-plaintext highlighter-rouge">0.5</code>, and rather than spend a tap on it we fold that
<code class="language-plaintext highlighter-rouge">0.5</code> straight into the <code class="language-plaintext highlighter-rouge">Fs/4</code> mix (<code class="language-plaintext highlighter-rouge">q := 0.5 * x</code> above). The delay line just
lines Q up with the FIR’s group delay.</li>
</ul>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/airspy/iqconverter.go</span>
<span class="c">// firI evaluates the symmetric half-band FIR over the current window, with</span>
<span class="c">// hbKernel[0] weighting the newest sample and hbKernel[hbTaps-1] the oldest.</span>
<span class="k">func</span> <span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">iqConverter</span><span class="p">)</span> <span class="n">firI</span><span class="p">()</span> <span class="kt">float32</span> <span class="p">{</span>
    <span class="k">var</span> <span class="n">acc</span> <span class="kt">float32</span>
    <span class="n">idx</span> <span class="o">:=</span> <span class="n">c</span><span class="o">.</span><span class="n">iwinPos</span> <span class="o">-</span> <span class="m">1</span>
    <span class="k">if</span> <span class="n">idx</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="n">idx</span> <span class="o">+=</span> <span class="n">hbTaps</span>
    <span class="p">}</span>
    <span class="k">for</span> <span class="n">j</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="n">hbTaps</span><span class="p">;</span> <span class="n">j</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">acc</span> <span class="o">+=</span> <span class="n">hbKernel</span><span class="p">[</span><span class="n">j</span><span class="p">]</span> <span class="o">*</span> <span class="n">c</span><span class="o">.</span><span class="n">iwin</span><span class="p">[</span><span class="n">idx</span><span class="p">]</span>
        <span class="k">if</span> <span class="n">idx</span><span class="o">--</span><span class="p">;</span> <span class="n">idx</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
            <span class="n">idx</span> <span class="o">+=</span> <span class="n">hbTaps</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">acc</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Both lanes carry state — the in-phase <code class="language-plaintext highlighter-rouge">iwin</code> FIR window and the quadrature
<code class="language-plaintext highlighter-rouge">qline</code> delay line are circular buffers on the <code class="language-plaintext highlighter-rouge">iqConverter</code> struct:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/airspy/iqconverter.go  (shape)</span>
<span class="k">type</span> <span class="n">iqConverter</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">avg</span>     <span class="kt">float32</span>         <span class="c">// leaky DC-blocker accumulator</span>
    <span class="n">phase</span>   <span class="kt">int</span>             <span class="c">// Fs/4 mix phase, 0..3, persists across packets</span>
    <span class="n">iwin</span>    <span class="p">[</span><span class="n">hbTaps</span><span class="p">]</span><span class="kt">float32</span> <span class="c">// in-phase FIR window</span>
    <span class="n">iwinPos</span> <span class="kt">int</span>
    <span class="n">lastI</span>   <span class="kt">float32</span>         <span class="c">// in-phase output awaiting its paired quadrature</span>
    <span class="n">qline</span>   <span class="p">[</span><span class="n">hbHalf</span><span class="p">]</span><span class="kt">float32</span> <span class="c">// quadrature delay line</span>
    <span class="n">qpos</span>    <span class="kt">int</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="stage-4--decimate-and-pair-up">Stage 4 — decimate, and pair up</h3>

<p>Because we only ever emit on the odd (quadrature) phases, and only consume the
in-phase FIR’s latest output (<code class="language-plaintext highlighter-rouge">c.lastI</code>) when we do, the decimation by two is
implicit: every fourth real sample produces no output, every pair of lanes
collapses to one <code class="language-plaintext highlighter-rouge">complex64</code>. The driver allocates <code class="language-plaintext highlighter-rouge">len(buf)/4</code> outputs and
returns them:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/airspy/iqconverter.go</span>
<span class="k">func</span> <span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">iqConverter</span><span class="p">)</span> <span class="n">processRaw</span><span class="p">(</span><span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">[]</span><span class="kt">complex64</span> <span class="p">{</span>
    <span class="n">nReal</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="o">/</span> <span class="m">2</span>
    <span class="n">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">complex64</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">nReal</span><span class="o">/</span><span class="m">2</span><span class="o">+</span><span class="m">1</span><span class="p">)</span>
    <span class="c">// ...per-sample pipeline above...</span>
    <span class="k">return</span> <span class="n">out</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One bulk-IN payload of <code class="language-plaintext highlighter-rouge">N</code> real bytes-as-uint16 yields <code class="language-plaintext highlighter-rouge">N/2</code> real samples and
<code class="language-plaintext highlighter-rouge">N/4</code> complex samples, at exactly the configured IQ rate.</p>

<h2 id="the-problem-we-hit-a-half-band-hilbert-that-joined-packets-seamlessly-454">The problem we hit: a half-band Hilbert that joined packets seamlessly (#454)</h2>

<p><strong>Symptom.</strong> When the Airspy R2 first came up, nothing locked. The constellation
showed a massive quadrature imbalance — about <strong>78°</strong> off from the 90° it should
be — and essentially <strong>no image rejection</strong> (~3 dB). Every downstream decoder
mistuned; on a quiet band all that survived was the DC spike. The device
streamed bytes fine, but the signal was garbage.</p>

<p><strong>Root cause.</strong> Two compounding mistakes. First, the driver originally treated
the receiver’s <strong>real</strong> ADC stream as if it were interleaved I/Q — pairing
adjacent real samples into <code class="language-plaintext highlighter-rouge">complex(I, Q)</code>. That is simply the wrong model for a
real-sampling front end: there is no Q channel on the wire, so the synthetic Q
was just a delayed copy of I, hence the ~78° imbalance and no image rejection.
Second, once we built the proper Fs/4-plus-half-band converter, the filter and
mix had to be <strong>stateful across USB packets</strong>. A naive implementation that reset
the FIR window, the DC accumulator, and the <code class="language-plaintext highlighter-rouge">phase</code> counter at the start of each
<code class="language-plaintext highlighter-rouge">processRaw</code> call produced a discontinuity every 6 ms — a click train at the
packet rate that smeared the spectrum.</p>

<p><strong>The Go fix.</strong> All converter memory lives on the <code class="language-plaintext highlighter-rouge">iqConverter</code> struct, and a
single converter instance threads through an entire stream. The packet handler
just calls <code class="language-plaintext highlighter-rouge">processRaw</code> on the <em>same</em> converter, packet after packet:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/airspy/airspy.go</span>
<span class="c">// Fresh real-to-IQ converter per stream so filter memory never carries</span>
<span class="c">// over from a previous session.</span>
<span class="n">d</span><span class="o">.</span><span class="n">cnv</span> <span class="o">=</span> <span class="n">newIQConverter</span><span class="p">()</span>

<span class="n">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="p">[]</span><span class="kt">complex64</span><span class="p">,</span> <span class="m">8</span><span class="p">)</span>
<span class="n">onPacket</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">samples</span> <span class="o">:=</span> <span class="n">d</span><span class="o">.</span><span class="n">cnv</span><span class="o">.</span><span class="n">processRaw</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span>
    <span class="k">select</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">out</span> <span class="o">&lt;-</span> <span class="n">samples</span><span class="o">:</span>
    <span class="k">case</span> <span class="o">&lt;-</span><span class="n">ctx</span><span class="o">.</span><span class="n">Done</span><span class="p">()</span><span class="o">:</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The converter is created <strong>per stream</strong> (so a retune starts from silent filter
memory, phase 0) but is <strong>shared across every packet within that stream</strong> (so the
FIR window, DC tracker, and mix phase flow continuously across boundaries). The
zero value of <code class="language-plaintext highlighter-rouge">iqConverter</code> is a valid reset state, which is what makes
<code class="language-plaintext highlighter-rouge">newIQConverter()</code> a one-liner. After the fix, image rejection came back to
~<strong>70 dB</strong>, matching what libairspy’s own IQ modes deliver.</p>

<h2 id="the-design-principle-dsp-in-the-driver-from-first-principles">The design principle: DSP in the driver, from first principles</h2>

<p>The Airspy driver is the first place in GopherTrunk where the driver does real
<em>signal processing</em>, not just byte shuffling. <strong>The principle: when the hardware
hands you something other than the abstraction the rest of the system wants
(<code class="language-plaintext highlighter-rouge">[]complex64</code>), the driver pays the cost of bridging the gap — and it does it
from first principles, not by linking someone else’s DSP library.</strong></p>

<h3 id="how-that-principle-shaped-the-go-code">How that principle shaped the Go code</h3>

<ul>
  <li><strong>The abstraction boundary holds.</strong> Upstream code never learns the Airspy is a
real-sampling device. <code class="language-plaintext highlighter-rouge">StreamIQ</code> returns the same <code class="language-plaintext highlighter-rouge">&lt;-chan []complex64</code> every
other driver returns; the Fs/4 mix and half-band pair are an implementation
detail behind it.</li>
  <li><strong>State is explicit and owned.</strong> The DSP carries memory — DC accumulator, mix
phase, FIR window, delay line — and all of it is fields on one struct with a
clear lifetime (one per stream). There are no package globals and no hidden
static buffers, so two Airspys streaming at once can’t corrupt each other.</li>
  <li><strong>Cheap by construction, not by optimization.</strong> The Fs/4 choice turns the mixer
into a sign flip; the half-band structure zeroes half the taps and folds the
center tap into the mix. The fast path is fast because the <em>math</em> was chosen
well, not because the loop was hand-tuned.</li>
  <li><strong>Correctness is testable without hardware.</strong> Because <code class="language-plaintext highlighter-rouge">processRaw</code> is a pure
function of <code class="language-plaintext highlighter-rouge">(converter state, bytes)</code>, the in-package tests feed synthesized
real tones through it and assert on image rejection — no Airspy required.</li>
</ul>

<h2 id="folding-in-the-airspy-hf">Folding in the Airspy HF+</h2>

<p>The Airspy <strong>HF+</strong> family (HF+, HF+ Discovery, HF+ Dual Port) is a sibling, not a
twin, and it is instructive precisely because it makes the opposite choice. It
covers <strong>9 kHz–31 MHz HF plus 60–260 MHz VHF</strong>, and it delivers <strong>native
interleaved int16 IQ</strong> — so there is no real-to-complex stage at all. Decoding is
the boring two-integers-to-one-complex loop:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/airspyhf/airspyhf.go</span>
<span class="k">func</span> <span class="n">decodeInt16IQ</span><span class="p">(</span><span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">[]</span><span class="kt">complex64</span> <span class="p">{</span>
    <span class="n">n</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="o">/</span> <span class="m">4</span>
    <span class="n">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">complex64</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">iv</span> <span class="o">:=</span> <span class="kt">int16</span><span class="p">(</span><span class="n">binary</span><span class="o">.</span><span class="n">LittleEndian</span><span class="o">.</span><span class="n">Uint16</span><span class="p">(</span><span class="n">buf</span><span class="p">[</span><span class="m">4</span><span class="o">*</span><span class="n">i</span><span class="o">:</span><span class="p">]))</span>
        <span class="n">qv</span> <span class="o">:=</span> <span class="kt">int16</span><span class="p">(</span><span class="n">binary</span><span class="o">.</span><span class="n">LittleEndian</span><span class="o">.</span><span class="n">Uint16</span><span class="p">(</span><span class="n">buf</span><span class="p">[</span><span class="m">4</span><span class="o">*</span><span class="n">i</span><span class="o">+</span><span class="m">2</span><span class="o">:</span><span class="p">]))</span>
        <span class="n">out</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="nb">complex</span><span class="p">(</span><span class="kt">float32</span><span class="p">(</span><span class="n">iv</span><span class="p">)</span><span class="o">/</span><span class="m">32768</span><span class="p">,</span> <span class="kt">float32</span><span class="p">(</span><span class="n">qv</span><span class="p">)</span><span class="o">/</span><span class="m">32768</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">out</span>
<span class="p">}</span>
</code></pre></div></div>

<p>No converter, no filter state, no <code class="language-plaintext highlighter-rouge">2×</code> rate trick. The HF+ proves the point that
the R2/Mini converter is <em>device-specific</em>: the complex-baseband abstraction is
the same, but how much work the driver does to honor it depends entirely on what
the silicon streams.</p>

<p>The HF+ has two wrinkles worth calling out. First, <strong>all three variants share one
VID:PID</strong> (<code class="language-plaintext highlighter-rouge">0x03eb:0x800c</code>); the USB descriptor’s <strong>Product string</strong> is the only
observable model identifier, so the driver disambiguates on it:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/airspyhf/airspyhf.go</span>
<span class="k">func</span> <span class="n">variantName</span><span class="p">(</span><span class="n">product</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
    <span class="n">p</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">ToUpper</span><span class="p">(</span><span class="n">product</span><span class="p">)</span>
    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="s">"DISCOVERY"</span><span class="p">)</span><span class="o">:</span>
        <span class="k">return</span> <span class="s">"Airspy HF+ Discovery"</span>
    <span class="k">case</span> <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="s">"DUAL"</span><span class="p">)</span><span class="o">:</span>
        <span class="k">return</span> <span class="s">"Airspy HF+ Dual Port"</span>
    <span class="k">default</span><span class="o">:</span>
        <span class="k">return</span> <span class="s">"Airspy HF+"</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Second, “gain” on the HF+ is mostly <strong>attenuation reduction</strong>. The front end has
a fixed conversion stage; what the operator controls is HF_AGC (firmware-managed
LNA + mixer), HF_ATT (a 0–48 dB attenuator in 6 dB steps), and HF_LNA (a fixed
<code class="language-plaintext highlighter-rouge">+6</code> dB preamp). A negative tenth-dB target enables AGC and zeroes the rest;
positive values disable AGC, compute an attenuator step, and switch the LNA in
once attenuation reduction alone has been spent:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/airspyhf/airspyhf.go  (shape)</span>
<span class="n">att</span> <span class="o">:=</span> <span class="n">tenthDB</span> <span class="o">/</span> <span class="n">hfATTStepTenthDB</span>      <span class="c">// 6 dB steps, clamp 0..8</span>
<span class="n">remaining</span> <span class="o">:=</span> <span class="n">tenthDB</span> <span class="o">-</span> <span class="n">att</span><span class="o">*</span><span class="n">hfATTStepTenthDB</span>
<span class="n">lnaOn</span> <span class="o">:=</span> <span class="n">remaining</span> <span class="o">&gt;=</span> <span class="n">hfLNAGainTenthDB</span> <span class="c">// +6 dB preamp</span>
</code></pre></div></div>

<p>Note also that the HF+ vendor opcodes are <strong>not</strong> the R2/Mini’s — sibling
devices, sibling protocols (<code class="language-plaintext highlighter-rouge">SET_SAMPLERATE</code> is 4 here, 12 on the R2). The two
drivers live in separate packages for exactly that reason.</p>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>We now have three of the four wire formats covered: RTL-SDR’s unsigned 8-bit IQ,
the Airspy R2/Mini’s synthesized complex baseband, and the HF+’s native int16 IQ.
The simplest of all — HackRF’s signed 8-bit interleaved IQ, plus the
identity-by-board-ID dance at open time — is
<a href="/blog/deep-dives/rf-front-end-11-hackrf-one/">Part 11</a>.
After that, Part 12 zooms back out to the pool and USB hotplug watchdog that
keeps all of these streaming through unplug-and-replug.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Why not just pair adjacent real samples as I and Q?</strong>
Because there is no Q on the wire. The Airspy R2/Mini stream a single real ADC
channel; pairing reals fabricates a Q that’s just a delayed I, which is what gave
us the ~78° imbalance and ~3 dB image rejection in #454. You have to <em>synthesize</em>
quadrature with a Hilbert-pair filter.</p>

<p><strong>Why a half-band filter specifically?</strong>
Half-band filters have their odd taps zeroed (except the center) and a center tap
of exactly 0.5. That halves the multiplies in the in-phase lane and lets us fold
the center tap into the free Fs/4 mix — the structure does double duty as the
anti-alias filter for the decimate-by-two.</p>

<p><strong>Why is the converter created per stream but shared per packet?</strong>
Per stream so a retune starts from clean filter memory and can’t inherit a stale
DC estimate. Per packet so the FIR window, DC accumulator, and mix phase flow
continuously and packets join without a click at the boundary.</p>

<p><strong>Does the HF+ ever need the converter?</strong>
No. The HF+ delivers complex IQ natively (int16 I,Q), so its driver just
normalizes integers to <code class="language-plaintext highlighter-rouge">[-1, 1]</code>. The real-to-complex converter is unique to the
R2/Mini’s real-sampling front end.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 10 of 14</strong> · ←
<a href="/blog/deep-dives/rf-front-end-09-rtlsdr-streaming-gc-churn/">Part 9</a>
· Next →
<a href="/blog/deep-dives/rf-front-end-11-hackrf-one/">Part 11: HackRF One — signed 8-bit IQ end to end</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="sdr" /><category term="go" /><category term="airspy" /><category term="dsp" /><category term="software-design" /><summary type="html"><![CDATA[How GopherTrunk turns the Airspy R2/Mini's bare real ADC stream into complex baseband entirely on the host — a leaky DC blocker, a multiplier-free Fs/4 mix, and a stateful half-band Hilbert pair — then folds in the Airspy HF+, whose native int16 IQ needs none of it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Build in the Open, Part 9: Documentation Done Right — What Lives Where</title><link href="https://gophertrunk.org/blog/tutorials/build-in-the-open-09-documentation-done-right/" rel="alternate" type="text/html" title="Build in the Open, Part 9: Documentation Done Right — What Lives Where" /><published>2026-06-26T00:00:00-05:00</published><updated>2026-06-26T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/tutorials/build-in-the-open-09-documentation-done-right</id><content type="html" xml:base="https://gophertrunk.org/blog/tutorials/build-in-the-open-09-documentation-done-right/"><![CDATA[<blockquote>
  <p><strong>TL;DR:</strong> Documentation isn’t one thing — it’s several kinds with different
jobs and different homes. The README is your front door; standard files like
CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, CHANGELOG, and LICENSE each answer one
recurring question; everything deeper lives in an in-repo <code class="language-plaintext highlighter-rouge">/docs</code> folder. Use
the <strong>Diátaxis</strong> framework — tutorials, how-to guides, reference, explanation —
to decide what kind of doc you’re writing and where it belongs. Keep docs next
to the code so they version and rot together, and write each piece for one
specific reader.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li>Docs come in types; put each type where readers expect to find it.</li>
  <li>The README is the front door — what, why, and a fast path to “it works.”</li>
  <li>Diátaxis splits docs into tutorials, how-to, reference, and explanation.</li>
  <li>Keep docs in the repo, next to the code, so they version with it and rot less.</li>
  <li>Every doc has one reader and one job — write to that, not to everyone at once.</li>
</ul>

<p><em>This is Part 9 of <strong>Build in the Open</strong>, a 14-part series on taking a software
project from a blank idea to a public release using GitHub and Claude Code. Each
post teaches a technique you can apply to any project in any language, then shows
how the open-source <a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a>
scanner does it for real.</em></p>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>The standard files</strong> every repo should have — and who each is for.</li>
  <li><strong>What goes in the README</strong> versus everywhere else.</li>
  <li><strong>The Diátaxis framework</strong> for organizing the deeper <code class="language-plaintext highlighter-rouge">/docs</code>.</li>
  <li><strong>Why docs belong next to the code</strong>, and how to keep them from rotting.</li>
  <li><strong>How GopherTrunk does it</strong>, as a concrete example you can copy.</li>
</ul>

<h2 id="the-standard-files-and-who-theyre-for">The standard files and who they’re for</h2>

<p>A handful of files have become conventions because each answers a question
someone will inevitably ask. GitHub even surfaces several of them in its UI:</p>

<ul>
  <li><strong>README</strong> — <em>“What is this and how do I start?”</em> The front door. Everyone
reads it first.</li>
  <li><strong>CONTRIBUTING</strong> — <em>“How do I help?”</em> Build, test, branch, and PR conventions
for would-be contributors.</li>
  <li><strong>SECURITY</strong> — <em>“I found a vulnerability, now what?”</em> The private disclosure
process, so bugs don’t get reported publicly.</li>
  <li><strong>CODE_OF_CONDUCT</strong> — <em>“What behaviour is expected here?”</em> The community
ground rules.</li>
  <li><strong>CHANGELOG</strong> — <em>“What changed between versions?”</em> For users deciding whether
to upgrade (more on this in Part 11).</li>
  <li><strong>LICENSE</strong> — <em>“What am I allowed to do with this?”</em> The legal terms. Without
it, the default is “no rights granted.”</li>
</ul>

<p>You don’t need all of them on day one, but knowing the slot each fills means you
never have to wonder <em>where</em> a given piece of information should go.</p>

<h2 id="what-belongs-in-the-readme">What belongs in the README?</h2>

<p>The README is the highest-traffic page you will ever write, so it earns the most
care. It should answer, fast and in order:</p>

<ol>
  <li><strong>What is this?</strong> One or two sentences, no jargon.</li>
  <li><strong>Why does it exist / why would I use it?</strong> The problem it solves.</li>
  <li><strong>How do I get to “it works”?</strong> Install or run, in the fewest steps possible.</li>
  <li><strong>Where do I go next?</strong> Links out to the deeper docs — <em>not</em> the docs
themselves.</li>
</ol>

<p>The README’s job is to get someone oriented and then <em>hand them off</em>. The
moment it tries to be the complete manual, it becomes too long to be the front
door. Link out to <code class="language-plaintext highlighter-rouge">/docs</code> for everything past “hello world.”</p>

<h2 id="the-diátaxis-framework-four-kinds-of-docs">The Diátaxis framework: four kinds of docs</h2>

<p>Past the README, documentation gets disorganized because people mix four
fundamentally different things into one page. <strong>Diátaxis</strong> is a widely used
framework that names them and keeps them apart:</p>

<ul>
  <li><strong>Tutorials</strong> — <em>learning-oriented.</em> A guided lesson for a newcomer: “build
your first X.” The reader is a student; success is that they finish and feel
capable.</li>
  <li><strong>How-to guides</strong> — <em>task-oriented.</em> Steps to accomplish a specific goal the
reader already has: “how to configure TLS.” The reader knows what they want.</li>
  <li><strong>Reference</strong> — <em>information-oriented.</em> Dry, complete, accurate descriptions:
every flag, every field, every endpoint. The reader is looking something up.</li>
  <li><strong>Explanation</strong> — <em>understanding-oriented.</em> The why and the how-it-works:
architecture, design decisions, background. The reader wants the mental model.</li>
</ul>

<p>The power of Diátaxis is diagnostic: when a doc feels muddled, it’s usually
trying to be two of these at once. A tutorial bloated with reference detail
loses the beginner; a reference padded with tutorial hand-holding annoys the
expert. Split them, and each gets clearer.</p>

<h2 id="keep-docs-next-to-the-code">Keep docs next to the code</h2>

<p>Wherever it’s reasonable, keep documentation <strong>in the repository</strong>, in a <code class="language-plaintext highlighter-rouge">/docs</code>
folder, versioned alongside the code:</p>

<ul>
  <li><strong>It versions with the code.</strong> A doc change rides in the same commit and PR as
the behaviour change, so the docs for v2 describe v2.</li>
  <li><strong>It’s reviewable.</strong> Doc changes go through the same review as code changes —
reviewers can flag a stale instruction the way they’d flag a bug.</li>
  <li><strong>It rots less.</strong> Docs in a separate wiki or external site drift, because
nobody updates them when they change the code. Docs in the diff are right
there, demanding to be updated.</li>
</ul>

<p>The discipline that keeps docs alive is a simple rule in your CONTRIBUTING file:
<em>if your change alters user-visible behaviour, update the docs in the same PR.</em>
That turns documentation from a someday chore into part of “done.”</p>

<h2 id="how-gophertrunk-does-it">How GopherTrunk does it</h2>

<p><a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a> treats docs as a
first-class part of the repo, and the layout maps cleanly onto everything above:</p>

<ul>
  <li><strong>The README is the front door — and literally so.</strong> Its
<a href="/">Pages landing page</a> is <em>synthesized from the README
at build time</em> (see Part 10), so the front door and the homepage never drift
apart. The README links out to the deeper docs rather than absorbing them.</li>
  <li><strong>The standard files are all present.</strong> <code class="language-plaintext highlighter-rouge">CONTRIBUTING.md</code> documents the build,
test, branch, and PR conventions (including the table of <code class="language-plaintext highlighter-rouge">make</code> targets and the
opt-in hardware-test env vars); <code class="language-plaintext highlighter-rouge">SECURITY.md</code> carries the threat model and
private-disclosure process; <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> follows Keep a Changelog with an
<code class="language-plaintext highlighter-rouge">## [Unreleased]</code> section; <code class="language-plaintext highlighter-rouge">LICENSE</code> sets the terms.</li>
  <li><strong>The <code class="language-plaintext highlighter-rouge">docs/</code> folder is large and structured</strong> — around <strong>50 markdown files</strong>.
It’s clearly organized along Diátaxis lines:
    <ul>
      <li><strong>Tutorials / learning:</strong> a <strong>30-lesson learning path</strong> under <code class="language-plaintext highlighter-rouge">docs/learn/rf-sdr/</code>
(from <code class="language-plaintext highlighter-rouge">what-is-sdr.md</code> through <code class="language-plaintext highlighter-rouge">clock-recovery.md</code>, <code class="language-plaintext highlighter-rouge">digital-voice.md</code>, and a
<code class="language-plaintext highlighter-rouge">glossary.md</code>) — the learning-oriented tier.</li>
      <li><strong>Reference:</strong> a <strong>~180-entry Field Guide</strong> under
<code class="language-plaintext highlighter-rouge">docs/reference/</code> — the information-oriented tier you look things up in.</li>
      <li><strong>How-to guides:</strong> install guides per platform (<code class="language-plaintext highlighter-rouge">install-linux.md</code>,
<code class="language-plaintext highlighter-rouge">install-macos.md</code>, <code class="language-plaintext highlighter-rouge">install-windows.md</code>), getting-started pages, and
operational guides (<code class="language-plaintext highlighter-rouge">guide-basics.md</code>, <code class="language-plaintext highlighter-rouge">guide-intermediate.md</code>,
<code class="language-plaintext highlighter-rouge">guide-advanced.md</code>, <code class="language-plaintext highlighter-rouge">hardening.md</code>).</li>
      <li><strong>Explanation:</strong> <code class="language-plaintext highlighter-rouge">architecture.md</code> for the design, plus living-status docs
<code class="language-plaintext highlighter-rouge">status.md</code> and <code class="language-plaintext highlighter-rouge">roadmap.md</code> for where the project is and where it’s going.</li>
    </ul>
  </li>
  <li><strong>Everything is reachable from the README</strong>, which links out to install, the
guides, the learning path, the reference, and the status/roadmap docs — the
front door pointing at every room in the house.</li>
</ul>

<p>You don’t need 50 files to apply this. Even a five-file project benefits from a
README that hands off, the standard community files, and a <code class="language-plaintext highlighter-rouge">/docs</code> folder where
each page knows whether it’s a tutorial, a how-to, reference, or explanation.</p>

<h2 id="faq">FAQ</h2>

<p><strong>What’s the minimum documentation a project needs?</strong>
A README that says what the project is and how to run it, and a LICENSE so people
know what they’re allowed to do. Add CONTRIBUTING and SECURITY the moment you
invite outside contributors, and a CHANGELOG when you start cutting releases.</p>

<p><strong>What is the Diátaxis framework?</strong>
A way to organize documentation into four distinct types — tutorials (learning),
how-to guides (tasks), reference (lookup), and explanation (understanding) — each
written for a different reader need. Its main value is spotting when one doc is
trying to do two of those jobs and should be split.</p>

<p><strong>Should documentation live in the repo or on a separate site?</strong>
Keep the source in the repo, next to the code, so docs version and get reviewed
alongside changes. You can still <em>publish</em> them to a website (Part 10 covers
that) — but the source of truth belongs in the diff, where it’s least likely to
rot.</p>

<p><strong>How do I keep docs from going stale?</strong>
Make updating them part of “done.” A line in CONTRIBUTING — “update the docs in
the same PR as any user-visible change” — plus reviewers who enforce it does more
than any tooling. Docs that travel with the code stay closer to the truth.</p>

<p><strong>Where does the README stop and <code class="language-plaintext highlighter-rouge">/docs</code> begin?</strong>
The README orients and hands off: what, why, get-it-running, and links onward.
Anything past the first successful run — full configuration, guides, reference,
architecture — belongs in <code class="language-plaintext highlighter-rouge">/docs</code>, so the front door stays short enough to be a
front door.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 9 of 14</strong> · ←
<a href="/blog/tutorials/build-in-the-open-08-testing-how-to-write-tests/">Part 8</a>
· Next →
<a href="/blog/tutorials/build-in-the-open-10-websites-support-pages-github-pages/">Part 10: Websites, Support Pages &amp; GitHub Pages</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="tutorials" /><category term="github" /><category term="claude-code" /><category term="documentation" /><category term="writing" /><category term="open-source" /><summary type="html"><![CDATA[How to organize project documentation — README, CONTRIBUTING, SECURITY, CHANGELOG, in-repo docs — using the Diátaxis framework so docs are findable and don't rot.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RF Front End, Part 9: RTL-SDR III — IQ Streaming &amp;amp; the GC-Churn Bug</title><link href="https://gophertrunk.org/blog/deep-dives/rf-front-end-09-rtlsdr-streaming-gc-churn/" rel="alternate" type="text/html" title="RF Front End, Part 9: RTL-SDR III — IQ Streaming &amp;amp; the GC-Churn Bug" /><published>2026-06-26T00:00:00-05:00</published><updated>2026-06-26T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/rf-front-end-09-rtlsdr-streaming-gc-churn</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/rf-front-end-09-rtlsdr-streaming-gc-churn/"><![CDATA[<p><em>Part 9 of <strong>RF Front End</strong>. The RTL2832U is initialized
(<a href="/blog/deep-dives/rf-front-end-07-rtlsdr-rtl2832u-bringup/">Part 7</a>)
and the tuner is locked
(<a href="/blog/deep-dives/rf-front-end-08-rtlsdr-r82xx-blog-v4/">Part 8</a>).
Now we have to keep a 2.4 MS/s flood of samples moving without dropping any — and
this is where Go’s garbage collector, of all things, became the enemy.</em></p>

<blockquote>
  <p><strong>TL;DR</strong> — This is sustained 2.4 MS/s IQ streaming off the RTL2832U: 32 async
USB buffers, a deep consumer channel, and a bit-identical U8-to-complex64
lookup table. The headline bug: per-chunk allocations drove GC churn that shed
25–48% of live IQ — fixed with a zero-allocation reuse ring. Two more bugs turn
silent IQ loss and a silently dead stream into explicit, observable failures.</p>
</blockquote>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>The streaming geometry</strong> — 32 async USB buffers × 16 KiB, a deep consumer
channel, and why the numbers are what they are.</li>
  <li><strong>The U8-to-complex64 conversion</strong> — a precomputed lookup table that’s
bit-identical to the old CGO driver.</li>
  <li><strong>The problem we hit (issue #489):</strong> per-chunk allocations created GC churn
that pushed the control-channel decoder over its real-time budget and shed
25–48% of live IQ — fixed with a zero-allocation reuse ring.</li>
  <li><strong>Two more failure-signaling bugs (issues #402 and #345):</strong> silent live IQ loss
made invisible by a drop counter, and a silent USB-reaper death that left the
consumer blocked forever.</li>
</ul>

<h2 id="what-sustained-streaming-does">What sustained streaming does</h2>

<p>Once the chip is tuned, the RTL2832U dumps a continuous stream of unsigned 8-bit
IQ pairs over a USB bulk-IN endpoint. At 2.4 MS/s that’s 4.8 MB/s of raw bytes
that never stops. The streaming layer’s job is to pull those bytes off the wire,
convert each U8 IQ pair into a <code class="language-plaintext highlighter-rouge">complex64</code>, and hand the result to the consumer
(a control-channel decoder, a trunking engine, an IQ recorder) as a channel of
<code class="language-plaintext highlighter-rouge">[]complex64</code> chunks — without ever blocking the kernel’s USB reaper and without
dropping samples when the consumer hiccups.</p>

<p>The whole thing lives in <code class="language-plaintext highlighter-rouge">internal/sdr/rtlsdr/purego/stream.go</code>, with the device
state machine in <code class="language-plaintext highlighter-rouge">device.go</code>.</p>

<h2 id="how-gophertrunk-implements-it-in-go">How GopherTrunk implements it in Go</h2>

<h3 id="the-geometry">The geometry</h3>

<p><strong>The numbers are copied verbatim from the old CGO driver so the pure-Go backend is
a behavioral drop-in:</strong></p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go</span>
<span class="k">const</span> <span class="p">(</span>
    <span class="n">asyncBufCount</span> <span class="o">=</span> <span class="m">32</span>
    <span class="n">asyncBufLen</span>   <span class="o">=</span> <span class="m">16</span> <span class="o">*</span> <span class="m">1024</span>

    <span class="n">streamChanDepth</span> <span class="o">=</span> <span class="m">32</span>
    <span class="n">bulkInEndpoint</span>  <span class="o">=</span> <span class="m">0x81</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Thirty-two async USB buffers of 16 KiB each is about <strong>6 ms of headroom at 2.4
MS/s</strong> — enough that a brief scheduler stall doesn’t starve the bulk-IN ring. The
consumer channel is <code class="language-plaintext highlighter-rouge">streamChanDepth = 32</code> chunks deep, roughly 110 ms of slack.
The CGO driver used a depth of 8; the pure-Go backend deliberately runs deeper,
specifically to absorb GC and scheduler jitter on the consumer — and <em>that</em>
number is part of the story below.</p>

<p><code class="language-plaintext highlighter-rouge">StreamIQ</code> resets the USB FIFO, provisions a reuse ring (more on that in a
moment), kicks off the bulk-IN ring on the transport, and returns the channel:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go (shape)</span>
<span class="n">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="p">[]</span><span class="kt">complex64</span><span class="p">,</span> <span class="n">streamChanDepth</span><span class="p">)</span>
<span class="n">d</span><span class="o">.</span><span class="n">out</span> <span class="o">=</span> <span class="n">out</span>
<span class="c">// ...provision the reuse ring...</span>
<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">d</span><span class="o">.</span><span class="n">transport</span><span class="o">.</span><span class="n">StartBulkIn</span><span class="p">(</span><span class="n">bulkInEndpoint</span><span class="p">,</span> <span class="n">asyncBufCount</span><span class="p">,</span> <span class="n">asyncBufLen</span><span class="p">,</span>
    <span class="n">d</span><span class="o">.</span><span class="n">deliver</span><span class="p">,</span> <span class="n">d</span><span class="o">.</span><span class="n">onStreamDead</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
    <span class="n">d</span><span class="o">.</span><span class="n">out</span> <span class="o">=</span> <span class="no">nil</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"rtlsdr: StartBulkIn: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
<span class="p">}</span>
<span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span> <span class="o">&lt;-</span><span class="n">ctx</span><span class="o">.</span><span class="n">Done</span><span class="p">();</span> <span class="n">d</span><span class="o">.</span><span class="n">cancelStream</span><span class="p">()</span> <span class="p">}()</span>
<span class="k">return</span> <span class="n">out</span><span class="p">,</span> <span class="no">nil</span>
</code></pre></div></div>

<h3 id="the-conversion">The conversion</h3>

<p>Each completed URB calls <code class="language-plaintext highlighter-rouge">deliver</code>, which converts the U8 IQ bytes into
<code class="language-plaintext highlighter-rouge">complex64</code>. The conversion math is the bit-identical port of the CGO driver:
subtract a DC bias of 127.5 (mid-range of a <code class="language-plaintext highlighter-rouge">u8</code>) and divide by 127.5 to scale to
[-1, +1). Done naively that’s a subtract and a divide per sample, 4.8 million
times a second per dongle. Instead we precompute a 256-entry lookup table once:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go</span>
<span class="k">var</span> <span class="n">u8ToF32</span> <span class="p">[</span><span class="m">256</span><span class="p">]</span><span class="kt">float32</span>

<span class="k">func</span> <span class="n">init</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">b</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">b</span> <span class="o">&lt;</span> <span class="m">256</span><span class="p">;</span> <span class="n">b</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">u8ToF32</span><span class="p">[</span><span class="n">b</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="kt">float32</span><span class="p">(</span><span class="n">b</span><span class="p">)</span> <span class="o">-</span> <span class="m">127.5</span><span class="p">)</span> <span class="o">/</span> <span class="m">127.5</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">convertU8IQInto</span><span class="p">(</span><span class="n">dst</span> <span class="p">[]</span><span class="kt">complex64</span><span class="p">,</span> <span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">n</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="o">/</span> <span class="m">2</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">dst</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="nb">complex</span><span class="p">(</span><span class="n">u8ToF32</span><span class="p">[</span><span class="n">buf</span><span class="p">[</span><span class="m">2</span><span class="o">*</span><span class="n">i</span><span class="p">]],</span> <span class="n">u8ToF32</span><span class="p">[</span><span class="n">buf</span><span class="p">[</span><span class="m">2</span><span class="o">*</span><span class="n">i</span><span class="o">+</span><span class="m">1</span><span class="p">]])</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Each entry uses the <em>exact same</em> float32 expression as the original per-sample
math, so the result is bit-identical — pinned by
<code class="language-plaintext highlighter-rouge">TestConvertU8IQ_BitIdenticalWithCGO</code> so any drift from the CGO driver shows up
immediately. The hot path is now two table lookups per sample instead of a
subtract+divide.</p>

<h2 id="the-problem-we-hit-gc-churn-shedding-live-iq-issue-489">The problem we hit: GC churn shedding live IQ (issue #489)</h2>

<p><strong>The symptom.</strong> A control-channel RTL-SDR was dropping <strong>25–48% of its IQ
chunks</strong> under load. Not occasionally — sustained. The decoder downstream was
clearly busy, but it should have had 110 ms of channel slack to ride out a busy
moment. Something was making the consumer <em>systematically</em> unable to keep up.</p>

<p><strong>The root cause.</strong> <code class="language-plaintext highlighter-rouge">convertU8IQInto</code>’s allocating wrapper was the culprit.
Originally, <code class="language-plaintext highlighter-rouge">deliver</code> allocated a fresh <code class="language-plaintext highlighter-rouge">[]complex64</code> for every chunk:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go</span>
<span class="k">func</span> <span class="n">convertU8IQ</span><span class="p">(</span><span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">[]</span><span class="kt">complex64</span> <span class="p">{</span>
    <span class="n">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">complex64</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span><span class="o">/</span><span class="m">2</span><span class="p">)</span>
    <span class="n">convertU8IQInto</span><span class="p">(</span><span class="n">out</span><span class="p">,</span> <span class="n">buf</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">out</span>
<span class="p">}</span>
</code></pre></div></div>

<p>At 16 KiB per URB that’s an 8192-element <code class="language-plaintext highlighter-rouge">complex64</code> slice — ~64 KiB — allocated
fresh, hundreds of times a second, then thrown away as soon as the consumer
finished with it. That allocation rate drove the garbage collector hard, and the
GC pauses landed <em>on the consumer goroutine</em>, pushing the control-channel decoder
over its real-time budget. The decoder fell behind, the channel filled, and
<code class="language-plaintext highlighter-rouge">deliver</code>’s drop-on-overrun branch started shedding chunks — a quarter to half of
them. The deep channel didn’t help because the problem wasn’t a transient stall;
it was steady-state allocation pressure that the channel depth couldn’t paper
over.</p>

<p><strong>The fix.</strong> A ring of preallocated <code class="language-plaintext highlighter-rouge">complex64</code> buffers, reused across chunks.
<code class="language-plaintext highlighter-rouge">StreamIQ</code> provisions <code class="language-plaintext highlighter-rouge">streamChanDepth + 2</code> of them per stream:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go</span>
<span class="n">ring</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([][]</span><span class="kt">complex64</span><span class="p">,</span> <span class="n">streamChanDepth</span><span class="o">+</span><span class="m">2</span><span class="p">)</span>
<span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">ring</span> <span class="p">{</span>
    <span class="n">ring</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">complex64</span><span class="p">,</span> <span class="n">asyncBufLen</span><span class="o">/</span><span class="m">2</span><span class="p">)</span>
<span class="p">}</span>
<span class="n">d</span><span class="o">.</span><span class="n">ring</span> <span class="o">=</span> <span class="n">ring</span>
<span class="n">d</span><span class="o">.</span><span class="n">ringIdx</span> <span class="o">=</span> <span class="m">0</span>
</code></pre></div></div>

<p>The sizing is exact: at most <code class="language-plaintext highlighter-rouge">streamChanDepth</code> buffers can sit queued on the
channel, plus one in the consumer, so cycling through <code class="language-plaintext highlighter-rouge">depth + 2</code> slots
guarantees the producer never overwrites a buffer still in flight. <code class="language-plaintext highlighter-rouge">deliver</code> now
fills the current ring slot instead of allocating, and — critically — only
advances the ring index on a <em>successful</em> enqueue:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go</span>
<span class="n">samples</span> <span class="o">:=</span> <span class="n">d</span><span class="o">.</span><span class="n">fillBufferLocked</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span>
<span class="k">select</span> <span class="p">{</span>
<span class="k">case</span> <span class="n">out</span> <span class="o">&lt;-</span> <span class="n">samples</span><span class="o">:</span>
    <span class="c">// Advance the ring only on a successful enqueue: a dropped chunk</span>
    <span class="c">// reuses the same buffer next time, so a buffer's slot is never</span>
    <span class="c">// recycled until that chunk has actually been handed to the consumer.</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">ring</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="n">d</span><span class="o">.</span><span class="n">ringIdx</span> <span class="o">=</span> <span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">ringIdx</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span> <span class="o">%</span> <span class="nb">len</span><span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">ring</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="n">d</span><span class="o">.</span><span class="n">streamMu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
<span class="k">default</span><span class="o">:</span>
    <span class="n">d</span><span class="o">.</span><span class="n">streamMu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
    <span class="n">d</span><span class="o">.</span><span class="n">dropped</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="n">sdr</span><span class="o">.</span><span class="n">NotifyIQDrop</span><span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">info</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">fillBufferLocked</code> writes into the ring slot via the allocation-free
<code class="language-plaintext highlighter-rouge">convertU8IQInto</code>, falling back to a fresh allocation only when no ring is
provisioned (direct deliver in tests) or for a packet too large to fit a slot:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go</span>
<span class="k">func</span> <span class="p">(</span><span class="n">d</span> <span class="o">*</span><span class="n">Device</span><span class="p">)</span> <span class="n">fillBufferLocked</span><span class="p">(</span><span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">[]</span><span class="kt">complex64</span> <span class="p">{</span>
    <span class="n">n</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="o">/</span> <span class="m">2</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">ring</span><span class="p">)</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">convertU8IQ</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="n">dst</span> <span class="o">:=</span> <span class="n">d</span><span class="o">.</span><span class="n">ring</span><span class="p">[</span><span class="n">d</span><span class="o">.</span><span class="n">ringIdx</span><span class="p">]</span>
    <span class="k">if</span> <span class="nb">cap</span><span class="p">(</span><span class="n">dst</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">n</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">convertU8IQ</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="c">// oversized packet; don't disturb the ring</span>
    <span class="p">}</span>
    <span class="n">dst</span> <span class="o">=</span> <span class="n">dst</span><span class="p">[</span><span class="o">:</span><span class="n">n</span><span class="p">]</span>
    <span class="n">convertU8IQInto</span><span class="p">(</span><span class="n">dst</span><span class="p">,</span> <span class="n">buf</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">dst</span>
<span class="p">}</span>
</code></pre></div></div>

<p>With the ring in place the steady state allocates <strong>nothing</strong> per chunk, the GC
went quiet, the consumer kept its budget, and the drop rate fell to zero. The
deeper channel depth from earlier is slack on top of this — not a substitute for
it.</p>

<h2 id="the-second-problem-silent-live-iq-loss-issue-402">The second problem: silent live IQ loss (issue #402)</h2>

<p><strong>The symptom.</strong> When the control channel <em>did</em> drop IQ, there was no way to tell.
A decode failure looked identical whether it came from a weak signal, multipath,
or the live path silently shedding chunks. Live IQ loss was indistinguishable
from an RF problem.</p>

<p><strong>The root cause and fix.</strong> The drop branch in <code class="language-plaintext highlighter-rouge">deliver</code> was genuinely silent — it
threw the chunk away and moved on. We added a lifetime drop counter and a
notification hook:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/device.go</span>
<span class="n">dropped</span> <span class="n">atomic</span><span class="o">.</span><span class="n">Uint64</span>

<span class="k">func</span> <span class="p">(</span><span class="n">d</span> <span class="o">*</span><span class="n">Device</span><span class="p">)</span> <span class="n">DroppedChunks</span><span class="p">()</span> <span class="kt">uint64</span> <span class="p">{</span> <span class="k">return</span> <span class="n">d</span><span class="o">.</span><span class="n">dropped</span><span class="o">.</span><span class="n">Load</span><span class="p">()</span> <span class="p">}</span>
</code></pre></div></div>

<p>Now the drop branch records every discard and notifies an observer outside the
lock (so the callback can’t stall the reaper):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go (drop branch)</span>
<span class="n">d</span><span class="o">.</span><span class="n">dropped</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
<span class="n">sdr</span><span class="o">.</span><span class="n">NotifyIQDrop</span><span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">info</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">NotifyIQDrop</code> drives an operator-facing Prometheus counter
(<code class="language-plaintext highlighter-rouge">iq_underruns_total</code>) plus a rate-limited warning. A non-zero count during a
decode failure now points squarely at a live-path overrun rather than the air — a
diagnosis you simply could not make before. (The same issue-#402 work also
exposed <code class="language-plaintext highlighter-rouge">ActualSampleRate</code>, because the down-converter has to derive its symbol
clock from the rate the chip is <em>actually</em> delivering, not the requested one — but
that’s a decode-path story.)</p>

<h2 id="the-third-problem-a-silently-dead-stream-issue-345">The third problem: a silently dead stream (issue #345)</h2>

<p><strong>The symptom.</strong> When a dongle fell off the USB bus mid-stream — unplugged, or an
unrecoverable <code class="language-plaintext highlighter-rouge">ENODEV</code>/<code class="language-plaintext highlighter-rouge">EPROTO</code> — the consumer goroutine would block on the IQ
channel <strong>forever</strong>. No error, no EOF, just a permanent hang.</p>

<p><strong>The root cause.</strong> The USB reaper goroutine inside <code class="language-plaintext highlighter-rouge">StartBulkIn</code> exits when every
bulk-IN URB dies of an unrecoverable error. But it exits <em>without</em> anyone calling
<code class="language-plaintext highlighter-rouge">StopBulkIn</code>, so the consumer channel was never closed. The consumer’s <code class="language-plaintext highlighter-rouge">range</code>
over the channel had nothing to wake it.</p>

<p><strong>The fix.</strong> A stream-death callback. <code class="language-plaintext highlighter-rouge">StartBulkIn</code> takes an <code class="language-plaintext highlighter-rouge">onStreamDead</code>
handler that fires exactly when the reaper exits abnormally, and it runs
<code class="language-plaintext highlighter-rouge">cancelStream</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/stream.go</span>
<span class="k">func</span> <span class="p">(</span><span class="n">d</span> <span class="o">*</span><span class="n">Device</span><span class="p">)</span> <span class="n">onStreamDead</span><span class="p">(</span><span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">d</span><span class="o">.</span><span class="n">cancelStream</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cancelStream</code> is the same idempotent teardown that context-cancel uses — guarded
by a <code class="language-plaintext highlighter-rouge">sync.Once</code>, it stops the bulk-IN ring and <strong>closes the consumer channel</strong>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/purego/device.go</span>
<span class="k">func</span> <span class="p">(</span><span class="n">d</span> <span class="o">*</span><span class="n">Device</span><span class="p">)</span> <span class="n">cancelStream</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">d</span><span class="o">.</span><span class="n">stopOnce</span><span class="o">.</span><span class="n">Do</span><span class="p">(</span><span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">_</span> <span class="o">=</span> <span class="n">d</span><span class="o">.</span><span class="n">transport</span><span class="o">.</span><span class="n">StopBulkIn</span><span class="p">()</span>
        <span class="n">d</span><span class="o">.</span><span class="n">streamMu</span><span class="o">.</span><span class="n">Lock</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">d</span><span class="o">.</span><span class="n">out</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="nb">close</span><span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">out</span><span class="p">)</span>
            <span class="n">d</span><span class="o">.</span><span class="n">out</span> <span class="o">=</span> <span class="no">nil</span>
        <span class="p">}</span>
        <span class="n">d</span><span class="o">.</span><span class="n">streamMu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
    <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now a dead stream surfaces to the consumer as a closed channel — a clean EOF — so
the decoder sees <code class="language-plaintext highlighter-rouge">ErrIQStreamClosed</code>, the daemon logs it once and decides whether
to retry or exit. The watchdog that does the actual reconnect is the subject of a
later post; here the point is that <em>failure became visible</em> instead of becoming a
hang.</p>

<h2 id="the-design-principle-zero-allocation-steady-state--explicit-failure-signaling">The design principle: zero-allocation steady state + explicit failure signaling</h2>

<p>The streaming layer is governed by two rules that show up over and over in
real-time Go: <strong>the hot path should allocate nothing in steady state</strong>, and
<strong>every failure mode should produce a signal, never silence.</strong></p>

<p>The GC-churn bug is the canonical lesson in the first rule. The allocation wasn’t
wrong — it was correct, simple Go — but at 2.4 MS/s its <em>aggregate</em> cost was a GC
load heavy enough to break an unrelated real-time consumer two layers away. The
fix wasn’t cleverer code; it was <em>no</em> allocation, achieved by reusing buffers
whose lifecycle is tied precisely to the channel’s depth.</p>

<p>The drop counter and the stream-death callback are the second rule. A dropped
chunk and a dead stream were both <em>silent</em> — and silence in a real-time pipeline
is the worst possible failure, because it’s indistinguishable from “working.” Both
fixes turn an invisible failure into an explicit, observable one.</p>

<h3 id="how-that-principle-shaped-the-go-code">How that principle shaped the Go code</h3>

<ul>
  <li><strong>Buffers are pooled, not allocated.</strong> The reuse ring is sized exactly to the
number of buffers that can be in flight (<code class="language-plaintext highlighter-rouge">streamChanDepth + 2</code>), so steady-state
IQ delivery allocates nothing and the GC stays out of the consumer’s budget.</li>
  <li><strong>The ring advances only on success.</strong> A dropped chunk reuses its slot, so a
buffer is never recycled until the consumer has actually taken it — backpressure
and reuse-safety in one rule.</li>
  <li><strong>Drops are counted and reported.</strong> <code class="language-plaintext highlighter-rouge">dropped atomic.Uint64</code> plus <code class="language-plaintext highlighter-rouge">NotifyIQDrop</code>
turn a silent overrun into a Prometheus counter and a rate-limited warning,
fired outside the lock so telemetry can’t stall the reaper.</li>
  <li><strong>Stream death closes the channel.</strong> <code class="language-plaintext highlighter-rouge">onStreamDead → cancelStream</code> makes an
unrecoverable USB error surface as a clean EOF, so no consumer ever blocks
forever. <code class="language-plaintext highlighter-rouge">sync.Once</code> makes the teardown idempotent across ctx-cancel, close, and
reaper-death.</li>
  <li><strong>Conversion stays bit-identical.</strong> The reuse ring changed <em>where</em> samples land,
never <em>what</em> they are — <code class="language-plaintext highlighter-rouge">convertU8IQInto</code> is the same math as the CGO driver,
pinned by a golden test.</li>
</ul>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>That closes out the RTL-SDR trilogy: chip bring-up, tuner, and now sustained
streaming. The next family streams differently — the
<a href="/reference/airspy/">Airspy</a> R2 and Mini deliver <strong>real</strong>
samples, not complex IQ, so
<a href="/blog/deep-dives/rf-front-end-10-airspy-real-to-complex/">Part 10</a>
takes apart the real-to-complex-baseband conversion that turns a real ADC stream
into the <code class="language-plaintext highlighter-rouge">[]complex64</code> the rest of the pipeline expects.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Why not just use <code class="language-plaintext highlighter-rouge">sync.Pool</code> instead of a hand-rolled ring?</strong>
<code class="language-plaintext highlighter-rouge">sync.Pool</code> doesn’t give the lifetime guarantee we need: a buffer must not be
reused until the consumer has finished with it, and that’s determined by the
channel depth, not by GC timing. The ring sized <code class="language-plaintext highlighter-rouge">streamChanDepth + 2</code> encodes
exactly that invariant — and a fresh ring per stream means a slow consumer
draining the <em>previous</em> (closed) channel can never alias a buffer the new stream
is writing.</p>

<p><strong>Why advance the ring index only on a successful send?</strong>
Because a dropped chunk’s buffer was never handed to anyone, so its slot is still
safe to overwrite next time. Advancing on drop would waste a slot and, worse,
could recycle a slot while its chunk is still queued. Advancing only on success
ties the ring’s recycling directly to delivery.</p>

<p><strong>Does the drop-on-overrun policy lose data we care about?</strong>
Dropping is deliberate: a slow consumer should shed live IQ rather than
back-pressure the kernel’s USB reaper, which would stall every stream. The fix for
issue #489 was to make sure the consumer <em>isn’t</em> slow in steady state; the
drop-and-count path is the safety valve when it momentarily is, and the counter
tells you when it fired.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 9 of 14</strong> · ←
<a href="/blog/deep-dives/rf-front-end-08-rtlsdr-r82xx-blog-v4/">Part 8</a>
· Next →
<a href="/blog/deep-dives/rf-front-end-10-airspy-real-to-complex/">Part 10: Airspy R2/Mini &amp; HF+ — real samples to complex baseband</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="sdr" /><category term="go" /><category term="rtl-sdr" /><category term="streaming" /><category term="software-design" /><summary type="html"><![CDATA[Sustained IQ streaming from the RTL2832U in pure Go — 32 async USB buffers, a deep consumer channel, and a bit-identical U8-to-complex64 lookup table. Plus the headline bug: per-chunk allocations whose GC churn shed a quarter of the control channel's live IQ, and the zero-allocation reuse ring that fixed it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Build in the Open, Part 8: Testing — How to Build and Write Tests</title><link href="https://gophertrunk.org/blog/tutorials/build-in-the-open-08-testing-how-to-write-tests/" rel="alternate" type="text/html" title="Build in the Open, Part 8: Testing — How to Build and Write Tests" /><published>2026-06-25T00:00:00-05:00</published><updated>2026-06-25T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/tutorials/build-in-the-open-08-testing-how-to-write-tests</id><content type="html" xml:base="https://gophertrunk.org/blog/tutorials/build-in-the-open-08-testing-how-to-write-tests/"><![CDATA[<blockquote>
  <p><strong>TL;DR:</strong> Tests exist to let you change code without fear. Build them in
layers — lots of fast unit tests, fewer integration tests, a handful of
end-to-end tests — and make the fast layer the gate your CI enforces on every
pull request. Write tests as data (table-driven cases), pin tricky outputs
with golden files, turn the race detector on, and treat coverage as a
spotlight, not a score. Claude Code is excellent at the tedious parts:
generating table cases, hunting edge cases, and writing the regression test
that locks a bug shut.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li>The point of a test is <em>confidence to change things</em>, not a green checkmark.</li>
  <li>Use the testing pyramid: many unit tests, fewer integration, few end-to-end.</li>
  <li>Make tests data, not code — table-driven cases scale better than copy-paste.</li>
  <li>Gate merges on the fast tests (Part 7’s CI); keep slow/hardware tests opt-in.</li>
  <li>Coverage tells you what <em>ran</em>, not what’s <em>correct</em> — use it to find gaps.</li>
</ul>

<p><em>This is Part 8 of <strong>Build in the Open</strong>, a 14-part series on taking a software
project from a blank idea to a public release using GitHub and Claude Code. Each
post teaches a technique you can apply to any project in any language, then shows
how the open-source <a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a>
scanner does it for real.</em></p>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>Why test at all</strong> — and what’s actually worth testing.</li>
  <li><strong>The testing pyramid</strong> — unit, integration, end-to-end, and the right mix.</li>
  <li><strong>How to write good tests</strong> — table-driven cases, fixtures, golden files,
fakes vs. mocks, the race detector, and coverage.</li>
  <li><strong>Opt-in and hardware-gated tests</strong> — for things CI can’t run.</li>
  <li><strong>Tests as the CI gate</strong> — connecting this to Part 7’s workflows.</li>
  <li><strong>Writing tests with Claude Code</strong> — where the leverage really is.</li>
  <li><strong>How GopherTrunk does it</strong>, as a concrete example you can copy.</li>
</ul>

<h2 id="why-test-at-all">Why test at all?</h2>

<p>The honest answer isn’t “to catch bugs” — it’s <strong>to let you change code without
being afraid of it</strong>. A codebase with good tests is one you can refactor,
upgrade, and hand to a stranger, because the tests will shout the moment
something breaks. A codebase without them is one you tiptoe around.</p>

<p>That reframing also tells you <em>what</em> to test. You don’t test getters and
one-line wrappers; you test the things that would hurt to get wrong:</p>

<ul>
  <li><strong>Logic with branches</strong> — anything with <code class="language-plaintext highlighter-rouge">if</code>/<code class="language-plaintext highlighter-rouge">switch</code>, math, parsing, or state.</li>
  <li><strong>Boundaries</strong> — empty input, the maximum, off-by-one, the malformed packet.</li>
  <li><strong>Bugs you’ve already hit</strong> — every fixed bug deserves a test so it stays fixed.</li>
  <li><strong>Contracts</strong> — the promises your public functions and APIs make to callers.</li>
</ul>

<p>Skip the trivial. A test that can only fail if the language itself is broken is
just maintenance cost.</p>

<h2 id="what-is-the-testing-pyramid">What is the testing pyramid?</h2>

<p>The testing pyramid is a rule of thumb for the <em>mix</em> of tests, from cheap and
plentiful at the bottom to expensive and rare at the top:</p>

<ol>
  <li><strong>Unit tests</strong> (the wide base): one function or type in isolation,
milliseconds each, no network or disk. You write thousands of these and run
them constantly.</li>
  <li><strong>Integration tests</strong> (the middle): several components wired together — a
handler plus its database, a pipeline end-to-end — with real-ish parts but no
real outside world. Slower, fewer.</li>
  <li><strong>End-to-end tests</strong> (the tip): the whole system as a user hits it. Slowest,
flakiest, and you keep only a handful that cover the critical paths.</li>
</ol>

<p>Most pain in real projects comes from an <em>inverted</em> pyramid — a few brittle
end-to-end tests and no unit tests underneath. Push the bulk of your coverage
down to the fast layer, where a failure points straight at the broken function.</p>

<h2 id="how-do-you-write-a-good-test">How do you write a good test?</h2>

<p>A few techniques transfer to any language and test runner.</p>

<h3 id="make-tests-data-table-driven-cases">Make tests data: table-driven cases</h3>

<p>Instead of copy-pasting a test five times with different inputs, define a list
of cases — input, expected output, a name — and loop over them. Adding a case
becomes one line, and the failure message tells you exactly which row broke.
Every mature test suite leans on this.</p>

<h3 id="pin-tricky-outputs-with-golden-files">Pin tricky outputs with golden files</h3>

<p>When the expected output is large or fiddly (rendered HTML, a decoded frame, a
formatted report), store a known-good copy as a <em>golden file</em> and assert the
test output matches it. A flag like <code class="language-plaintext highlighter-rouge">-update</code> regenerates the goldens after an
intentional change. You review the diff in code review like any other change.</p>

<h3 id="fakes-vs-mocks">Fakes vs. mocks</h3>

<p>Both stand in for real dependencies, but they’re not the same:</p>

<ul>
  <li>A <strong>fake</strong> is a working lightweight implementation — an in-memory database, a
scripted data source. It behaves; you assert on results.</li>
  <li>A <strong>mock</strong> records calls and lets you assert <em>that</em> something was called, with
what arguments. Useful, but over-mocking produces tests that pass while the
real system is broken. Prefer fakes when you can.</li>
</ul>

<h3 id="turn-on-the-race-detector">Turn on the race detector</h3>

<p>If your language has concurrency, it has a race detector or equivalent (Go’s
<code class="language-plaintext highlighter-rouge">-race</code>, ThreadSanitizer, etc.). Run your tests under it. Data races are the
bugs that pass a thousand times and corrupt data on the thousand-and-first;
the detector catches them deterministically.</p>

<h3 id="use-coverage-as-a-spotlight-not-a-score">Use coverage as a spotlight, not a score</h3>

<p>Coverage tells you which lines <em>executed</em> during the tests — not whether the
assertions were meaningful. Chasing 100% rewards writing tests for trivial code
and punishes nothing. Use coverage to <em>find untested branches</em> you care about,
then decide if they’re worth a test. The number is a map, not the territory.</p>

<h2 id="opt-in-and-hardware-gated-tests">Opt-in and hardware-gated tests</h2>

<p>Some tests can’t run in CI: they need a GPU, a USB device, a paid API key, a
specific OS. Don’t delete them — <strong>gate</strong> them. Make the test skip by default
unless an environment variable or build flag opts it in. The test lives in the
repo, documents the expected behaviour, and runs on the one machine that can,
while CI stays green and fast.</p>

<h2 id="tests-as-the-ci-gate">Tests as the CI gate</h2>

<p>This is where Part 7 pays off. Once you have a fast, reliable test command, you
wire it into a CI workflow that runs on every pull request, and you mark it a
<strong>required status check</strong> so a PR can’t merge until it’s green. That single rule
turns “we have tests” into “broken code can’t reach <code class="language-plaintext highlighter-rouge">main</code>.” The fast layer
(unit + the cheap integration tests) is the gate; the slow and hardware tests
stay opt-in and run out of band.</p>

<h2 id="writing-tests-with-claude-code">Writing tests with Claude Code</h2>

<p>Tests are some of the highest-leverage work to hand to Claude Code, because the
work is structured and the right answer is checkable:</p>

<ul>
  <li><strong>Generate table cases.</strong> Give it a function and ask for a table-driven test
covering the obvious paths — it’ll scaffold the cases and the loop in seconds.</li>
  <li><strong>Hunt edge cases.</strong> “What inputs would break this?” surfaces the empty
string, the negative number, the overflow, the nil you forgot.</li>
  <li><strong>Write the regression test for a bug.</strong> Describe the bug (or paste the stack
trace), and ask for a test that fails <em>before</em> the fix and passes <em>after</em>. Land
that test in the same PR as the fix — exactly the discipline good projects use.</li>
</ul>

<p>Always read what it produces. Claude is great at breadth; you supply the
judgment about which cases actually matter and whether the assertions are real.</p>

<h2 id="how-gophertrunk-does-it">How GopherTrunk does it</h2>

<p><a href="https://github.com/MattCheramie/GopherTrunk">GopherTrunk</a> is a pure-Go SDR
trunking scanner, and its testing maps the pyramid onto a <code class="language-plaintext highlighter-rouge">Makefile</code> you can read
top to bottom:</p>

<ul>
  <li><strong>The fast unit layer is <code class="language-plaintext highlighter-rouge">make test</code>.</strong> It runs
<code class="language-plaintext highlighter-rouge">go test -race -count=1 ./...</code> — every package, under the race detector, with
caching disabled — and finishes in <strong>under 30 seconds</strong>. This is the command
contributors run constantly and the one CI gates on.</li>
  <li><strong>Integration tests are build-tagged so they don’t slow the unit run.</strong>
<code class="language-plaintext highlighter-rouge">make integration</code> runs the tests behind <code class="language-plaintext highlighter-rouge">//go:build integration</code>, booting the
wired daemon end-to-end (no real SDR) and asserting the engine, recorder, call
log, metrics, and API all agree on a synthetic call. The build tag keeps them
out of the default <code class="language-plaintext highlighter-rouge">make test</code> path.</li>
  <li><strong>Per-protocol “lights up” checks.</strong> There’s a focused integration target per
trunked protocol — <code class="language-plaintext highlighter-rouge">make integration-cc-nxdn</code>, <code class="language-plaintext highlighter-rouge">make integration-cc-dmr</code>,
<code class="language-plaintext highlighter-rouge">make integration-cc-tetra</code>, <code class="language-plaintext highlighter-rouge">make integration-cc-p25p2</code>, and so on — each of
which synthesizes IQ for that protocol’s control channel and asserts the real
pipeline recovers the lock. That’s an end-to-end critical path, isolated per
protocol so a failure points right at the broken decoder.</li>
  <li><strong>Table-driven tests and <code class="language-plaintext highlighter-rouge">t.Parallel()</code> are house rules.</strong> <code class="language-plaintext highlighter-rouge">CONTRIBUTING.md</code>
spells it out: tests are “parallel where it’s safe (<code class="language-plaintext highlighter-rouge">t.Parallel()</code>),
table-driven for any function with more than two interesting inputs,
<code class="language-plaintext highlighter-rouge">t.Helper()</code> on helper functions so failure locations surface correctly.”</li>
  <li><strong>Golden IQ captures live under <code class="language-plaintext highlighter-rouge">samples/</code>.</strong> Known-good signal captures act
as fixtures the decoders run against — golden files for radio.</li>
  <li><strong>Real-hardware tests are env-gated.</strong> <code class="language-plaintext highlighter-rouge">make test-airspy-real</code> sets
<code class="language-plaintext highlighter-rouge">GOPHERTRUNK_AIRSPY_REAL=1</code>; the package skips entirely unless that variable is
set, so the test ships in the repo but “never runs in CI,” exactly as
<code class="language-plaintext highlighter-rouge">CONTRIBUTING.md</code> says. Overrides like <code class="language-plaintext highlighter-rouge">GOPHERTRUNK_AIRSPY_REAL_BIAS_TEE=1</code>
toggle extra checks. CI runs <code class="language-plaintext highlighter-rouge">make test</code>, <code class="language-plaintext highlighter-rouge">make integration</code>, and
<code class="language-plaintext highlighter-rouge">make test-dvsi</code> on every PR — a green CI is required before merge.</li>
</ul>

<p>You don’t need a radio for any of this to transfer. Swap “decode a control
channel” for “render an invoice” or “parse a config file”: fast unit tests as
the gate, tagged integration tests for the wiring, golden files for fiddly
output, and opt-in tests for whatever CI can’t reach.</p>

<h2 id="faq">FAQ</h2>

<p><strong>How many tests is enough?</strong>
Enough that you’d trust a stranger to refactor your code and find out from a
failing test, not from production. That usually means solid unit coverage of
your branching logic plus a few integration tests over the critical paths — not
a coverage percentage you chase for its own sake.</p>

<p><strong>Should I write tests before or after the code?</strong>
Either works; the discipline that matters is that tests exist and run in CI.
Test-first (TDD) helps when the design is unclear; test-after is fine when it
isn’t. For bug fixes, always write the failing test first — it proves the bug is
real and that your fix actually fixes it.</p>

<p><strong>What’s the difference between a fake and a mock?</strong>
A fake is a real (if simplified) working implementation you assert results
against — an in-memory store, a scripted source. A mock records calls so you can
assert that something was invoked. Fakes tend to produce more durable tests;
heavy mocking can make tests pass while the system is broken.</p>

<p><strong>How do I test something that needs hardware or a paid API?</strong>
Gate it. Make the test skip unless an environment variable or build tag opts it
in, the way GopherTrunk gates its Airspy tests behind
<code class="language-plaintext highlighter-rouge">GOPHERTRUNK_AIRSPY_REAL=1</code>. The test stays in the repo and runs where it can,
while CI stays fast and green.</p>

<p><strong>Can Claude Code write my tests for me?</strong>
It can write most of the scaffolding — table cases, edge cases, regression tests
for a described bug — very well, and very fast. You still review the result and
decide which cases matter. Treat it as a fast pair, not an oracle.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 8 of 14</strong> · ←
<a href="/blog/tutorials/build-in-the-open-07-github-actions-which-workflows/">Part 7</a>
· Next →
<a href="/blog/tutorials/build-in-the-open-09-documentation-done-right/">Part 9: Documentation Done Right</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="tutorials" /><category term="github" /><category term="claude-code" /><category term="testing" /><category term="ci" /><category term="go" /><category term="quality" /><summary type="html"><![CDATA[How to test software well — the testing pyramid, table-driven tests, golden files, race detection, coverage limits, and writing tests with Claude Code.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RF Front End, Part 8: RTL-SDR II — The R82xx Tuner &amp;amp; the Blog V4 Deafness</title><link href="https://gophertrunk.org/blog/deep-dives/rf-front-end-08-rtlsdr-r82xx-blog-v4/" rel="alternate" type="text/html" title="RF Front End, Part 8: RTL-SDR II — The R82xx Tuner &amp;amp; the Blog V4 Deafness" /><published>2026-06-25T00:00:00-05:00</published><updated>2026-06-25T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/rf-front-end-08-rtlsdr-r82xx-blog-v4</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/rf-front-end-08-rtlsdr-r82xx-blog-v4/"><![CDATA[<p><em>Part 8 of <strong>RF Front End</strong>. The RTL2832U from
<a href="/blog/deep-dives/rf-front-end-07-rtlsdr-rtl2832u-bringup/">Part 7</a>
can’t tune anything on its own — the actual frequency synthesis lives in a
separate tuner chip on the I2C bridge. This post brings up the
<a href="/reference/r820t-tuner/">R820T</a>/R828D, and tells the
story of the bug that ate the most field debugging time in the whole RTL-SDR
driver: the RTL-SDR Blog V4 that came up deaf.</em></p>

<blockquote>
  <p><strong>TL;DR</strong> — This is the pure-Go R820T/R828D tuner driver, built around a
shadow-register cache that makes read-modify-write free. The headline bug: the
RTL-SDR Blog V4 came up deaf and mistuned by ~1.8×, fixed with a crystal
override plus per-band input switching. A second bug (#248) — a 17-byte I2C
burst stalling on NESDR v5 — is fixed by halving the chunk size until it fits.</p>
</blockquote>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>What the R82xx tuner does</strong> — PLL frequency synthesis, the 3.57 MHz IF, and
why the RTL2832U only ever sees an intermediate frequency.</li>
  <li><strong>The shadow-register cache</strong> — how mirroring the chip’s writable registers in
Go makes read-modify-write free and papers over a real R820T quirk.</li>
  <li><strong>Tuner auto-detection</strong> — probing I2C addresses in librtlsdr’s exact order.</li>
  <li><strong>The problem we hit (issue #264):</strong> the RTL-SDR Blog V4 mistunes by ~1.8× and
receives only noise, and the manual override + per-band input switching that
fixed it.</li>
  <li><strong>A second bug (issue #248):</strong> a multi-byte I2C burst stalls on NESDR v5
silicon, and the chunk-halving recovery that got it streaming.</li>
</ul>

<h2 id="what-the-r82xx-tuner-is">What the R82xx tuner is</h2>

<p>The R820T, R820T2, and R828D (collectively the “R82xx” family) share one I2C
register map and one PLL synthesizer. The tuner’s job is to take an RF frequency
from the antenna, mix it down to a fixed <strong>3.57 MHz intermediate frequency</strong>, and
hand that to the RTL2832U’s ADC. So when you ask GopherTrunk to tune 154 MHz, the
tuner’s PLL doesn’t target 154 MHz — it targets 154 MHz + 3.57 MHz, and the
RTL2832U is configured (back in <code class="language-plaintext highlighter-rouge">PrepareDemod</code>) to expect signal at that IF
offset.</p>

<p>The driver is a straight port of osmocom librtlsdr’s <code class="language-plaintext highlighter-rouge">tuner_r82xx.c</code> — register
addresses, the init flood, the PLL math, and the frequency-range mux table are
all kept byte-identical so real-hardware captures replay-validate against the
mock USB transport. The whole driver lives in
<code class="language-plaintext highlighter-rouge">internal/sdr/rtlsdr/tuners/r82xx.go</code>.</p>

<h2 id="how-gophertrunk-implements-it-in-go">How GopherTrunk implements it in Go</h2>

<h3 id="the-shadow-register-cache">The shadow-register cache</h3>

<p><strong>The single most important design decision in the R82xx driver is the
shadow-register cache.</strong> The <code class="language-plaintext highlighter-rouge">R82xx</code> struct keeps a local mirror of the chip’s
registers:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go</span>
<span class="k">type</span> <span class="n">R82xx</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">demod</span>    <span class="o">*</span><span class="n">rtl2832u</span><span class="o">.</span><span class="n">Demod</span>
    <span class="n">i2cAddr</span>  <span class="kt">uint8</span>
    <span class="n">chipType</span> <span class="n">Type</span>
    <span class="n">xtalHz</span>   <span class="kt">uint32</span>

    <span class="c">// regs[0x05..0x1F] is the shadow for writable registers.</span>
    <span class="c">// regs[0x00..0x04] holds the most recently read read-only status bytes.</span>
    <span class="n">regs</span> <span class="p">[</span><span class="n">r82xxNumRegs</span><span class="p">]</span><span class="kt">byte</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This solves a concrete hardware problem: the R820T’s writable registers <strong>can’t
be read back</strong> through the I2C bridge. Read-modify-write — “set these three bits,
leave the rest” — is impossible if you can’t read the current value. The shadow
makes it trivial, and it has a free side benefit: the chip silently drops a write
whose value matches the previous one, so eliding redundant writes saves a USB
roundtrip without changing observable behavior. Every masked write goes through
<code class="language-plaintext highlighter-rouge">writeRegMask</code>, which reads the shadow, applies the masked bits, and only touches
the wire if the result actually changed:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go</span>
<span class="k">func</span> <span class="p">(</span><span class="n">r</span> <span class="o">*</span><span class="n">R82xx</span><span class="p">)</span> <span class="n">writeRegMask</span><span class="p">(</span><span class="n">addr</span> <span class="kt">uint8</span><span class="p">,</span> <span class="n">val</span><span class="p">,</span> <span class="n">mask</span> <span class="kt">byte</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">addr</span> <span class="o">&lt;</span> <span class="n">r82xxShadowStart</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"r82xx writeRegMask: addr=0x%02x is read-only"</span><span class="p">,</span> <span class="n">addr</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="n">cur</span> <span class="o">:=</span> <span class="n">r</span><span class="o">.</span><span class="n">regs</span><span class="p">[</span><span class="n">addr</span><span class="p">]</span>
    <span class="n">next</span> <span class="o">:=</span> <span class="p">(</span><span class="n">cur</span> <span class="o">&amp;^</span> <span class="n">mask</span><span class="p">)</span> <span class="o">|</span> <span class="p">(</span><span class="n">val</span> <span class="o">&amp;</span> <span class="n">mask</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">cur</span> <span class="o">==</span> <span class="n">next</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span>
    <span class="p">}</span>
    <span class="n">r</span><span class="o">.</span><span class="n">regs</span><span class="p">[</span><span class="n">addr</span><span class="p">]</span> <span class="o">=</span> <span class="n">next</span>
    <span class="k">return</span> <span class="n">r</span><span class="o">.</span><span class="n">writeBurstRaw</span><span class="p">(</span><span class="n">addr</span><span class="p">,</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">{</span><span class="n">next</span><span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="pll-tuning">PLL tuning</h3>

<p><code class="language-plaintext highlighter-rouge">setPLL</code> is a faithful port of <code class="language-plaintext highlighter-rouge">r82xx_set_pll</code>: it sweeps the mixer divider to
land the VCO inside its valid range, computes an integer + sigma-delta fractional
division, and compensates with a VCO fine-tune read back from the chip. The math
is intricate but the load-bearing detail for this post is small — the divider
sweep and the <code class="language-plaintext highlighter-rouge">nint</code>/<code class="language-plaintext highlighter-rouge">vcoFra</code> split off the reference crystal:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go (shape)</span>
<span class="n">vcoFreq</span> <span class="o">:=</span> <span class="kt">uint64</span><span class="p">(</span><span class="n">freqHz</span><span class="p">)</span> <span class="o">*</span> <span class="kt">uint64</span><span class="p">(</span><span class="n">mixDiv</span><span class="p">)</span>
<span class="n">effXtal</span> <span class="o">:=</span> <span class="n">r</span><span class="o">.</span><span class="n">effectiveXtalHz</span><span class="p">()</span>
<span class="n">pllRef</span> <span class="o">:=</span> <span class="kt">uint64</span><span class="p">(</span><span class="n">effXtal</span><span class="p">)</span>
<span class="n">nint</span> <span class="o">:=</span> <span class="kt">uint32</span><span class="p">(</span><span class="n">vcoFreq</span> <span class="o">/</span> <span class="p">(</span><span class="m">2</span> <span class="o">*</span> <span class="n">pllRef</span><span class="p">))</span>
<span class="n">vcoFra</span> <span class="o">:=</span> <span class="kt">uint32</span><span class="p">((</span><span class="n">vcoFreq</span> <span class="o">-</span> <span class="m">2</span><span class="o">*</span><span class="n">pllRef</span><span class="o">*</span><span class="kt">uint64</span><span class="p">(</span><span class="n">nint</span><span class="p">))</span> <span class="o">/</span> <span class="m">1000</span><span class="p">)</span>
</code></pre></div></div>

<p>Notice <code class="language-plaintext highlighter-rouge">effectiveXtalHz()</code>. <em>Every</em> PLL division is computed off the reference
crystal. If the driver believes the crystal is 16 MHz when it’s actually 28.8
MHz, every tune is wrong by the ratio 28.8/16 = <strong>1.8×</strong>. Hold that thought.</p>

<h3 id="tuner-auto-detection">Tuner auto-detection</h3>

<p>Before any of this runs, we have to figure out <em>which</em> tuner is on the board.
<code class="language-plaintext highlighter-rouge">detect.go</code> probes candidate I2C addresses in librtlsdr’s exact order, reading
the chip-ID byte off each. For the R82xx family that’s a 0x69 ID at address 0x34
(R820T) or 0x74 (R828D):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go</span>
<span class="k">func</span> <span class="n">detectR82xx</span><span class="p">(</span><span class="n">d</span> <span class="o">*</span><span class="n">rtl2832u</span><span class="o">.</span><span class="n">Demod</span><span class="p">)</span> <span class="n">Tuner</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">c</span> <span class="o">:=</span> <span class="k">range</span> <span class="p">[]</span><span class="k">struct</span> <span class="p">{</span>
        <span class="n">addr</span> <span class="kt">uint8</span>
        <span class="n">typ</span>  <span class="n">Type</span>
    <span class="p">}{</span>
        <span class="p">{</span><span class="n">addr</span><span class="o">:</span> <span class="n">r82xxI2CAddr</span><span class="p">,</span> <span class="n">typ</span><span class="o">:</span> <span class="n">TypeR820T2</span><span class="p">},</span>
        <span class="p">{</span><span class="n">addr</span><span class="o">:</span> <span class="n">r828dI2CAddr</span><span class="p">,</span> <span class="n">typ</span><span class="o">:</span> <span class="n">TypeR828D</span><span class="p">},</span>
    <span class="p">}</span> <span class="p">{</span>
        <span class="n">out</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">d</span><span class="o">.</span><span class="n">I2CRead</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">addr</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="o">||</span> <span class="nb">len</span><span class="p">(</span><span class="n">out</span><span class="p">)</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
            <span class="k">continue</span>
        <span class="p">}</span>
        <span class="n">id</span> <span class="o">:=</span> <span class="n">r82xxBitReverse</span><span class="p">(</span><span class="n">out</span><span class="p">[</span><span class="m">0</span><span class="p">])</span>
        <span class="k">if</span> <span class="n">id</span> <span class="o">==</span> <span class="m">0x69</span> <span class="o">||</span> <span class="n">id</span> <span class="o">==</span> <span class="m">0x96</span> <span class="p">{</span> <span class="c">// includes some bit-reversed clones</span>
            <span class="k">return</span> <span class="n">NewR82xx</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">addr</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">typ</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The whole detection runs under a single <code class="language-plaintext highlighter-rouge">SetI2CRepeater(true)/(false)</code> bracket so
the bridge doesn’t flap between candidates. That detail matters more than it
looks — it sets up the second bug below.</p>

<h2 id="the-problem-we-hit-the-rtl-sdr-blog-v4-deafness-issue-264">The problem we hit: the RTL-SDR Blog V4 deafness (issue #264)</h2>

<p><strong>The symptom.</strong> Plug in an RTL-SDR Blog V4 — a popular R828D-based dongle — and
it would detect fine, claim its interface, accept every tune… and receive only
noise. An R820T2 dongle on the exact same signal decoded cleanly. Worse, when it
<em>did</em> seem to tune, frequencies were off by a factor of roughly 1.8.</p>

<p><strong>The root cause — three of them, actually.</strong> The V4 is an R828D, and our
<code class="language-plaintext highlighter-rouge">NewR82xx</code> defaults every R828D to a 16 MHz crystal — which is correct for a
generic R828D. But the Blog V4 runs its R828D from the RTL2832U’s <strong>28.8 MHz</strong>
reference crystal. There’s the 1.8× mistune, straight out of the PLL math above:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go</span>
<span class="k">func</span> <span class="n">NewR82xx</span><span class="p">(</span><span class="n">d</span> <span class="o">*</span><span class="n">rtl2832u</span><span class="o">.</span><span class="n">Demod</span><span class="p">,</span> <span class="n">i2cAddr</span> <span class="kt">uint8</span><span class="p">,</span> <span class="n">chip</span> <span class="n">Type</span><span class="p">)</span> <span class="o">*</span><span class="n">R82xx</span> <span class="p">{</span>
    <span class="n">xtal</span> <span class="o">:=</span> <span class="n">r82xxXtalHz</span>
    <span class="k">if</span> <span class="n">chip</span> <span class="o">==</span> <span class="n">TypeR828D</span> <span class="p">{</span>
        <span class="n">xtal</span> <span class="o">=</span> <span class="n">r828dXtalHz</span> <span class="c">// 16 MHz — wrong for the Blog V4</span>
    <span class="p">}</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>But fixing the crystal alone still left it deaf, because the V4 has a switched
front end: an HF/VHF/UHF input bank with an on-board upconverter for HF. The stock
R828D init leaves every V4 input <em>off</em>, so even perfectly tuned, no RF reaches the
mixer. And detection can’t reliably tell a V4 from a generic R828D — the
distinguishing signal is the USB iManufacturer/iProduct strings, which are
sometimes blank or non-standard on real units.</p>

<p><strong>The fix — three parts.</strong> First, an explicit <code class="language-plaintext highlighter-rouge">SetBlogV4</code> that restores the right
crystal and arms the V4 path:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go</span>
<span class="k">func</span> <span class="p">(</span><span class="n">r</span> <span class="o">*</span><span class="n">R82xx</span><span class="p">)</span> <span class="n">SetBlogV4</span><span class="p">(</span><span class="n">lite</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">r</span><span class="o">.</span><span class="n">blogV4</span> <span class="o">=</span> <span class="no">true</span>
    <span class="n">r</span><span class="o">.</span><span class="n">blogV4L</span> <span class="o">=</span> <span class="n">lite</span>
    <span class="n">r</span><span class="o">.</span><span class="n">xtalHz</span> <span class="o">=</span> <span class="n">r82xxXtalHz</span> <span class="c">// 28.8 MHz, overriding the R828D 16 MHz default</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Detection keys off the USB strings, but because that misses some units, the
driver also exposes a <em>manual</em> override all the way up at the <code class="language-plaintext highlighter-rouge">Device</code> level —
<code class="language-plaintext highlighter-rouge">SetBlogV4</code> implements <code class="language-plaintext highlighter-rouge">sdr.BlogV4Forcer</code>, and the pool applies it from a config
hint before the first tune. Autodetection when it can; explicit override when it
can’t.</p>

<p>Second, per-band input switching. The V4 routes HF through an upconverter (so the
R828D actually sees <code class="language-plaintext highlighter-rouge">hz + 28.8 MHz</code>), and switches a physical input bank per band.
<code class="language-plaintext highlighter-rouge">SetFreq</code> adjusts the PLL target for HF and then drives the input switches:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go</span>
<span class="n">target</span> <span class="o">:=</span> <span class="n">hz</span>
<span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">blogV4</span> <span class="o">&amp;&amp;</span> <span class="n">hz</span> <span class="o">&lt;=</span> <span class="n">r82xxV4HFCrossHz</span> <span class="p">{</span>
    <span class="n">target</span> <span class="o">=</span> <span class="n">hz</span> <span class="o">+</span> <span class="n">r82xxXtalHz</span> <span class="c">// HF reaches the mixer through the upconverter</span>
<span class="p">}</span>
<span class="c">// ...setMux(target), then:</span>
<span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">blogV4</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">r</span><span class="o">.</span><span class="n">applyBlogV4Band</span><span class="p">(</span><span class="n">hz</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"r82xx SetFreq: v4 band: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">applyBlogV4Band</code> drives the notch filter, the HF tracking-filter bypass, and the
HF/VHF/UHF input relays — ported verbatim from the rtlsdr-blog fork’s
<code class="language-plaintext highlighter-rouge">r82xx_set_freq</code> V4 block. It caches the last-selected band in a <code class="language-plaintext highlighter-rouge">v4Band</code> enum so
it only rewrites the input switches when the band actually changes:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go</span>
<span class="n">band</span> <span class="o">:=</span> <span class="n">v4BandFor</span><span class="p">(</span><span class="n">hz</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">blogV4L</span><span class="p">)</span>
<span class="c">// HF: bypass tracking filter (re-applied every tune since setMux rewrites 0x1A/0x1B)</span>
<span class="k">if</span> <span class="n">band</span> <span class="o">==</span> <span class="n">v4BandHF</span> <span class="p">{</span> <span class="c">/* ...writeRegMask(0x1A,...) ... */</span> <span class="p">}</span>
<span class="c">// Only rewrite the input switches on a band change.</span>
<span class="k">if</span> <span class="n">band</span> <span class="o">==</span> <span class="n">r</span><span class="o">.</span><span class="n">v4Input</span> <span class="p">{</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
<span class="n">r</span><span class="o">.</span><span class="n">v4Input</span> <span class="o">=</span> <span class="n">band</span>
<span class="c">// ...cable2 = HF input, GPIO5 upconverter relay, cable1 = VHF, air-in = UHF...</span>
</code></pre></div></div>

<p>Third, the gain path. The V4 is a marginal-signal dongle, and there was a latent
AGC-mode bug that compounded the deafness: in AGC mode librtlsdr pins the VGA at a
fixed +16.3 dB, but <code class="language-plaintext highlighter-rouge">SetGain</code> is a no-op in AGC mode, so the VGA was being left at
its init default — running the front end <strong>~17 dB low</strong>. <code class="language-plaintext highlighter-rouge">SetGainMode</code> now writes
the VGA in the AGC branch:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go</span>
<span class="c">// AGC mode: pin the VGA at librtlsdr's fixed default (+16.3 dB).</span>
<span class="k">if</span> <span class="o">!</span><span class="n">manual</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">r</span><span class="o">.</span><span class="n">writeRegMask</span><span class="p">(</span><span class="m">0x0C</span><span class="p">,</span> <span class="m">0x0B</span><span class="p">,</span> <span class="m">0x9F</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">err</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There was even a fourth, subtler V4 issue tucked into the PLL: the rtlsdr-blog
fork lowers the VCO power reference from 2 to 1 for the R828D, and without that
the V4’s LO mistunes and receives noise while an R820T2 on the same signal
decodes cleanly. <code class="language-plaintext highlighter-rouge">vcoPowerRef()</code> returns 1 for <code class="language-plaintext highlighter-rouge">TypeR828D</code>. The V4 needed <em>every
one</em> of these to receive — which is exactly why it was so painful to chase.</p>

<p>To make the state visible, <code class="language-plaintext highlighter-rouge">TunerDiag</code>/<code class="language-plaintext highlighter-rouge">IsBlogV4</code>/<code class="language-plaintext highlighter-rouge">XtalHz</code> surface the effective
crystal and whether the V4 path armed, so the pool can log a boot-time line: a 16
MHz R828D means the V4 path did <em>not</em> arm and the LO is mistuned by 1.8×; 28.8 MHz
means <code class="language-plaintext highlighter-rouge">SetBlogV4</code> ran.</p>

<h2 id="the-second-problem-a-17-byte-i2c-burst-that-stalls-issue-248">The second problem: a 17-byte I2C burst that stalls (issue #248)</h2>

<p><strong>The symptom.</strong> Two NESDR SMArt v5 units would fail tuner init with an <code class="language-plaintext highlighter-rouge">EPIPE</code>
(Linux) / <code class="language-plaintext highlighter-rouge">ERROR_GEN_FAILURE</code> (Windows) on the very first multi-byte I2C burst —
the 27-byte R82xx init flood. Detection succeeded; the burst write that follows it
did not.</p>

<p><strong>The root cause.</strong> librtlsdr assumes the chip’s I2C-bridge FIFO can swallow a
16-byte write (<code class="language-plaintext highlighter-rouge">NMAX_WRITES = 16</code>). On that specific firmware revision the FIFO
depth appears to be smaller, so the 17-byte first chunk (1 address byte + 16 data
bytes) NACKs. An earlier fix added a per-chunk retry; it wasn’t enough.</p>

<p><strong>The fix.</strong> <code class="language-plaintext highlighter-rouge">writeBurstRaw</code> halves the chunk size on a stall — 16 → 8 → 4 — until
one size succeeds:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sdr/rtlsdr/tuners/r82xx.go</span>
<span class="k">func</span> <span class="p">(</span><span class="n">r</span> <span class="o">*</span><span class="n">R82xx</span><span class="p">)</span> <span class="n">writeBurstRaw</span><span class="p">(</span><span class="n">addr</span> <span class="kt">uint8</span><span class="p">,</span> <span class="n">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="k">var</span> <span class="n">lastErr</span> <span class="kt">error</span>
    <span class="k">for</span> <span class="n">chunkSize</span> <span class="o">:=</span> <span class="n">r82xxBurstMaxData</span><span class="p">;</span> <span class="n">chunkSize</span> <span class="o">&gt;=</span> <span class="n">r82xxBurstMinData</span><span class="p">;</span> <span class="n">chunkSize</span> <span class="o">/=</span> <span class="m">2</span> <span class="p">{</span>
        <span class="n">err</span> <span class="o">:=</span> <span class="n">r</span><span class="o">.</span><span class="n">writeBurstAtSize</span><span class="p">(</span><span class="n">addr</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">chunkSize</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="o">!</span><span class="n">isI2CBurstStall</span><span class="p">(</span><span class="n">err</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">err</span>
        <span class="p">}</span>
        <span class="n">lastErr</span> <span class="o">=</span> <span class="n">err</span>
        <span class="k">if</span> <span class="n">chunkSize</span> <span class="o">&gt;</span> <span class="n">r82xxBurstMinData</span> <span class="p">{</span>
            <span class="n">time</span><span class="o">.</span><span class="n">Sleep</span><span class="p">(</span><span class="n">r82xxBurstRetryDelayMillis</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"tried chunk sizes 16,8,4; all stalled: %w"</span><span class="p">,</span> <span class="n">lastErr</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two details make this robust. The stall predicate has to be
cross-platform — the same logical stall is a raw <code class="language-plaintext highlighter-rouge">syscall.EPIPE</code> on Linux and a
mapped <code class="language-plaintext highlighter-rouge">usb.ErrPipeStalled</code> on Windows, so <code class="language-plaintext highlighter-rouge">isI2CBurstStall</code> checks both;
checking only <code class="language-plaintext highlighter-rouge">EPIPE</code> meant the whole recovery silently never fired on Windows.
And there’s a detection-side dependency: <code class="language-plaintext highlighter-rouge">Detect</code> deliberately toggles the I2C
repeater <em>off</em> before returning so that <code class="language-plaintext highlighter-rouge">Init</code>’s leading <code class="language-plaintext highlighter-rouge">SetI2CRepeater(true)</code> is
a <em>fresh</em> wire write — that explicit “kick” is load-bearing on NESDR v5 to arm the
bridge, even though the cache from Part 7 would otherwise elide it.</p>

<h2 id="the-design-principle-defensive-hardware-quirk-handling">The design principle: defensive hardware-quirk handling</h2>

<p>Both bugs come from the same place: <strong>real hardware deviates from the reference,
and when it does, autodetection is not enough — you need explicit, observable
overrides.</strong></p>

<p>The Blog V4 looks like a generic R828D until it doesn’t. The NESDR v5 looks like
a standard RTL-SDR until its FIFO chokes on a standard-sized write. A driver that
assumes every chip matches the datasheet — or even matches librtlsdr — comes up
deaf on exactly the dongles people actually buy.</p>

<h3 id="how-that-principle-shaped-the-go-code">How that principle shaped the Go code</h3>

<ul>
  <li><strong>Autodetect first, override explicitly.</strong> USB-string detection arms the V4
path automatically when it can; <code class="language-plaintext highlighter-rouge">SetBlogV4</code> / <code class="language-plaintext highlighter-rouge">sdr.BlogV4Forcer</code> is the manual
escape hatch for units it misses. The default path stays correct for the common
case and the override is one config hint away.</li>
  <li><strong>Quirk state is observable.</strong> <code class="language-plaintext highlighter-rouge">IsBlogV4</code>, <code class="language-plaintext highlighter-rouge">XtalHz</code>, and <code class="language-plaintext highlighter-rouge">TunerDiag</code> exist so a
failed override shows up as a boot-time diagnostic (“16 MHz R828D → mistuned
1.8×”) instead of a silent deaf dongle.</li>
  <li><strong>Recovery degrades gracefully.</strong> The burst write doesn’t give up at the first
stall — it halves the chunk size and retries, so one firmware’s small FIFO
costs a few extra round-trips instead of a hard failure. The stall predicate is
written cross-platform so the recovery fires on every OS.</li>
  <li><strong>Quirk reproduction stays faithful underneath.</strong> Every V4-specific write —
band switching, VGA pinning, the VCO power reference — is ported verbatim from
the rtlsdr-blog fork with a comment tying it to the source, so the override path
is just as byte-faithful as the default path.</li>
</ul>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>The RTL2832U is initialized and the R82xx is tuned and locked. The only thing
left is to actually <em>move samples</em> — to keep a 2.4 MS/s bulk-IN stream flowing
without dropping IQ.
<a href="/blog/deep-dives/rf-front-end-09-rtlsdr-streaming-gc-churn/">Part 9</a>
takes apart the streaming layer and the GC-churn bug that was shedding a quarter
of the control channel’s live IQ.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Why does the R828D default to 16 MHz if the V4 needs 28.8?</strong>
Because a <em>generic</em> R828D really does run from a separate 16 MHz crystal — that
default is correct for the common case. The Blog V4 is the special case, and
<code class="language-plaintext highlighter-rouge">SetBlogV4</code> is how we mark it. Defaulting to 28.8 would mistune every non-V4
R828D instead.</p>

<p><strong>Why not just always chunk I2C writes at 4 bytes to dodge issue #248?</strong>
Because that triples the round-trips for every tuner init on every dongle to work
around one firmware revision. The halving fallback only pays the cost when a chunk
actually stalls; healthy chips still write 16 bytes at a time.</p>

<p><strong>Is the shadow cache ever wrong?</strong>
Only for the read-only status registers (0x00–0x04), which we refresh by reading
the chip. The writable shadow (0x05–0x1F) is authoritative because those
registers can’t be read back anyway — the shadow <em>is</em> the source of truth, and
<code class="language-plaintext highlighter-rouge">Init</code> primes it with the init-flood values before the first burst.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 8 of 14</strong> · ←
<a href="/blog/deep-dives/rf-front-end-07-rtlsdr-rtl2832u-bringup/">Part 7</a>
· Next →
<a href="/blog/deep-dives/rf-front-end-09-rtlsdr-streaming-gc-churn/">Part 9: RTL-SDR III — IQ streaming &amp; the GC-churn bug</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="sdr" /><category term="go" /><category term="rtl-sdr" /><category term="r82xx" /><category term="software-design" /><summary type="html"><![CDATA[GopherTrunk's pure-Go R820T/R828D tuner driver — PLL tuning, a shadow-register cache that makes read-modify-write free, and tuner auto-detection. Plus the headline bug: why the RTL-SDR Blog V4 came up deaf and mistuned by 1.8×, and how a manual override plus per-band input switching fixed it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>