<?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-10-15T14:43:04-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">Beyond Voice, Part 14: Adding a Decoder in a Day — The Checklist</title><link href="https://gophertrunk.org/blog/deep-dives/beyond-voice-14-adding-a-decoder-checklist/" rel="alternate" type="text/html" title="Beyond Voice, Part 14: Adding a Decoder in a Day — The Checklist" /><published>2026-09-30T00:00:00-05:00</published><updated>2026-09-30T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/beyond-voice-14-adding-a-decoder-checklist</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/beyond-voice-14-adding-a-decoder-checklist/"><![CDATA[<p><em>Part 14 — the last — of <strong>Beyond Voice</strong>, a 14-part deep dive into
everything GopherTrunk decodes that is not a trunked voice call — the AFSK
signalling formats, paging, APRS, ADS-B, AIS, DSC, LoRa, M17 and the amateur
digital-voice family — and the one eleven-place wiring pattern that carries
each of them from a burst on the air to a row in the web console.
<a href="/blog/deep-dives/beyond-voice-13-dstar-ysf-dpmr/">Part 13</a>
finished the tour of physical layers; this closing part turns the pattern
into a checklist, names the tests that fail when a place is missed, sorts
message decoders from trunked protocols, and lists what remains open.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> A GopherTrunk decoder is done when eleven places agree;
FleetSync is the worked example: protocol core + <code class="language-plaintext highlighter-rouge">afsk</code> front end
(<code class="language-plaintext highlighter-rouge">internal/radio/fleetsync</code>), <code class="language-plaintext highlighter-rouge">events.KindFleetSyncMessage</code>,
<code class="language-plaintext highlighter-rouge">storage.FleetSyncLog</code> + the <code class="language-plaintext highlighter-rouge">fleetsync_log</code> table + <code class="language-plaintext highlighter-rouge">decoderLogTables</code>,
<code class="language-plaintext highlighter-rouge">GET /api/v1/fleetsync/messages</code>, the <code class="language-plaintext highlighter-rouge">/fleetsync</code> panel + nav entry,
<code class="language-plaintext highlighter-rouge">FleetSyncConfig</code> + the <code class="language-plaintext highlighter-rouge">fleetsync</code> tab key, FieldMeta help, the Config
Builder <code class="language-plaintext highlighter-rouge">types.ts</code> + section, the daemon wiring, the <code class="language-plaintext highlighter-rouge">doctor</code> “needs
storage.path” list, and the <code class="language-plaintext highlighter-rouge">config.example.yaml</code> block + <code class="language-plaintext highlighter-rouge">docs/fleetsync.md</code>.
Three tests police it — <code class="language-plaintext highlighter-rouge">TestFieldHelpCoverage</code>,
<code class="language-plaintext highlighter-rouge">TestConfigSchemaCoveredByWebBuilder</code>, and the web <code class="language-plaintext highlighter-rouge">registry.test.ts</code> /
<code class="language-plaintext highlighter-rouge">App.panels.test.tsx</code> route lists — plus the <code class="language-plaintext highlighter-rouge">web-typecheck</code> CI job, because
<code class="language-plaintext highlighter-rouge">vitest</code> never typechecks. Trunked protocols take a different route
(<code class="language-plaintext highlighter-rouge">Protocol</code> enum, pipeline factory, composer voice kind, recorder vocoder
map). M17 shows a decoder that stopped short: REST but no panel.</p>
</blockquote>

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

<ul>
  <li><strong>The pattern is the product.</strong> Twelve physical layers reduce to one
shape because everything above the framer — bus, storage, REST, panel,
config, help, doctor, example, docs — is identical machinery.</li>
  <li><strong>Tests police places, not behaviour.</strong> <code class="language-plaintext highlighter-rouge">TestFieldHelpCoverage</code> and
<code class="language-plaintext highlighter-rouge">TestConfigSchemaCoveredByWebBuilder</code> walk <code class="language-plaintext highlighter-rouge">config.Config</code> by reflection;
the web route tests walk <code class="language-plaintext highlighter-rouge">App.tsx</code>.</li>
  <li><strong>Green <code class="language-plaintext highlighter-rouge">npm test</code> is not a built console.</strong> <code class="language-plaintext highlighter-rouge">vitest</code> never typechecks;
only <code class="language-plaintext highlighter-rouge">tsc --noEmit</code> catches an unused parameter, so <code class="language-plaintext highlighter-rouge">web-typecheck</code> runs
it over all five SPAs.</li>
  <li><strong>Wired is not verified.</strong> All eleven places can be present and the
decoder still synthetic-green; the open list below is the ledger.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Field help guard</td>
      <td>every exported <code class="language-plaintext highlighter-rouge">config</code> field needs <code class="language-plaintext highlighter-rouge">FieldMeta.Help</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/configbuilder/fieldmeta_test.go</code></td>
    </tr>
    <tr>
      <td>Web schema guard</td>
      <td>every config field appears in the builder’s <code class="language-plaintext highlighter-rouge">types.ts</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/configbuilder/webschema_test.go</code></td>
    </tr>
    <tr>
      <td>TUI twin</td>
      <td>every leaf gets a handled widget kind</td>
      <td><code class="language-plaintext highlighter-rouge">internal/configtui/meta_drift_test.go</code> (<code class="language-plaintext highlighter-rouge">TestEveryLeafEditable</code>)</td>
    </tr>
    <tr>
      <td>Route guards</td>
      <td>every <code class="language-plaintext highlighter-rouge">&lt;Route&gt;</code> has a nav entry and mounts clean</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/nav/registry.test.ts</code>, <code class="language-plaintext highlighter-rouge">web/src/App.panels.test.tsx</code></td>
    </tr>
    <tr>
      <td>Typecheck job</td>
      <td><code class="language-plaintext highlighter-rouge">tsc --noEmit</code> over all five SPAs</td>
      <td><code class="language-plaintext highlighter-rouge">.github/workflows/ci.yml</code> (<code class="language-plaintext highlighter-rouge">web-typecheck</code>)</td>
    </tr>
    <tr>
      <td>Doctor preflight</td>
      <td>names decoders configured without <code class="language-plaintext highlighter-rouge">storage.path</code></td>
      <td><code class="language-plaintext highlighter-rouge">cmd/gophertrunk/preflight.go</code></td>
    </tr>
    <tr>
      <td>Retention membership</td>
      <td>decoder log tables swept by <code class="language-plaintext highlighter-rouge">retention.log_days</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/storage/retention.go</code> (<code class="language-plaintext highlighter-rouge">decoderLogTables</code>)</td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>The eleven places</strong> — one table, FleetSync’s file at each.</li>
  <li><strong>The three tests that police it</strong> — reflection walks and route lists.</li>
  <li><strong>The CI job vitest cannot replace</strong> — <code class="language-plaintext highlighter-rouge">web-typecheck</code> and TS6133.</li>
  <li><strong>Message decoder or trunked protocol?</strong> — the decision table, and M17.</li>
  <li><strong>What’s still open</strong> — the honest ledger.</li>
  <li><strong>Where to go from here</strong> — the series index and its siblings.</li>
</ul>

<h2 id="the-eleven-places">The eleven places</h2>

<p><a href="/blog/deep-dives/beyond-voice-01-eleven-place-pattern/">Part 1</a>
introduced the pattern through FleetSync’s landing
(<a href="/blog/deep-dives/beyond-voice-04-fleetsync-wav-that-lied/">Part 4</a>);
here it is as the checklist, with the real file at every step and the test
that fails when the row is skipped.</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Place</th>
      <th>FleetSync’s file</th>
      <th>Policed by</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Protocol core + front end</td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/fleetsync/framer.go</code>, <code class="language-plaintext highlighter-rouge">fleetsync/afsk/receiver.go</code></td>
      <td><code class="language-plaintext highlighter-rouge">TestReceiverPublishesRealAirBurstsOnBus</code></td>
    </tr>
    <tr>
      <td>2</td>
      <td>Bus kind</td>
      <td><code class="language-plaintext highlighter-rouge">internal/events/bus.go</code> (<code class="language-plaintext highlighter-rouge">KindFleetSyncMessage = "fleetsync.message"</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">docs/api-events.md</code> passthrough list</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Storage log + table + retention</td>
      <td><code class="language-plaintext highlighter-rouge">internal/storage/fleetsynclog.go</code>, <code class="language-plaintext highlighter-rouge">sqlite.go</code> (<code class="language-plaintext highlighter-rouge">fleetsync_log</code>), <code class="language-plaintext highlighter-rouge">retention.go</code></td>
      <td><code class="language-plaintext highlighter-rouge">TestFleetSyncLogSweptByRetention</code></td>
    </tr>
    <tr>
      <td>4</td>
      <td>REST route + provider</td>
      <td><code class="language-plaintext highlighter-rouge">internal/api/handlers_fleetsync.go</code>, <code class="language-plaintext highlighter-rouge">server.go</code> (<code class="language-plaintext highlighter-rouge">Options.FleetSync</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">TestFleetSyncMessagesReturns503WhenNotWired</code></td>
    </tr>
    <tr>
      <td>5</td>
      <td>Web client, panel, route, nav</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/api/fleetsync.ts</code>, <code class="language-plaintext highlighter-rouge">panels/FleetSync.tsx</code>, <code class="language-plaintext highlighter-rouge">App.tsx</code>, <code class="language-plaintext highlighter-rouge">nav/registry.ts</code></td>
      <td><code class="language-plaintext highlighter-rouge">registry.test.ts</code>, <code class="language-plaintext highlighter-rouge">App.panels.test.tsx</code>, <code class="language-plaintext highlighter-rouge">FleetSync.test.tsx</code></td>
    </tr>
    <tr>
      <td>6</td>
      <td>Config struct + tab key</td>
      <td><code class="language-plaintext highlighter-rouge">internal/config/config_peripherals.go</code> (<code class="language-plaintext highlighter-rouge">FleetSyncConfig</code>), <code class="language-plaintext highlighter-rouge">config.go</code> (<code class="language-plaintext highlighter-rouge">"fleetsync": true</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">TestConfigSchemaCoveredByWebBuilder</code></td>
    </tr>
    <tr>
      <td>7</td>
      <td>Field help</td>
      <td><code class="language-plaintext highlighter-rouge">internal/configbuilder/fieldmeta.go</code>, <code class="language-plaintext highlighter-rouge">sections.go</code></td>
      <td><code class="language-plaintext highlighter-rouge">TestFieldHelpCoverage</code>, <code class="language-plaintext highlighter-rouge">TestSectionMetaComplete</code></td>
    </tr>
    <tr>
      <td>8</td>
      <td>Config Builder</td>
      <td><code class="language-plaintext highlighter-rouge">web/configbuilder/src/api/types.ts</code>, <code class="language-plaintext highlighter-rouge">sections/FleetSync.tsx</code>, <code class="language-plaintext highlighter-rouge">sections/index.tsx</code></td>
      <td><code class="language-plaintext highlighter-rouge">TestConfigSchemaCoveredByWebBuilder</code>, <code class="language-plaintext highlighter-rouge">web-typecheck</code></td>
    </tr>
    <tr>
      <td>9</td>
      <td>Daemon wiring</td>
      <td><code class="language-plaintext highlighter-rouge">cmd/gophertrunk/daemon.go</code> (receivers, <code class="language-plaintext highlighter-rouge">NewFleetSyncLog</code>, provider)</td>
      <td><code class="language-plaintext highlighter-rouge">TestDaemonWiresFleetSyncChannels</code>, <code class="language-plaintext highlighter-rouge">TestDaemonOpensFleetSyncLogWithStorage</code></td>
    </tr>
    <tr>
      <td>10</td>
      <td>Doctor preflight</td>
      <td><code class="language-plaintext highlighter-rouge">cmd/gophertrunk/preflight.go</code> (<code class="language-plaintext highlighter-rouge">needs = append(needs, "fleetsync")</code>)</td>
      <td>the WARN text below</td>
    </tr>
    <tr>
      <td>11</td>
      <td>Example config + docs</td>
      <td><code class="language-plaintext highlighter-rouge">config.example.yaml</code> (<code class="language-plaintext highlighter-rouge">fleetsync:</code> block), <code class="language-plaintext highlighter-rouge">docs/fleetsync.md</code>, <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code></td>
      <td>the 4 Sep rule; <code class="language-plaintext highlighter-rouge">guide-advanced.md</code> index</td>
    </tr>
  </tbody>
</table>

<p>Three rows are where earlier landings went wrong. <strong>Row 3</strong> is one
generic: <code class="language-plaintext highlighter-rouge">storage.FleetSyncLog</code> embeds <code class="language-plaintext highlighter-rouge">*eventLog[FleetSyncMessage]</code>, the
shared subscribe → drain → close lifecycle, so a new decoder supplies only
its <code class="language-plaintext highlighter-rouge">insert</code> and <code class="language-plaintext highlighter-rouge">Recent</code> SQL — and must add its table to
<code class="language-plaintext highlighter-rouge">decoderLogTables</code>, or <code class="language-plaintext highlighter-rouge">retention.log_days</code> never sweeps it
(<a href="/blog/deep-dives/recording-streaming-10-call-log-sqlite/">Recording Part 10</a>).
<strong>Row 4</strong> returns a bare array; the History panel once read <code class="language-plaintext highlighter-rouge">r.rows</code> from
a <code class="language-plaintext highlighter-rouge">{"calls": […]}</code> envelope and rendered “No calls” for every query, so the
row 5 panel test renders a real burst. <strong>Row 10</strong> exists because message decoders surface <em>only</em>
through SQLite: with <code class="language-plaintext highlighter-rouge">storage.path</code> empty the receiver runs, the route
returns 503 and the panel stays empty — a silent failure (#565) that
<code class="language-plaintext highlighter-rouge">doctor</code> now names:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WRN storage.path is empty but these decoders need it to surface decoded
    messages: paging, fleetsync — they will run, but their REST endpoints
    (e.g. /api/v1/pager/messages) return 503 and the web panels stay empty.
    Set storage.path to enable persistence.
</code></pre></div></div>

<p>Row 11’s rule dates from 4 September, when <code class="language-plaintext highlighter-rouge">diversity_capture_format</code>
shipped in code but not in <code class="language-plaintext highlighter-rouge">config.example.yaml</code>.</p>

<h2 id="the-three-tests-that-police-it">The three tests that police it</h2>

<p>The guards walk the <em>structure</em>, so they fail the day a place is skipped.
The first walks <code class="language-plaintext highlighter-rouge">config.Config</code> by reflection and demands help text for
every exported field of every struct in <code class="language-plaintext highlighter-rouge">internal/config</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/configbuilder/fieldmeta_test.go (shape)</span>
<span class="k">func</span> <span class="n">TestFieldHelpCoverage</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">walk</span> <span class="o">=</span> <span class="k">func</span><span class="p">(</span><span class="n">rt</span> <span class="n">reflect</span><span class="o">.</span><span class="n">Type</span><span class="p">)</span> <span class="p">{</span>
        <span class="c">/* only structs whose PkgPath ends in internal/config */</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">rt</span><span class="o">.</span><span class="n">NumField</span><span class="p">();</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
            <span class="n">f</span> <span class="o">:=</span> <span class="n">rt</span><span class="o">.</span><span class="n">Field</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>
            <span class="k">if</span> <span class="o">!</span><span class="n">isRoot</span> <span class="o">&amp;&amp;</span> <span class="n">FieldMetaFor</span><span class="p">(</span><span class="n">rt</span><span class="o">.</span><span class="n">Name</span><span class="p">(),</span> <span class="n">f</span><span class="o">.</span><span class="n">Name</span><span class="p">)</span><span class="o">.</span><span class="n">Help</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
                <span class="n">missing</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">missing</span><span class="p">,</span> <span class="n">rt</span><span class="o">.</span><span class="n">Name</span><span class="p">()</span><span class="o">+</span><span class="s">"."</span><span class="o">+</span><span class="n">f</span><span class="o">.</span><span class="n">Name</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="n">walk</span><span class="p">(</span><span class="n">f</span><span class="o">.</span><span class="n">Type</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="n">walk</span><span class="p">(</span><span class="n">reflect</span><span class="o">.</span><span class="n">TypeOf</span><span class="p">(</span><span class="n">config</span><span class="o">.</span><span class="n">Config</span><span class="p">{}))</span>
    <span class="c">/* t.Errorf listing every missing Struct.Field */</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It is load-bearing for both editors: the terminal and web builders read
help from the same registry (the web store fetches <code class="language-plaintext highlighter-rouge">/config/fieldmeta</code>), so
a <code class="language-plaintext highlighter-rouge">FleetSyncChannelConfig.DropBadCRC</code> without a <code class="language-plaintext highlighter-rouge">Help</code> ships in neither. The
second test reads the web builder’s TypeScript schema off disk —
<code class="language-plaintext highlighter-rouge">readWebTypes</code> resolves <code class="language-plaintext highlighter-rouge">web/configbuilder/src/api/types.ts</code> from
<code class="language-plaintext highlighter-rouge">runtime.Caller</code> — and fails when a config field has no counterpart there
and is not on the round-trip allow-list, “a config change that the web
Config Builder would silently drop on save/load”. The TUI has its twin,
<code class="language-plaintext highlighter-rouge">TestEveryLeafEditable</code>. The third is on the web side: <code class="language-plaintext highlighter-rouge">registry.test.ts</code>
holds a <code class="language-plaintext highlighter-rouge">ROUTED_PATHS</code> list — “every top-level route mounted by App.tsx
must have a registry entry, otherwise it becomes unreachable by navigation
(the pre-overhaul bug, where six DSP panels were orphaned)” — and
<code class="language-plaintext highlighter-rouge">App.panels.test.tsx</code> mounts each listed route connected and asserts the
error boundary never trips, with <code class="language-plaintext highlighter-rouge">vi.mock("./api/fleetsync")</code> supplying the
client
(<a href="/blog/deep-dives/operator-cockpit-14-testing-uis/">Operator Cockpit Part 14</a>).
Adding <code class="language-plaintext highlighter-rouge">/fleetsync</code> to <code class="language-plaintext highlighter-rouge">App.tsx</code> without both lists fails one of them.</p>

<h2 id="the-ci-job-vitest-cannot-replace">The CI job vitest cannot replace</h2>

<p>This guard came from the console, not a decoder. <code class="language-plaintext highlighter-rouge">npm test</code> runs <code class="language-plaintext highlighter-rouge">vitest</code>,
which transpiles with esbuild and never typechecks; <code class="language-plaintext highlighter-rouge">npm run build</code> is
<code class="language-plaintext highlighter-rouge">tsc --noEmit &amp;&amp; vite build</code>. Three <code class="language-plaintext highlighter-rouge">TS6133</code> unused-parameter errors in
<code class="language-plaintext highlighter-rouge">web/src/api/reconnectingSocket.test.ts</code> landed on main with green SPA
jobs and broke <code class="language-plaintext highlighter-rouge">make web-build</code> for an operator. The only job that caught
them was “Windows installer (PR)” — not a required check — so the PR merged
red and read as passed.</p>

<p><code class="language-plaintext highlighter-rouge">ci.yml</code> now carries <code class="language-plaintext highlighter-rouge">web-typecheck</code>, matrixed over <code class="language-plaintext highlighter-rouge">web</code>,
<code class="language-plaintext highlighter-rouge">web/configbuilder</code>, <code class="language-plaintext highlighter-rouge">web/siglab</code>, <code class="language-plaintext highlighter-rouge">web/rfscope</code> and <code class="language-plaintext highlighter-rouge">web/cryptolab</code> — the
last two built by no other workflow — running <code class="language-plaintext highlighter-rouge">npm run typecheck</code> in each. For a decoder landing: run <code class="language-plaintext highlighter-rouge">cd web &amp;&amp; npm run typecheck</code> before
pushing a panel, remember <code class="language-plaintext highlighter-rouge">tsconfig.json</code> sets <code class="language-plaintext highlighter-rouge">noUnusedLocals</code> and
<code class="language-plaintext highlighter-rouge">noUnusedParameters</code> with test files inside <code class="language-plaintext highlighter-rouge">include: ["src"]</code> (an unused
mock parameter needs a leading underscore), and check the non-required
jobs on the PR too.</p>

<h2 id="message-decoder-or-trunked-protocol">Message decoder or trunked protocol?</h2>

<p>Not every decoder in this series took the eleven-place route. The AFSK,
paging, APRS, ADS-B, AIS, DSC, LoRa and M17 decoders are <strong>message
decoders</strong> with their own config section, bus kind, table and panel. The
Part 13 trio are <strong>trunked protocols</strong>: they join the engine, whose Active,
History and recorder are their UI. Decide before touching a file:</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th>Message decoder (FleetSync shape)</th>
      <th>Trunked protocol (dPMR shape)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Identity</td>
      <td>own config section (<code class="language-plaintext highlighter-rouge">fleetsync.channels</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">Protocol</code> enum in <code class="language-plaintext highlighter-rouge">internal/trunking/site.go</code></td>
    </tr>
    <tr>
      <td>Channel rate</td>
      <td>receiver picks (9600 Hz for 1200-baud FFSK)</td>
      <td><code class="language-plaintext highlighter-rouge">ddcTargetForProtocol</code></td>
    </tr>
    <tr>
      <td>Decode entry</td>
      <td>daemon builds receivers from config</td>
      <td>factory in the <code class="language-plaintext highlighter-rouge">ccdecoder</code> pipelines map</td>
    </tr>
    <tr>
      <td>Events</td>
      <td>a new <code class="language-plaintext highlighter-rouge">events.Kind</code> + payload struct</td>
      <td><code class="language-plaintext highlighter-rouge">KindCCLocked</code> / <code class="language-plaintext highlighter-rouge">KindGrant</code> with <code class="language-plaintext highlighter-rouge">trunking.Grant</code></td>
    </tr>
    <tr>
      <td>Storage</td>
      <td>own <code class="language-plaintext highlighter-rouge">*_log</code> table in <code class="language-plaintext highlighter-rouge">decoderLogTables</code></td>
      <td><code class="language-plaintext highlighter-rouge">call_log</code> via the engine</td>
    </tr>
    <tr>
      <td>UI</td>
      <td>own panel, route, nav entry, tab key</td>
      <td>Active / History / Scanner, unchanged</td>
    </tr>
    <tr>
      <td>Voice</td>
      <td>none</td>
      <td>composer <code class="language-plaintext highlighter-rouge">classifyVoiceKind</code> + recorder vocoder map</td>
    </tr>
    <tr>
      <td>Doctor</td>
      <td>in the “needs storage.path” list</td>
      <td>not needed</td>
    </tr>
  </tbody>
</table>

<p>The routes are exclusive by design: the D-STAR landing touched <code class="language-plaintext highlighter-rouge">site.go</code>,
<code class="language-plaintext highlighter-rouge">pipelines.go</code>, <code class="language-plaintext highlighter-rouge">composer.go</code> and <code class="language-plaintext highlighter-rouge">recorder.go</code> but no <code class="language-plaintext highlighter-rouge">App.tsx</code>.
M17 is the reminder that the message route must be walked to the end: it
has rows 1–4 and 6–11 — <code class="language-plaintext highlighter-rouge">KindM17LinkSetup</code>, <code class="language-plaintext highlighter-rouge">m17_log</code>,
<code class="language-plaintext highlighter-rouge">GET /api/v1/m17/linksetups</code>, <code class="language-plaintext highlighter-rouge">M17Config</code>, FieldMeta, <code class="language-plaintext highlighter-rouge">M17.tsx</code>, the example
block, the doctor entry, <code class="language-plaintext highlighter-rouge">docs/m17.md</code> — and no row 5: no <code class="language-plaintext highlighter-rouge">/m17</code> in
<code class="language-plaintext highlighter-rouge">registry.ts</code>, no panel in <code class="language-plaintext highlighter-rouge">App.tsx</code>, no <code class="language-plaintext highlighter-rouge">m17</code> tab key. Nothing fails,
because the route tests police routes that exist. That is the one gap the
checklist cannot automate, and why the list is written down.</p>

<figure class="lab-figure">
<svg viewBox="0 0 680 250" width="680" height="250" role="img" aria-label="The eleven places as two rows of boxes: data flow on top from front end to web panel, configuration below from config struct to example and docs. Braces mark the tests that police them, and a dashed panel box marks the place M17 never reached.">
  <rect x="8" y="30" width="112" height="30" fill="none" stroke="currentColor" />
  <text x="64" y="49" text-anchor="middle" fill="currentColor" font-size="8">1 front end + framer</text>
  <rect x="130" y="30" width="96" height="30" fill="none" stroke="currentColor" />
  <text x="178" y="49" text-anchor="middle" fill="currentColor" font-size="8">2 events.Kind</text>
  <rect x="236" y="30" width="120" height="30" fill="none" stroke="currentColor" />
  <text x="296" y="49" text-anchor="middle" fill="currentColor" font-size="8">3 storage log + table</text>
  <rect x="366" y="30" width="110" height="30" fill="none" stroke="currentColor" />
  <text x="421" y="49" text-anchor="middle" fill="currentColor" font-size="8">4 REST /api/v1/…</text>
  <rect x="486" y="30" width="186" height="30" fill="none" stroke="var(--accent)" stroke-dasharray="4 3" />
  <text x="579" y="49" text-anchor="middle" fill="var(--accent)" font-size="8">5 panel + route + nav (M17: missing)</text>
  <rect x="8" y="110" width="104" height="30" fill="none" stroke="currentColor" />
  <text x="60" y="129" text-anchor="middle" fill="currentColor" font-size="8">6 config struct</text>
  <rect x="122" y="110" width="96" height="30" fill="none" stroke="currentColor" />
  <text x="170" y="129" text-anchor="middle" fill="currentColor" font-size="8">7 FieldMeta help</text>
  <rect x="228" y="110" width="120" height="30" fill="none" stroke="currentColor" />
  <text x="288" y="129" text-anchor="middle" fill="currentColor" font-size="8">8 builder types.ts</text>
  <rect x="358" y="110" width="96" height="30" fill="none" stroke="currentColor" />
  <text x="406" y="129" text-anchor="middle" fill="currentColor" font-size="8">9 daemon wiring</text>
  <rect x="464" y="110" width="90" height="30" fill="none" stroke="currentColor" />
  <text x="509" y="129" text-anchor="middle" fill="currentColor" font-size="8">10 doctor</text>
  <rect x="564" y="110" width="108" height="30" fill="none" stroke="currentColor" />
  <text x="618" y="129" text-anchor="middle" fill="currentColor" font-size="8">11 example + docs</text>
  <path d="M 8 156 L 8 168 L 348 168 L 348 156" fill="none" stroke="var(--accent)" />
  <text x="178" y="184" text-anchor="middle" fill="var(--accent)" font-size="8">TestFieldHelpCoverage · TestConfigSchemaCoveredByWebBuilder · TestEveryLeafEditable</text>
  <path d="M 486 66 L 486 74 L 672 74 L 672 66" fill="none" stroke="var(--accent)" />
  <text x="579" y="92" text-anchor="middle" fill="var(--accent)" font-size="8">registry.test.ts · App.panels.test.tsx</text>
  <path d="M 228 200 L 228 212 L 672 212 L 672 200" fill="none" stroke="var(--fg-muted)" />
  <text x="450" y="228" text-anchor="middle" fill="var(--fg-muted)" font-size="8">web-typecheck: tsc --noEmit — what vitest never runs</text>
</svg>
<figcaption>Eleven places in two rows, and the guards that span them. A test can only police a place that exists — how M17 shipped with row 5 empty.</figcaption>
</figure>

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

<ul>
  <li><strong>One generic per layer.</strong> <code class="language-plaintext highlighter-rouge">eventLog[T]</code> for storage, <code class="language-plaintext highlighter-rouge">FieldMetaFor</code> for
help, <code class="language-plaintext highlighter-rouge">useDataPoll</code> for panels: a new decoder adds data, not machinery.</li>
  <li><strong>Registries are walked, not listed.</strong> The guards reflect over
<code class="language-plaintext highlighter-rouge">config.Config</code> and scan <code class="language-plaintext highlighter-rouge">App.tsx</code>’s routes — the structure is the
checklist.</li>
  <li><strong>Optional providers, nil-safe.</strong> A nil <code class="language-plaintext highlighter-rouge">api.Options.FleetSync</code> disables
the route with a 503 that names <code class="language-plaintext highlighter-rouge">storage.path</code>.</li>
  <li><strong>The doctor speaks the operator’s language.</strong> The preflight WARN names
the decoder, the endpoint and the fix, because the failure it prevents
was silent.</li>
</ul>

<h2 id="whats-still-open">What’s still open</h2>

<p>Each open item sits at a named gate:</p>

<ul>
  <li><strong>FleetSync on a live Kenwood fleet</strong> — offline-verified on the
<a href="https://github.com/MattCheramie/GopherTrunk/issues/1184">#1184</a> captures
(5/6 and 8/8 CRC-valid, Fleet 107 / Unit 1772); the reporter’s on-air lab
test is still open, and #437 with it.</li>
  <li><strong>M17’s row 5 and its voice</strong> — no <code class="language-plaintext highlighter-rouge">/m17</code> panel or tab key; no Codec 2
path; Golay matrix and slicer constants synthetic-verified only
(<a href="/blog/deep-dives/beyond-voice-12-m17-open-digital-voice/">Part 12</a>).</li>
  <li><strong>YSF’s FICH caller</strong> — <code class="language-plaintext highlighter-rouge">DecodeFICHOnAir</code> and <code class="language-plaintext highlighter-rouge">ProcessFICH</code> are tested but
unjoined on the hot path, so a Fusion repeater locks and never grants
(<a href="/blog/deep-dives/beyond-voice-13-dstar-ysf-dpmr/">Part 13</a>).</li>
  <li><strong>dPMR’s CSBK channel coding</strong> — the cyclic + rate-¾ convolutional +
interleave stage is absent; CSBKs are parsed as already-clean bits.</li>
  <li><strong>D-STAR and dPMR voice tables</strong> — placeholder deinterleaves, synthetic
round-trip only; the D-STAR header scrambler and interleaver await
MMDVMHost table confirmation. Each needs a voice capture.</li>
  <li><strong>Rows 10 and 11 have no test.</strong> A reflection walk diffing
<code class="language-plaintext highlighter-rouge">config.Config</code> against <code class="language-plaintext highlighter-rouge">config.example.yaml</code> would close that.</li>
</ul>

<p>None is blocked on effort; each is blocked on a capture, a reporter, or a
wiring step whose verification needs one — the closing line of
<a href="/blog/deep-dives/from-spec-to-shipping-14-definition-of-verified/">From Spec to Shipping Part 14</a>,
because it is the project’s operating principle: green synthetic is not
on-air correct.</p>

<h2 id="where-to-go-from-here">Where to go from here</h2>

<p>The <a href="/blog/series/beyond-voice/">series index</a>
lists all fourteen parts. For the machinery every row rests on,
<a href="/blog/series/sdr-internals/">SDR Internals</a> is the
foundation, starting with
<a href="/blog/deep-dives/sdr-internals-11-trunking-engine-event-bus/">Part 11</a>
where <code class="language-plaintext highlighter-rouge">events.Kind</code> begins. For the method behind every “unverified on air”
above — <a href="/blog/series/from-spec-to-shipping/">From Spec to Shipping</a>.
Two series launch alongside this one:
<a href="/blog/series/dmr-end-to-end/">DMR End to End</a>
follows the trunked-protocol route to decrypted voice, and
<a href="/blog/series/field-notebook/">The Field Notebook</a>
reads the daemon’s log one line family at a time.</p>

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

<p><strong>What files does a new GopherTrunk message decoder touch?</strong>
Eleven places: the protocol package and front end, an <code class="language-plaintext highlighter-rouge">events.Kind</code>, a
storage log with its SQLite table and retention membership, a REST handler,
the web client, panel, route and nav entry, the config struct
and tab key, FieldMeta help, the Config Builder <code class="language-plaintext highlighter-rouge">types.ts</code> and section, the
daemon wiring, the doctor preflight list, and <code class="language-plaintext highlighter-rouge">config.example.yaml</code> plus docs.</p>

<p><strong>Which tests fail if I forget a config field’s help text?</strong>
<code class="language-plaintext highlighter-rouge">TestFieldHelpCoverage</code> in <code class="language-plaintext highlighter-rouge">internal/configbuilder</code> walks every struct
reachable from <code class="language-plaintext highlighter-rouge">config.Config</code> by reflection and lists each exported field
without a non-empty <code class="language-plaintext highlighter-rouge">Help</code> in the FieldMeta registry. Both the terminal and
web Config Builders read that registry, so the field ships in neither
editor until help is written.</p>

<p><strong>Why does <code class="language-plaintext highlighter-rouge">npm test</code> pass on code that fails <code class="language-plaintext highlighter-rouge">npm run build</code>?</strong>
<code class="language-plaintext highlighter-rouge">vitest</code> transpiles with esbuild and never typechecks; <code class="language-plaintext highlighter-rouge">npm run build</code> runs
<code class="language-plaintext highlighter-rouge">tsc --noEmit</code> first. With <code class="language-plaintext highlighter-rouge">noUnusedLocals</code> and <code class="language-plaintext highlighter-rouge">noUnusedParameters</code> set,
an unused parameter is a <code class="language-plaintext highlighter-rouge">TS6133</code> error the tests never see. Run
<code class="language-plaintext highlighter-rouge">npm run typecheck</code> before pushing; the <code class="language-plaintext highlighter-rouge">web-typecheck</code> job also runs it.</p>

<p><strong>Why does my decoder’s REST endpoint return 503?</strong>
The daemon started without <code class="language-plaintext highlighter-rouge">storage.path</code>. Message decoders surface only
through SQLite, so with storage off the receiver runs but nothing persists,
and the handler answers 503 naming the fix. <code class="language-plaintext highlighter-rouge">gophertrunk doctor</code>
warns at preflight for every message decoder that needs storage.</p>

<p><strong>Should a new protocol be a message decoder or a trunked protocol?</strong>
If it grants voice onto a channel the engine follows, it is a trunked
protocol: add a <code class="language-plaintext highlighter-rouge">Protocol</code> enum, a pipeline factory, a composer voice kind
and a vocoder mapping. If it produces messages to log and list —
ANI bursts, pages, positions — it takes the eleven-place route.</p>

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

<p><strong>Part 14 of 14</strong> · ←
<a href="/blog/deep-dives/beyond-voice-13-dstar-ysf-dpmr/">Part 13: D-STAR, System Fusion &amp; dPMR</a>
· <a href="/blog/series/beyond-voice/">Back to the series index</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="beyond-voice" /><category term="checklist" /><category term="architecture" /><category term="testing" /><category term="methodology" /><category term="go" /><summary type="html"><![CDATA[The finale — the eleven places a new GopherTrunk decoder touches, from front end to config.example.yaml, with the real FleetSync file for each; the three tests that police the pattern and the CI job vitest cannot replace; a decision table for message decoders versus trunked protocols; and the honest list of what this series leaves open.]]></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">DMR End to End, Part 14: The DMR Playbook</title><link href="https://gophertrunk.org/blog/deep-dives/dmr-end-to-end-14-playbook/" rel="alternate" type="text/html" title="DMR End to End, Part 14: The DMR Playbook" /><published>2026-09-30T00:00:00-05:00</published><updated>2026-09-30T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/dmr-end-to-end-14-playbook</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/dmr-end-to-end-14-playbook/"><![CDATA[<p><em>Part 14 — the last — of <strong>DMR End to End</strong>, a 14-part deep dive that
follows the world’s most widely deployed digital PMR protocol through
GopherTrunk — from a 4FSK carrier to two simultaneous recorded calls,
direct-mode handhelds, and decrypted Enhanced Privacy voice. Thirteen parts
ago we started with four deviations on a 4FSK ladder; since then we have
climbed through sync and polarity, two slots and two cadences, the forged
terminator, link control, IPSC, idle beacons, Tier III, direct mode,
wideband, AMBE+2, RC4 and the testing discipline that holds it together.
This closing part compresses the climb into what you need at the bench: one
layer map from symptom to part, the failure signatures operators report, the
ledger of everything DMR carries two of, and an honest list of what remains
open.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> Debugging DMR in GopherTrunk is deciding <strong>which layer</strong> a
symptom lives in, then reaching for that layer’s instrument. Never syncs →
the 4FSK front end and, on a handheld, the carrier gate (Parts 1, 9).
Syncs but no grants → cadence and link control (Parts 3, 5). One call for
two talkgroups → the two-slot machinery (Part 6). “Call ended”
mid-sentence → the forged terminator (Part 4). Missed replies → re-key
rules (Part 6). A tap deaf for minutes at steady power → the bin edge and
the deaf heal (Part 10). Frames that look scrambled → the Golay histogram
(Parts 12, 13). Silent recordings on an encrypted call → the PI header
line (Part 12). The running thread is the
<strong>twin pair</strong>: one carrier carries two of everything — TS1/TS2, data/voice
polarity, 132/288 cadence, Tier II/III, DDC/channelizer, live/replay — and
half the confusing reports were one twin behaving differently from its
sibling.</p>
</blockquote>

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

<ul>
  <li><strong>Locate the layer before touching anything.</strong> Every layer has a distinct
counter in the activity line; the costliest mistake in this series was
chasing ppm and gain on a receiver blinded by inter-burst gaps.</li>
  <li><strong>Ask “which twin am I on?” early.</strong> Tier II never stamped the flag Tier
III did; the harness sliced at 132 while production sliced at 288; the
channelizer was deaf where the DDC heard.</li>
  <li><strong>Counters beat absolute levels.</strong> <code class="language-plaintext highlighter-rouge">dibits</code>, <code class="language-plaintext highlighter-rouge">sync_hits</code>, <code class="language-plaintext highlighter-rouge">beacons</code>,
<code class="language-plaintext highlighter-rouge">late_entries</code>, <code class="language-plaintext highlighter-rouge">rekeys</code> and <code class="language-plaintext highlighter-rouge">deaf_heals</code> diagnose; a dBFS figure never
does — the deaf tap sat at −51 dBFS throughout.</li>
  <li><strong>The open items are gated on evidence, not effort.</strong> Two live runs, one
proprietary CSBK train and one unnamed emitter each wait for a capture
this playbook names.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>“Never syncs” triage</td>
      <td>4FSK demod, then the direct-mode gate, then RF</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-01-4fsk-carrier/">Part 1</a>, <a href="/blog/deep-dives/dmr-end-to-end-09-direct-mode-carrier-gate/">Part 9</a></td>
    </tr>
    <tr>
      <td>“Syncs, no grants” triage</td>
      <td>cadence → late entry → Tier III band plan</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-03-two-slots-one-carrier/">Part 3</a>, <a href="/blog/deep-dives/dmr-end-to-end-05-link-control-late-entry/">Part 5</a>, <a href="/blog/deep-dives/dmr-end-to-end-08-tier3-trunking/">Part 8</a></td>
    </tr>
    <tr>
      <td>Two slots, one carrier</td>
      <td>synthetic Timeslot 1/2, <code class="language-plaintext highlighter-rouge">slotRouter</code>, re-key rules</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-06-conventional-ipsc-two-calls/">Part 6</a></td>
    </tr>
    <tr>
      <td>Deaf wideband tap</td>
      <td>bin-edge flatness, <code class="language-plaintext highlighter-rouge">healDeafTier2</code>, coarse-offset reject</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-10-wideband-bin-edge-deaf-heal/">Part 10</a></td>
    </tr>
    <tr>
      <td>Scrambled-looking frames</td>
      <td>Golay C0/C1 histogram before cipher theory</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-12-enhanced-privacy-rc4/">Part 12</a>, <a href="/blog/deep-dives/dmr-end-to-end-13-testing-dmr/">Part 13</a></td>
    </tr>
    <tr>
      <td>Reading the activity line</td>
      <td>field by field</td>
      <td><a href="/blog/tutorials/field-notebook-05-dmr-activity-line/">Field Notebook Part 5</a></td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>The layer map</strong> — the whole stack, one table, symptom → instrument → part.</li>
  <li><strong>Failure signatures</strong> — the reports operators actually file, decoded.</li>
  <li><strong>The twin ledger</strong> — every pair where a fix touched one sibling, with receipts.</li>
  <li><strong>What’s still open</strong> — the honest punch list, each item at a named gate.</li>
</ul>

<h2 id="the-layer-map">The layer map</h2>

<p>The series in one table: find the row whose symptom matches, read its
instrument, and only then open the code.</p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Symptom when broken</th>
      <th>Instrument</th>
      <th>Part</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>4FSK demod</td>
      <td>never syncs; <code class="language-plaintext highlighter-rouge">dibits</code> climbing, <code class="language-plaintext highlighter-rouge">sync_hits=0</code></td>
      <td>eye diagram, <code class="language-plaintext highlighter-rouge">capture</code>’s verdicts</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-01-4fsk-carrier/">1</a></td>
    </tr>
    <tr>
      <td>Sync + polarity</td>
      <td>data bursts read as voice</td>
      <td><code class="language-plaintext highlighter-rouge">SyncIsDataAtPolarity</code></td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-02-bursts-sync-polarity/">2</a></td>
    </tr>
    <tr>
      <td>Slot cadence</td>
      <td>bogus <code class="language-plaintext highlighter-rouge">ambe_ok</code>, <code class="language-plaintext highlighter-rouge">lc_superframes=0</code> on a handheld</td>
      <td>the harness’s MS-sourced note</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-03-two-slots-one-carrier/">3</a></td>
    </tr>
    <tr>
      <td>FEC + slot type</td>
      <td>“call ended” mid-sentence</td>
      <td><code class="language-plaintext highlighter-rouge">terminator slot type without a BPTC-valid payload ignored</code></td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-04-fec-stack-forged-terminator/">4</a></td>
    </tr>
    <tr>
      <td>Link control</td>
      <td>keyups missed on a weak tap</td>
      <td><code class="language-plaintext highlighter-rouge">late_entries</code></td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-05-link-control-late-entry/">5</a></td>
    </tr>
    <tr>
      <td>IPSC two-slot</td>
      <td>one call for two TGs, missed replies</td>
      <td><code class="language-plaintext highlighter-rouge">rekeys</code>, <code class="language-plaintext highlighter-rouge">dmr_interleaved_voice</code></td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-06-conventional-ipsc-two-calls/">6</a></td>
    </tr>
    <tr>
      <td>Idle + CSBK</td>
      <td><code class="language-plaintext highlighter-rouge">beacons=0</code> on a keyed repeater; CRC-fail storms</td>
      <td><code class="language-plaintext highlighter-rouge">site alive (idle beacon)</code>, <code class="language-plaintext highlighter-rouge">info_hex</code></td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-07-idle-beacon-csbk/">7</a></td>
    </tr>
    <tr>
      <td>Tier III</td>
      <td>locks on C_ALOHA, grants tune to static</td>
      <td>LCN → Hz by hand from <code class="language-plaintext highlighter-rouge">dmr_band_plan</code></td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-08-tier3-trunking/">8</a></td>
    </tr>
    <tr>
      <td>Direct mode</td>
      <td>a handheld never decodes</td>
      <td><code class="language-plaintext highlighter-rouge">NoCarrierGate</code> A/B, timing acquisition</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-09-direct-mode-carrier-gate/">9</a></td>
    </tr>
    <tr>
      <td>Wideband</td>
      <td>one tap deaf for minutes at steady power</td>
      <td><code class="language-plaintext highlighter-rouge">deaf_heals</code>, the heal WARN’s <code class="language-plaintext highlighter-rouge">coarse_offset_hz</code></td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-10-wideband-bin-edge-deaf-heal/">10</a></td>
    </tr>
    <tr>
      <td>AMBE+2 voice</td>
      <td>“computer voice” after every pause</td>
      <td>band fractions on voice frames only</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-11-ambe2-silence-frames/">11</a></td>
    </tr>
    <tr>
      <td>Enhanced Privacy</td>
      <td>encrypted call records silence</td>
      <td>the PI header INFO line, <code class="language-plaintext highlighter-rouge">key_configured</code></td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-12-enhanced-privacy-rc4/">12</a></td>
    </tr>
    <tr>
      <td>Testing</td>
      <td>green suite, red air</td>
      <td>replay harnesses + scrubs, Golay histogram</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-13-testing-dmr/">13</a></td>
    </tr>
  </tbody>
</table>

<figure class="lab-figure">
<svg viewBox="0 0 680 160" width="680" height="160" role="img" aria-label="The DMR decode stack from antenna to WAV with the series part covering each block — DDC or channelizer, 4FSK demodulator with carrier gate, sync and polarity, the 132 or 288 slicer, the FEC stack, link control and Tier II or III, AMBE+2 and Enhanced Privacy — and a brace below marking the testing pyramid across everything.">
  <g fill="none" stroke="currentColor">
    <rect x="4" y="50" width="62" height="40" rx="6" /><rect x="76" y="50" width="70" height="40" rx="6" />
    <rect x="156" y="50" width="86" height="40" rx="6" /><rect x="252" y="50" width="70" height="40" rx="6" />
    <rect x="332" y="50" width="76" height="40" rx="6" /><rect x="418" y="50" width="60" height="40" rx="6" />
    <rect x="488" y="50" width="96" height="40" rx="6" /><rect x="594" y="50" width="82" height="40" rx="6" />
    <line x1="66" y1="70" x2="76" y2="70" /><line x1="146" y1="70" x2="156" y2="70" /><line x1="242" y1="70" x2="252" y2="70" />
    <line x1="322" y1="70" x2="332" y2="70" /><line x1="408" y1="70" x2="418" y2="70" /><line x1="478" y1="70" x2="488" y2="70" /><line x1="584" y1="70" x2="594" y2="70" />
  </g>
  <g text-anchor="middle" font-size="9" fill="currentColor">
    <text x="35" y="74">antenna</text>
    <text x="111" y="67">DDC / channelizer</text>
    <text x="199" y="67">4FSK + carrier gate</text>
    <text x="287" y="67">sync + polarity</text>
    <text x="370" y="67">slicer 132 / 288</text>
    <text x="448" y="67">FEC stack</text>
    <text x="536" y="67">LC · Tier II / III</text>
    <text x="635" y="67">AMBE+2 → EP → WAV</text>
  </g>
  <g text-anchor="middle" font-size="8" fill="var(--accent)">
    <text x="111" y="81">P10</text><text x="199" y="81">P1 · P9</text><text x="287" y="81">P2</text>
    <text x="370" y="81">P3</text><text x="448" y="81">P4</text><text x="536" y="81">P5 · P6 · P8</text><text x="635" y="81">P11 · P12</text>
  </g>
  <path d="M 4 118 L 4 130 L 676 130 L 676 118" fill="none" stroke="var(--accent)" />
  <text x="340" y="148" text-anchor="middle" fill="var(--accent)" font-size="9">testing: literal vectors → honest synthetics → committed slices → replay harnesses → on-air — P13</text>
</svg>
<figcaption>Antenna to WAV with part numbers attached, and the testing pyramid spanning every block.</figcaption>
</figure>

<h2 id="failure-signatures">Failure signatures</h2>

<p>The layer map is for methodical triage; these are the shortcuts — the
reports operators filed during this series, matched to their cause.</p>

<p><strong>“It never syncs.”</strong> On a <em>handheld</em>, inter-burst noise blinded the
receiver throughout
<a href="https://github.com/MattCheramie/GopherTrunk/issues/836">#836</a> — with
<code class="language-plaintext highlighter-rouge">NoCarrierGate</code> the production receiver yields <strong>zero</strong> sync words from the
reporter’s 446.500 MHz slices
(<a href="/blog/deep-dives/dmr-end-to-end-09-direct-mode-carrier-gate/">Part 9</a>).
On a <em>repeater</em>, the wideband hint <code class="language-plaintext highlighter-rouge">strong in-channel signal but no sync</code>
names an uncorrected tuner offset, and its overload variant says gain comes
first — though the #836 captures decode at 36–40 % of samples on the rail.</p>

<p><strong>“It syncs but never grants.”</strong> Ask which cadence the voice decoder slices
at: a handheld’s bursts are 288 dibits apart, and the single-slot 132 decoder
slices the gaps — bogus <code class="language-plaintext highlighter-rouge">ambe_ok</code>, <code class="language-plaintext highlighter-rouge">lc_superframes=0</code>, no late entry
(<a href="/blog/deep-dives/dmr-end-to-end-03-two-slots-one-carrier/">Part 3</a>).
If headers are lost on a weak tap, late entry grants from two agreeing
CRC-valid embedded LCs ~720 ms in — check <code class="language-plaintext highlighter-rouge">late_entries</code>. On Tier III, work
one LCN through <code class="language-plaintext highlighter-rouge">dmr_band_plan</code>
(<a href="/blog/deep-dives/dmr-end-to-end-08-tier3-trunking/">Part 8</a>).</p>

<p><strong>“One call for two talkgroups, and the audio is DJ-scratchy.”</strong>
<code class="language-plaintext highlighter-rouge">dmr_interleaved_voice</code> was computed by the daemon but Tier II’s <code class="language-plaintext highlighter-rouge">Options</code>
had no field for it, so both slots’ AMBE frames were sliced into one
superframe. Fixed with a per-destination map assigning each call a
<strong>synthetic</strong> Timeslot 1/2 and <code class="language-plaintext highlighter-rouge">dmrSameCarrierTaps = 2</code>
(<a href="/blog/deep-dives/dmr-end-to-end-06-conventional-ipsc-two-calls/">Part 6</a>).
With no embedded LC, both <code class="language-plaintext highlighter-rouge">slotRouter</code>s fall back to phase parity.</p>

<p><strong>“GT said the call ended, but the conversation continued.”</strong> A forged
terminator: Golay(20,8) decodes roughly a third of arbitrary words to <em>some</em>
codeword, so about one voice burst A in twelve read as Terminator-with-LC.
Slot types now come only from data-sync bursts, and an undecodable
terminator needs a BPTC-valid payload
(<a href="/blog/deep-dives/dmr-end-to-end-04-fec-stack-forged-terminator/">Part 4</a>).</p>

<p><strong>“Calls completely missed while the radios decode.”</strong> A re-key within
hangtime: the slicer decoded the terminator 0.8–6.5 s after the composer, so
the reply’s header was deduped as a repeat. A header more than
<code class="language-plaintext highlighter-rouge">headerRekeyDibits</code> (0.25 s) past the call’s anchor re-grants; a fresh
embedded LC after <code class="language-plaintext highlighter-rouge">superframeRekeyGapDibits</code> (2 s) re-grants by late entry —
watch <code class="language-plaintext highlighter-rouge">rekeys</code>. Its mirror: ten header copies over 0.6 s are <strong>one</strong> keyup,
anchored on the last copy
(<a href="/blog/deep-dives/dmr-end-to-end-05-link-control-late-entry/">Part 5</a>).</p>

<p><strong>“A wideband tap goes deaf for minutes at normal power.”</strong> Two mechanisms
(<a href="/blog/deep-dives/dmr-end-to-end-10-wideband-bin-edge-deaf-heal/">Part 10</a>):
the channelizer bin was −6 dB <em>at</em> its edge until <code class="language-plaintext highlighter-rouge">channelizer.Oversampled</code>,
and the coarse acquirer froze on a −20.1 kHz neighbour in every idle gap.
<code class="language-plaintext highlighter-rouge">healDeafTier2</code> resets a tap that sees no sync for three windows within 6 dB
of the level it <strong>last synced at</strong> — never an absolute dBFS — and rejects an
engage the channel never synced under; <code class="language-plaintext highlighter-rouge">deaf_heals</code> counts.</p>

<p><strong>“The frames look scrambled.”</strong> Count Golay corrections before any cipher
theory: random words need exactly three ~73 % of the time, the shape the
#1187 bursts showed when a simplex radio was sliced at 132
(<a href="/blog/deep-dives/dmr-end-to-end-13-testing-dmr/">Part 13</a>).</p>

<p><strong>“An encrypted call records silence.”</strong> Read <code class="language-plaintext highlighter-rouge">composer: dmr PI header —
call is encrypted</code>: <code class="language-plaintext highlighter-rouge">algorithm</code>, <code class="language-plaintext highlighter-rouge">key_id</code>, <code class="language-plaintext highlighter-rouge">key_configured</code>. A key under a
different <code class="language-plaintext highlighter-rouge">key_id</code> is never applied; Hytera, DES and AES are identified, not
decrypted
(<a href="/blog/deep-dives/dmr-end-to-end-12-enhanced-privacy-rc4/">Part 12</a>).</p>

<h2 id="the-twin-ledger">The twin ledger</h2>

<p>The series’ running thread, settled as a checklist. Every entry is a real
divergence with a receipt — ask which side you are on before concluding
anything:</p>

<table>
  <thead>
    <tr>
      <th>Twin pair</th>
      <th>The drift, when it happened</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>TS1 ↔ TS2</td>
      <td>scalar call state folded both slots into one call; now a per-destination map with synthetic Timeslots (Part 6)</td>
    </tr>
    <tr>
      <td>data ↔ voice polarity</td>
      <td>the sync words are each other’s <code class="language-plaintext highlighter-rouge">PolarityFlip</code> image, so “is this a data burst?” is per polarity — <code class="language-plaintext highlighter-rouge">SyncIsDataAtPolarity</code> (Parts 2, 4)</td>
    </tr>
    <tr>
      <td>132 ↔ 288 cadence</td>
      <td>the EP harness sliced a handheld at 132 the day after production moved to cadence detection (#1192) (Parts 3, 13)</td>
    </tr>
    <tr>
      <td>Tier II ↔ Tier III</td>
      <td>Tier III stamped <code class="language-plaintext highlighter-rouge">Grant.DMRInterleavedVoice</code>, Tier II never did, so <code class="language-plaintext highlighter-rouge">dmr_interleaved_voice</code> was dropped on the floor (Part 6)</td>
    </tr>
    <tr>
      <td>DDC ↔ channelizer</td>
      <td>polyphase 1991 beacons / 2 grants vs DDC 6103 / 7 over the same 300 s until the bank was oversampled (Part 10)</td>
    </tr>
    <tr>
      <td>slicer ↔ composer</td>
      <td>the composer saw the release 0.8–6.5 s before the tier2 slicer, hence the re-key rule (Part 6)</td>
    </tr>
    <tr>
      <td>live ↔ replay</td>
      <td>a harness that lags production produces a symptom production no longer has (Part 13)</td>
    </tr>
  </tbody>
</table>

<p>The lesson has
<a href="/blog/solution-postmortem/from-the-issue-tracker-22-two-pipelines/">its own postmortem</a>:
every fix needs an explicit answer to “and the twin?” — in the PR, not in
the next bug report.</p>

<h3 id="how-the-twin-thread-shaped-the-go-code">How the twin thread shaped the Go code</h3>

<ul>
  <li><strong>One flag, threaded through both tiers.</strong> <code class="language-plaintext highlighter-rouge">InterleavedVoice</code> rides
<code class="language-plaintext highlighter-rouge">tier2.Options</code> and both constructors (<code class="language-plaintext highlighter-rouge">ccdecoder/pipelines.go</code>,
<code class="language-plaintext highlighter-rouge">widebandt2/engine.go</code>).</li>
  <li><strong>Per-polarity predicates, not a global sync table.</strong>
<code class="language-plaintext highlighter-rouge">SyncIsDataAtPolarity</code> makes the data/voice question exact.</li>
  <li><strong>Cadence is a default, not a config key.</strong> <code class="language-plaintext highlighter-rouge">DMRVoiceCadenceDetected</code> is
true for every DMR protocol.</li>
  <li><strong>Harnesses call the production constructors.</strong> <code class="language-plaintext highlighter-rouge">TestDMRIPSCReplay</code> builds
<code class="language-plaintext highlighter-rouge">tier2.New</code> as the daemon does.</li>
</ul>

<h2 id="whats-still-open">What’s still open</h2>

<p>An honest playbook ends with its open items, each at a named gate, in the
repo’s own verified-language:</p>

<ul>
  <li><strong>Direct mode on a live build</strong> — verified on the reporter’s 15 Sep
captures; the <strong>live daemon run</strong> on a fixed build is the gate on #836.</li>
  <li><strong>Enhanced Privacy through the daemon</strong> — capture-verified (b0 continuity
0.77 / 0.74); a <strong>live call</strong> with <code class="language-plaintext highlighter-rouge">encryption_keys</code> recording intelligible
audio closes
<a href="https://github.com/MattCheramie/GopherTrunk/issues/1187">#1187</a>.</li>
  <li><strong>The cc=7 CSBK-CRC-fail train</strong> — a 30 ms-cadence train of BPTC-clean,
CRC-failing CSBKs on the 443.2375 MHz repeater of a cc=12 system; real,
not noise. Parked with <code class="language-plaintext highlighter-rouge">info_hex</code>; a capture of that frequency idle pins it.</li>
  <li><strong>The −20 kHz emitter</strong> — ≈442.3675 MHz, dominating the Fire2 tap only in
the repeater’s idle gaps. A ±100 kHz capture centred on 442.3875 MHz over
one idle cycle names it; do <strong>not</strong> add a frequency bound to the acquirer.</li>
  <li><strong>Two-slot audio on air</strong> — synthetic-verified; the A/B of two
simultaneously recorded talkgroups still needs a decodable capture.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">srcList: []</code></strong> — the per-call JSON shipped an empty talker list where
trunk-recorder listed two radios; not investigated.</li>
</ul>

<p>Every item is blocked on evidence, not effort — the sentence the
<a href="/blog/deep-dives/p25-end-to-end-14-playbook/">P25 playbook</a>
closed on, because it is the project’s operating principle.</p>

<h2 id="where-to-go-from-here">Where to go from here</h2>

<p>For the same protocol-length treatment of the other trunking families, cross
to <a href="/blog/series/tetra-end-to-end/">TETRA End to End</a>
and <a href="/blog/series/p25-end-to-end/">P25 End to End</a>.
Two series launch alongside this one:
<a href="/blog/series/beyond-voice/">Beyond Voice</a> follows
everything GopherTrunk decodes that is not a trunked call through one
eleven-place wiring pattern, and
<a href="/blog/series/field-notebook/">The Field Notebook</a>
reads <code class="language-plaintext highlighter-rouge">debug.log</code> one line family at a time — its
<a href="/blog/tutorials/field-notebook-05-dmr-activity-line/">Part 5</a>
is the DMR activity line. The Cookbook’s
<a href="/blog/tutorials/operator-cookbook-02-dmr-tier3/">Tier III</a>
and <a href="/blog/tutorials/operator-cookbook-03-conventional-dmr-two-slots/">two-slot conventional</a>
recipes run every layer mapped here; the
<a href="/reference/dmr/">DMR reference</a> is the glossary.</p>

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

<p><strong>My DMR system won’t decode — where do I start?</strong>
At the bottom of the layer map, with counters: is <code class="language-plaintext highlighter-rouge">dibits</code> climbing while
<code class="language-plaintext highlighter-rouge">sync_hits</code> stays at zero? Is the radio a handheld or a repeater? Does the
wideband hint name a tuner offset or an overloaded front end? A “no” at any
row names your layer.</p>

<p><strong>Why does GopherTrunk grant one call but I hear two conversations?</strong>
The conventional path once folded a repeater’s two timeslots into one call
because Tier II never stamped the interleaved-voice flag. Since the two-slot
fix each concurrent talkgroup gets its own synthetic Timeslot and
same-carrier tap; the two-slot A/B on a decodable capture is still pending.</p>

<p><strong>What does a burst of “CSBK CRC mismatch” lines mean?</strong>
Usually between-beacon noise, parked to one line per 10 s with a
<code class="language-plaintext highlighter-rouge">suppressed_repeats</code> count. A sustained 30 ms-cadence train at a colour code
your system does not use (cc=7 on a cc=12 repeater) is a real proprietary
train that is still unpinned — its <code class="language-plaintext highlighter-rouge">info_hex</code> is the evidence to file.</p>

<p><strong>Is DMR direct mode supported?</strong>
Yes, and verified on the reporter’s 15 Sep captures: the carrier gate,
feed-forward timing acquisition and header-train anchoring let a simplex
handheld decode from its first burst. Still open is the reporter’s live
daemon run on a build with those fixes (#764/#771).</p>

<p><strong>Does this playbook transfer to TETRA or P25?</strong>
The method does — locate the layer, read its counter, ask which twin you are
on. The rows differ: P25’s twins are C4FM/CQPSK and Phase 1/2, TETRA’s are
slot-grid and scramble-seed questions. Their playbooks close the
<a href="/blog/series/tetra-end-to-end/">TETRA</a> and
<a href="/blog/series/p25-end-to-end/">P25</a> series.</p>

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

<p><strong>Part 14 of 14</strong> · ←
<a href="/blog/deep-dives/dmr-end-to-end-13-testing-dmr/">Part 13: Testing DMR Without a Repeater</a>
· <a href="/blog/series/dmr-end-to-end/">Back to the series index</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="dmr-end-to-end" /><category term="dmr" /><category term="playbook" /><category term="troubleshooting" /><category term="methodology" /><category term="go" /><summary type="html"><![CDATA[The finale — the whole DMR stack folded into one layer map from antenna to WAV: which symptom points at which layer, the instrument that confirms it, the failure signatures operators actually report, the twin ledger of everything DMR carries two of, and the honest list of what is still open.]]></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">The Field Notebook, Part 14: The One-Page Field Card</title><link href="https://gophertrunk.org/blog/tutorials/field-notebook-14-field-card/" rel="alternate" type="text/html" title="The Field Notebook, Part 14: The One-Page Field Card" /><published>2026-09-30T00:00:00-05:00</published><updated>2026-09-30T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/tutorials/field-notebook-14-field-card</id><content type="html" xml:base="https://gophertrunk.org/blog/tutorials/field-notebook-14-field-card/"><![CDATA[<p><em>Part 14 — the last — of <strong>The Field Notebook</strong>, a 14-part operator’s
tutorial that reads GopherTrunk’s <code class="language-plaintext highlighter-rouge">debug.log</code> one line family at a time —
what each field measures, what a healthy rig prints, which number is a noise
meter and which one means traffic, and which deep dive to open when a line
goes wrong. Thirteen parts read the log from the startup banner
(<a href="/blog/tutorials/field-notebook-01-startup-lines/">Part 1</a>)
to the bug report
(<a href="/blog/tutorials/field-notebook-13-from-log-to-issue/">Part 13</a>).
This closing part folds them onto one page: the card to keep beside the
terminal.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> On any report, read five lines first: the <code class="language-plaintext highlighter-rouge">daemon:</code> WARNs in
the startup block, the protocol’s <code class="language-plaintext highlighter-rouge">cc locked</code> line, its periodic status
line (<code class="language-plaintext highlighter-rouge">tetra: decode status</code>, <code class="language-plaintext highlighter-rouge">widebandt2: channel decode activity</code>,
<code class="language-plaintext highlighter-rouge">tetra dmo: decode status</code>, the MRC health line), the <code class="language-plaintext highlighter-rouge">soapyremote: SDR
overruns</code> / <code class="language-plaintext highlighter-rouge">ccdecoder: decode can't keep up</code> WARNs, and
<code class="language-plaintext highlighter-rouge">recorder: call ended … reason=</code>. Then the family tables give each line’s
healthy shape, its worry-when, and the part that explains it. Keep noise
meters apart from traffic counters — <code class="language-plaintext highlighter-rouge">dnb_total</code> vs <code class="language-plaintext highlighter-rouge">dnb_qualified</code>,
<code class="language-plaintext highlighter-rouge">bsch_ok</code> vs <code class="language-plaintext highlighter-rouge">sch_pdus</code>, <code class="language-plaintext highlighter-rouge">dibits</code> vs <code class="language-plaintext highlighter-rouge">sync_hits</code>, <code class="language-plaintext highlighter-rouge">coherence</code> vs
<code class="language-plaintext highlighter-rouge">lock_gate</code>, <code class="language-plaintext highlighter-rouge">updates</code> vs <code class="language-plaintext highlighter-rouge">holds</code>. And know what the log cannot yet say:
the −20 kHz emitter, the cc=7 CSBK train, and the on-air gates
(<a href="https://github.com/MattCheramie/GopherTrunk/issues/836">#836</a>,
<a href="https://github.com/MattCheramie/GopherTrunk/issues/1003">#1003</a>,
<a href="https://github.com/MattCheramie/GopherTrunk/issues/1187">#1187</a>).</p>
</blockquote>

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

<ul>
  <li><strong>Five lines settle most reports.</strong> Startup WARNs, lock, status line,
host WARNs, teardown reason — in that order, before any DSP theory.</li>
  <li><strong>Every family has a noise meter and a traffic counter.</strong> The card names
the pair; reason from the one that means decode.</li>
  <li><strong>“Worry when” is relative, never absolute.</strong> Deaf is judged at the
channel’s <em>own</em> last-decoding level, coherence against its <em>own</em>
<code class="language-plaintext highlighter-rouge">lock_gate</code>.</li>
  <li><strong>The card ends where the log does.</strong> Some symptoms are open questions
with an instrument waiting for a capture; the card says which.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Startup &amp; lock</td>
      <td>banner, <code class="language-plaintext highlighter-rouge">daemon:</code> WARNs, <code class="language-plaintext highlighter-rouge">cchunt:</code> lines, <code class="language-plaintext highlighter-rouge">cc locked</code></td>
      <td>Parts <a href="/blog/tutorials/field-notebook-01-startup-lines/">1</a>, <a href="/blog/tutorials/field-notebook-02-lock-lines/">2</a></td>
    </tr>
    <tr>
      <td>TETRA TMO</td>
      <td><code class="language-plaintext highlighter-rouge">tetra: decode status</code>, the #815 offset WARN</td>
      <td>Parts <a href="/blog/tutorials/field-notebook-03-tetra-decode-status/">3</a>, <a href="/blog/tutorials/field-notebook-04-carrier-offset-warn/">4</a></td>
    </tr>
    <tr>
      <td>DMR</td>
      <td><code class="language-plaintext highlighter-rouge">widebandt2: channel decode activity</code>, deaf heal, idle beacon</td>
      <td>Part <a href="/blog/tutorials/field-notebook-05-dmr-activity-line/">5</a></td>
    </tr>
    <tr>
      <td>TETRA DMO</td>
      <td><code class="language-plaintext highlighter-rouge">tetra dmo: decode status</code>, seed lines, grant</td>
      <td>Part <a href="/blog/tutorials/field-notebook-06-dmo-status-line/">6</a></td>
    </tr>
    <tr>
      <td>Host &amp; front end</td>
      <td>overruns, <code class="language-plaintext highlighter-rouge">decode can't keep up</code>, MRC health line</td>
      <td>Parts <a href="/blog/tutorials/field-notebook-07-overruns-host-drops/">7</a>, <a href="/blog/tutorials/field-notebook-08-mrc-health-line/">8</a></td>
    </tr>
    <tr>
      <td>Captures &amp; recordings</td>
      <td><code class="language-plaintext highlighter-rouge">siglab: capture …</code>, <code class="language-plaintext highlighter-rouge">recorder: call …</code>, composer lines</td>
      <td>Parts <a href="/blog/tutorials/field-notebook-09-capture-lines/">9</a>, <a href="/blog/tutorials/field-notebook-10-recorder-lines/">10</a>, <a href="/blog/tutorials/field-notebook-11-voice-chain-lines/">11</a></td>
    </tr>
    <tr>
      <td>Web &amp; reporting</td>
      <td>panel symptoms with log evidence; what to paste</td>
      <td>Parts <a href="/blog/tutorials/field-notebook-12-web-symptoms-log-stories/">12</a>, <a href="/blog/tutorials/field-notebook-13-from-log-to-issue/">13</a></td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>The five lines to read first</strong> — the order that settles most reports.</li>
  <li><strong>The card</strong> — one table per family: line → healthy → worry → open this.</li>
  <li><strong>Noise meters vs traffic counters</strong> — the pairs that mislead.</li>
  <li><strong>What the log still cannot tell you</strong> — the honest list.</li>
</ul>

<h2 id="the-five-lines-to-read-first">The five lines to read first</h2>

<p>Every report in this series’ source material was settled faster by this
reading order than by any theory.</p>

<p><strong>1. The startup WARNs.</strong> <code class="language-plaintext highlighter-rouge">daemon: gain looks like dB, not tenths-of-dB —
radio may be effectively deaf</code> explained a −70 dBFS control channel before
anyone measured anything. Two systems on one <code class="language-plaintext highlighter-rouge">role: control</code> tuner, dead
<code class="language-plaintext highlighter-rouge">channels:</code>, a missing band plan — all announced here.</p>

<p><strong>2. The lock line.</strong> <code class="language-plaintext highlighter-rouge">tetra cc locked freq= mcc= mnc= la=</code>,
<code class="language-plaintext highlighter-rouge">control channel locked nac= freq=</code> (P25), <code class="language-plaintext highlighter-rouge">dmr cc locked freq= cc= sysid=</code>,
<code class="language-plaintext highlighter-rouge">tetra dmo cc locked</code>. Without one, nothing downstream can be judged;
<code class="language-plaintext highlighter-rouge">cchunt: hunt failed — no control-channel lock</code> with <code class="language-plaintext highlighter-rouge">iq_observed=</code> says
whether IQ even arrived.</p>

<p><strong>3. The status line.</strong> The protocol’s periodic DEBUG line is the
instrument. Read its trend across intervals, never one line.</p>

<p><strong>4. The host WARNs.</strong> <code class="language-plaintext highlighter-rouge">soapyremote: SDR overruns … host_drops=</code> and
<code class="language-plaintext highlighter-rouge">ccdecoder: decode can't keep up with real time … dropped_since_last=</code> are
downstream signals — the consumer stopped draining
(<a href="/blog/tutorials/field-notebook-07-overruns-host-drops/">Part 7</a>).</p>

<p><strong>5. The teardown reason.</strong> <code class="language-plaintext highlighter-rouge">recorder: call ended … reason=timeout</code> on
every call means the voice chain decoded nothing that refreshed liveness —
a decode problem in a recorder’s clothes
(<a href="/blog/tutorials/field-notebook-11-voice-chain-lines/">Part 11</a>).</p>

<p>Healthy, on a TETRA rig:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INF tetra cc locked freq=467912500 mcc=250 mnc=1 la=1021 system=Metro-TETRA
DBG tetra: decode status system=Metro-TETRA locked=true carrier_off_hz=-412.5 baud=18000 baud_dev_pct=0.01 sb_bursts=72 bsch_ok=72 bsch_fail=0 sysinfo=18 sch_pdus=340 sch_pdus_fail=6 frag_abandons=1 grants=3 colour_code=1
INF recorder: call ended device=cc:same-carrier:1 wav=../recordings/Metro-TETRA/2001/... duration=5.6s reason=normal
</code></pre></div></div>

<p>Unhealthy — three lines, three different parts:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WRN daemon: gain looks like dB, not tenths-of-dB — radio may be effectively deaf serial=32B0A4C configured=50 parsed_db=5 did_you_mean=500
DBG tetra: decode status system=Metro-TETRA locked=true carrier_off_hz=4950.0 baud=17998 baud_dev_pct=0.02 sb_bursts=70 bsch_ok=69 bsch_fail=1 sysinfo=0 sch_pdus=0 sch_pdus_fail=58 frag_abandons=0 grants=0 colour_code=1
INF recorder: call ended device=cc:same-carrier:1 wav=../recordings/Site-DMO/0/... duration=7.0s reason=timeout
</code></pre></div></div>

<p>The middle line is the 19 Aug “locked but deaf” signature — <code class="language-plaintext highlighter-rouge">bsch_ok</code> ~100 %,
<code class="language-plaintext highlighter-rouge">sch_pdus=0</code>, <code class="language-plaintext highlighter-rouge">carrier_off_hz</code> one alias bucket (4500 Hz) off
(<a href="/blog/tutorials/field-notebook-04-carrier-offset-warn/">Part 4</a>).</p>

<h2 id="the-card">The card</h2>

<p>Every field below is a real <code class="language-plaintext highlighter-rouge">slog</code> key; every “open” is the part or deep
dive that explains the mechanism.</p>

<p><strong>Startup &amp; lock — all protocols</strong></p>

<table>
  <thead>
    <tr>
      <th>Line / field</th>
      <th>Healthy</th>
      <th>Worry when</th>
      <th>Open</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">daemon:</code> WARNs at startup</td>
      <td>none</td>
      <td>any — <code class="language-plaintext highlighter-rouge">gain</code> in dB, dead <code class="language-plaintext highlighter-rouge">channels:</code>, two systems on one control tuner</td>
      <td><a href="/blog/tutorials/field-notebook-01-startup-lines/">Part 1</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cchunt: hunt failed — no control-channel lock</code> <code class="language-plaintext highlighter-rouge">iq_observed=</code></td>
      <td>absent</td>
      <td>repeating — <code class="language-plaintext highlighter-rouge">false</code> means no IQ, <code class="language-plaintext highlighter-rouge">true</code> means IQ but no decode</td>
      <td><a href="/blog/tutorials/field-notebook-02-lock-lines/">Part 2</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ccdecoder: control carrier offset far from configured frequency … (issue #815)</code> <code class="language-plaintext highlighter-rouge">offset_hz=</code></td>
      <td>absent</td>
      <td>persistent &gt; 10 s — wrong site or mistuned; a one-off 5004 Hz was an alias bucket</td>
      <td><a href="/blog/tutorials/field-notebook-04-carrier-offset-warn/">Part 4</a></td>
    </tr>
  </tbody>
</table>

<p><strong>TETRA TMO</strong></p>

<table>
  <thead>
    <tr>
      <th>Line / field</th>
      <th>Healthy</th>
      <th>Worry when</th>
      <th>Open</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bsch_ok</code> / <code class="language-plaintext highlighter-rouge">bsch_fail</code></td>
      <td>~100 % ok</td>
      <td><code class="language-plaintext highlighter-rouge">bsch_fail</code> climbing, <code class="language-plaintext highlighter-rouge">sb_bursts</code> collapsing → resync storm, <code class="language-plaintext highlighter-rouge">MarkLost</code></td>
      <td><a href="/blog/deep-dives/tetra-end-to-end-10-control-channel-sync-loss/">TETRA 10</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sch_pdus</code> vs <code class="language-plaintext highlighter-rouge">bsch_ok</code></td>
      <td>both advancing</td>
      <td><code class="language-plaintext highlighter-rouge">bsch_ok</code> high, <code class="language-plaintext highlighter-rouge">sch_pdus=0</code> — locked but deaf (AFC alias)</td>
      <td><a href="/blog/tutorials/field-notebook-03-tetra-decode-status/">Part 3</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tetra: payload drought persists across resyncs … declaring lock lost</code></td>
      <td>absent</td>
      <td>present — the escape hatch fired; re-hunt follows</td>
      <td><a href="/blog/tutorials/field-notebook-03-tetra-decode-status/">Part 3</a></td>
    </tr>
  </tbody>
</table>

<p><strong>TETRA DMO</strong></p>

<table>
  <thead>
    <tr>
      <th>Line / field</th>
      <th>Healthy</th>
      <th>Worry when</th>
      <th>Open</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">dnb_total</code></td>
      <td>thousands, ~18/s on silence</td>
      <td>never — a noise meter</td>
      <td><a href="/blog/tutorials/field-notebook-06-dmo-status-line/">Part 6</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">dnb_qualified</code>, <code class="language-plaintext highlighter-rouge">tch_crc</code></td>
      <td>rise together in a PTT</td>
      <td><code class="language-plaintext highlighter-rouge">dsb_schs_crc</code> climbs, <code class="language-plaintext highlighter-rouge">tch_crc</code> at the floor — seed not recovered</td>
      <td><a href="/blog/tutorials/operator-cookbook-05-tetra-dmo/">Cookbook 5</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">seed_known</code> / <code class="language-plaintext highlighter-rouge">seed_verified</code> / <code class="language-plaintext highlighter-rouge">solve_rejects</code></td>
      <td>known + verified per PTT</td>
      <td><code class="language-plaintext highlighter-rouge">composer: tetra DMO scramble seed changed</code> flip-flopping — rejected solves are now counted, not adopted</td>
      <td><a href="/blog/deep-dives/tetra-end-to-end-13-dmo-pipeline-grants/">TETRA 13</a></td>
    </tr>
  </tbody>
</table>

<p><strong>DMR — wideband and conventional</strong></p>

<table>
  <thead>
    <tr>
      <th>Line / field</th>
      <th>Healthy</th>
      <th>Worry when</th>
      <th>Open</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">dibits</code> vs <code class="language-plaintext highlighter-rouge">sync_hits</code></td>
      <td>both advancing</td>
      <td><code class="language-plaintext highlighter-rouge">dibits</code> up, <code class="language-plaintext highlighter-rouge">sync_hits=0</code> for minutes at the channel’s normal level — the deaf tap</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-10-wideband-bin-edge-deaf-heal/">DMR 10</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">csbk_crc_fail</code></td>
      <td>~0</td>
      <td>a 30 ms-cadence train at another colour code — real, vendor, unpinned</td>
      <td><a href="/blog/deep-dives/dmr-end-to-end-07-idle-beacon-csbk/">DMR 7</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deaf_heals</code>, <code class="language-plaintext highlighter-rouge">widebandt2: conventional DMR tap deaf at its decoding level — resetting the receiver</code></td>
      <td>0</td>
      <td>every ~15 s with <code class="language-plaintext highlighter-rouge">coarse_offset_hz</code> far off — a false coarse engage, now rejected</td>
      <td><a href="/blog/tutorials/field-notebook-05-dmr-activity-line/">Part 5</a></td>
    </tr>
  </tbody>
</table>

<p><strong>Host &amp; front end</strong></p>

<table>
  <thead>
    <tr>
      <th>Line / field</th>
      <th>Healthy</th>
      <th>Worry when</th>
      <th>Open</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">soapyremote: SDR overruns …</code> <code class="language-plaintext highlighter-rouge">host_drops=</code></td>
      <td>absent</td>
      <td>present — find what got slower downstream</td>
      <td><a href="/blog/tutorials/field-notebook-07-overruns-host-drops/">Part 7</a></td>
    </tr>
    <tr>
      <td>MRC <code class="language-plaintext highlighter-rouge">coherence</code> vs <code class="language-plaintext highlighter-rouge">lock_gate</code></td>
      <td>above the gate, <code class="language-plaintext highlighter-rouge">calibrated=true</code></td>
      <td>below — judge against the logged gate, never 0.5</td>
      <td><a href="/blog/deep-dives/weak-signal-engineering-10-mrc-calibration/">Weak-Signal 10</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">updates</code> vs <code class="language-plaintext highlighter-rouge">holds</code></td>
      <td><code class="language-plaintext highlighter-rouge">updates</code> climbing, <code class="language-plaintext highlighter-rouge">holds</code> ~0</td>
      <td><code class="language-plaintext highlighter-rouge">updates</code> frozen while <code class="language-plaintext highlighter-rouge">holds</code> climbs — stale gain</td>
      <td><a href="/blog/tutorials/field-notebook-08-mrc-health-line/">Part 8</a></td>
    </tr>
  </tbody>
</table>

<p><strong>Captures, recorder, voice chain</strong></p>

<table>
  <thead>
    <tr>
      <th>Line / field</th>
      <th>Healthy</th>
      <th>Worry when</th>
      <th>Open</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">siglab: capture started</code> <code class="language-plaintext highlighter-rouge">center_hz</code> vs <code class="language-plaintext highlighter-rouge">requested_center_hz</code></td>
      <td>equal</td>
      <td>differ — the slice was carved at the tuner centre</td>
      <td><a href="/blog/tutorials/field-notebook-09-capture-lines/">Part 9</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">composer: … voice follow ended</code> <code class="language-plaintext highlighter-rouge">speech_frames</code>, <code class="language-plaintext highlighter-rouge">bfi_count</code>, <code class="language-plaintext highlighter-rouge">vocoder_drops</code></td>
      <td><code class="language-plaintext highlighter-rouge">speech_frames</code> ≫ <code class="language-plaintext highlighter-rouge">bfi_count</code></td>
      <td><code class="language-plaintext highlighter-rouge">speech_frames=0</code> — the seed or the tap</td>
      <td><a href="/blog/tutorials/field-notebook-11-voice-chain-lines/">Part 11</a></td>
    </tr>
  </tbody>
</table>

<p><strong>Web</strong></p>

<table>
  <thead>
    <tr>
      <th>Line / field</th>
      <th>Healthy</th>
      <th>Worry when</th>
      <th>Open</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>History vs <code class="language-plaintext highlighter-rouge">recorder: call started</code> timestamps</td>
      <td>agree in local time</td>
      <td>offset by exactly your UTC offset — a render bug</td>
      <td><a href="/blog/tutorials/field-notebook-12-web-symptoms-log-stories/">Part 12</a></td>
    </tr>
  </tbody>
</table>

<figure class="lab-figure">
<svg viewBox="0 0 680 250" width="680" height="250" role="img" aria-label="The field card as a procedure: a report passes through five numbered checks in order — startup WARNs, the lock line, the status-line trend, host WARNs, the teardown reason — and the first failing check routes to a family table, which points to the Field Notebook part and deep dive that explain the mechanism.">
  <rect x="8" y="100" width="70" height="40" rx="5" fill="none" stroke="currentColor" />
  <text x="43" y="124" text-anchor="middle" fill="currentColor" font-size="10">report</text>
  <line x1="78" y1="120" x2="100" y2="120" stroke="currentColor" /><polygon points="98,116 106,120 98,124" fill="currentColor" />
  <rect x="106" y="24" width="120" height="30" rx="4" fill="none" stroke="var(--accent)" />
  <text x="166" y="43" text-anchor="middle" fill="var(--accent)" font-size="9">1 startup WARNs</text>
  <rect x="106" y="62" width="120" height="30" rx="4" fill="none" stroke="var(--accent)" />
  <text x="166" y="81" text-anchor="middle" fill="var(--accent)" font-size="9">2 cc locked</text>
  <rect x="106" y="100" width="120" height="30" rx="4" fill="none" stroke="var(--accent)" />
  <text x="166" y="119" text-anchor="middle" fill="var(--accent)" font-size="9">3 status-line trend</text>
  <rect x="106" y="138" width="120" height="30" rx="4" fill="none" stroke="var(--accent)" />
  <text x="166" y="157" text-anchor="middle" fill="var(--accent)" font-size="9">4 host WARNs</text>
  <rect x="106" y="176" width="120" height="30" rx="4" fill="none" stroke="var(--accent)" />
  <text x="166" y="195" text-anchor="middle" fill="var(--accent)" font-size="9">5 reason=</text>
  <line x1="226" y1="39" x2="270" y2="39" stroke="currentColor" /><polygon points="268,35 276,39 268,43" fill="currentColor" />
  <line x1="226" y1="77" x2="270" y2="77" stroke="currentColor" /><polygon points="268,73 276,77 268,81" fill="currentColor" />
  <line x1="226" y1="115" x2="270" y2="115" stroke="currentColor" /><polygon points="268,111 276,115 268,119" fill="currentColor" />
  <line x1="226" y1="153" x2="270" y2="153" stroke="currentColor" /><polygon points="268,149 276,153 268,157" fill="currentColor" />
  <line x1="226" y1="191" x2="270" y2="191" stroke="currentColor" /><polygon points="268,187 276,191 268,195" fill="currentColor" />
  <rect x="276" y="18" width="200" height="214" rx="6" fill="none" stroke="var(--fg-muted)" stroke-dasharray="4 3" />
  <text x="376" y="34" text-anchor="middle" fill="var(--fg-muted)" font-size="9">family tables</text>
  <text x="376" y="82" text-anchor="middle" fill="currentColor" font-size="9">startup · TETRA · DMO · DMR</text>
  <text x="376" y="120" text-anchor="middle" fill="currentColor" font-size="9">host · captures · recorder · web</text>
  <line x1="476" y1="125" x2="520" y2="125" stroke="currentColor" /><polygon points="518,121 526,125 518,129" fill="currentColor" />
  <rect x="526" y="90" width="146" height="70" rx="6" fill="none" stroke="var(--accent)" stroke-width="2" />
  <text x="599" y="120" text-anchor="middle" fill="var(--accent)" font-size="9">Field Notebook part</text>
</svg>
<figcaption>Five checks in order; the first failure routes to a family table, and every row ends in a part that explains the mechanism.</figcaption>
</figure>

<h2 id="noise-meters-vs-traffic-counters">Noise meters vs traffic counters</h2>

<p>Five pairs recur through the series; in each, the first moves on noise and
the second means decode. Reason from the second.</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">dnb_total</code> vs <code class="language-plaintext highlighter-rouge">dnb_qualified</code></strong> (DMO). The DNB correlator false-fires
~18/s on a silent channel by design arithmetic; only slot-grid-qualified
bursts mean traffic.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">bsch_ok</code> vs <code class="language-plaintext highlighter-rouge">sch_pdus</code></strong> (TETRA). BSCH survives a 4-rotation search and
heavy FEC, so it decodes through a wrong AFC alias bucket; <code class="language-plaintext highlighter-rouge">sch_pdus</code> does
not.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">dibits</code> vs <code class="language-plaintext highlighter-rouge">sync_hits</code></strong> (DMR). Dibits prove the stream flows, not that
any of it is DMR; the 12 Sep tap produced dibits for minutes with
<code class="language-plaintext highlighter-rouge">sync_hits=0</code>.</li>
  <li><strong>Wideband <code class="language-plaintext highlighter-rouge">coherence</code> vs <code class="language-plaintext highlighter-rouge">lock_gate</code></strong> (MRC). |ρ| is diluted by every
hertz of noise-only bandwidth — 0.16 on a rig decoding 1425 CRC-clean
BSCH — so it only means something against the logged gate.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">updates</code> vs <code class="language-plaintext highlighter-rouge">holds</code></strong> (MRC). <code class="language-plaintext highlighter-rouge">updates</code> frozen with <code class="language-plaintext highlighter-rouge">holds</code> climbing is
a combine running on a gain measured minutes ago.</li>
</ul>

<p>Two rules of the same shape: <strong>never judge a channel by absolute dBFS</strong>
(<a href="/blog/tutorials/analog-edge-13-coherence-not-dbfs/">Analog Edge 13</a>),
and <strong>CRC yield is the only trustworthy metric; EVM is a trap</strong>
(<a href="/blog/deep-dives/weak-signal-engineering-02-metrics-that-lie/">Weak-Signal 2</a>).</p>

<h2 id="what-the-log-still-cannot-tell-you">What the log still cannot tell you</h2>

<p>An honest card ends with the rows that have no “healthy” column yet.</p>

<ul>
  <li><strong>The −20 kHz emitter near 442.3675 MHz</strong> (15 Sep). The deaf heal rejects
the false coarse engage it caused, but what transmits there is unknown; a
±100 kHz capture centred on 442.3875 MHz over one idle cycle is the
instrument.</li>
  <li><strong>The cc=7 CSBK-CRC-fail train</strong> on a cc=12 IPSC system (9 Sep). Parked
with <code class="language-plaintext highlighter-rouge">csbko</code>/<code class="language-plaintext highlighter-rouge">fid</code>/<code class="language-plaintext highlighter-rouge">info_hex</code> in the DEBUG line.</li>
  <li><strong>Three on-air gates.</strong> DMR direct mode is capture-verified but not yet
run live on the fixed build (#836); the DMO pre-roll and grant-on-adoption
pair is capture-validated only (#1003); DMR Enhanced Privacy decrypts the
known-key captures and the live call is still owed (#1187). Those rows
read “capture-verified, live run pending”.</li>
  <li><strong>P25 Phase 1 weak-signal voice.</strong> The one C4FM path with neither
equalizer nor soft FEC; no fix without a marginal voice capture
(<a href="/blog/deep-dives/p25-end-to-end-12-weak-signal-gap/">P25 12</a>).</li>
</ul>

<h3 id="how-this-card-shapes-operator-practice">How this card shapes operator practice</h3>

<ul>
  <li><strong>Read in order, stop at the first failure.</strong> The checks are ranked by
how often they ended a thread.</li>
  <li><strong>Quote the pair, not the number.</strong> <code class="language-plaintext highlighter-rouge">bsch_ok=72 sch_pdus=0</code> is a
diagnosis; <code class="language-plaintext highlighter-rouge">bsch_ok=72</code> alone is a reassurance.</li>
  <li><strong>Judge every “worry” relatively.</strong> Against the channel’s own level, its
own gate, its own previous interval.</li>
  <li><strong>Treat the open rows as invitations.</strong> Each names the capture that would
close it; <a href="/blog/tutorials/field-notebook-13-from-log-to-issue/">Part 13</a>
says how to make and paste it.</li>
</ul>

<h2 id="where-to-go-from-here">Where to go from here</h2>

<p>The notebook read the log; the rest of the blog explains the machinery. The
<a href="/blog/series/field-notebook/">series index</a> is the
card’s table of contents. For the recipes that produce these logs,
<a href="/blog/series/operator-cookbook/">The Operator’s Cookbook</a>;
for when a fix is <em>verified</em>,
<a href="/blog/series/from-spec-to-shipping/">From Spec to Shipping</a>.
Two series launch alongside this finale:
<a href="/blog/series/dmr-end-to-end/">DMR End to End</a> follows
one carrier from 4FSK to decrypted Enhanced Privacy, and
<a href="/blog/series/beyond-voice/">Beyond Voice</a> walks the
eleven-place pattern behind every non-voice decoder. When a row sends you to
a capture, the <a href="/blog/series/analog-edge/">Analog Edge</a>
tells you how to record it well.</p>

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

<p><strong>What are the first lines to check in a GopherTrunk log?</strong>
In order: the <code class="language-plaintext highlighter-rouge">daemon:</code> WARNs in the startup block, the protocol’s
<code class="language-plaintext highlighter-rouge">cc locked</code> line, the trend of its periodic status line, the
<code class="language-plaintext highlighter-rouge">soapyremote: SDR overruns</code> / <code class="language-plaintext highlighter-rouge">ccdecoder: decode can't keep up</code> WARNs, and
<code class="language-plaintext highlighter-rouge">recorder: call ended … reason=</code>. The first one that fails names the family
table — and the part — to open.</p>

<p><strong>Which log counters are noise meters rather than traffic?</strong>
<code class="language-plaintext highlighter-rouge">dnb_total</code> (DMO), <code class="language-plaintext highlighter-rouge">dibits</code> (DMR), <code class="language-plaintext highlighter-rouge">bsch_ok</code> on its own (TETRA), wideband
<code class="language-plaintext highlighter-rouge">coherence</code> read without its <code class="language-plaintext highlighter-rouge">lock_gate</code>, and <code class="language-plaintext highlighter-rouge">calibrated=true</code> without
<code class="language-plaintext highlighter-rouge">updates</code> moving. Their partners — <code class="language-plaintext highlighter-rouge">dnb_qualified</code>, <code class="language-plaintext highlighter-rouge">sync_hits</code>,
<code class="language-plaintext highlighter-rouge">sch_pdus</code>, <code class="language-plaintext highlighter-rouge">lock_gate</code>, <code class="language-plaintext highlighter-rouge">updates</code> — are the numbers that mean decode.</p>

<p><strong>What does “locked but deaf” look like in the TETRA status line?</strong>
<code class="language-plaintext highlighter-rouge">locked=true</code>, <code class="language-plaintext highlighter-rouge">bsch_ok</code> near 100 % of <code class="language-plaintext highlighter-rouge">sb_bursts</code>, <code class="language-plaintext highlighter-rouge">sch_pdus=0</code>, and
<code class="language-plaintext highlighter-rouge">carrier_off_hz</code> about ±4500 Hz from where it sat — the AFC latched an alias
bucket. The payload heartbeat forces a resync after 12 s and escalates to
<code class="language-plaintext highlighter-rouge">MarkLost</code> after three fruitless ones.</p>

<p><strong>Why does the card give no dBFS thresholds?</strong>
Because every absolute-power gate in the project’s history became a
gain-staging trap: a −56 dBFS control channel decoded every C_ALOHA while a
WARN called it too weak, and MRC once demanded −40 dBFS to calibrate. The
card judges everything relative to the channel’s own level or gate.</p>

<p><strong>Which parts of GopherTrunk are still awaiting on-air verification?</strong>
DMR direct mode’s live run on the fixed build (#836), the DMO pre-roll and
grant-on-adoption pair in the live daemon (#1003), a live DMR Enhanced
Privacy call with <code class="language-plaintext highlighter-rouge">encryption_keys</code> (#1187), and P25 Phase 1 weak-signal
voice, which needs a marginal capture first.</p>

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

<p><strong>Part 14 of 14</strong> · ←
<a href="/blog/tutorials/field-notebook-13-from-log-to-issue/">Part 13: From Log to Issue — What to Paste, What to Capture</a>
· <a href="/blog/series/field-notebook/">Back to the series index</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="tutorials" /><category term="field-notebook" /><category term="logs" /><category term="reference" /><category term="finale" /><category term="troubleshooting" /><category term="tutorial" /><summary type="html"><![CDATA[The series finale — one card for reading any GopherTrunk debug.log: the five lines to read first on any report, one table per protocol family mapping each line to healthy, worry-when and the part that explains it, the noise-meter-versus-traffic-counter list, and an honest note on what the log still cannot tell you.]]></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">Beyond Voice, Part 13: D-STAR, System Fusion &amp;amp; dPMR</title><link href="https://gophertrunk.org/blog/deep-dives/beyond-voice-13-dstar-ysf-dpmr/" rel="alternate" type="text/html" title="Beyond Voice, Part 13: D-STAR, System Fusion &amp;amp; dPMR" /><published>2026-09-29T00:00:00-05:00</published><updated>2026-09-29T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/beyond-voice-13-dstar-ysf-dpmr</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/beyond-voice-13-dstar-ysf-dpmr/"><![CDATA[<p><em>Part 13 of <strong>Beyond Voice</strong>, a 14-part deep dive into everything GopherTrunk
decodes that is not a trunked voice call — the AFSK signalling formats,
paging, APRS, ADS-B, AIS, DSC, LoRa, M17 and the amateur digital-voice
family — and the one eleven-place wiring pattern that carries each of them
from a burst on the air to a row in the web console.
<a href="/blog/deep-dives/beyond-voice-12-m17-open-digital-voice/">Part 12</a>
took the open end of digital voice; this part takes the three modes built
on AMBE — D-STAR, Yaesu System Fusion and ETSI dPMR — and is precise about
what each decodes today, what is built but unwired, and which tables are
placeholders waiting for a capture.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> Three packages, one shape, three verification states.
<strong>D-STAR</strong> (<code class="language-plaintext highlighter-rouge">internal/radio/dstar</code>): GMSK at 4800 bps, BT 0.5, a 24-bit
Frame Sync <code class="language-plaintext highlighter-rouge">0xEAA060</code>, a 41-byte PCH header (flags, RPT2, RPT1, UR, MY1,
MY2, CRC-CCITT) whose 660-bit FEC shell — <code class="language-plaintext highlighter-rouge">framing.DecodeDStarHeaderFEC</code>:
K=5 ½-rate Viterbi, PN15 descramble, 22×30 deinterleave — runs under
<code class="language-plaintext highlighter-rouge">dstar_fec_mode: "on"</code>; a <code class="language-plaintext highlighter-rouge">CQCQCQ</code> or <code class="language-plaintext highlighter-rouge">/</code>-routed UR becomes a
<code class="language-plaintext highlighter-rouge">trunking.Grant</code> with <code class="language-plaintext highlighter-rouge">Protocol "dstar"</code>.
<strong>YSF</strong> (<code class="language-plaintext highlighter-rouge">internal/radio/ysf</code>): C4FM 4800 baud, 40-bit FSW <code class="language-plaintext highlighter-rouge">0xD471C9634D</code>,
a full FICH codec (<code class="language-plaintext highlighter-rouge">DecodeFICHOnAir</code>: 10×10 deinterleave, puncture
<code class="language-plaintext highlighter-rouge">{0,1,102,103}</code>, <code class="language-plaintext highlighter-rouge">ViterbiK5</code>, CRC init <code class="language-plaintext highlighter-rouge">0x0000</code>) that <strong>only tests call</strong>
— the live pipeline locks on the FSW and stops. <strong>dPMR Mode 3</strong>
(<code class="language-plaintext highlighter-rouge">internal/radio/dpmr</code>): 4FSK at 2400 sym/s, FS3 → 80-bit CSBK → grants
resolved through a <code class="language-plaintext highlighter-rouge">LinearBandPlan</code>; the CSBK FEC is not implemented.
The D-STAR and dPMR voice chains (v1.1.2) decode AMBE 3600×2400 and AMBE+2
3600×2450 on placeholder deinterleave tables — <strong>unverified on air</strong>.</p>
</blockquote>

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

<ul>
  <li><strong>The same sync → slice → parse → grant shape covers all three.</strong>
<code class="language-plaintext highlighter-rouge">SyncDetector</code>, a countdown <code class="language-plaintext highlighter-rouge">Process</code> adapter, a <code class="language-plaintext highlighter-rouge">ControlChannel</code>
publishing lock and grant — D-STAR merely runs it on bits.</li>
  <li><strong>D-STAR’s FEC chain is complete but self-consistent.</strong> Matching
MMDVMHost’s exact scrambler and interleaver tables “is a follow-up
calibration step”.</li>
  <li><strong>YSF is one wiring step from a real decoder.</strong> <code class="language-plaintext highlighter-rouge">DecodeFICHOnAir</code>
survives every single-bit flip, yet <code class="language-plaintext highlighter-rouge">ProcessFICH</code> has no caller outside
its tests.</li>
  <li><strong>A placeholder table is a placeholder, not a guess.</strong> Both voice chains
deinterleave with a sequential split labelled <code class="language-plaintext highlighter-rouge">PLACEHOLDER</code>, so the swap
is one function — and the first capture confirms it.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>D-STAR bits</td>
      <td>FM → Gaussian matched filter → MM timing → 2-level slice</td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/dstar/receiver/receiver.go</code></td>
    </tr>
    <tr>
      <td>D-STAR header</td>
      <td>Frame Sync <code class="language-plaintext highlighter-rouge">0xEAA060</code> → 328 or 660 bits → <code class="language-plaintext highlighter-rouge">ParseHeader</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/dstar/process.go</code>, <code class="language-plaintext highlighter-rouge">header.go</code></td>
    </tr>
    <tr>
      <td>YSF frame + FICH</td>
      <td>FSW 20 dibits, FICH 100, DCH 360</td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/ysf/frame.go</code>, <code class="language-plaintext highlighter-rouge">fich.go</code>, <code class="language-plaintext highlighter-rouge">fich_trellis.go</code></td>
    </tr>
    <tr>
      <td>dPMR CSBK</td>
      <td>FS3 → 40 dibits → <code class="language-plaintext highlighter-rouge">CSBKFromBits</code> → <code class="language-plaintext highlighter-rouge">Ingest</code> → grant</td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/dpmr/process.go</code>, <code class="language-plaintext highlighter-rouge">control.go</code></td>
    </tr>
    <tr>
      <td>dPMR channel → Hz</td>
      <td><code class="language-plaintext highlighter-rouge">LinearBandPlan</code> / <code class="language-plaintext highlighter-rouge">TableBandPlan</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/dpmr/bandplan.go</code></td>
    </tr>
    <tr>
      <td>Voice chains</td>
      <td><code class="language-plaintext highlighter-rouge">VoiceChannel</code> (96-bit DV) / <code class="language-plaintext highlighter-rouge">TrafficChannel</code> (4×72 TCH)</td>
      <td><code class="language-plaintext highlighter-rouge">dstar/voice.go</code>, <code class="language-plaintext highlighter-rouge">dpmr/traffic.go</code>, <code class="language-plaintext highlighter-rouge">internal/voice/recorder.go</code></td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>One shape, three modulations</strong> — where the protocols enter.</li>
  <li><strong>D-STAR</strong> — the GMSK header and its 660-bit shell.</li>
  <li><strong>System Fusion</strong> — a FICH codec waiting for its caller.</li>
  <li><strong>dPMR Mode 3</strong> — CSBKs, band plans and the FEC that is not there.</li>
  <li><strong>The voice chains</strong> — two AMBE paths and their placeholder tables.</li>
</ul>

<h2 id="one-shape-three-modulations">One shape, three modulations</h2>

<p>All three are trunking-engine protocols rather than message decoders:
<code class="language-plaintext highlighter-rouge">ProtocolDPMR</code>, <code class="language-plaintext highlighter-rouge">ProtocolYSF</code> and <code class="language-plaintext highlighter-rouge">ProtocolDStar</code> live in
<code class="language-plaintext highlighter-rouge">internal/trunking/site.go</code>, each has a <code class="language-plaintext highlighter-rouge">ccdecoder</code> pipeline factory, and
all three run at the 48 kHz <code class="language-plaintext highlighter-rouge">ddcTargetRateHz</code> the C4FM family shares. That is the
shape
<a href="/blog/deep-dives/protocol-decoders-01-anatomy-of-a-cc-decoder/">Protocol Decoders Part 1</a>
laid down: a <code class="language-plaintext highlighter-rouge">SyncDetector</code> finds frame boundaries, a <code class="language-plaintext highlighter-rouge">Process</code> adapter
counts out the payload after each match, a <code class="language-plaintext highlighter-rouge">ControlChannel</code> parses it and
publishes <code class="language-plaintext highlighter-rouge">events.KindCCLocked</code> and <code class="language-plaintext highlighter-rouge">events.KindGrant</code>.</p>

<p>What differs is the physics. YSF is 4800-baud C4FM with RRC α = 0.20 — the
P25 Phase 1 receiver with a different sync word. dPMR is the same 4-level
scheme at <strong>2400 sym/s</strong> with 900 Hz peak deviation. D-STAR is <strong>GMSK at
4800 bps, BT 0.5</strong>: one bit per symbol, so its receiver emits a <code class="language-plaintext highlighter-rouge">BitSink</code>.
D-STAR and YSF are conventional — a “grant” announces a transmission on the
monitored repeater; only dPMR Mode 3 has a real control channel and a band
plan.</p>

<h2 id="d-star-a-gmsk-header-and-its-660-bit-shell">D-STAR: a GMSK header and its 660-bit shell</h2>

<p><code class="language-plaintext highlighter-rouge">internal/radio/dstar/receiver</code> composes <code class="language-plaintext highlighter-rouge">demod.FM</code> → <code class="language-plaintext highlighter-rouge">demod.GFSK</code> (a
Gaussian matched filter and 2-level slicer) → <code class="language-plaintext highlighter-rouge">sync.MuellerMuller</code> →
<code class="language-plaintext highlighter-rouge">dstar.BitSink</code> (<a href="/reference/gmsk/">reference</a>).
<code class="language-plaintext highlighter-rouge">ControlChannel.Process</code> runs a <code class="language-plaintext highlighter-rouge">SyncDetector</code> on the 24-bit Header Frame
Sync at tolerance 2, then counts down a header window whose size <code class="language-plaintext highlighter-rouge">FECMode</code>
chooses: <code class="language-plaintext highlighter-rouge">HeaderBits</code> (328) under <code class="language-plaintext highlighter-rouge">FECOff</code>, or
<code class="language-plaintext highlighter-rouge">framing.DStarHeaderChannelBits</code> (660) under <code class="language-plaintext highlighter-rouge">FECOn</code>, where
<code class="language-plaintext highlighter-rouge">framing.DecodeDStarHeaderFEC</code> recovers the 41 bytes first. Either way
<code class="language-plaintext highlighter-rouge">ParseHeader</code> and a <code class="language-plaintext highlighter-rouge">ComputeCRC</code> check follow.</p>

<p><code class="language-plaintext highlighter-rouge">FECOff</code> is the default and reads 328 information bits straight off the
wire — right for synthesised fixtures. <code class="language-plaintext highlighter-rouge">FECOn</code>, selected per system with
<code class="language-plaintext highlighter-rouge">dstar_fec_mode: "on"</code> and threaded through <code class="language-plaintext highlighter-rouge">dstar.ParseFECMode</code> in
<code class="language-plaintext highlighter-rouge">newDStarPipeline</code>, is “the path that lights up on a live-air capture”. Its chain
(<a href="/reference/dstar-header-fec/">reference</a>): 328
info bits plus a 4-bit tail → K=5 rate-½ convolutional code (<code class="language-plaintext highlighter-rouge">G1=0x19</code>,
<code class="language-plaintext highlighter-rouge">G2=0x17</code>, the <code class="language-plaintext highlighter-rouge">ViterbiK5</code> pair) → 664 channel bits → puncture four → 660 →
PN15 scramble → 22×30 block interleave, inverted on receive;
<code class="language-plaintext highlighter-rouge">TestProcess_FECOnSurvivesSingleBitError</code> and
<code class="language-plaintext highlighter-rouge">TestDaemonCCDecodesDStarFECOn</code> pin it. The file’s own caveat: the
scrambler and interleaver are “self-consistent encode/decode pairs” whose
match to MMDVMHost’s exact tables “is a follow-up calibration step” — the
<a href="/blog/solution-postmortem/from-the-issue-tracker-20-self-consistent-trap/">self-consistent trap</a>
named in advance, and why <code class="language-plaintext highlighter-rouge">FECOff</code> stays the default.</p>

<p>The 41 recovered bytes are the PCH header
(<a href="/reference/d-star/">reference</a>): three flag
bytes, four 8-character callsigns — <code class="language-plaintext highlighter-rouge">RPT2</code>, <code class="language-plaintext highlighter-rouge">RPT1</code>, <code class="language-plaintext highlighter-rouge">UR</code>, <code class="language-plaintext highlighter-rouge">MY1</code> — a
4-character <code class="language-plaintext highlighter-rouge">MY2</code>, and a CRC-16-CCITT (poly <code class="language-plaintext highlighter-rouge">0x1021</code>, init <code class="language-plaintext highlighter-rouge">0xFFFF</code>),
pinned by <code class="language-plaintext highlighter-rouge">TestComputeCRCKnownVector</code> to the standard <code class="language-plaintext highlighter-rouge">"123456789"</code> →
<code class="language-plaintext highlighter-rouge">0x29B1</code> vector. <code class="language-plaintext highlighter-rouge">Ingest</code> decides everything from the header: a data header
(<code class="language-plaintext highlighter-rouge">Flag1Data</code>) is ignored; the first valid header locks with <code class="language-plaintext highlighter-rouge">RPT2</code> as the
repeater; a <code class="language-plaintext highlighter-rouge">UR</code> of <code class="language-plaintext highlighter-rouge">CQCQCQ</code> or any <code class="language-plaintext highlighter-rouge">/</code>-prefixed routing publishes a grant
with <code class="language-plaintext highlighter-rouge">Protocol "dstar"</code>, the trimmed <code class="language-plaintext highlighter-rouge">UR</code> hashed into <code class="language-plaintext highlighter-rouge">GroupID</code> and <code class="language-plaintext highlighter-rouge">MY1</code>
into <code class="language-plaintext highlighter-rouge">SourceID</code> (<code class="language-plaintext highlighter-rouge">hashCallsign</code>). <code class="language-plaintext highlighter-rouge">LockedNAC</code> packs two bytes of the RPT2
callsign into the NAC slot as a stable per-repeater identity.</p>

<h2 id="system-fusion-a-fich-codec-waiting-for-its-caller">System Fusion: a FICH codec waiting for its caller</h2>

<p>YSF’s frame is 480 dibits — 100 ms at 4800 baud
(<a href="/reference/system-fusion-ysf/">reference</a>): 20
dibits of Frame Sync Word (<code class="language-plaintext highlighter-rouge">FSWBits</code> <code class="language-plaintext highlighter-rouge">0xD471C9634D</code>), 100 of FICH, 360 of
DCH. <code class="language-plaintext highlighter-rouge">ysf.SyncDetector</code> matches the 20-dibit <code class="language-plaintext highlighter-rouge">FSWPattern</code> within a
tolerance of 2, and <code class="language-plaintext highlighter-rouge">ControlChannel.Process</code> does one thing with a hit:
<code class="language-plaintext highlighter-rouge">maybeLock</code>, logging <code class="language-plaintext highlighter-rouge">ysf cc locked</code>. That is the whole live path.</p>

<p>The FICH is where the interesting code sits
(<a href="/reference/ysf-fich/">reference</a>): 32 information
bits — frame type, call type, block and frame counters, data type, squelch
mode and a 7-bit squelch code — plus a CRC-CCITT computed with
<code class="language-plaintext highlighter-rouge">framing.CRCCCITTWithInit(b[:4], 0x0000)</code>, the XMODEM initial value
(<code class="language-plaintext highlighter-rouge">TestFICHCRCUsesXMODEMInit</code>). On the air those 48 bits
pass through a K=5 ½-rate trellis with four tail bits (<code class="language-plaintext highlighter-rouge">FICHChannelBits</code>
104), lose the punctured positions <code class="language-plaintext highlighter-rouge">{0, 1, 102, 103}</code> (<code class="language-plaintext highlighter-rouge">FICHOnAirBits</code> 100)
and are permuted by a column-major 10×10 interleaver — MMDVMHost’s
schedule. <code class="language-plaintext highlighter-rouge">DecodeFICHOnAir</code> inverts it: deinterleave, depuncture with
<code class="language-plaintext highlighter-rouge">framing.DepunctureMark</code>, <code class="language-plaintext highlighter-rouge">framing.ViterbiK5</code> to 48 info bits plus a metric. <code class="language-plaintext highlighter-rouge">TestFICHOnAirRecoversFromSingleBitFlip</code> confirms every one of the
100 on-air positions is corrected. And <code class="language-plaintext highlighter-rouge">ControlChannel.ProcessFICH</code> knows
what to do with a decoded FICH: a Header frame with <code class="language-plaintext highlighter-rouge">CallTypeGroup</code>
publishes a <code class="language-plaintext highlighter-rouge">trunking.Grant</code> with <code class="language-plaintext highlighter-rouge">Protocol "ysf"</code> and <code class="language-plaintext highlighter-rouge">GroupID</code> = the
squelch code (the DG-ID) when <code class="language-plaintext highlighter-rouge">SquelchMode</code> is set, else 0; a Terminator
clears the dedup state.</p>

<p>The gap is the join. <code class="language-plaintext highlighter-rouge">newYSFPipeline</code> wires the receiver’s <code class="language-plaintext highlighter-rouge">DibitSink</code> to
<code class="language-plaintext highlighter-rouge">cc.Process</code> only; nothing slices the 100-dibit FICH region, runs
<code class="language-plaintext highlighter-rouge">DecodeFICHOnAir</code> and calls <code class="language-plaintext highlighter-rouge">ProcessFICH</code> — its only callers are in
<code class="language-plaintext highlighter-rouge">control_test.go</code>. So a live YSF repeater locks, and no grant, call or
recording follows; the control comment says so: “A future PR closes the IQ
→ dibit → FICH gap on the hot path”. <code class="language-plaintext highlighter-rouge">samples/ysf/README.md</code> names the
fallback if MMDVMHost’s schedule fails a real FICH CRC: DSDcc’s spread
puncture <code class="language-plaintext highlighter-rouge">{0, 51, 52, 103}</code>.</p>

<h2 id="dpmr-mode-3-csbks-band-plans-and-the-fec-that-is-not-there">dPMR Mode 3: CSBKs, band plans and the FEC that is not there</h2>

<p><a href="/blog/deep-dives/protocol-decoders-06-nxdn-dpmr/">Protocol Decoders Part 6</a>
introduced dPMR’s three sync words and its 80-bit CSBK.
<code class="language-plaintext highlighter-rouge">internal/radio/dpmr/receiver</code> is the C4FM chain at
<code class="language-plaintext highlighter-rouge">SymbolRate</code> 2400 with <code class="language-plaintext highlighter-rouge">DeviationHz</code> 900
(<a href="/reference/dpmr/">reference</a>). The control path
in <code class="language-plaintext highlighter-rouge">process.go</code> runs one <code class="language-plaintext highlighter-rouge">SyncDetector</code> on <code class="language-plaintext highlighter-rouge">FS3Dibits()</code> at tolerance 1
and, after each match, collects 40 dibits, converts them with
<code class="language-plaintext highlighter-rouge">framing.DibitsToBits</code>, parses with <code class="language-plaintext highlighter-rouge">CSBKFromBits</code> and calls <code class="language-plaintext highlighter-rouge">Ingest</code>
(<a href="/reference/dpmr-frame-sync/">sync</a>,
<a href="/reference/dpmr-csbk/">CSBK</a>).</p>

<p><code class="language-plaintext highlighter-rouge">Ingest</code> is the state machine: <code class="language-plaintext highlighter-rouge">MsgIdle</code> is dropped;
<code class="language-plaintext highlighter-rouge">MsgStandingServiceStatus</code> locks the channel with the broadcast’s <code class="language-plaintext highlighter-rouge">DestID</code>
as <code class="language-plaintext highlighter-rouge">SystemID</code> (<code class="language-plaintext highlighter-rouge">dpmr cc locked</code>); <code class="language-plaintext highlighter-rouge">MsgVoiceServiceAllocation</code> or
<code class="language-plaintext highlighter-rouge">MsgIndividualVoiceAllocation</code> locks if needed and publishes a grant. The 16-bit opcode-specific field carries the channel
number, and a <code class="language-plaintext highlighter-rouge">Resolver</code> turns it into hertz: <code class="language-plaintext highlighter-rouge">LinearBandPlan</code> applies
<code class="language-plaintext highlighter-rouge">BaseHz + (channel + Offset) × SpacingHz</code> — for PMR446, base 446 006 250 Hz,
6 250 Hz spacing, offset −1 — and <code class="language-plaintext highlighter-rouge">TableBandPlan</code> maps explicit entries.
With no resolver the grant carries <code class="language-plaintext highlighter-rouge">FrequencyHz</code> 0.
<code class="language-plaintext highlighter-rouge">SetStrictValidation</code> drops any CSBK whose 5-bit type is outside the
documented set (<code class="language-plaintext highlighter-rouge">TestStrictValidationDropsUnknownMessageType</code>), the defence
against a misaligned-but-passing window producing a phantom grant.</p>

<p>What is <em>not</em> there is the channel coding
(<a href="/reference/dpmr-channel-coding/">reference</a>): Mode
3 CSBKs carry a short-block cyclic code under a rate-¾ convolutional outer
code plus an interleaver, and the package doc says “the parsing here assumes
the upstream caller has already corrected errors”. The 40 dibits after FS3
are read as 80 clean bits — which is why strict validation exists and why
<code class="language-plaintext highlighter-rouge">TestDaemonCCDecodesDPMR</code> is a synthetic lock, not a field result.</p>

<h2 id="the-voice-chains-and-their-placeholder-tables">The voice chains and their placeholder tables</h2>

<p>Both voice chains landed in v1.1.2 (2026-09-12) labelled “experimental,
unverified on air”.</p>

<p><strong>D-STAR</strong> voice is the original AMBE 3600×2400 — exactly the base
<code class="language-plaintext highlighter-rouge">internal/voice/ambe2</code> decoder, so the recorder’s map has <code class="language-plaintext highlighter-rouge">"dstar": "ambe2"</code>
(<a href="/blog/deep-dives/sdr-internals-12-voice-coding-vocoders/">Voice Coding</a>,
<a href="/reference/ambe/">AMBE</a>). <code class="language-plaintext highlighter-rouge">VoiceChannel</code> free-runs a
96-bit DV cadence — 72 voice bits then 24 slow-data bits — anchored on the
24-bit Slow Data sync (<code class="language-plaintext highlighter-rouge">0x552D16</code>) that every 21st DV frame carries; it
re-anchors on every match and drops the anchor after two sync-less cycles.
<code class="language-plaintext highlighter-rouge">DecodeDVVoiceBits</code> then runs the AMBE FEC shared with DMR and NXDN:
Golay(23,12) over C0, a C0-seeded keystream XORed onto C1 before its own
Golay, and the 12 + 12 + 11 + 14 = 49-bit <code class="language-plaintext highlighter-rouge">ambe_d</code> assembly.</p>

<p><strong>dPMR</strong> voice is AMBE+2 3600×2450 — the DMR/NXDN codec — so <code class="language-plaintext highlighter-rouge">"dpmr"</code> maps to
<code class="language-plaintext highlighter-rouge">"ambe2-dmr"</code> (<a href="/reference/ambe-plus-2-fec/">AMBE+2 FEC</a>)
— v1.1.2 fixed a map that had pointed at the 2400 codebook.
<code class="language-plaintext highlighter-rouge">TrafficChannel</code> runs FS1 and FS2 detectors, collects 168 dibits per 80 ms
frame — 24 of CCH, skipped, then 144 of TCH — and <code class="language-plaintext highlighter-rouge">ExtractTCHFrames</code> carves
four 72-bit AMBE+2 frames. <code class="language-plaintext highlighter-rouge">runDPMRVoiceChain</code> and <code class="language-plaintext highlighter-rouge">runDStarVoiceChain</code>
decimate to 48 kHz and feed the recorder 7-byte frames.</p>

<p>The placeholder is the same in both: the function that maps 72 on-air bits
into the C0..C3 sub-vectors. <code class="language-plaintext highlighter-rouge">dstarAMBEDeinterleave</code> and
<code class="language-plaintext highlighter-rouge">dpmrAMBEDeinterleave</code> are direct sequential splits — C0 = bits 0..23,
C1 = 24..46, C2 = 47..57, C3 = 58..71 — each marked <code class="language-plaintext highlighter-rouge">PLACEHOLDER</code>, to be
replaced “once a capture is available to verify against”.
<code class="language-plaintext highlighter-rouge">TestDVVoiceBitsRoundTrip</code> and <code class="language-plaintext highlighter-rouge">TestTCHFrameRoundTrip</code> are green — and
prove only that the encoder inverse matches the decoder. The source cites
CLAUDE.md’s TETRA-CRC lesson for why the real tables are not guessed: a
wrong table that round-trips is the worst bug, because nothing in the tree
can disagree with it
(<a href="/blog/deep-dives/from-spec-to-shipping-07-tests-that-can-disagree/">From Spec to Shipping Part 7</a>).
A voice capture confirms the table first and, for dPMR, the 2450-versus-2400
codebook second.</p>

<figure class="lab-figure">
<svg viewBox="0 0 680 250" width="680" height="250" role="img" aria-label="Three lanes, one per protocol. D-STAR: GMSK bits and sync, header with optional FEC, grant, then a dashed DV cadence and AMBE voice. YSF: C4FM dibits and FSW, lock, then a dashed FICH codec with no caller and dashed undecoded voice. dPMR: 4FSK and FS3, CSBK and band plan, grant, then dashed traffic frames and AMBE+2 voice. Solid boxes are wired; dashed boxes are built but unwired or placeholder-backed.">
  <text x="8" y="41" fill="currentColor" font-size="9" font-weight="bold">D-STAR</text>
  <rect x="62" y="24" width="140" height="28" fill="none" stroke="currentColor" />
  <text x="132" y="42" text-anchor="middle" fill="currentColor" font-size="8">GMSK bits · sync 0xEAA060</text>
  <rect x="212" y="24" width="150" height="28" fill="none" stroke="currentColor" />
  <text x="287" y="42" text-anchor="middle" fill="currentColor" font-size="8">41-byte header · FEC 660→328</text>
  <rect x="372" y="24" width="60" height="28" fill="none" stroke="var(--accent)" />
  <text x="402" y="42" text-anchor="middle" fill="var(--accent)" font-size="8">grant</text>
  <rect x="442" y="24" width="230" height="28" fill="none" stroke="var(--fg-muted)" stroke-dasharray="4 3" />
  <text x="557" y="42" text-anchor="middle" fill="var(--fg-muted)" font-size="8">96-bit DV cadence → AMBE 2400</text>
  <text x="8" y="111" fill="currentColor" font-size="9" font-weight="bold">YSF</text>
  <rect x="62" y="94" width="140" height="28" fill="none" stroke="currentColor" />
  <text x="132" y="112" text-anchor="middle" fill="currentColor" font-size="8">C4FM dibits · FSW</text>
  <rect x="212" y="94" width="60" height="28" fill="none" stroke="var(--accent)" />
  <text x="242" y="112" text-anchor="middle" fill="var(--accent)" font-size="8">lock</text>
  <rect x="282" y="94" width="200" height="28" fill="none" stroke="var(--fg-muted)" stroke-dasharray="4 3" />
  <text x="382" y="112" text-anchor="middle" fill="var(--fg-muted)" font-size="8">FICH codec — no caller</text>
  <rect x="492" y="94" width="180" height="28" fill="none" stroke="var(--fg-muted)" stroke-dasharray="4 3" />
  <text x="582" y="112" text-anchor="middle" fill="var(--fg-muted)" font-size="8">DCH voice — not decoded</text>
  <text x="8" y="181" fill="currentColor" font-size="9" font-weight="bold">dPMR</text>
  <rect x="62" y="164" width="140" height="28" fill="none" stroke="currentColor" />
  <text x="132" y="182" text-anchor="middle" fill="currentColor" font-size="8">4FSK 2400 sym/s · FS3</text>
  <rect x="212" y="164" width="150" height="28" fill="none" stroke="currentColor" />
  <text x="287" y="182" text-anchor="middle" fill="currentColor" font-size="8">CSBK · band plan</text>
  <rect x="372" y="164" width="60" height="28" fill="none" stroke="var(--accent)" />
  <text x="402" y="182" text-anchor="middle" fill="var(--accent)" font-size="8">grant</text>
  <rect x="442" y="164" width="230" height="28" fill="none" stroke="var(--fg-muted)" stroke-dasharray="4 3" />
  <text x="557" y="182" text-anchor="middle" fill="var(--fg-muted)" font-size="8">FS1/FS2 → 4×72 TCH → AMBE+2 2450</text>
  <text x="340" y="228" text-anchor="middle" fill="currentColor" font-size="9">solid: wired, synthetic-verified · dashed: unwired or placeholder-backed</text>
</svg>
<figcaption>Three lanes, one shape. Every solid box is exercised by the daemon today; every dashed box is code that exists — a FICH codec with no caller, two voice chains on placeholder tables — and waits for an IQ capture.</figcaption>
</figure>

<h3 id="how-these-three-shaped-the-go-code">How these three shaped the Go code</h3>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">FECMode</code> as a per-window latch.</strong> <code class="language-plaintext highlighter-rouge">Process</code> samples the D-STAR FEC
mode at each Frame Sync match, so a mid-window flip cannot desynchronise
the bit count.</li>
  <li><strong>Voice trackers independent of the header.</strong> <code class="language-plaintext highlighter-rouge">VoiceChannel</code> and
<code class="language-plaintext highlighter-rouge">TrafficChannel</code> anchor on their own syncs; a lost header costs a grant,
never the audio cadence.</li>
  <li><strong>Placeholders isolated to one function each</strong>, with encoder inverses in
lock-step, so the capture-driven swap is a one-function change.</li>
  <li><strong>Strictness as a knob, not a default.</strong> <code class="language-plaintext highlighter-rouge">SetStrictValidation</code> and the
<code class="language-plaintext highlighter-rouge">FECOff</code> default keep fixtures green with the live-air posture one config
line away.</li>
</ul>

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

<p>Twelve physical layers later, the pattern is the story.
<a href="/blog/deep-dives/beyond-voice-14-adding-a-decoder-checklist/">Part 14</a>
closes the series with the checklist: the eleven places a new decoder
touches, the three tests that police them, and the honest open list —
including the FICH caller and the placeholder tables above.</p>

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

<p><strong>Does GopherTrunk decode D-STAR voice?</strong>
Experimentally. The <code class="language-plaintext highlighter-rouge">dstar</code> voice chain anchors a 96-bit DV cadence on the
Slow Data sync, FEC-decodes 72-bit AMBE frames and renders them through the
base <code class="language-plaintext highlighter-rouge">ambe2</code> (3600×2400) vocoder. Its deinterleave table is a documented
placeholder validated only by synthetic round-trip, so the path is
unverified on air until a real capture lands.</p>

<p><strong>What does <code class="language-plaintext highlighter-rouge">dstar_fec_mode</code> do?</strong>
It selects how many bits follow a Frame Sync. Off (the default) reads 328
information bits and parses them directly — right for pre-decoded fixtures.
On reads the 660 on-wire bits and runs <code class="language-plaintext highlighter-rouge">framing.DecodeDStarHeaderFEC</code>
(deinterleave, PN15 descramble, depuncture, K=5 Viterbi) first, which a
live JARL DV-mode header needs.</p>

<p><strong>Why does a System Fusion repeater lock but never record?</strong>
Because the live YSF pipeline stops at the Frame Sync Word. The FICH codec
(<code class="language-plaintext highlighter-rouge">DecodeFICHOnAir</code>) and the grant logic (<code class="language-plaintext highlighter-rouge">ProcessFICH</code>) both exist and are
tested, but nothing on the hot path slices the FICH region and joins them,
so YSF produces <code class="language-plaintext highlighter-rouge">cc.locked</code> and nothing else until that wiring lands.</p>

<p><strong>How does dPMR turn a channel number into a frequency?</strong>
Through a <code class="language-plaintext highlighter-rouge">Resolver</code>. <code class="language-plaintext highlighter-rouge">LinearBandPlan</code> applies base + (channel + offset) ×
spacing — for PMR446, base 446.006250 MHz, 6.25 kHz spacing, offset −1 so
channel 1 lands on the base — and <code class="language-plaintext highlighter-rouge">TableBandPlan</code> maps explicit entries.
Without a resolver the grant carries frequency 0.</p>

<p><strong>Are the D-STAR and dPMR AMBE interleave tables correct?</strong>
Unknown — that is the point of labelling them placeholders. Both use a
sequential C0|C1|C2|C3 split so the FEC machinery can be exercised end to
end; the real D-STAR dW/dX schedule and the dPMR TS 102 658 §6 table await a
capture, because a guessed table that round-trips would hide the bug.</p>

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

<p><strong>Part 13 of 14</strong> · ←
<a href="/blog/deep-dives/beyond-voice-12-m17-open-digital-voice/">Part 12: M17 — The Open Digital Voice Link Layer</a>
· Next →
<a href="/blog/deep-dives/beyond-voice-14-adding-a-decoder-checklist/">Part 14: Adding a Decoder in a Day — The Checklist</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="beyond-voice" /><category term="d-star" /><category term="system-fusion" /><category term="dpmr" /><category term="ambe" /><category term="go" /><summary type="html"><![CDATA[Three AMBE-era digital modes at three distances from on-air verification: D-STAR's GMSK header behind a 660-bit FEC shell, System Fusion's FICH trellis codec that is built but not yet on the hot path, and dPMR Mode 3's CSBK trunking — plus the two voice chains whose interleave tables are honest placeholders.]]></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">DMR End to End, Part 13: Testing DMR Without a Repeater</title><link href="https://gophertrunk.org/blog/deep-dives/dmr-end-to-end-13-testing-dmr/" rel="alternate" type="text/html" title="DMR End to End, Part 13: Testing DMR Without a Repeater" /><published>2026-09-29T00:00:00-05:00</published><updated>2026-09-29T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/dmr-end-to-end-13-testing-dmr</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/dmr-end-to-end-13-testing-dmr/"><![CDATA[<p><em>Part 13 of <strong>DMR End to End</strong>, a 14-part deep dive that follows the world’s
most widely deployed digital PMR protocol through GopherTrunk — from a 4FSK
carrier to two simultaneous recorded calls, direct-mode handhelds, and
decrypted Enhanced Privacy voice.
<a href="/blog/deep-dives/dmr-end-to-end-12-enhanced-privacy-rc4/">Part 12</a>
closed with a working cipher hidden by two receiving-side defects that only
instruments exposed. This part is about those instruments: what a DMR test
can prove at each layer, the fixture that lied to every earlier test by
transmitting like a repeater, and the harnesses that let an operator’s
capture argue with the code.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> GopherTrunk tests DMR in four layers. <strong>Literal vectors</strong> pin
constants against sources that are not this code — real off-air CSBKs
prove the <code class="language-plaintext highlighter-rouge">0x5A5A</code> CRC mask, MMDVMHost’s <code class="language-plaintext highlighter-rouge">DMR_IDLE_DATA</code> BPTC-decodes to
<code class="language-plaintext highlighter-rouge">IdleInfoPattern</code>, an independent CRC pins the PI header, and
<code class="language-plaintext highlighter-rouge">ep_issue1187_ptt1.json</code> holds on-air Enhanced Privacy frames. <strong>Synthetic
streams</strong> now transmit like a handheld — one 132-dibit burst per 288-dibit
frame with receiver noise in the gaps — because every earlier fixture laid
bursts back-to-back like a base station and hid
<a href="https://github.com/MattCheramie/GopherTrunk/issues/836">#836</a> for its
whole life. <strong>Committed slices</strong> (<code class="language-plaintext highlighter-rouge">dmr-*.cfile</code>,
<code class="language-plaintext highlighter-rouge">dmr-directmode-446500-*.cs16</code>) replay real air in CI. <strong>Replay harnesses</strong>
(<code class="language-plaintext highlighter-rouge">TestDMRIPSCReplay</code> with <code class="language-plaintext highlighter-rouge">GT_DMR_DROP_HEADERS</code> / <code class="language-plaintext highlighter-rouge">GT_DMR_DROP_TERMINATORS</code>,
<code class="language-plaintext highlighter-rouge">TestDMRIPSCWidebandReplay</code>) turn an operator’s capture into an A/B. A
C0/C1 Golay histogram is the first instrument when frames look scrambled:
73 % at exactly three corrections means mis-slicing, not a cipher.</p>
</blockquote>

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

<ul>
  <li><strong>A fixture that models the wrong transmitter validates the wrong
receiver.</strong> Back-to-back bursts hid the direct-mode blindness; a header
framed with the voice sync passed only because the slicer parsed slot
types on every sync match.</li>
  <li><strong>Constants need a witness that is not you.</strong> Off-air CSBKs, an MMDVMHost
template, a Python CRC and a reporter’s frames each caught what a
round-trip could not.</li>
  <li><strong>Scrubs turn a capture into a counterfactual.</strong> Deleting every header or
terminator from a real dibit stream models the fade or the missed release
— and the harness must still grant.</li>
  <li><strong>Read the Golay histogram before any cipher theory.</strong> Random words need
exactly three corrections ~73 % of the time; real frames mostly need none.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Off-air CSBK vectors</td>
      <td>pins CRC mask <code class="language-plaintext highlighter-rouge">0x5A5A</code> + opcodes against real Tier III bursts</td>
      <td><code class="language-plaintext highlighter-rouge">tier3/csbk_realvectors_test.go</code> (<code class="language-plaintext highlighter-rouge">TestParseCSBKRealOffAirVectors</code>)</td>
    </tr>
    <tr>
      <td>Idle-beacon constant</td>
      <td>MMDVMHost <code class="language-plaintext highlighter-rouge">DMR_IDLE_DATA</code> → <code class="language-plaintext highlighter-rouge">ff83df1732094ed1e7cd8a91</code></td>
      <td><code class="language-plaintext highlighter-rouge">tier2/conventional_rekey_test.go</code> (<code class="language-plaintext highlighter-rouge">TestIdleInfoPatternMatchesMMDVMHostConstant</code>)</td>
    </tr>
    <tr>
      <td>Direct-mode fixture</td>
      <td>288-dibit frame grid, noise gaps, delayed RRC gating</td>
      <td><code class="language-plaintext highlighter-rouge">receiver/receiver_burst_test.go</code> (<code class="language-plaintext highlighter-rouge">directModeIQ</code>)</td>
    </tr>
    <tr>
      <td>Forged terminator pin</td>
      <td>a voice burst A cannot end a call</td>
      <td><code class="language-plaintext highlighter-rouge">tier2/conventional_lateentry_test.go</code> (<code class="language-plaintext highlighter-rouge">TestConventionalVoiceBurstCannotForgeTerminator</code>)</td>
    </tr>
    <tr>
      <td>Committed real air</td>
      <td>Tier III CC lock, FLC decode, direct-mode keyup</td>
      <td><code class="language-plaintext highlighter-rouge">cmd/gophertrunk/testdata/dmr-*.cfile</code>, <code class="language-plaintext highlighter-rouge">dmr-directmode-446500-*.cs16</code></td>
    </tr>
    <tr>
      <td>IPSC replay + scrubs</td>
      <td>grants / late entries / re-keys from a capture</td>
      <td><code class="language-plaintext highlighter-rouge">cmd/gophertrunk/dmr_ipsc_replay_test.go</code> (<code class="language-plaintext highlighter-rouge">TestDMRIPSCReplay</code>)</td>
    </tr>
    <tr>
      <td>Scrambled-or-sliced?</td>
      <td>per-frame Golay corrections in the EP dump</td>
      <td><code class="language-plaintext highlighter-rouge">dmr_ep_replay_test.go</code> (<code class="language-plaintext highlighter-rouge">GT_DMR_EP_DUMP</code>, <code class="language-plaintext highlighter-rouge">golay_errs</code>)</td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>The fixture that transmitted like a repeater</strong> — how self-consistency hid #836.</li>
  <li><strong>Literal vectors</strong> — constants pinned against witnesses outside the repo.</li>
  <li><strong>Honest synthetic streams</strong> — gaps, header trains, phase sweeps, deaf taps.</li>
  <li><strong>Committed real air</strong> — the slices CI replays every run.</li>
  <li><strong>Replay harnesses and scrubs</strong> — the operator’s capture as an argument.</li>
  <li><strong>What only air can prove</strong> — the open items and the histogram habit.</li>
</ul>

<h2 id="the-fixture-that-transmitted-like-a-repeater">The fixture that transmitted like a repeater</h2>

<p>The founding bug of this part is the one
<a href="/blog/deep-dives/dmr-end-to-end-09-direct-mode-carrier-gate/">Part 9</a>
fixed. A simplex handheld transmits one 27.5 ms burst per 60 ms frame — 132
dibits on, 156 off — and in the gap the discriminator of receiver noise is
uniform over ±π, several times the signal’s swing. That noise inflated the
symbol AGC, decayed the AFC, halved the coarse acquirer’s window mean and
random-walked the timing loop, so no sync word ever matched. Every synthetic
DMR fixture had laid its bursts <strong>back to back</strong> like a base station, so
the receiver was never asked the question it failed.</p>

<p>That is the
<a href="/blog/solution-postmortem/from-the-issue-tracker-20-self-consistent-trap/">self-consistent trap</a>
in its transmitter-model dress, and DMR has two more specimens. The
siglab Tier I fixture framed a Voice LC Header — a <em>data</em> burst — with the
DM <strong>voice</strong> sync, and passed because the old slicer parsed a slot type on
every sync match; once slot types were read only from data-sync bursts
(<a href="/blog/deep-dives/dmr-end-to-end-04-fec-stack-forged-terminator/">Part 4</a>),
the fixture had to be corrected to <code class="language-plaintext highlighter-rouge">DMData1</code>.
And the idle beacon
(<a href="/blog/deep-dives/dmr-end-to-end-07-idle-beacon-csbk/">Part 7</a>)
read <code class="language-plaintext highlighter-rouge">beacons=0</code> because no fixture ever contained an Idle burst —
<code class="language-plaintext highlighter-rouge">IngestBurst</code> dropped slot type 9 and nothing objected. Each time, encoder
and decoder written from one reading of the air agreed with each other and
disagreed with the radio. The layers below are the countermeasures, in
increasing power of proof.</p>

<h2 id="literal-vectors-constants-with-an-outside-witness">Literal vectors: constants with an outside witness</h2>

<p>The cheapest defence is a test whose expected bytes came from outside this
code.</p>

<p><strong>Off-air CSBKs.</strong> <code class="language-plaintext highlighter-rouge">TestParseCSBKRealOffAirVectors</code> holds 12-byte info
blocks from 2 MS/s captures of two live Tier III control channels (440.5625
and 440.2625 MHz) that BPTC-decode with zero corrections. Under the
previous CRC convention every one failed and the
control channel never locked; under init <code class="language-plaintext highlighter-rouge">0x0000</code> with the <code class="language-plaintext highlighter-rouge">0x5A5A</code> mask
they validate — and their opcode bytes pinned the corrected table.</p>

<p><strong>A template from another project.</strong> <code class="language-plaintext highlighter-rouge">IdleInfoPattern</code>
(<code class="language-plaintext highlighter-rouge">ff83df1732094ed1e7cd8a91</code>) was measured on a beacon-only capture (2402 of
2416 Idle bursts). The independent witness is
<code class="language-plaintext highlighter-rouge">TestIdleInfoPatternMatchesMMDVMHostConstant</code>, which BPTC-decodes MMDVMHost’s
<code class="language-plaintext highlighter-rouge">DMR_IDLE_DATA</code> template from <code class="language-plaintext highlighter-rouge">DMRDefines.h</code> and requires the same 12 bytes
with zero corrections — two codebases, one constant.</p>

<p><strong>An independent CRC.</strong> <code class="language-plaintext highlighter-rouge">TestParsePIHeaderReferenceLiteral</code> pins the PI
header’s <code class="language-plaintext highlighter-rouge">0x9696</code> mask with a Python-computed vector, because
<code class="language-plaintext highlighter-rouge">AssemblePIHeader</code>’s round-trip agrees with any mask
(<a href="/blog/deep-dives/dmr-end-to-end-12-enhanced-privacy-rc4/">Part 12</a>).</p>

<p><strong>A reporter’s frames.</strong> <code class="language-plaintext highlighter-rouge">TestEPCaptureIssue1187</code> loads the PI header and
three on-air superframes from <code class="language-plaintext highlighter-rouge">voice/testdata/ep_issue1187_ptt1.json</code> and
asserts what no synthetic could: each embedded IV equals <code class="language-plaintext highlighter-rouge">AdvanceMI</code> of the
MI before it, and the chain yields speech while the old semantics stay at
ciphertext — the
<a href="/blog/deep-dives/from-spec-to-shipping-03-literal-vectors/">literal-vector rule</a>
at its strongest.</p>

<h2 id="honest-synthetic-streams">Honest synthetic streams</h2>

<p>Vectors test parsers; demodulators need signal that models the transmitter
the reporter owns. The direct-mode fixture in
<code class="language-plaintext highlighter-rouge">receiver/receiver_burst_test.go</code> is the template. <code class="language-plaintext highlighter-rouge">directModeIQ</code> lays bursts on a <code class="language-plaintext highlighter-rouge">dmFrameDibits</code> = 288 grid with
<code class="language-plaintext highlighter-rouge">dmOffDibits</code> = 156 of transmitter-OFF per frame, renders the OFF time as
AWGN when <code class="language-plaintext highlighter-rouge">gapped</code>, applies a carrier offset and per-axis noise, and gates on
the grid <strong>delayed by the RRC filter’s <code class="language-plaintext highlighter-rouge">modSpan·sps</code> samples</strong>;
<code class="language-plaintext highlighter-rouge">dmHeaderBursts</code> cycles the source ID from burst to burst.</p>

<p>Three lessons are encoded there. <strong>Gaps are
noise, not filler</strong> — the point of #836. <strong>Repetition is a bias</strong>: one
identical burst repeated for seconds carries a symbol-mean bias the open-loop
AFC reads as drift (the #402 failure mode), so the source ID varies, and the
pipeline fixture <code class="language-plaintext highlighter-rouge">directModeTransmissionIQ</code> models a PTT as <strong>3 headers plus
random voice bursts</strong>, two PTTs apart, at the reporter’s −1.2 kHz offset.
<strong>Group delay is real</strong>: gate on the undelayed grid and every burst loses its
tail.</p>

<p><code class="language-plaintext highlighter-rouge">TestReceiverDecodesDirectModeBurstCadence</code> is the failing-first pin — with
<code class="language-plaintext highlighter-rouge">NoCarrierGate</code> the production receiver yields zero sync words from a 27 dB
stream — and <code class="language-plaintext highlighter-rouge">TestReceiverCarrierGateIsNoOpOnContinuousCarrier</code> keeps the
repeater path byte-identical. Around it: <code class="language-plaintext highlighter-rouge">timing_acq_test.go</code> cold-starts a
keyup at every sub-symbol phase; <code class="language-plaintext highlighter-rouge">conventional_headertrain_test.go</code> lays ten
header copies 60 ms apart and requires <strong>one</strong> grant;
<code class="language-plaintext highlighter-rouge">conventional_lateentry_test.go</code> forges a terminator from voice bits
(<code class="language-plaintext highlighter-rouge">TestConventionalVoiceBurstCannotForgeTerminator</code>) and parks the CSBK
failure log (100 identical failures → one line, <code class="language-plaintext highlighter-rouge">suppressed_repeats=99</code>);
<code class="language-plaintext highlighter-rouge">conventional_rekey_test.go</code> and <code class="language-plaintext highlighter-rouge">conventional_twoslot_test.go</code> cover
re-keys and two-slot calls. Above the protocol, <code class="language-plaintext highlighter-rouge">engine_deafheal_test.go</code>
stands a <code class="language-plaintext highlighter-rouge">resetCountingReceiver</code> in for the Tier II receiver and feeds
<code class="language-plaintext highlighter-rouge">scaledIQ</code> at −51 dBFS: a tap that synced and then sees three windows with
no sync at its own level is reset once; one whose carrier drops to −70 dBFS
is idle and never reset. <code class="language-plaintext highlighter-rouge">coarse_reject_test.go</code> pins that a rejected engage
reverts and never re-engages nearby.</p>

<h2 id="committed-real-air">Committed real air</h2>

<p>The third layer embeds the reporter’s air in CI. <code class="language-plaintext highlighter-rouge">dmr-t3-cc.cfile</code> and
<code class="language-plaintext highlighter-rouge">dmr-voice-term.cfile</code> are channelized slices of a live 441 MHz Tier III
system; <code class="language-plaintext highlighter-rouge">TestReplayDMRTier3ControlDecodesRealAir</code> requires the production
receiver plus <code class="language-plaintext highlighter-rouge">ControlChannel</code> to lock on a CRC-valid Aloha, and
<code class="language-plaintext highlighter-rouge">TestReplayDMRVoiceLCFECDecodesRealAir</code> decodes Terminator-with-LC bursts
through the exact BPTC(196,96) → RS(12,9) → FLC stack the Voice LC Header
uses (TG 24 / source 4209000), the confirmation issue #527 called blocking.</p>

<p>The direct-mode pair is newer. <code class="language-plaintext highlighter-rouge">dmr-directmode-446500-keyup-48k.cs16</code> (a
ten-copy header train, then voice) and
<code class="language-plaintext highlighter-rouge">dmr-directmode-446500-ptt2-48k.cs16</code> (the second PTT’s onset) are 48 kHz
slices of the #836 reporter’s 446.500 MHz captures through the production
<code class="language-plaintext highlighter-rouge">ccdecoder.Downconverter</code>. They pin what only that air could: <code class="language-plaintext highlighter-rouge">TestDMRDirectModeRealAirKeyup</code> requires the
<strong>ungated</strong> receiver to find zero sync words (the gate is load-bearing) and
the gated one at least 8 MS-Data and 2 MS-Voice syncs;
<code class="language-plaintext highlighter-rouge">TestDMRDirectModeRealAirOnsetAtEveryPhase</code> cold-starts the second PTT at
ten sub-sample offsets and requires each to decode — the pre-fix receiver
took 1.3–2.8 s at three. With 36 % of raw samples at the ADC rail it decodes
anyway, which retired “clipped ⇒ undecodable” as too strong.</p>

<figure class="lab-figure">
<svg viewBox="0 0 680 190" width="680" height="190" role="img" aria-label="The DMR replay harness: an IQ capture enters the downconverter and production receiver, header and terminator scrubs act on the dibit stream, which feeds both the Tier II state machine reporting grants and re-keys and the voice superframe decoder reporting a Golay corrections histogram.">
  <g fill="none" stroke="currentColor">
    <rect x="8" y="80" width="90" height="40" rx="6" />
    <rect x="118" y="80" width="120" height="40" rx="6" />
    <rect x="258" y="66" width="130" height="68" rx="6" stroke-dasharray="4 3" />
    <rect x="410" y="30" width="130" height="50" rx="6" />
    <rect x="410" y="120" width="130" height="50" rx="6" />
    <line x1="98" y1="100" x2="112" y2="100" /><line x1="238" y1="100" x2="252" y2="100" />
    <path d="M 388 100 L 400 100 L 400 55 L 404 55" /><path d="M 400 100 L 400 145 L 404 145" />
    <line x1="540" y1="55" x2="556" y2="55" /><line x1="540" y1="145" x2="556" y2="145" />
  </g>
  <g fill="currentColor"><polygon points="110,96 118,100 110,104" /><polygon points="250,96 258,100 250,104" /><polygon points="402,51 410,55 402,59" /><polygon points="402,141 410,145 402,149" /></g>
  <g text-anchor="middle" font-size="10" fill="currentColor" font-weight="bold">
    <text x="53" y="97">capture</text>
    <text x="178" y="97">DDC → receiver</text>
    <text x="323" y="86">scrubs</text>
    <text x="475" y="50">Tier II state machine</text>
    <text x="475" y="140">voice superframes</text>
  </g>
  <g text-anchor="middle" font-size="9" fill="var(--fg-muted)">
    <text x="53" y="111">GT_DMR_IQ</text>
    <text x="323" y="100">GT_DMR_DROP_HEADERS</text>
    <text x="323" y="114">GT_DMR_DROP_TERMINATORS</text>
    <text x="475" y="66">grants · late_entries · rekeys</text>
    <text x="475" y="156">superframes · lc · ambe_ok</text>
  </g>
  <g text-anchor="start" font-size="9" fill="var(--accent)">
    <text x="560" y="58">drop terminators: 1 → 5 grants</text>
    <text x="560" y="148">Golay histogram: 73 % at 3</text>
  </g>
</svg>
<figcaption>The replay harness: one real dibit stream, optional scrubs, two consumers — the state machine's grant/re-key ledger and the voice decoder's Golay histogram.</figcaption>
</figure>

<h2 id="replay-harnesses-and-scrubs">Replay harnesses and scrubs</h2>

<p>Committed slices are seconds long; the reporter’s problem is minutes long.
The fourth layer is the skip-gated harness that takes an operator’s whole
capture: <code class="language-plaintext highlighter-rouge">TestDMRIPSCReplay</code> (<code class="language-plaintext highlighter-rouge">GT_DMR_IQ</code>; raw cs16/f32 take a rate and
format, wav and flac are content-sniffed).
It mirrors the daemon’s Tier II decode — DDC, receiver, then the dibits to
<strong>both</strong> the conventional state machine and the voice superframe decoder —
and prints one ledger:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>grants=5 late_entries=0 rekeys=4 fec_pass=… csbk_crc_fail=… beacons=… scrubbed_headers=0
superframes=… lc_superframes=… ambe_ok=… ambe_uncorrectable=…
</code></pre></div></div>

<p>The two consumers are the triage: zero superframes is a receiver problem;
superframes with AMBE decoding but no grants is a control-path problem. The
<strong>scrubs</strong> make the harness a counterfactual engine. <code class="language-plaintext highlighter-rouge">GT_DMR_DROP_HEADERS=1</code>
deletes every Voice LC Header burst from the real dibit stream — the on-air
model of a keyup lost to a fade — and late entry
(<a href="/blog/deep-dives/dmr-end-to-end-05-link-control-late-entry/">Part 5</a>)
must still grant every transmission from its embedded LC: 5/5 with
<code class="language-plaintext highlighter-rouge">late_entries=5</code> on the 9 Sep captures. <code class="language-plaintext highlighter-rouge">GT_DMR_DROP_TERMINATORS=1</code> deletes every
Terminator-with-LC instead — the 10 Sep condition where the control path
never saw the release — and each reply must be re-granted: old code 1 grant
for 5 transmissions, new 5 with <code class="language-plaintext highlighter-rouge">rekeys=4</code>
(<a href="/blog/deep-dives/dmr-end-to-end-06-conventional-ipsc-two-calls/">Part 6</a>).
<code class="language-plaintext highlighter-rouge">GT_DMR_INTERLEAVED=1</code> selects the cadence-detecting voice decoder — the
harness asks for it when it sees MS-sourced sync words, because the
single-slot decoder’s <code class="language-plaintext highlighter-rouge">ambe_ok</code> on a simplex capture is bogus and
<code class="language-plaintext highlighter-rouge">lc_superframes</code> reads 0
(<a href="/blog/deep-dives/dmr-end-to-end-03-two-slots-one-carrier/">Part 3</a>).</p>

<p><code class="language-plaintext highlighter-rouge">TestDMRIPSCWidebandReplay</code> (<code class="language-plaintext highlighter-rouge">GT_DMR_WB=1</code>) is the
<a href="/blog/solution-postmortem/from-the-issue-tracker-22-two-pipelines/">two-pipelines</a>
instrument: it interpolates a 25 kS/s slice to the field bin geometry (8
bins of 6.25 MS/s ÷ 32, the tap at +687.5 kHz, 0.48 bins off centre) and
feeds a <code class="language-plaintext highlighter-rouge">ChannelizerBank</code> and a <code class="language-plaintext highlighter-rouge">DDCBank</code> from the <strong>same</strong> stream
(<code class="language-plaintext highlighter-rouge">GT_DMR_WB_CHUNK</code> for live-sized chunks). It reproduced the 12 Sep deafness
exactly — polyphase 1991 beacons / 2 grants versus DDC 6103 / 7 over 300 s
— and after <code class="language-plaintext highlighter-rouge">channelizer.Oversampled</code> the arms match window for window
(<a href="/blog/deep-dives/dmr-end-to-end-10-wideband-bin-edge-deaf-heal/">Part 10</a>).
A clean synthetic carrier at residual 0.48 still <em>granted</em> through the old
bank, so the regression is a tone-flatness pin
(<code class="language-plaintext highlighter-rouge">TestChannelizerBankBinEdgeChannelIsFlat</code>), never a grant count. <code class="language-plaintext highlighter-rouge">TestDMREnhancedPrivacyReplay</code> completes the set, all following the
<a href="/blog/deep-dives/from-spec-to-shipping-11-capture-driven-development/">capture-driven method</a>.</p>

<h2 id="what-only-air-can-prove">What only air can prove</h2>

<p>The histogram habit first, because it settled #1187 in one pass. When frames
look scrambled, dump them (<code class="language-plaintext highlighter-rouge">GT_DMR_EP_DUMP</code>, <code class="language-plaintext highlighter-rouge">golay_errs</code> per frame) and
count Golay corrections per C0/C1 word before any cipher theory. Random
input needs exactly 3 corrections most of the time — the #1187 bursts B–F
showed <strong>73 % at exactly 3, 12 % at exactly 2</strong> while burst A was clean:
a slicer reading inter-burst gaps, not encryption. Real frames cluster at
zero.</p>

<p>Then the honest list — no layer here proves on-air behaviour, the #764/#771
rule this repo carries as policy. Direct mode is verified on the reporter’s
15 Sep captures and the <strong>live run on a fixed build is still open</strong>;
Enhanced Privacy is <strong>capture-verified, live daemon call open</strong>; the cc=7
CSBK-CRC-fail train and the −20 kHz emitter beside the IPSC repeater are
<strong>unresolved</strong>, waiting for the capture that names them.</p>

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

<ul>
  <li><strong>Fixtures model the transmitter the reporter owns.</strong> <code class="language-plaintext highlighter-rouge">directModeIQ</code> and
<code class="language-plaintext highlighter-rouge">directModeTransmissionIQ</code> carry the frame grid, gaps, header train and
RRC delay as named constants.</li>
  <li><strong>Every constant has a second source.</strong> <code class="language-plaintext highlighter-rouge">IdleInfoPattern</code>, the CSBK mask
and the PI mask each ship with a test whose expected value came from
elsewhere.</li>
  <li><strong>Harnesses skip, never rot.</strong> Each <code class="language-plaintext highlighter-rouge">GT_DMR_*</code> harness <code class="language-plaintext highlighter-rouge">t.Skip</code>s without
its input, so a contributed capture slots into an existing socket.</li>
  <li><strong>Scrubs are first-class.</strong> Header and terminator deletion live in the
harness — a field condition is one env var away.</li>
</ul>

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

<p>Thirteen parts down, one to go.
<a href="/blog/deep-dives/dmr-end-to-end-14-playbook/">Part 14</a>
folds the series into what you want at the bench: the layer map from antenna
to WAV, the failure signatures operators report, the twin ledger and the
open list with its gates.</p>

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

<p><strong>How do I test a DMR decoder without a repeater?</strong>
In layers: pin wire formats with vectors from independent decoders (off-air
CSBKs, MMDVMHost templates), synthesise carriers that model a real
transmitter’s gaps and header trains, replay the committed real-air slices,
and run your own capture through <code class="language-plaintext highlighter-rouge">TestDMRIPSCReplay</code> with <code class="language-plaintext highlighter-rouge">GT_DMR_IQ</code>.</p>

<p><strong>Why did synthetic DMR tests pass while direct mode never decoded?</strong>
Because every fixture laid bursts back-to-back like a base station. A
handheld transmits one burst per 60 ms frame with receiver noise between, and
that noise wrecked the AGC, AFC and timing loops. The fixture had to model
the gaps before the failure could be observed.</p>

<p><strong>What do the header and terminator scrubs prove?</strong>
Counterfactuals on real air. <code class="language-plaintext highlighter-rouge">GT_DMR_DROP_HEADERS=1</code> deletes every Voice LC
Header from a captured dibit stream and late entry must still grant every
transmission; <code class="language-plaintext highlighter-rouge">GT_DMR_DROP_TERMINATORS=1</code> deletes every release and each
reply must be re-granted. Both reproduce field reports from one capture.</p>

<p><strong>How can I tell scrambled frames from mis-sliced frames?</strong>
Count Golay corrections per frame (<code class="language-plaintext highlighter-rouge">GT_DMR_EP_DUMP</code> writes <code class="language-plaintext highlighter-rouge">golay_errs</code>).
Random words need exactly three corrections about 73 % of the time and two
about 12 %; real frames need almost none. A histogram peaked at three means
the slicer is reading inter-burst gaps, not that the payload is encrypted.</p>

<p><strong>What still needs on-air confirmation for DMR?</strong>
The reporter’s live direct-mode run on a build with the #836 fixes, a live
Enhanced Privacy call through the daemon with <code class="language-plaintext highlighter-rouge">encryption_keys</code> configured,
the cc=7 CSBK-CRC-fail train, and the −20 kHz emitter near the IPSC
repeater. Each waits for a capture (#764/#771).</p>

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

<p><strong>Part 13 of 14</strong> · ←
<a href="/blog/deep-dives/dmr-end-to-end-12-enhanced-privacy-rc4/">Part 12: Enhanced Privacy — RC4 &amp; the IV That Names the Next Superframe</a>
· Next →
<a href="/blog/deep-dives/dmr-end-to-end-14-playbook/">Part 14: The DMR Playbook</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="dmr-end-to-end" /><category term="dmr" /><category term="testing" /><category term="replay" /><category term="fixtures" /><category term="go" /><summary type="html"><![CDATA[How GopherTrunk tests a DMR decoder with no repeater in reach — literal vectors from independent decoders, synthetic streams that finally model a real transmitter's gaps, committed real-air slices in CI, replay harnesses with header and terminator scrubs, and the Golay histogram that says whether frames are scrambled or mis-sliced.]]></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">The Field Notebook, Part 13: From Log to Issue — What to Paste, What to Capture</title><link href="https://gophertrunk.org/blog/tutorials/field-notebook-13-from-log-to-issue/" rel="alternate" type="text/html" title="The Field Notebook, Part 13: From Log to Issue — What to Paste, What to Capture" /><published>2026-09-29T00:00:00-05:00</published><updated>2026-09-29T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/tutorials/field-notebook-13-from-log-to-issue</id><content type="html" xml:base="https://gophertrunk.org/blog/tutorials/field-notebook-13-from-log-to-issue/"><![CDATA[<p><em>Part 13 of <strong>The Field Notebook</strong>, a 14-part operator’s tutorial that reads
GopherTrunk’s <code class="language-plaintext highlighter-rouge">debug.log</code> one line family at a time — what each field
measures, what a healthy rig prints, which number is a noise meter and which
one means traffic, and which deep dive to open when a line goes wrong.
<a href="/blog/tutorials/field-notebook-12-web-symptoms-log-stories/">Part 12</a>
ended every web story the same way: the daemon line and the API answer for
the same second, pasted beside the symptom. This part makes that the method
— what to paste, which capture answers which question, and the words that
let an issue close as *verified</em>.*</p>

<blockquote>
  <p><strong>TL;DR:</strong> A report that gets fixed pastes three blocks — the <strong>startup
block</strong> (banner to first <code class="language-plaintext highlighter-rouge">cc locked</code>, WARNs included), the protocol’s
<strong>status-line family</strong> across the event (<code class="language-plaintext highlighter-rouge">tetra: decode status</code>, the DMR
activity line, the MRC health line), and <strong>30 s around the event</strong> with
the <code class="language-plaintext highlighter-rouge">siglab: capture started/ended</code> bracket so the file maps onto log time. Then the capture that matches the
question: <code class="language-plaintext highlighter-rouge">gophertrunk capture</code> (IQ + <code class="language-plaintext highlighter-rouge">.metadata.json</code>, with ppm and
clipping verdicts), <code class="language-plaintext highlighter-rouge">baseband.auto_record.on_cc_sync_loss</code> for a lock that
drops, <code class="language-plaintext highlighter-rouge">sdr.soapy_remote[].diversity_capture</code> for anything about MRC —
every other tap is <strong>post</strong>-combine. IQ, never audio, honest rate. The
close: only when a failing-first regression passes <strong>and</strong> the reporter
confirms; <code class="language-plaintext highlighter-rouge">.claude/hooks/guard-issue-close.py</code> asks a human first, and PRs
say <code class="language-plaintext highlighter-rouge">Refs #N</code>, not <code class="language-plaintext highlighter-rouge">Closes #N</code>, until then
(<a href="https://github.com/MattCheramie/GopherTrunk/issues/764">#764</a> /
<a href="https://github.com/MattCheramie/GopherTrunk/issues/771">#771</a>).</p>
</blockquote>

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

<ul>
  <li><strong>Paste the family, not the line.</strong> One WARN is a symptom; the status
lines around it are the instrument reading.</li>
  <li><strong>Align clocks before anyone reasons.</strong> <code class="language-plaintext highlighter-rouge">capture started</code> plus the file’s
<code class="language-plaintext highlighter-rouge">elapsed</code> turns “the deaf stretch” into a sample offset.</li>
  <li><strong>The right capture is taken at the right tap.</strong> Post-combine cannot
answer a combiner question; audio cannot answer a phase question.</li>
  <li><strong>“Fixed” and “verified” are different words on purpose.</strong> A merged PR is
a hypothesis; the issue closes when the symptom is gone.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Startup block</td>
      <td>proves config, driver and gain before any decode claim</td>
      <td>banner → <code class="language-plaintext highlighter-rouge">daemon:</code> WARNs → <code class="language-plaintext highlighter-rouge">cc locked</code> (<a href="/blog/tutorials/field-notebook-01-startup-lines/">Part 1</a>)</td>
    </tr>
    <tr>
      <td>Capture bracket</td>
      <td>start/end lines with <code class="language-plaintext highlighter-rouge">elapsed</code>, <code class="language-plaintext highlighter-rouge">samples</code>, <code class="language-plaintext highlighter-rouge">recorded_seconds</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/api/capture.go</code></td>
    </tr>
    <tr>
      <td>One-shot IQ + sidecar</td>
      <td>writes <code class="language-plaintext highlighter-rouge">&lt;stem&gt;.metadata.json</code>; <code class="language-plaintext highlighter-rouge">-sigmf</code> with <code class="language-plaintext highlighter-rouge">-bundle</code>; clip / ppm verdicts</td>
      <td><code class="language-plaintext highlighter-rouge">cmd/gophertrunk/capture.go</code>, <code class="language-plaintext highlighter-rouge">capture_carrier.go</code></td>
    </tr>
    <tr>
      <td>Sync-loss capture</td>
      <td>records the re-acquisition IQ when a lock drops</td>
      <td><code class="language-plaintext highlighter-rouge">baseband.auto_record.on_cc_sync_loss</code></td>
    </tr>
    <tr>
      <td>Pre-combine branches</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;prefix&gt;.br0.cs16</code>, <code class="language-plaintext highlighter-rouge">.br1.cs16</code>, <code class="language-plaintext highlighter-rouge">.diversity.json</code></td>
      <td><code class="language-plaintext highlighter-rouge">sdr.soapy_remote[].diversity_capture</code></td>
    </tr>
    <tr>
      <td>The close</td>
      <td>failing-first test passes <strong>and</strong> reporter confirms</td>
      <td><code class="language-plaintext highlighter-rouge">CLAUDE.md</code>, <code class="language-plaintext highlighter-rouge">.claude/hooks/guard-issue-close.py</code></td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>What a report is telling the maintainer</strong> — a claim and its evidence.</li>
  <li><strong>What to paste</strong> — three blocks; a healthy and an unhealthy report.</li>
  <li><strong>Aligning a capture to the log</strong> — the bracket fields.</li>
  <li><strong>Which capture to make</strong> — from question to tap.</li>
  <li><strong>The words that close an issue</strong> — the verified-language policy.</li>
</ul>

<h2 id="what-a-report-is-telling-the-maintainer">What a report is telling the maintainer</h2>

<p>An issue is a claim — <em>the rig did X when it should have done Y</em> — and
GopherTrunk’s verification culture
(<a href="/blog/deep-dives/from-spec-to-shipping-14-definition-of-verified/">From Spec to Shipping Part 14</a>)
accepts no claim above its evidence. The log turns the claim into a
measurement, and it was <em>designed</em> to be pasted:
<a href="/blog/deep-dives/from-spec-to-shipping-13-instruments-not-logs/">Part 13 of that series</a>
argued for branch-complete counters and persistent WARNs so a field log
answers the next question without a round trip. The log cannot supply the
<strong>version</strong>, or the <strong>config</strong> with secrets stripped
(<a href="/blog/tutorials/operator-cookbook-14-kitchen-sink-config/">Cookbook 14</a>’s
hygiene rule).</p>

<h2 id="what-to-paste">What to paste</h2>

<p>Three blocks; each exists because a real thread stalled without it.</p>

<p><strong>1. The startup block</strong> — banner to first lock, WARNs included. The 13 Sep
DMO operator’s CC sat at −70 dBFS because <code class="language-plaintext highlighter-rouge">gain: 50</code> meant 5 dB, and the
daemon had said so:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WRN daemon: gain looks like dB, not tenths-of-dB — radio may be effectively deaf serial=32B0A4C configured=50 parsed_db=5 did_you_mean=500 hint="gain: is in TENTHS of a dB (\"320\" = 32 dB). ..."
</code></pre></div></div>

<p>Omit it and you invite a week of DSP theory
(<a href="/blog/tutorials/field-notebook-01-startup-lines/">Part 1</a>).</p>

<p><strong>2. The status-line family across the event</strong> — the protocol’s periodic
line from minutes <em>before</em> the symptom to minutes after, so the trend shows:
<code class="language-plaintext highlighter-rouge">tetra: decode status</code>, <code class="language-plaintext highlighter-rouge">widebandt2: channel decode activity</code>, the MRC
health line, <code class="language-plaintext highlighter-rouge">tetra dmo: decode status</code>. These are DEBUG lines — run at
<code class="language-plaintext highlighter-rouge">log.level: debug</code> or the report describes a symptom nobody can measure.</p>

<p><strong>3. Thirty seconds around the event</strong>, with the <code class="language-plaintext highlighter-rouge">siglab: capture
started/ended</code> bracket if you recorded IQ, and the nearest
<code class="language-plaintext highlighter-rouge">runtime: heartbeat</code> so CPU starvation can be ruled in or out
(<a href="/blog/tutorials/field-notebook-07-overruns-host-drops/">Part 7</a>).</p>

<p>A report that answers, from a TETRA rig losing lock:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DBG tetra: decode status system=Metro-TETRA locked=true carrier_off_hz=-412.5 baud=18000 baud_dev_pct=0.01 sb_bursts=72 bsch_ok=72 bsch_fail=0 sysinfo=18 sch_pdus=340 sch_pdus_fail=6 frag_abandons=1 grants=3 colour_code=1
INF siglab: capture started serial=32B0A4C center_hz=467912500 sample_rate_hz=144000 bandwidth_hz=100000 requested_center_hz=467912500 tuner_center_hz=467700000 tuner_rate_hz=2000000 format=flac seconds=60 protocol=tetra path=../captures/tetra_467.9125MHz_144k.flac
DBG tetra: decode status system=Metro-TETRA locked=true carrier_off_hz=-418.0 baud=17998 baud_dev_pct=0.02 sb_bursts=70 bsch_ok=61 bsch_fail=9 sysinfo=14 sch_pdus=88 sch_pdus_fail=41 frag_abandons=4 grants=0 colour_code=1
DBG tetra: dsp resync (signal-time decode drought; reacquiring symbol timing from centre) system=Metro-TETRA
INF siglab: capture ended serial=32B0A4C center_hz=467912500 sample_rate_hz=144000 format=flac samples=8640000 elapsed=1m0.012s path=../captures/tetra_467.9125MHz_144k.flac recorded_seconds=60
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">bsch_fail</code> climbing, <code class="language-plaintext highlighter-rouge">sch_pdus</code> collapsing, a resync, sixty seconds of IQ
around it. The unhealthy twin arrives more often:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WRN tetra: payload drought persists across resyncs (sync bursts decoding, no SCH payload) — declaring lock lost to force a re-hunt system=Metro-TETRA attempts=3
</code></pre></div></div>

<p>One WARN, no status lines, no capture. It says the lock was declared lost,
not whether the carrier faded, the AFC latched an alias bucket
(<a href="/blog/tutorials/field-notebook-04-carrier-offset-warn/">Part 4</a>)
or the host starved. The next comment will ask for block 2.</p>

<h2 id="aligning-a-capture-to-the-log">Aligning a capture to the log</h2>

<p>A capture and a log are two clocks; the bracket fields map one onto the
other:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Measures</th>
      <th>Healthy</th>
      <th>Worry when</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">center_hz</code> / <code class="language-plaintext highlighter-rouge">requested_center_hz</code></td>
      <td>slice centre vs what you typed</td>
      <td>equal</td>
      <td>different — the 15 Sep grab was carved at the <em>tuner</em> centre</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tuner_center_hz</code>, <code class="language-plaintext highlighter-rouge">tuner_rate_hz</code></td>
      <td>the stream the slice came from</td>
      <td>your <code class="language-plaintext highlighter-rouge">sdr</code> block</td>
      <td>the slice sits at the tuner’s edge</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sample_rate_hz</code>, <code class="language-plaintext highlighter-rouge">format</code></td>
      <td>what the file actually is</td>
      <td>matches the sidecar</td>
      <td>an odd rate above 65535 Hz with <code class="language-plaintext highlighter-rouge">format=flac</code> (880029 Hz once aborted the encoder)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">seconds</code> vs <code class="language-plaintext highlighter-rouge">recorded_seconds</code></td>
      <td>asked vs delivered</td>
      <td>equal</td>
      <td>short — a <code class="language-plaintext highlighter-rouge">capture aborted</code> WARN follows</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">samples</code>, <code class="language-plaintext highlighter-rouge">elapsed</code></td>
      <td>length in both units</td>
      <td><code class="language-plaintext highlighter-rouge">samples / sample_rate_hz ≈ elapsed</code></td>
      <td>mismatch — dropped chunks; check <code class="language-plaintext highlighter-rouge">host_drops</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">path</code></td>
      <td>where the sidecar sits</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;stem&gt;.metadata.json</code> beside it</td>
      <td>renamed — <code class="language-plaintext highlighter-rouge">DiscoverMetadata</code> finds it by stem</td>
    </tr>
  </tbody>
</table>

<p>The alignment is one subtraction: <code class="language-plaintext highlighter-rouge">capture started</code> is the file’s sample
zero, so a log event at <em>T</em> sits <code class="language-plaintext highlighter-rouge">(T − started) × sample_rate_hz</code> samples
in. The 12 Sep IPSC investigation was decided this way — a 20-minute FLAC
aligned to the live log at offset 23:41:31.28 showed the Fire2 tap logging
<code class="language-plaintext highlighter-rouge">sync_hits=0</code> for six stretches of 180–210 s while the same seconds decoded
every transmission offline. That split “the capture is bad” from “the live
tap went deaf”; <code class="language-plaintext highlighter-rouge">dibits</code> and <code class="language-plaintext highlighter-rouge">healDeafTier2</code> followed from it. One trap: the log is local time while the API
and sidecars are UTC
(<a href="/blog/tutorials/field-notebook-12-web-symptoms-log-stories/">Part 12</a>).</p>

<figure class="lab-figure">
<svg viewBox="0 0 680 250" width="680" height="250" role="img" aria-label="A debug log timeline with three regions to paste — startup block, status-line family, and thirty seconds around the event bracketed by capture started and ended lines — and beneath it a capture file aligned to that bracket feeding a replay harness, a failing-first test, and a close that waits for the reporter's confirmation.">
  <line x1="20" y1="60" x2="660" y2="60" stroke="currentColor" />
  <text x="20" y="48" fill="var(--fg-muted)" font-size="9">debug.log</text>
  <rect x="24" y="66" width="120" height="30" rx="4" fill="none" stroke="var(--accent)" />
  <text x="84" y="85" text-anchor="middle" fill="var(--accent)" font-size="9">1. startup block</text>
  <rect x="170" y="66" width="230" height="30" rx="4" fill="none" stroke="var(--accent)" />
  <text x="285" y="85" text-anchor="middle" fill="var(--accent)" font-size="9">2. status-line family</text>
  <rect x="426" y="66" width="230" height="30" rx="4" fill="none" stroke="var(--accent)" />
  <text x="541" y="85" text-anchor="middle" fill="var(--accent)" font-size="9">3. 30 s around the event</text>
  <line x1="450" y1="56" x2="450" y2="130" stroke="currentColor" stroke-dasharray="3 3" />
  <text x="450" y="140" text-anchor="middle" fill="currentColor" font-size="8">capture started</text>
  <line x1="640" y1="56" x2="640" y2="130" stroke="currentColor" stroke-dasharray="3 3" />
  <text x="640" y="140" text-anchor="middle" fill="currentColor" font-size="8">capture ended</text>
  <rect x="450" y="150" width="190" height="22" rx="3" fill="none" stroke="currentColor" />
  <text x="545" y="165" text-anchor="middle" fill="currentColor" font-size="8">IQ file + .metadata.json</text>
  <line x1="450" y1="161" x2="120" y2="161" stroke="var(--fg-muted)" />
  <polygon points="122,157 114,161 122,165" fill="var(--fg-muted)" />
  <rect x="20" y="150" width="94" height="22" rx="3" fill="none" stroke="currentColor" />
  <text x="67" y="165" text-anchor="middle" fill="currentColor" font-size="8">replay harness</text>
  <line x1="67" y1="172" x2="67" y2="196" stroke="var(--fg-muted)" /><polygon points="63,194 67,202 71,194" fill="var(--fg-muted)" />
  <rect x="20" y="202" width="94" height="22" rx="3" fill="none" stroke="currentColor" />
  <text x="67" y="217" text-anchor="middle" fill="currentColor" font-size="8">failing-first test</text>
  <line x1="114" y1="213" x2="180" y2="213" stroke="var(--fg-muted)" /><polygon points="178,209 186,213 178,217" fill="var(--fg-muted)" />
  <path d="M 240 199 L 296 213 L 240 227 L 184 213 Z" fill="none" stroke="var(--accent)" />
  <text x="240" y="216" text-anchor="middle" fill="var(--accent)" font-size="8">reporter confirms?</text>
  <line x1="296" y1="213" x2="350" y2="213" stroke="var(--fg-muted)" /><polygon points="348,209 356,213 348,217" fill="var(--fg-muted)" />
  <rect x="356" y="202" width="70" height="22" rx="3" fill="none" stroke="var(--accent)" stroke-width="2" />
  <text x="391" y="217" text-anchor="middle" fill="var(--accent)" font-size="8">CLOSED</text>
  <text x="520" y="217" text-anchor="middle" fill="var(--fg-muted)" font-size="8">until then: Refs #N, issue open</text>
</svg>
<figcaption>Three pasted blocks, one capture aligned to the bracket, a harness, a failing-first test — and a close that waits for the reporter.</figcaption>
</figure>

<h2 id="which-capture-to-make">Which capture to make</h2>

<p>The expensive mistakes happen before recording starts
(<a href="/blog/deep-dives/from-spec-to-shipping-11-capture-driven-development/">From Spec to Shipping 11</a>):
wrong tap, wrong content, wrong physics.</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th>Capture</th>
      <th>Why this one</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>“It never locks / decodes garbage”</td>
      <td><code class="language-plaintext highlighter-rouge">gophertrunk capture -freq … -sample-rate … -seconds 60</code></td>
      <td>IQ + sidecar, two verdicts printed</td>
    </tr>
    <tr>
      <td>“The lock drops every few minutes”</td>
      <td><code class="language-plaintext highlighter-rouge">baseband.auto_record.on_cc_sync_loss: true</code> (+ <code class="language-plaintext highlighter-rouge">tap: ddc</code>)</td>
      <td>keeps the re-acquisition IQ (<a href="/blog/deep-dives/tetra-end-to-end-10-control-channel-sync-loss/">TETRA Part 10</a>)</td>
    </tr>
    <tr>
      <td>“MRC hurts / never calibrates”</td>
      <td><code class="language-plaintext highlighter-rouge">sdr.soapy_remote[].diversity_capture: &lt;prefix&gt;</code> (<code class="language-plaintext highlighter-rouge">_seconds</code> 1..1200)</td>
      <td>the <strong>only pre-combine</strong> tap — every other tap sits downstream of the combiner (<a href="/blog/tutorials/operator-cookbook-12-diversity-mrc/">Cookbook 12</a>)</td>
    </tr>
    <tr>
      <td>“This call sounds wrong”</td>
      <td><code class="language-plaintext highlighter-rouge">baseband.voice_iq_debug</code></td>
      <td>the exact channelised IQ the voice chain consumed, per call</td>
    </tr>
    <tr>
      <td>“P25 voice is weak / garbled”</td>
      <td>a <strong>voice-channel</strong> capture of a <em>marginal</em> call</td>
      <td><code class="language-plaintext highlighter-rouge">samples/p25/README.md</code>’s priority ask (<a href="/blog/deep-dives/p25-end-to-end-12-weak-signal-gap/">P25 12</a>)</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">gophertrunk capture</code> heads the table because it <em>grades</em> its recording.
Samples pinned at the ADC rail print <code class="language-plaintext highlighter-rouge">capture: WARNING — N% of the recorded
samples are pinned at the ADC rail: the front end was overloaded … NO
receiver … can decode this file</code> plus the remedy. A carrier off centre prints <code class="language-plaintext highlighter-rouge">capture: WARNING — the recorded
carrier sits N kHz above/below centre (≈N ppm at this frequency)</code>; the probe
reads only the first ~11 ms, so a momentarily-strong neighbour can fool it
(<a href="https://github.com/MattCheramie/GopherTrunk/issues/1143">#1143</a>).</p>

<p><strong>Physics, then rate, then metadata.</strong> <code class="language-plaintext highlighter-rouge">docs/decoder-capture-needs.md</code> puts
physics first: for anything carrying information in phase or 4-level
amplitude — TETRA, NXDN, YSF, DMR, M17, P25 — demodulated audio is useless.
IQ or nothing, <a href="/reference/cfile-format/"><code class="language-plaintext highlighter-rouge">.cfile</code></a>
or <a href="/reference/cs16-format/">cs16</a> — the
<a href="https://github.com/MattCheramie/GopherTrunk/issues/1187">#1187</a> “RC4 audio”
files failed <code class="language-plaintext highlighter-rouge">discriminatorAudioSanity</code>. Rate next: TETRA wants ≥ 72 kHz; 48 kHz
cannot lock. Then the sidecar: neither format embeds a rate or centre, so
<code class="language-plaintext highlighter-rouge">&lt;stem&gt;.metadata.json</code> with <code class="language-plaintext highlighter-rouge">protocol</code>, <code class="language-plaintext highlighter-rouge">sample_rate_hz</code> and
<code class="language-plaintext highlighter-rouge">center_freq_hz</code> is mandatory — <code class="language-plaintext highlighter-rouge">gophertrunk test -capture</code> grades against
it, and <code class="language-plaintext highlighter-rouge">-sigmf</code> emits a <a href="/reference/sigmf/">SigMF</a> twin
(<a href="/blog/tutorials/analog-edge-10-capture-discipline/">Analog Edge 10</a>
is the full treatment). Content matters too: a 25 s <em>silent</em> DMO PTT was a
poor vector; the ask that worked was <em>known colour, actually talking</em>.</p>

<h2 id="the-words-that-close-an-issue">The words that close an issue</h2>

<p>The maintainer writes the last part, under the repo’s strictest rule —
<code class="language-plaintext highlighter-rouge">CLAUDE.md</code>’s issue-closing policy:</p>

<ul>
  <li><strong>Never close an issue as completed until the fix is verified</strong>: a
failing-first regression test passes <strong>and</strong> the reporter has confirmed
it, or the original symptom was reproduced and shown resolved.</li>
  <li><strong>When you can’t verify, leave it open</strong> — post a concise status comment
saying what was found and what is blocking.</li>
  <li><strong>Address the latest follow-up, not the original report.</strong></li>
  <li><strong>In PRs, prefer <code class="language-plaintext highlighter-rouge">Refs #N</code> over <code class="language-plaintext highlighter-rouge">Closes #N</code></strong> until verified, so a merge
cannot auto-close. <code class="language-plaintext highlighter-rouge">not_planned</code> and <code class="language-plaintext highlighter-rouge">duplicate</code> are ungated.</li>
</ul>

<p>It exists because #764 was closed twice on an unverified fix while the
symptom stayed live (#771) — the two-pipelines trap of
<a href="/blog/solution-postmortem/from-the-issue-tracker-22-two-pipelines/">Issue Tracker Part 22</a> —
and a PreToolUse hook gives it teeth:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .claude/hooks/guard-issue-close.py (shape)
</span><span class="n">is_close_completed</span> <span class="o">=</span> <span class="p">(</span>
    <span class="n">ti</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"method"</span><span class="p">)</span> <span class="o">==</span> <span class="s">"update"</span>
    <span class="ow">and</span> <span class="n">ti</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"state"</span><span class="p">)</span> <span class="o">==</span> <span class="s">"closed"</span>
    <span class="ow">and</span> <span class="n">ti</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"state_reason"</span><span class="p">)</span> <span class="ow">in</span> <span class="p">(</span><span class="bp">None</span><span class="p">,</span> <span class="s">""</span><span class="p">,</span> <span class="s">"completed"</span><span class="p">)</span>
<span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">is_close_completed</span><span class="p">:</span>
    <span class="k">return</span> <span class="mi">0</span>  <span class="c1"># comments, labels, reopens, not_planned, duplicate pass
# else: emit an "ask" decision restating the bar
</span></code></pre></div></div>

<p>The PR template’s last line is literally <code class="language-plaintext highlighter-rouge">Closes #NNN / Refs #NNN</code>; <code class="language-plaintext highlighter-rouge">Refs</code>
is the default. For a reporter this means <strong>your confirmation is the last
gate</strong>: when a maintainer asks you to run the fix, or to replay your capture
through a harness such as <code class="language-plaintext highlighter-rouge">TestTETRADMOReplay</code> or
<code class="language-plaintext highlighter-rouge">TestDiversityCombinerReplay</code>, that is the step nobody may skip on your
behalf. “Capture-verified, live run pending” is the same discipline, spoken
carefully.</p>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Likely cause</th>
      <th>Fix / read</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Thread stalls after one WARN</td>
      <td>no status-line family, no startup block</td>
      <td>paste blocks 1–3</td>
    </tr>
    <tr>
      <td>Capture “looks strong”, never locks</td>
      <td>ADC clipping, wrong rate, audio not IQ</td>
      <td>the clip verdict; <a href="/blog/tutorials/analog-edge-04-clipping-overload-intermod/">Analog Edge 4</a></td>
    </tr>
    <tr>
      <td>MRC question, ordinary capture</td>
      <td>every other tap is post-combine</td>
      <td><code class="language-plaintext highlighter-rouge">diversity_capture</code>; <a href="/blog/deep-dives/weak-signal-engineering-12-proving-signal/">Weak-Signal 12</a></td>
    </tr>
    <tr>
      <td>Fix merged, issue still open</td>
      <td>waiting for <em>your</em> confirmation</td>
      <td>run it, or replay your capture</td>
    </tr>
  </tbody>
</table>

<h3 id="how-this-line-shapes-operator-practice">How this line shapes operator practice</h3>

<ul>
  <li><strong>Run at <code class="language-plaintext highlighter-rouge">debug</code> when something is wrong.</strong> The status-line families are
DEBUG; an INFO log has no counters to trend.</li>
  <li><strong>Bracket every capture.</strong> Start it from the daemon so the bracket lands
in the symptom’s log.</li>
  <li><strong>Never reason from a lone WARN.</strong> Find its family first.</li>
  <li><strong>Say what you verified, exactly.</strong> Capture-verified and live-verified
are different claims.</li>
</ul>

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

<p>Thirteen parts have read the log one family at a time; the finale folds them
into one page. <a href="/blog/tutorials/field-notebook-14-field-card/">Part 14</a>
is the field card — one table per protocol family, the noise-meter list, and
the five lines to read first on any report.</p>

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

<p><strong>What should I include in a GopherTrunk bug report?</strong>
The version, the config with secrets stripped, and three log blocks from a
<code class="language-plaintext highlighter-rouge">log.level: debug</code> run: the startup block through the first lock, the
protocol’s periodic status line for minutes either side of the event, and
thirty seconds around the event with any capture bracket. Attach or link
IQ, not audio.</p>

<p><strong>How do I line up an IQ capture with the daemon log?</strong>
The <code class="language-plaintext highlighter-rouge">siglab: capture started</code> timestamp is the file’s sample zero, and
<code class="language-plaintext highlighter-rouge">capture ended</code> carries <code class="language-plaintext highlighter-rouge">samples</code>, <code class="language-plaintext highlighter-rouge">elapsed</code> and <code class="language-plaintext highlighter-rouge">recorded_seconds</code>; a log
event at time T sits <code class="language-plaintext highlighter-rouge">(T − started) × sample_rate_hz</code> samples in. The log is
local time; sidecars are UTC.</p>

<p><strong>Which GopherTrunk capture do I use for a diversity or MRC problem?</strong>
Only <code class="language-plaintext highlighter-rouge">sdr.soapy_remote[].diversity_capture</code>. It taps both branches before
the combiner and writes <code class="language-plaintext highlighter-rouge">&lt;prefix&gt;.br0.cs16</code>, <code class="language-plaintext highlighter-rouge">.br1.cs16</code> and a
<code class="language-plaintext highlighter-rouge">.diversity.json</code> sidecar; every other recording path is downstream of the
combine and has one combiner baked in.</p>

<p><strong>Why won’t GopherTrunk close my issue when the PR is merged?</strong>
Because the policy separates fixed from verified: a close claims the symptom
is gone, earned by a failing-first regression passing <em>and</em> the reporter
confirming. PRs say <code class="language-plaintext highlighter-rouge">Refs #N</code> so a merge cannot auto-close, and a hook asks
a human before any close-as-completed.</p>

<p><strong>Can I send a WAV of the audio instead of IQ?</strong>
For FM-audio formats — MPT 1327, and as a fallback for POCSAG/FLEX/DSC —
yes. For TETRA, DMR, P25, NXDN, YSF and M17, no: the information is in phase
or 4-level amplitude and demodulated audio has destroyed it. Record IQ with
a <code class="language-plaintext highlighter-rouge">.metadata.json</code> sidecar.</p>

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

<p><strong>Part 13 of 14</strong> · ←
<a href="/blog/tutorials/field-notebook-12-web-symptoms-log-stories/">Part 12: Web Symptoms That Are Really Log Stories</a>
· Next →
<a href="/blog/tutorials/field-notebook-14-field-card/">Part 14: The One-Page Field Card</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="tutorials" /><category term="field-notebook" /><category term="logs" /><category term="issues" /><category term="captures" /><category term="methodology" /><category term="tutorial" /><summary type="html"><![CDATA[How to turn a GopherTrunk debug.log into a bug report that gets fixed — the three blocks to paste, aligning a capture's clock to the log's, which IQ capture answers which question (gophertrunk capture, pre-combine diversity_capture, on_cc_sync_loss), and the verified-language policy that decides when an issue may close.]]></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">Beyond Voice, Part 12: M17 — The Open Digital Voice Link Layer</title><link href="https://gophertrunk.org/blog/deep-dives/beyond-voice-12-m17-open-digital-voice/" rel="alternate" type="text/html" title="Beyond Voice, Part 12: M17 — The Open Digital Voice Link Layer" /><published>2026-09-28T00:00:00-05:00</published><updated>2026-09-28T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/beyond-voice-12-m17-open-digital-voice</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/beyond-voice-12-m17-open-digital-voice/"><![CDATA[<p><em>Part 12 of <strong>Beyond Voice</strong>, a 14-part deep dive into everything GopherTrunk
decodes that is not a trunked voice call — the AFSK signalling formats,
paging, APRS, ADS-B, AIS, DSC, LoRa, M17 and the amateur digital-voice
family — and the one eleven-place wiring pattern that carries each of them
from a burst on the air to a row in the web console.
<a href="/blog/deep-dives/beyond-voice-11-lora-chirp-receiver/">Part 11</a>
built a chirp receiver from scratch; this part returns to the 4FSK family
for a protocol whose every layer is published: M17. It follows a Link Setup
Frame from the 4800-baud symbol stream through the Golay-coded LICH to a
row in <code class="language-plaintext highlighter-rouge">m17_log</code>, and it is exact about the boundary — GopherTrunk decodes
M17’s link layer, not its voice.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> <code class="language-plaintext highlighter-rouge">internal/radio/m17</code> decodes the M17 <strong>link layer</strong>: who is
transmitting to whom, in what mode. The receiver
(<code class="language-plaintext highlighter-rouge">internal/radio/m17/receiver</code>) is the P25 Phase 1 front end re-tuned —
FM demod → resample to 48 kHz (<code class="language-plaintext highlighter-rouge">BaudHz</code> 4800 × <code class="language-plaintext highlighter-rouge">Oversample</code> 10) → C4FM
matched filter with <code class="language-plaintext highlighter-rouge">rrcAlpha</code> 0.5 → Mueller-Müller timing → 4FSK slice →
<code class="language-plaintext highlighter-rouge">symbolToDibit</code> (+1→00, +3→01, −1→10, −3→11). <code class="language-plaintext highlighter-rouge">Decoder.Push</code> hunts the
stream sync <code class="language-plaintext highlighter-rouge">0xFF5D</code>, collects the 96-bit <strong>LICH</strong> (four Golay(24,12)
codewords → 40 LSF bits + a 3-bit counter), skips the 272-bit payload, and
<code class="language-plaintext highlighter-rouge">LICHAssembler</code> stitches six chunks into the 240-bit <strong>LSF</strong> — DST 48 ·
SRC 48 · TYPE 16 · META 112 · CRC 16, base-40 callsigns, CRC-16 poly
<code class="language-plaintext highlighter-rouge">0x5935</code>. Each LSF becomes <code class="language-plaintext highlighter-rouge">events.KindM17LinkSetup</code>, an <code class="language-plaintext highlighter-rouge">m17_log</code> row and
<code class="language-plaintext highlighter-rouge">GET /api/v1/m17/linksetups</code>. <strong>Not decoded:</strong> the Codec 2 voice payload,
the dedicated LSF frame (<code class="language-plaintext highlighter-rouge">0x55F7</code>), packet frames (<code class="language-plaintext highlighter-rouge">0x75FF</code>) — and there
is <strong>no <code class="language-plaintext highlighter-rouge">/m17</code> web panel</strong> yet. Everything is spec-derived and
synthetic-verified only (<a href="https://github.com/MattCheramie/GopherTrunk/issues/479">#479</a>).</p>
</blockquote>

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

<ul>
  <li><strong>M17 is the one digital-voice mode with no closed layer.</strong> 4FSK, framing,
FEC and the Codec 2 vocoder are all published, so the decoder’s boundary
is a milestone line, not a licensing line.</li>
  <li><strong>The LICH is a late-entry mechanism, and GopherTrunk uses it as the
primary route.</strong> Every stream frame carries one sixth of the LSF under
Golay(24,12); six frames — about 240 ms — reassemble it, no Viterbi.</li>
  <li><strong>The decoder is a three-state machine that reads 96 bits per frame and
skips 272.</strong> <code class="language-plaintext highlighter-rouge">stHunt</code> → <code class="language-plaintext highlighter-rouge">stLICH</code> → <code class="language-plaintext highlighter-rouge">stSkip</code>.</li>
  <li><strong>Synthetic-green is the honest status.</strong> <code class="language-plaintext highlighter-rouge">TestDecodeLSFFromLICHStream</code>
proves the chain against GopherTrunk’s own encoder; the source says the
Golay matrix “should be confirmed against a capture”.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>4FSK front end</td>
      <td>FM → 48 kHz → RRC α=0.5 → MM timing → slice → dibits</td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/m17/receiver/receiver.go</code> (<code class="language-plaintext highlighter-rouge">feedSymbol</code>)</td>
    </tr>
    <tr>
      <td>Sync hunt</td>
      <td>16-bit sliding window for <code class="language-plaintext highlighter-rouge">SyncStream</code> <code class="language-plaintext highlighter-rouge">0xFF5D</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/m17/decoder.go</code> (<code class="language-plaintext highlighter-rouge">Decoder.Push</code>)</td>
    </tr>
    <tr>
      <td>LICH reassembly</td>
      <td>4 × Golay(24,12) → 40-bit fragment + counter → 6 chunks</td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/m17/lich.go</code> (<code class="language-plaintext highlighter-rouge">LICHAssembler.Push</code>)</td>
    </tr>
    <tr>
      <td>LSF parse</td>
      <td>base-40 DST/SRC, TYPE bits, META, CRC-16 <code class="language-plaintext highlighter-rouge">0x5935</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/m17/m17.go</code> (<code class="language-plaintext highlighter-rouge">ParseLSF</code>, <code class="language-plaintext highlighter-rouge">DecodeAddress</code>, <code class="language-plaintext highlighter-rouge">CRC16</code>)</td>
    </tr>
    <tr>
      <td>Bus → storage → REST</td>
      <td><code class="language-plaintext highlighter-rouge">KindM17LinkSetup</code> → <code class="language-plaintext highlighter-rouge">m17_log</code> → <code class="language-plaintext highlighter-rouge">/api/v1/m17/linksetups</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/events/bus.go</code>, <code class="language-plaintext highlighter-rouge">internal/storage/m17log.go</code>, <code class="language-plaintext highlighter-rouge">internal/api/handlers_m17.go</code></td>
    </tr>
    <tr>
      <td>Config, help, doctor</td>
      <td><code class="language-plaintext highlighter-rouge">m17.channels[]</code>, FieldMeta, storage.path WARN</td>
      <td><code class="language-plaintext highlighter-rouge">internal/config/config_peripherals.go</code>, <code class="language-plaintext highlighter-rouge">internal/configbuilder/fieldmeta.go</code>, <code class="language-plaintext highlighter-rouge">cmd/gophertrunk/preflight.go</code></td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>The friendly protocol</strong> — why openness changes what a decoder can promise.</li>
  <li><strong>Two roads to the LSF</strong> — the dedicated frame versus the LICH.</li>
  <li><strong>4FSK to bits</strong> — the receiver constants and the dibit map.</li>
  <li><strong>The three-state decoder</strong> — sync hunt, LICH collect, payload skip.</li>
  <li><strong>Inside the Link Setup Frame</strong> — base-40 addresses, TYPE bits, the CRC.</li>
  <li><strong>Where the decoder stops</strong> — Codec 2, packet mode, panel, capture gate.</li>
</ul>

<h2 id="the-friendly-protocol">The friendly protocol</h2>

<p>Every digital-voice mode this blog has covered ends at a vocoder somebody
else owns — IMBE, AMBE+2, the AMBE variants of
<a href="/blog/deep-dives/beyond-voice-13-dstar-ysf-dpmr/">Part 13</a>.
The <a href="/reference/m17-project/">M17 Project</a> instead
published a 4FSK air interface at 4800 symbols per second, a framing layer,
a convolutional-plus-Golay FEC stack and the royalty-free
<a href="/reference/codec2/">Codec 2</a> vocoder
(<a href="/reference/m17/">reference</a>). No layer is closed
to a pure-Go receiver.</p>

<p>That changes the honest claim. For D-STAR the line between decoded and
not-decoded is partly a licensing line; for M17 it is purely a milestone
line, and the <code class="language-plaintext highlighter-rouge">internal/radio/m17</code> package doc draws it in its first
paragraph: the package “owns the <em>metadata</em> path” and “the Codec2 voice
payload is a later milestone”. M17 runs in two link modes, both
visible in the LSF’s TYPE field: <strong>stream</strong> for real-time voice and/or
data, <strong>packet</strong> for connectionless data. Callsigns are <strong>base-40</strong>
integers in 48 bits, so a source and a destination fit in twelve bytes.</p>

<h2 id="two-roads-to-the-link-setup-frame">Two roads to the Link Setup Frame</h2>

<p>The LSF is 240 bits — a 48-bit destination, a 48-bit source, a 16-bit TYPE,
a 112-bit META block and a 16-bit CRC
(<a href="/reference/m17-link-information/">reference</a>) —
and it reaches a receiver two ways. Choosing between them is the package’s
central design decision.</p>

<p>The first road is the <strong>dedicated LSF frame</strong> at the head of a
transmission, sync word <code class="language-plaintext highlighter-rouge">0x55F7</code>. Its payload is convolutionally coded and
punctured, so decoding it needs a K=5 Viterbi decoder and a depuncture
table, which the M17 package deliberately does not use.</p>

<p>The second road is the <strong>LICH</strong> — the Link Information CHannel in <em>every</em>
stream frame. Each frame carries one sixth of the LSF, 40 bits, under four
Golay(24,12) codewords, plus a 3-bit counter naming which sixth. Six
consecutive frames reassemble the whole LSF. This is M17’s
<a href="/reference/late-entry/">late-entry</a> mechanism, and
it needs only <code class="language-plaintext highlighter-rouge">framing.GolayDecode24_12</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/radio/m17/lich.go (shape)</span>
<span class="k">const</span> <span class="n">LICHBits</span> <span class="o">=</span> <span class="m">96</span>  <span class="c">// four Golay(24,12) codewords per stream frame</span>
<span class="k">const</span> <span class="n">lichChunks</span> <span class="o">=</span> <span class="m">6</span> <span class="c">// six 40-bit fragments per 240-bit LSF</span>

<span class="k">func</span> <span class="p">(</span><span class="n">a</span> <span class="o">*</span><span class="n">LICHAssembler</span><span class="p">)</span> <span class="n">Push</span><span class="p">(</span><span class="n">lich</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="n">LSF</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">var</span> <span class="n">chunk</span> <span class="kt">uint64</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="m">4</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">data</span><span class="p">,</span> <span class="n">errs</span> <span class="o">:=</span> <span class="n">framing</span><span class="o">.</span><span class="n">GolayDecode24_12</span><span class="p">(</span><span class="n">cw</span><span class="p">)</span> <span class="c">// cw: 24 wire bits</span>
        <span class="k">if</span> <span class="n">errs</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
            <span class="n">a</span><span class="o">.</span><span class="n">golayErrors</span><span class="o">++</span>
            <span class="k">return</span> <span class="n">LSF</span><span class="p">{},</span> <span class="no">false</span> <span class="c">// uncorrectable — drop this LICH</span>
        <span class="p">}</span>
        <span class="n">chunk</span> <span class="o">=</span> <span class="n">chunk</span><span class="o">&lt;&lt;</span><span class="m">12</span> <span class="o">|</span> <span class="kt">uint64</span><span class="p">(</span><span class="n">data</span><span class="o">&amp;</span><span class="m">0x0FFF</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="n">frag</span> <span class="o">:=</span> <span class="n">chunk</span> <span class="o">&gt;&gt;</span> <span class="m">8</span>              <span class="c">// top 40 bits: the LSF fragment</span>
    <span class="n">cnt</span> <span class="o">:=</span> <span class="kt">int</span><span class="p">((</span><span class="n">chunk</span> <span class="o">&gt;&gt;</span> <span class="m">5</span><span class="p">)</span> <span class="o">&amp;</span> <span class="m">0x07</span><span class="p">)</span> <span class="c">// next 3 bits: which sixth</span>
    <span class="c">/* store frag at lsf[cnt*5:], mark have[cnt]; when all six: ParseLSF */</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A stream frame is 40 ms, so six are ~240 ms. A LICH with an uncorrectable
codeword is simply dropped: the assembler keeps its fragments and waits for
that sixth to repeat, because the LSF repeats for the life of the
transmission.</p>

<h2 id="4fsk-to-bits">4FSK to bits</h2>

<p>The receiver is the C4FM chain from
<a href="/blog/deep-dives/p25-end-to-end-01-c4fm-carrier/">P25 End to End Part 1</a>
with M17’s numbers dropped in:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/radio/m17/receiver/receiver.go (shape)</span>
<span class="k">const</span> <span class="p">(</span>
    <span class="n">BaudHz</span>      <span class="o">=</span> <span class="m">4800</span>   <span class="c">// symbols/s</span>
    <span class="n">Oversample</span>  <span class="o">=</span> <span class="m">10</span>     <span class="c">// matched-filter samples per symbol</span>
    <span class="n">AudioRateHz</span> <span class="o">=</span> <span class="n">BaudHz</span> <span class="o">*</span> <span class="n">Oversample</span> <span class="c">// 48 000</span>
    <span class="n">rrcAlpha</span>    <span class="o">=</span> <span class="m">0.5</span>    <span class="c">// M17 RRC roll-off</span>
    <span class="n">deviationHz</span> <span class="o">=</span> <span class="m">2400.0</span> <span class="c">// peak (outer-symbol) deviation</span>
    <span class="n">mmGain</span>      <span class="o">=</span> <span class="m">0.05</span>
<span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">New</code> builds <code class="language-plaintext highlighter-rouge">demod.NewC4FM(Oversample, 8, rrcAlpha, dev)</code> where <code class="language-plaintext highlighter-rouge">dev</code> is
<code class="language-plaintext highlighter-rouge">2π·deviationHz/AudioRateHz</code> — the outer symbol’s phase step per sample,
the slicer’s scale — and a <code class="language-plaintext highlighter-rouge">MuellerMuller</code> loop at gain 0.05
(<a href="/blog/deep-dives/sdr-internals-07-symbol-timing-sync-recovery/">SDR Internals Part 7</a>).
Two numbers differ from the P25 chain: roll-off <strong>0.5</strong>, not 0.2, and
<strong>2400 Hz</strong> deviation at the outer symbol. <code class="language-plaintext highlighter-rouge">feedSymbol</code> slices each symbol,
maps it with the M17 convention (<code class="language-plaintext highlighter-rouge">+1→00, +3→01, −1→10, −3→11</code>) and pushes
the two bits into the decoder MSB first. The package doc calls these
constants <strong>nominal</strong>: “like the POCSAG frontend, on-air calibration
against a real capture is a follow-up”. <code class="language-plaintext highlighter-rouge">receiver_test.go</code> pins
construction and the dibit map (<code class="language-plaintext highlighter-rouge">TestSymbolToDibit</code>) — not decode yield on
real IQ.</p>

<h2 id="the-three-state-decoder">The three-state decoder</h2>

<p><code class="language-plaintext highlighter-rouge">m17.Decoder</code> consumes one bit at a time. <code class="language-plaintext highlighter-rouge">stHunt</code> shifts bits into a
16-bit register until it equals <code class="language-plaintext highlighter-rouge">SyncStream</code>; <code class="language-plaintext highlighter-rouge">stLICH</code> collects 96 bits for
the assembler; <code class="language-plaintext highlighter-rouge">stSkip</code> counts out the rest of the 368-bit payload — 272
bits of frame number and Codec 2 — and returns to the hunt:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/radio/m17/decoder.go (shape)</span>
<span class="k">const</span> <span class="p">(</span>
    <span class="n">SyncStream</span> <span class="kt">uint16</span> <span class="o">=</span> <span class="m">0xFF5D</span> <span class="c">// stream frame</span>
    <span class="n">SyncLSF</span>    <span class="kt">uint16</span> <span class="o">=</span> <span class="m">0x55F7</span> <span class="c">// dedicated link-setup frame</span>
    <span class="n">SyncPacket</span> <span class="kt">uint16</span> <span class="o">=</span> <span class="m">0x75FF</span> <span class="c">// packet frame</span>
<span class="p">)</span>
<span class="k">const</span> <span class="n">streamPayloadBits</span> <span class="o">=</span> <span class="m">368</span> <span class="c">// 96 LICH + 272 skipped</span>

<span class="k">case</span> <span class="n">stHunt</span><span class="o">:</span>
    <span class="n">d</span><span class="o">.</span><span class="n">reg</span> <span class="o">=</span> <span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">reg</span> <span class="o">&lt;&lt;</span> <span class="m">1</span><span class="p">)</span> <span class="o">|</span> <span class="kt">uint16</span><span class="p">(</span><span class="n">b</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">d</span><span class="o">.</span><span class="n">reg</span> <span class="o">==</span> <span class="n">SyncStream</span> <span class="p">{</span>
        <span class="n">d</span><span class="o">.</span><span class="n">syncs</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">d</span><span class="o">.</span><span class="n">st</span> <span class="o">=</span> <span class="n">stLICH</span><span class="p">;</span> <span class="n">d</span><span class="o">.</span><span class="n">n</span> <span class="o">=</span> <span class="m">0</span>
    <span class="p">}</span>
    <span class="c">// LSF / packet syncs are recognised but their payloads need the</span>
    <span class="c">// convolutional path; the LICH route carries the same metadata.</span>
</code></pre></div></div>

<p>That comment is the architecture in two lines: the LSF and packet sync
words are <em>known</em>, but a match on either does nothing. <code class="language-plaintext highlighter-rouge">Stats</code> exposes
<code class="language-plaintext highlighter-rouge">Syncs</code> and <code class="language-plaintext highlighter-rouge">LSFDecoded</code> — the counters to read first if a configured M17
channel stays silent.</p>

<figure class="lab-figure">
<svg viewBox="0 0 680 250" width="680" height="250" role="img" aria-label="An M17 stream frame as three fields: the sync word 0xFF5D, a 96-bit Golay-coded LICH, and a skipped 272-bit Codec 2 payload; below, the LICH's 40-bit fragment and 3-bit counter, and six fragments assembling into the 240-bit Link Setup Frame.">
  <rect x="10" y="22" width="44" height="34" rx="4" fill="none" stroke="var(--accent)" />
  <text x="32" y="43" text-anchor="middle" fill="var(--accent)" font-size="9">0xFF5D</text>
  <rect x="54" y="22" width="150" height="34" rx="4" fill="none" stroke="currentColor" />
  <text x="129" y="37" text-anchor="middle" fill="currentColor" font-size="9">LICH — 96 bits</text>
  <text x="129" y="49" text-anchor="middle" fill="var(--fg-muted)" font-size="8">4 × Golay(24,12)</text>
  <rect x="204" y="22" width="466" height="34" rx="4" fill="none" stroke="var(--fg-muted)" stroke-dasharray="4 3" />
  <text x="437" y="43" text-anchor="middle" fill="var(--fg-muted)" font-size="9">payload — 272 bits: frame number + Codec 2 — stSkip, not decoded</text>
  <line x1="129" y1="56" x2="129" y2="96" stroke="currentColor" />
  <polygon points="125,94 129,102 133,94" fill="currentColor" />
  <rect x="34" y="104" width="140" height="26" rx="4" fill="none" stroke="currentColor" />
  <text x="104" y="121" text-anchor="middle" fill="currentColor" font-size="9">LSF fragment · 40 bits</text>
  <rect x="174" y="104" width="50" height="26" rx="4" fill="none" stroke="var(--accent)" />
  <text x="199" y="121" text-anchor="middle" fill="var(--accent)" font-size="9">cnt · 3</text>
  <text x="129" y="146" text-anchor="middle" fill="var(--fg-muted)" font-size="8">Golay-decoded 48 bits: fragment + which sixth (0..5)</text>
  <line x1="224" y1="117" x2="300" y2="117" stroke="currentColor" />
  <polygon points="298,113 306,117 298,121" fill="currentColor" />
  <text x="264" y="110" text-anchor="middle" fill="var(--fg-muted)" font-size="8">× 6 frames</text>
  <rect x="310" y="96" width="120" height="30" rx="3" fill="none" stroke="var(--accent)" />
  <text x="370" y="115" text-anchor="middle" fill="var(--accent)" font-size="9">DST 48 · SRC 48</text>
  <rect x="430" y="96" width="240" height="30" rx="3" fill="none" stroke="currentColor" />
  <text x="550" y="115" text-anchor="middle" fill="currentColor" font-size="9">TYPE 16 · META 112 · CRC 16</text>
  <text x="490" y="146" text-anchor="middle" fill="var(--fg-muted)" font-size="8">240-bit LSF · base-40 callsigns · CRC-16 poly 0x5935 → ParseLSF → KindM17LinkSetup → m17_log</text>
  <text x="340" y="196" text-anchor="middle" fill="currentColor" font-size="9">six frames ≈ 240 ms: a receiver joining mid-transmission still learns who is calling whom</text>
</svg>
<figcaption>The LICH route: 96 Golay-coded bits read per stream frame, 272 skipped, six 40-bit fragments stacked into the Link Setup Frame — no Viterbi decoder in the path.</figcaption>
</figure>

<h2 id="inside-the-link-setup-frame">Inside the Link Setup Frame</h2>

<p><code class="language-plaintext highlighter-rouge">ParseLSF</code> takes the 30 assembled bytes and never errors — a bad CRC sets
<code class="language-plaintext highlighter-rouge">CRCOK=false</code> while the fields stay populated, so a marginal decode is shown
flagged rather than swallowed:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/radio/m17/m17.go (shape)</span>
<span class="k">type</span> <span class="n">LSF</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Dst</span><span class="p">,</span> <span class="n">Src</span> <span class="kt">string</span> <span class="c">// callsign / "BROADCAST" / "#RESERVED"</span>
    <span class="n">Meta</span>     <span class="p">[]</span><span class="kt">byte</span> <span class="c">// 14-byte META block</span>
    <span class="n">CRCOK</span>    <span class="kt">bool</span>
    <span class="n">Stream</span>   <span class="kt">bool</span>   <span class="c">// TYPE bit 0: stream (vs packet) mode</span>
    <span class="n">DataMode</span> <span class="kt">uint8</span>  <span class="c">// TYPE bits 1..2: 1 = data, 2 = voice, 3 = voice+data</span>
    <span class="n">CAN</span>      <span class="kt">uint8</span>  <span class="c">// TYPE bits 7..10: channel access number</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">DecodeAddress</code> treats the 48-bit value as a base-40 integer over the
alphabet <code class="language-plaintext highlighter-rouge">" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/."</code>, taking <code class="language-plaintext highlighter-rouge">addr % 40</code>
as the next character, dividing, and reversing at the end. Zero decodes to
the empty string, all-ones to <code class="language-plaintext highlighter-rouge">BROADCAST</code>, and anything above 40⁹
(<code class="language-plaintext highlighter-rouge">0xEE6B28000000</code>) to <code class="language-plaintext highlighter-rouge">#RESERVED</code>. The CRC is polynomial <code class="language-plaintext highlighter-rouge">0x5935</code>, initial
value <code class="language-plaintext highlighter-rouge">0xFFFF</code>, MSB-first, no final XOR, over DST through META.
<code class="language-plaintext highlighter-rouge">TestCRC16DetectsCorruption</code> flips one bit and asserts the check fails;
<code class="language-plaintext highlighter-rouge">TestDecodeLSFFromLICHStream</code> pushes six encoded LICH frames through the
decoder and asserts <code class="language-plaintext highlighter-rouge">AB1CDE</code> → <code class="language-plaintext highlighter-rouge">M17-USA</code> decodes with <code class="language-plaintext highlighter-rouge">CRCOK</code> true and
<code class="language-plaintext highlighter-rouge">DataMode</code> 2.</p>

<p>The receiver’s <code class="language-plaintext highlighter-rouge">publish</code> then builds a <code class="language-plaintext highlighter-rouge">storage.M17LinkSetup</code> — <code class="language-plaintext highlighter-rouge">Src</code>,
<code class="language-plaintext highlighter-rouge">Dst</code>, <code class="language-plaintext highlighter-rouge">Mode</code> (<code class="language-plaintext highlighter-rouge">ModeString</code>: voice / data / voice+data / packet), <code class="language-plaintext highlighter-rouge">CAN</code>, hex
<code class="language-plaintext highlighter-rouge">Meta</code>, <code class="language-plaintext highlighter-rouge">CRCOK</code>, a display <code class="language-plaintext highlighter-rouge">Body</code> — and publishes <code class="language-plaintext highlighter-rouge">events.KindM17LinkSetup</code>
(<code class="language-plaintext highlighter-rouge">m17.linksetup</code> over SSE). <code class="language-plaintext highlighter-rouge">storage.M17Log</code>, one instantiation of the
generic <code class="language-plaintext highlighter-rouge">eventLog[T]</code> writer, lands rows in <code class="language-plaintext highlighter-rouge">m17_log</code>, indexed on
<code class="language-plaintext highlighter-rouge">received_at</code> and <code class="language-plaintext highlighter-rouge">src</code> and swept by <code class="language-plaintext highlighter-rouge">retention.log_days</code>
(<a href="/blog/deep-dives/recording-streaming-10-call-log-sqlite/">Recording Part 10</a>);
<code class="language-plaintext highlighter-rouge">GET /api/v1/m17/linksetups?limit=N</code> reads them back newest first.</p>

<h2 id="where-the-decoder-stops">Where the decoder stops</h2>

<p>Four things are not decoded today, each a different kind of gap.</p>

<p><strong>The Codec 2 voice payload.</strong> The 272 bits after the LICH are consumed by
<code class="language-plaintext highlighter-rouge">stSkip</code> and discarded. Decoding them needs the K=5 Viterbi plus depuncture
path and a pure-Go Codec 2 port that does not exist in the tree; the
vocoder registry from
<a href="/blog/deep-dives/sdr-internals-12-voice-coding-vocoders/">SDR Internals Part 12</a>
has no Codec 2 entry.</p>

<p><strong>The dedicated LSF frame and packet mode.</strong> <code class="language-plaintext highlighter-rouge">SyncLSF</code> and <code class="language-plaintext highlighter-rouge">SyncPacket</code> are
recognised; their payloads need the convolutional decoder the package
omits. <code class="language-plaintext highlighter-rouge">docs/m17.md</code> defers the standalone LSF decode “since the LICH route
already covers it”, but packet frames carry data the LICH never sees, so
packet M17 is invisible beyond its sync word.</p>

<p><strong>The web panel.</strong> This is the eleven-place pattern from
<a href="/blog/deep-dives/beyond-voice-01-eleven-place-pattern/">Part 1</a>
with a hole in it. M17 has the config struct, FieldMeta help, a Config
Builder section (<code class="language-plaintext highlighter-rouge">web/configbuilder/src/sections/M17.tsx</code>), the
<code class="language-plaintext highlighter-rouge">config.example.yaml</code> block, the doctor entry, bus kind, storage table,
retention membership and REST route — but <code class="language-plaintext highlighter-rouge">web/src/nav/registry.ts</code> has no
<code class="language-plaintext highlighter-rouge">/m17</code> entry, <code class="language-plaintext highlighter-rouge">App.tsx</code> mounts no M17 panel, and the <code class="language-plaintext highlighter-rouge">web.tabs</code> key list
has no <code class="language-plaintext highlighter-rouge">m17</code>. Decoded LSFs are reachable over REST and nowhere in the
console —
<a href="/blog/deep-dives/beyond-voice-14-adding-a-decoder-checklist/">Part 14</a>’s
example of a decoder that stopped two places short.</p>

<p><strong>The capture.</strong> Everything is transcribed from the specification and
validated against GopherTrunk’s own synthetic encoder; the package doc says
the Golay matrix “should be confirmed against a capture”, and
<code class="language-plaintext highlighter-rouge">docs/decoder-capture-needs.md</code> says that capture <strong>must be IQ</strong>. This is the
<a href="/blog/solution-postmortem/from-the-issue-tracker-20-self-consistent-trap/">self-consistent trap</a>
in its mildest form: nothing is known wrong, but the passing tests share an
author with the code. The status is <strong>synthetic-verified only</strong>.</p>

<h3 id="how-the-lich-route-shaped-the-go-code">How the LICH route shaped the Go code</h3>

<ul>
  <li><strong>One decoder, no Viterbi dependency.</strong> The LICH route kept
<code class="language-plaintext highlighter-rouge">internal/radio/m17</code> to three files and one framing import.</li>
  <li><strong>Never-erroring parse, flagged instead.</strong> <code class="language-plaintext highlighter-rouge">ParseLSF</code> populates every
field and sets <code class="language-plaintext highlighter-rouge">CRCOK</code>, so marginal decodes stay visible.</li>
  <li><strong>Per-frame drop, per-sixth retry.</strong> An uncorrectable Golay codeword costs
one LICH, not the transmission.</li>
  <li><strong>Counters over logs.</strong> <code class="language-plaintext highlighter-rouge">Decoder.Stats</code> and <code class="language-plaintext highlighter-rouge">Receiver.Stats</code> are the
instruments the first field report will need
(<a href="/blog/deep-dives/from-spec-to-shipping-13-instruments-not-logs/">From Spec to Shipping Part 13</a>).</li>
</ul>

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

<p>M17 is the open end of the amateur digital-voice spectrum; the other end is
three modes built on AMBE.
<a href="/blog/deep-dives/beyond-voice-13-dstar-ysf-dpmr/">Part 13</a>
takes D-STAR’s GMSK header and its 660-bit FEC chain, System Fusion’s FICH
trellis, and dPMR Mode 3’s CSBKs — three decoders at three distances from
on-air verification.</p>

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

<p><strong>Does GopherTrunk decode M17 voice?</strong>
No. It decodes the M17 link layer — source and destination callsigns, stream
versus packet mode, data mode and channel access number — from the LICH in
every stream frame. The Codec 2 payload is skipped; decoding it needs the
convolutional path and a pure-Go Codec 2 port, both pending in <code class="language-plaintext highlighter-rouge">docs/m17.md</code>.</p>

<p><strong>Why decode the LICH instead of the Link Setup Frame itself?</strong>
The dedicated LSF frame is convolutionally coded and punctured, so it needs
a Viterbi decoder. The LICH carries the same 240 bits one sixth per stream
frame under Golay(24,12), needing only a Golay decoder, and supports late
entry: a receiver joining mid-transmission has the full LSF after six
frames, about 240 milliseconds.</p>

<p><strong>How are M17 callsigns encoded on the air?</strong>
As base-40 integers in a 48-bit field over the alphabet space, A–Z, 0–9,
hyphen, slash and dot. <code class="language-plaintext highlighter-rouge">DecodeAddress</code> repeatedly takes the value modulo 40
and divides. All-ones is <code class="language-plaintext highlighter-rouge">BROADCAST</code>, zero is the reserved empty address, and
values above 40⁹ decode as <code class="language-plaintext highlighter-rouge">#RESERVED</code>.</p>

<p><strong>Where do decoded M17 link setups show up?</strong>
On the events bus as <code class="language-plaintext highlighter-rouge">m17.linksetup</code>, in the <code class="language-plaintext highlighter-rouge">m17_log</code> SQLite table, and at
<code class="language-plaintext highlighter-rouge">GET /api/v1/m17/linksetups?limit=N</code>. Both require <code class="language-plaintext highlighter-rouge">storage.path</code>;
<code class="language-plaintext highlighter-rouge">gophertrunk doctor</code> warns when <code class="language-plaintext highlighter-rouge">m17.channels</code> is configured without it.
There is no <code class="language-plaintext highlighter-rouge">/m17</code> web panel yet, so REST is the operator-facing surface.</p>

<p><strong>Has the M17 decoder been verified on air?</strong>
Not yet. The sync words, LSF layout, CRC-16 polynomial 0x5935, base-40
alphabet and LICH structure come from the M17 specification and are proven
against GopherTrunk’s own synthetic encoder. The exact Golay matrix, slicer
deviation and timing constants await an IQ capture of real M17 traffic, per
<code class="language-plaintext highlighter-rouge">docs/decoder-capture-needs.md</code>.</p>

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

<p><strong>Part 12 of 14</strong> · ←
<a href="/blog/deep-dives/beyond-voice-11-lora-chirp-receiver/">Part 11: LoRa — A Pure-Go Chirp Receiver</a>
· Next →
<a href="/blog/deep-dives/beyond-voice-13-dstar-ysf-dpmr/">Part 13: D-STAR, System Fusion &amp; dPMR</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="beyond-voice" /><category term="m17" /><category term="amateur-radio" /><category term="golay" /><category term="link-layer" /><category term="go" /><summary type="html"><![CDATA[How GopherTrunk decodes M17's link layer without touching the voice: the 4FSK stream frame, the Golay-coded LICH that spreads a Link Setup Frame across six frames, base-40 callsigns, the CRC-16 with polynomial 0x5935 — and exactly where the decoder stops today, Codec 2 payload and web panel included.]]></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">DMR End to End, Part 12: Enhanced Privacy — RC4 &amp;amp; the IV That Names the Next Superframe</title><link href="https://gophertrunk.org/blog/deep-dives/dmr-end-to-end-12-enhanced-privacy-rc4/" rel="alternate" type="text/html" title="DMR End to End, Part 12: Enhanced Privacy — RC4 &amp;amp; the IV That Names the Next Superframe" /><published>2026-09-28T00:00:00-05:00</published><updated>2026-09-28T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/dmr-end-to-end-12-enhanced-privacy-rc4</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/dmr-end-to-end-12-enhanced-privacy-rc4/"><![CDATA[<p><em>Part 12 of <strong>DMR End to End</strong>, a 14-part deep dive that follows the world’s
most widely deployed digital PMR protocol through GopherTrunk — from a 4FSK
carrier to two simultaneous recorded calls, direct-mode handhelds, and
decrypted Enhanced Privacy voice.
<a href="/blog/deep-dives/dmr-end-to-end-11-ambe2-silence-frames/">Part 11</a>
ended with clear AMBE+2 frames reaching the vocoder. This part is about the
frames that arrive scrambled: where a transmission announces its cipher, how
an RC4 keystream is laid onto eighteen vocoder frames, and the convention —
the embedded IV names the *next</em> superframe — that no round-trip could have
caught and a reporter’s known-key capture did.*</p>

<blockquote>
  <p><strong>TL;DR:</strong> DMR “Enhanced Privacy” (DMRA algorithm <code class="language-plaintext highlighter-rouge">0x21</code>) is RC4 keyed
with <strong>key‖MI</strong> — the operator’s key followed by the 32-bit Message
Indicator — with <strong>256</strong> keystream bytes discarded and <strong>7 bytes per
49-bit AMBE+2 frame</strong> applied across a superframe (<code class="language-plaintext highlighter-rouge">EPKeystream</code>,
<code class="language-plaintext highlighter-rouge">DescrambleSuperframe</code> in <code class="language-plaintext highlighter-rouge">internal/radio/dmr/voice/ep.go</code>). The MI
arrives in the <strong>Privacy Indicator header</strong> (<code class="language-plaintext highlighter-rouge">dmr.ParsePIHeader</code>, CRC mask
<code class="language-plaintext highlighter-rouge">0x9696</code>) and advances per superframe through the LFSR x³²+x⁴+x²+1
(<code class="language-plaintext highlighter-rouge">AdvanceMI</code>). Every superframe also embeds an IV as three Golay(24,12)
codewords — and that IV is the <strong>NEXT</strong> superframe’s MI. <code class="language-plaintext highlighter-rouge">EPTracker</code>
applied it to the current one, so everything after the first superframe
decoded on the wrong MI; that fix plus <code class="language-plaintext highlighter-rouge">RewindMI</code> for late entry lifted the
<a href="https://github.com/MattCheramie/GopherTrunk/issues/1187">#1187</a> captures
from ciphertext (b0 continuity ≈0.15) to speech (0.77 / 0.74).
Capture-verified; the live daemon call is still the open gate.</p>
</blockquote>

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

<ul>
  <li><strong>Encryption is announced in a data burst, then hidden in the voice.</strong> The
PI header carries algorithm, key ID and MI at keyup; afterwards the voice
bursts carry the chain, a nibble per frame.</li>
  <li><strong>The keystream construction was right the whole time.</strong> Both defects that
hid a working decrypt were receiving-side: an IV applied one superframe too
early, and a harness slicing bursts out of the gaps.</li>
  <li><strong>A Golay-verified IV is weak evidence on its own.</strong> The radius-3 sphere
covers ~57 % of 24-bit words, so clear vocoder bits “verify” ~1 % of the
time — a disagreeing IV is trusted only when it decoded clean.</li>
  <li><strong>Judge a decrypt by pitch continuity, never loudness.</strong> Random AMBE+2
parameters are loud; only a slowly moving fundamental says the key was
right.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Privacy Indicator header</td>
      <td>alg / FID / key ID / 32-bit MI / dest, CRC-CCITT ^ <code class="language-plaintext highlighter-rouge">0x9696</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/dmr/pi.go</code> (<code class="language-plaintext highlighter-rouge">ParsePIHeader</code>, <code class="language-plaintext highlighter-rouge">IsRC4</code>)</td>
    </tr>
    <tr>
      <td>Finding the header burst</td>
      <td>data-sync slice → slot type <code class="language-plaintext highlighter-rouge">0x0</code> → BPTC → CRC</td>
      <td><code class="language-plaintext highlighter-rouge">dmr/voice/pi_detector.go</code> (<code class="language-plaintext highlighter-rouge">PIHeaderDetector</code>)</td>
    </tr>
    <tr>
      <td>Keystream</td>
      <td>RC4(key‖MI), drop 256, 7 bytes/frame, silence frames in clear</td>
      <td><code class="language-plaintext highlighter-rouge">dmr/voice/ep.go</code> (<code class="language-plaintext highlighter-rouge">EPKeystream</code>, <code class="language-plaintext highlighter-rouge">DescrambleSuperframe</code>)</td>
    </tr>
    <tr>
      <td>MI schedule</td>
      <td>x³²+x⁴+x²+1, one step per superframe, invertible</td>
      <td><code class="language-plaintext highlighter-rouge">AdvanceMI</code> / <code class="language-plaintext highlighter-rouge">RewindMI</code></td>
    </tr>
    <tr>
      <td>Embedded IV</td>
      <td>18 nibbles at on-air bits 71/67/63/59 → 3 × Golay(24,12) + CRC-4</td>
      <td><code class="language-plaintext highlighter-rouge">ExtractEmbeddedIV</code>, <code class="language-plaintext highlighter-rouge">EPTracker.Next</code></td>
    </tr>
    <tr>
      <td>Reporter harness</td>
      <td><code class="language-plaintext highlighter-rouge">GT_DMR_EP_IQ</code> / <code class="language-plaintext highlighter-rouge">GT_DMR_EP_AUDIO</code> + <code class="language-plaintext highlighter-rouge">GT_DMR_EP_KEY</code>, pitch-continuity <code class="language-plaintext highlighter-rouge">VERDICT</code></td>
      <td><code class="language-plaintext highlighter-rouge">cmd/gophertrunk/dmr_ep_replay_test.go</code></td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>The header that says how</strong> — twelve octets and a CRC mask.</li>
  <li><strong>Key‖MI, drop 256, seven bytes a frame</strong> — the RC4 construction.</li>
  <li><strong>The IV that names the next superframe</strong> — embedded MIs, the tracker, the rewind.</li>
  <li><strong>Composer to recorder</strong> — key resolution, events, the crypto-frame bridge.</li>
  <li><strong>The capture that settled it</strong> — two receiving-side defects, one verdict.</li>
</ul>

<h2 id="the-header-that-says-how">The header that says how</h2>

<blockquote>
  <p><strong>Authorized use only.</strong> Everything here is <em>known-key</em> decryption: the
operator supplies a key they are authorized to hold, and GopherTrunk
performs no key recovery of any kind — the same model as SDRTrunk, DSD-FME
and OP25. Decrypting traffic you are not permitted to touch is illegal in
most jurisdictions; the
<a href="/blog/tutorials/crypto-lab-01-breaking-it-is-the-test/">Crypto Lab</a>
framing applies unchanged — measure your own deployment, or one you have
written permission to assess.</p>
</blockquote>

<p>P25 announces encryption twice —
<a href="/blog/deep-dives/p25-end-to-end-09-encryption/">a grant bit and an in-call Encryption Sync</a>.
DMR is similar: the Full Link Control’s service options carry a privacy
bit (<a href="/blog/deep-dives/dmr-end-to-end-05-link-control-late-entry/">Part 5</a>),
and the <em>how</em> rides a dedicated data burst — the <strong>Privacy Indicator
header</strong>, slot type <code class="language-plaintext highlighter-rouge">0x0</code> (<code class="language-plaintext highlighter-rouge">DTPIHeader</code>), sent after the Voice LC Header. Its BPTC(196,96)-recovered block is twelve octets, pinned against
SDRTrunk and DSD-FME: algorithm
(<code class="language-plaintext highlighter-rouge">0x21</code> RC4, <code class="language-plaintext highlighter-rouge">0x22</code> DES, <code class="language-plaintext highlighter-rouge">0x24</code>/<code class="language-plaintext highlighter-rouge">0x25</code> AES), feature-set ID (<code class="language-plaintext highlighter-rouge">0x10</code> DMRA,
<code class="language-plaintext highlighter-rouge">0x68</code> Hytera, <code class="language-plaintext highlighter-rouge">0x0A</code> Kirisun), key ID, the 32-bit MI in octets 3–6, a
24-bit destination, and a CRC-CCITT (poly <code class="language-plaintext highlighter-rouge">0x1021</code>, init 0) XORed with the
PI-header mask.</p>

<p>That mask costs newcomers an afternoon. ETSI writes it as <code class="language-plaintext highlighter-rouge">0x6969</code> but
defines the CRC with its output inverted; <code class="language-plaintext highlighter-rouge">framing.CRCCCITTWithInit</code> is the
plain form, so <code class="language-plaintext highlighter-rouge">piHeaderCRCMask</code> is <code class="language-plaintext highlighter-rouge">^0x6969 = 0x9696</code> — the relation
<code class="language-plaintext highlighter-rouge">csbkCRCMask</code> (<code class="language-plaintext highlighter-rouge">0x5A5A</code>) bears to ETSI’s <code class="language-plaintext highlighter-rouge">0xA5A5</code> in
<a href="/blog/deep-dives/dmr-end-to-end-04-fec-stack-forged-terminator/">Part 4</a>.
<code class="language-plaintext highlighter-rouge">TestParsePIHeaderReferenceLiteral</code> pins it with an independent Python CRC’s
vector — the
<a href="/blog/deep-dives/from-spec-to-shipping-03-literal-vectors/">literal-vector rule</a>,
since a round-trip through <code class="language-plaintext highlighter-rouge">AssemblePIHeader</code> agrees with either mask.</p>

<p>Two choices are deliberate. <code class="language-plaintext highlighter-rouge">IsRC4</code> keys on the <strong>low three bits</strong> of the
algorithm octet, as DSD-FME does — but only under feature set <code class="language-plaintext highlighter-rouge">0x10</code>:
Hytera’s Enhanced Privacy (FID <code class="language-plaintext highlighter-rouge">0x68</code>, 40-bit MI) is a different
construction and is not claimed. And the header has no RS(12,9)
trailer, so its CRC is the only integrity check: a BPTC-clean block that
fails it is dropped, never guessed, and <code class="language-plaintext highlighter-rouge">PIHeaderDetector.Rejects</code> keeps its
hex as the instrument for an unnamed vendor layout. The detector runs beside
the voice <code class="language-plaintext highlighter-rouge">Decoder</code> exactly like the terminator detector: data-sync slice,
both polarities, slot type, BPTC, <code class="language-plaintext highlighter-rouge">ParsePIHeader</code>.</p>

<h2 id="keymi-drop-256-seven-bytes-a-frame">Key‖MI, drop 256, seven bytes a frame</h2>

<p>The construction is short, and every line is a protocol fact two open
decoders agree on.
<code class="language-plaintext highlighter-rouge">EPKeystream</code> appends the four MI bytes (MSB first) to the key, seeds Go’s
<code class="language-plaintext highlighter-rouge">rc4.NewCipher</code>, discards <code class="language-plaintext highlighter-rouge">EPKeystreamDrop</code> = 256 bytes and returns the
next <em>n</em>; <code class="language-plaintext highlighter-rouge">AdvanceMI</code> clocks a 32-bit LFSR with feedback
<code class="language-plaintext highlighter-rouge">(l&gt;&gt;31)^(l&gt;&gt;3)^(l&gt;&gt;1)</code> thirty-two times — x³²+x⁴+x²+1.</p>

<p><code class="language-plaintext highlighter-rouge">DescrambleSuperframe</code> XORs those 126 bytes onto the eighteen FEC-decoded
payloads <strong>in place</strong>. Two rules keep alignment honest: a frame that failed
FEC (<code class="language-plaintext highlighter-rouge">nil</code>) is skipped but <strong>still consumes its 7-byte slot</strong>, and a frame
carrying the AMBE+2 <strong>silence vector</strong> (<code class="language-plaintext highlighter-rouge">0xF801A99F8CE080</code>,
<a href="/blog/deep-dives/dmr-end-to-end-11-ambe2-silence-frames/">Part 11</a>’s
subject) is sent in clear and passes through untouched while consuming its
slot — DSD-FME’s silence guard.</p>

<p>The kinship with P25 is close: <code class="language-plaintext highlighter-rouge">internal/radio/p25/phase1/adp.go</code> keys RC4
with the key plus the first <strong>8</strong> octets of P25’s 72-bit MI, drops 256, and
lays the stream onto IMBE frames from absolute byte 267 (LDU1) and 368
(LDU2) — OP25’s layout, verified on the same reporter’s ADP call (pitch
track 0.13 → 0.57). One cipher, one warm-up, and — next — one convention.</p>

<h2 id="the-iv-that-names-the-next-superframe">The IV that names the next superframe</h2>

<p>If the header were the only MI source, a scanner tuning in late — grant,
retune, DDC settle, frame lock — would never decrypt anything. So every
superframe also carries an MI: each of the 18
on-air frames donates one nibble in its unprotected C3 sub-vector — bits
71, 67, 63, 59, <code class="language-plaintext highlighter-rouge">C3[0..3]</code> after deinterleave. The 72 bits form three
<a href="/reference/golay-code/">Golay(24,12)</a> codewords —
codeword <em>j</em> takes data from bursts A, B, C and parity from D, E, F — whose
36 data bits are the 32-bit MI plus a CRC-4 (x⁴+x+1, output inverted). That
is <code class="language-plaintext highlighter-rouge">ExtractEmbeddedIV</code>.</p>

<p>Now the convention. <strong>The embedded IV is the NEXT superframe’s MI</strong> — the
<a href="/reference/p25-encryption-sync/">P25 LDU2 Encryption Sync</a>
rule. On the #1187 radio every superframe’s IV equals <code class="language-plaintext highlighter-rouge">AdvanceMI</code> of the one
before, and the first equals <code class="language-plaintext highlighter-rouge">AdvanceMI</code> of the header’s; DSD-FME agrees
(<code class="language-plaintext highlighter-rouge">dmr_alg_refresh</code> LFSR-advances at burst F <em>before</em> <code class="language-plaintext highlighter-rouge">dmr_late_entry_mi</code>
compares).</p>

<figure class="lab-figure">
<svg viewBox="0 0 680 190" width="680" height="190" role="img" aria-label="A Privacy Indicator header announces MI zero; two voice superframes each decrypt with the MI already known for them while their embedded IV names the next superframe's MI, AdvanceMI arrows link successive MIs, and a dashed RewindMI arrow runs back from the first embedded IV to MI zero for a late entrant that missed the header.">
  <g fill="none" stroke="currentColor">
    <rect x="20" y="70" width="110" height="54" rx="6" />
    <rect x="190" y="60" width="190" height="74" rx="6" />
    <rect x="440" y="60" width="190" height="74" rx="6" />
    <line x1="130" y1="97" x2="184" y2="97" /><line x1="380" y1="97" x2="434" y2="97" />
  </g>
  <g fill="none" stroke="var(--accent)">
    <path d="M 75 70 L 75 40 L 285 40 L 285 60" />
    <path d="M 285 60 L 285 22 L 535 22 L 535 60" />
  </g>
  <path d="M 285 134 L 285 160 L 75 160 L 75 124" fill="none" stroke="var(--fg-muted)" stroke-dasharray="4 3" />
  <g fill="currentColor"><polygon points="182,93 190,97 182,101" /><polygon points="432,93 440,97 432,101" /></g>
  <g fill="var(--accent)"><polygon points="281,54 285,62 289,54" /><polygon points="531,54 535,62 539,54" /></g>
  <polygon points="71,130 75,122 79,130" fill="var(--fg-muted)" />
  <g text-anchor="middle" font-size="10" fill="currentColor" font-weight="bold">
    <text x="75" y="92">PI header</text>
    <text x="285" y="80">superframe 0</text>
    <text x="535" y="80">superframe 1</text>
  </g>
  <g text-anchor="middle" font-size="9" fill="currentColor">
    <text x="285" y="98">decrypt with MI₀</text>
    <text x="535" y="98">decrypt with MI₁</text>
  </g>
  <g text-anchor="middle" font-size="9" fill="var(--accent)">
    <text x="75" y="112">MI₀ (alg 0x21, key ID)</text>
    <text x="285" y="124">embedded IV = MI₁ (3 × Golay(24,12) + CRC-4)</text>
    <text x="535" y="124">embedded IV = MI₂</text>
    <text x="180" y="34">AdvanceMI(MI₀) = MI₁</text>
    <text x="410" y="16">AdvanceMI(MI₁) = MI₂</text>
  </g>
  <g text-anchor="middle" font-size="9" fill="var(--fg-muted)">
    <text x="285" y="112">FEC-failed frame keeps its 7 bytes</text>
    <text x="180" y="174">late entry, header missed: RewindMI(IV) = MI₀</text>
  </g>
</svg>
<figcaption>Each superframe decrypts on what was already known for it; its embedded IV only steers what comes next — and a late entrant rewinds the LFSR once to recover the superframe it is standing in.</figcaption>
</figure>

<p><code class="language-plaintext highlighter-rouge">EPTracker.Next</code> is the rule as a switch. A verified IV equal to
<code class="language-plaintext highlighter-rouge">AdvanceMI(next)</code> confirms the chain; with no chain at all, a verified IV
yields the current MI by <code class="language-plaintext highlighter-rouge">RewindMI</code>, so late entry decrypts the very
superframe it verified on. A verified IV that <em>disagrees</em>
is where the Golay gate matters: a radius-3 sphere covers ~57 % of all
24-bit words, so clear vocoder bits pass all three codewords plus the CRC-4
about <strong>1 %</strong> of the time, whereas three <em>clean</em> codewords plus CRC are
~2⁻⁴⁰ by chance. A clean disagreement means a missed superframe and the MI
is re-derived from it; a corrected one holds the prediction (<code class="language-plaintext highlighter-rouge">Mismatches</code>
counts both).</p>

<h2 id="composer-to-recorder">Composer to recorder</h2>

<p>The per-call state (<code class="language-plaintext highlighter-rouge">dmrEPState</code>, <code class="language-plaintext highlighter-rouge">composer/dmr_ep.go</code>) never guesses a
key and never acts on an embedded IV for a call with no other encryption
evidence unless that IV decoded perfectly clean. <code class="language-plaintext highlighter-rouge">onPIHeader</code> seeds the
tracker (<code class="language-plaintext highlighter-rouge">SetHeaderMI</code>),
resolves the key through <code class="language-plaintext highlighter-rouge">KeyResolver</code> when <code class="language-plaintext highlighter-rouge">IsRC4</code>, and publishes
<code class="language-plaintext highlighter-rouge">events.KindCallEncryption</code> with <code class="language-plaintext highlighter-rouge">Protocol: "dmr"</code>; <code class="language-plaintext highlighter-rouge">superframe</code> runs
<code class="language-plaintext highlighter-rouge">ExtractEmbeddedIV</code> → <code class="language-plaintext highlighter-rouge">EPTracker.Next</code> → <code class="language-plaintext highlighter-rouge">DescrambleSuperframe</code> <strong>in
place</strong>.</p>

<p><code class="language-plaintext highlighter-rouge">KeyResolver</code> is built from <code class="language-plaintext highlighter-rouge">trunking.systems[].encryption_keys</code>
(<code class="language-plaintext highlighter-rouge">buildKeyResolver</code>, logging <code class="language-plaintext highlighter-rouge">daemon: in-process decryption keys configured
(DMR enhanced privacy / P25 ADP)</code>); <code class="language-plaintext highlighter-rouge">algorithm</code> must be <code class="language-plaintext highlighter-rouge">rc4</code> / <code class="language-plaintext highlighter-rouge">arc4</code> /
<code class="language-plaintext highlighter-rouge">adp</code> (DES and AES fail at load with “not supported yet”), <code class="language-plaintext highlighter-rouge">key</code> is 1–32 hex
bytes, and a key under a different ID is never applied. The header lands at
INFO as <code class="language-plaintext highlighter-rouge">composer: dmr PI header — call is encrypted</code>, and the counters —
<code class="language-plaintext highlighter-rouge">pi_headers</code>, <code class="language-plaintext highlighter-rouge">iv_verified</code>, <code class="language-plaintext highlighter-rouge">mi_predicted</code>, <code class="language-plaintext highlighter-rouge">mi_mismatches</code>,
<code class="language-plaintext highlighter-rouge">descrambled_frames</code>, <code class="language-plaintext highlighter-rouge">no_key</code> — ride the debug <code class="language-plaintext highlighter-rouge">composer: dmr enhanced
privacy</code> line.</p>

<p>The event is the same <code class="language-plaintext highlighter-rouge">KindCallEncryption</code> P25 publishes, so the engine’s
<a href="/blog/deep-dives/trunking-engine-11-encrypted-mode/">follow / metadata / ignore policy</a>
and its configured-key exemption apply unchanged, and
<code class="language-plaintext highlighter-rouge">recordings.skip_encrypted</code>
(<a href="/blog/deep-dives/recording-streaming-07-correctness-guards/">Recording &amp; Streaming Part 7</a>)
still decides whether a keyless call reaches disk. Independently of any key,
<code class="language-plaintext highlighter-rouge">recordings.crypto_capture_path</code> hands every superframe’s <strong>ciphertext plus
its MI</strong> to the <code class="language-plaintext highlighter-rouge">cryptocap</code> bridge for the
<a href="/blog/tutorials/crypto-lab-05-keystream-reuse-mtp/">keystream-reuse workflow</a>.
<code class="language-plaintext highlighter-rouge">TestComposerDMREnhancedPrivacyDecryptsWithConfiguredKey</code> runs the full
composer and requires the recorder’s <code class="language-plaintext highlighter-rouge">.raw</code> sidecar to hold <strong>clear</strong>
payloads.</p>

<h2 id="the-capture-that-settled-it">The capture that settled it</h2>

<p>The #1187 reporter posted two known-key discriminator captures — key IDs 11
and 22, colour code 2, TG 582743, simplex — and
<code class="language-plaintext highlighter-rouge">TestDMREnhancedPrivacyReplay</code> judged them: production DDC → <code class="language-plaintext highlighter-rouge">dmrrx</code>
receiver → <code class="language-plaintext highlighter-rouge">PIHeaderDetector</code> + superframe decoder → <code class="language-plaintext highlighter-rouge">EPTracker</code> →
<code class="language-plaintext highlighter-rouge">DescrambleSuperframe</code> → <code class="language-plaintext highlighter-rouge">ambe2-dmr</code>, printing every header, the IV/MI
timeline and a one-line <code class="language-plaintext highlighter-rouge">VERDICT</code>. Knobs: <code class="language-plaintext highlighter-rouge">GT_DMR_EP_IQ</code> or
<code class="language-plaintext highlighter-rouge">GT_DMR_EP_AUDIO</code> (a discriminator WAV, FM re-modulated), <code class="language-plaintext highlighter-rouge">GT_DMR_EP_KEY</code>,
<code class="language-plaintext highlighter-rouge">GT_DMR_EP_KEYID</code>, <code class="language-plaintext highlighter-rouge">GT_DMR_EP_OUT</code>, and <code class="language-plaintext highlighter-rouge">GT_DMR_EP_DUMP</code>, which writes every
superframe <em>before</em> descrambling so a keystream hypothesis is testable
offline.</p>

<p>Both runs failed at first, for <strong>opposite reasons</strong>; neither was the cipher. First, the harness sliced voice with the back-to-back 132-dibit
single-slot decoder; a simplex handheld sends one burst per 60 ms frame,
288 dibits apart
(<a href="/blog/deep-dives/dmr-end-to-end-03-two-slots-one-carrier/">Part 3</a>),
so bursts B–F were read out of the gaps: burst A decoded FEC-clean, B–F
carried the random-word Golay signature — <strong>73 % needing exactly 3
corrections, 12 % exactly 2</strong> — which looks exactly like post-FEC
scrambling and is not — the same defect
<a href="https://github.com/MattCheramie/GopherTrunk/issues/1192">#1192</a> had fixed
in production the day before. The harness now defaults to the
cadence-detecting decoder, and a C0/C1 Golay histogram is the <em>first</em>
instrument to read when frames look scrambled. Second, the IV convention:
correctly sliced frames decoded on the wrong MI while the wrongly sliced ones
had decoded on the right one.</p>

<p>The verdict metric matters because loudness would have lied: random AMBE+2
parameters through the vocoder are <strong>loud</strong>, so the harness reports RMS but
never trusts it. It measures <strong>pitch continuity</strong>, the fraction of
consecutive frame pairs whose fundamental index moves by at most 10: speech
≳0.5, ciphertext ≈0.15. Post-fix the captures score <strong>0.77 and 0.74</strong>.
<code class="language-plaintext highlighter-rouge">TestEPCaptureIssue1187</code> pins it against literal on-air frames
(<code class="language-plaintext highlighter-rouge">voice/testdata/ep_issue1187_ptt1.json</code>): the header→IV chain yields
continuity ≥ 0.55, late entry rewinds to the header’s MI, and decrypting each
superframe with its <em>own</em> IV stays ≤ 0.35 — the old semantics fail it.</p>

<p>Verified-language, as the repo states it: Enhanced Privacy is
<strong>capture-verified</strong>. Open on #1187 is the <strong>daemon path on air</strong> — a live
call with <code class="language-plaintext highlighter-rouge">encryption_keys</code> configured recording intelligible audio (the
#764/#771 discipline). Not decoded: Hytera EP, Kirisun, DES/AES; the
<a href="/reference/dmr-encryption/">DMR encryption reference</a>
and <a href="/reference/rc4-cipher/">RC4 page</a> cover the
landscape, and
<a href="/dmr-encryption.html"><code class="language-plaintext highlighter-rouge">docs/dmr-encryption.md</code></a>
has the capture-contribution recipe.</p>

<h3 id="how-the-iv-convention-shaped-the-go-code">How the IV convention shaped the Go code</h3>

<ul>
  <li><strong>The tracker decodes on what it already knows.</strong> <code class="language-plaintext highlighter-rouge">Next</code> returns the
<em>current</em> MI from the header, the previous IV or the prediction, and lets
the embedded IV steer only <code class="language-plaintext highlighter-rouge">next</code> — the convention lives in control flow.</li>
  <li><strong>Verification strength is a parameter, not a boolean.</strong> <code class="language-plaintext highlighter-rouge">ExtractEmbeddedIV</code>
returns <code class="language-plaintext highlighter-rouge">corrected</code> beside <code class="language-plaintext highlighter-rouge">ok</code>; a clean triple and a ~1 % false verify are
treated differently.</li>
  <li><strong>Instruments outlive the fix.</strong> <code class="language-plaintext highlighter-rouge">Mismatches</code>, <code class="language-plaintext highlighter-rouge">PIHeaderDetector.Rejects</code>
and <code class="language-plaintext highlighter-rouge">GT_DMR_EP_DUMP</code> pin the next vendor variant.</li>
</ul>

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

<p>Twice here a receiving-side defect masqueraded as a cipher problem, and
instruments caught both.
<a href="/blog/deep-dives/dmr-end-to-end-13-testing-dmr/">Part 13</a>
makes that the subject: testing DMR without a repeater — committed real-air
slices, the replay harnesses and their scrubs, and the fixture lessons that
hid direct mode.</p>

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

<p><strong>Can GopherTrunk decrypt DMR Enhanced Privacy?</strong>
Yes, with a key the operator supplies under
<code class="language-plaintext highlighter-rouge">trunking.systems[].encryption_keys</code> whose <code class="language-plaintext highlighter-rouge">key_id</code> matches the Privacy
Indicator header. The voice chain descrambles RC4 “Enhanced Privacy” in
process and records clear audio; without a key the call is recorded as
ciphertext with algorithm and key ID surfaced.</p>

<p><strong>Where does a DMR call carry its Message Indicator?</strong>
Twice: in the Privacy Indicator header sent after the Voice LC Header at
keyup (32 bits, octets 3–6), and embedded in every voice superframe as 18
nibbles forming three Golay(24,12) codewords plus a CRC-4 — the late-entry
copy, which names the <em>next</em> superframe’s MI, not the one carrying it.</p>

<p><strong>Why did a working keystream produce garbage at first?</strong>
Two receiving-side defects. The tracker applied each embedded IV to its own
superframe instead of the next, so every superframe after the first used the
wrong MI; and the replay harness sliced a simplex handheld’s bursts with the
back-to-back single-slot decoder, reading bursts B–F out of the gaps.</p>

<p><strong>How do I know a decrypt actually worked?</strong>
Not by ear or level — random vocoder parameters are loud. The harness
measures pitch continuity, the fraction of consecutive AMBE+2 frames whose
fundamental index (bits 0..3 and 37..39) moves by at most 10: speech ≳0.5,
ciphertext ≈0.15. The #1187 captures score 0.77 and 0.74.</p>

<p><strong>Is DMR Enhanced Privacy verified on air in GopherTrunk?</strong>
Capture-verified: two known-key captures decode to speech through
<code class="language-plaintext highlighter-rouge">TestDMREnhancedPrivacyReplay</code>, with literal on-air frames pinned in
<code class="language-plaintext highlighter-rouge">TestEPCaptureIssue1187</code>. The remaining gate on #1187 is a live daemon call
with <code class="language-plaintext highlighter-rouge">encryption_keys</code> configured that records intelligible audio.</p>

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

<p><strong>Part 12 of 14</strong> · ←
<a href="/blog/deep-dives/dmr-end-to-end-11-ambe2-silence-frames/">Part 11: AMBE+2 Voice &amp; the Silence-Frame Bug</a>
· Next →
<a href="/blog/deep-dives/dmr-end-to-end-13-testing-dmr/">Part 13: Testing DMR Without a Repeater</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="dmr-end-to-end" /><category term="dmr" /><category term="encryption" /><category term="rc4" /><category term="voice" /><category term="go" /><summary type="html"><![CDATA[How GopherTrunk decrypts DMR Enhanced Privacy with an operator-held key: the Privacy Indicator header, the RC4 key‖MI keystream with its 256-byte warm-up, the 32-bit LFSR that advances the Message Indicator, the embedded IV that names the NEXT superframe, and the capture that settled 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">The Field Notebook, Part 12: Web Symptoms That Are Really Log Stories</title><link href="https://gophertrunk.org/blog/tutorials/field-notebook-12-web-symptoms-log-stories/" rel="alternate" type="text/html" title="The Field Notebook, Part 12: Web Symptoms That Are Really Log Stories" /><published>2026-09-28T00:00:00-05:00</published><updated>2026-09-28T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/tutorials/field-notebook-12-web-symptoms-log-stories</id><content type="html" xml:base="https://gophertrunk.org/blog/tutorials/field-notebook-12-web-symptoms-log-stories/"><![CDATA[<p><em>Part 12 of <strong>The Field Notebook</strong>, a 14-part operator’s tutorial that reads
GopherTrunk’s <code class="language-plaintext highlighter-rouge">debug.log</code> one line family at a time — what each field
measures, what a healthy rig prints, which number is a noise meter and which
one means traffic, and which deep dive to open when a line goes wrong.
<a href="/blog/tutorials/field-notebook-11-voice-chain-lines/">Part 11</a>
read the composer’s voice-chain lines down to the teardown reason. This part
leaves the log for the browser and comes straight back: five things reported
as <strong>web bugs</strong>, each of which the log named before anyone opened devtools.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> The web console renders what <code class="language-plaintext highlighter-rouge">/api/v1/*</code> returns, so when a
panel lies the referee is the daemon log — <code class="language-plaintext highlighter-rouge">recorder: call started</code> and
<code class="language-plaintext highlighter-rouge">call ended reason=…</code> are the truth a table must agree with. Five field
reports, five seams: History “stopped at 00:15” while the feed said 03:15
(a UTC string slice — <code class="language-plaintext highlighter-rouge">lib/formatTime.ts</code> now owns every timestamp); an
<em>empty</em> History for every query (<code class="language-plaintext highlighter-rouge">r.rows</code> read off a <code class="language-plaintext highlighter-rouge">{"calls":[…]}</code>
envelope); a stale tab reconnecting 2–4×/s forever (the daemon upgraded the
WebSocket <strong>before</strong> resolving the SDR — now a 404 via <code class="language-plaintext highlighter-rouge">api.ErrUnknownDevice</code>
plus <code class="language-plaintext highlighter-rouge">reconnectingSocket.ts</code>); <code class="language-plaintext highlighter-rouge">symbol: poor</code> next to <code class="language-plaintext highlighter-rouge">decode: clean</code> on a
TETRA rig (a P25 C4FM receiver on a π/4-DQPSK carrier — fixed by
<code class="language-plaintext highlighter-rouge">symbol_proto</code>); and a silent 0.1 s WAV row (a DMO voice chain with
<code class="language-plaintext highlighter-rouge">speech_frames=0</code> torn down by <code class="language-plaintext highlighter-rouge">reason=timeout</code>). Meta-lesson: <code class="language-plaintext highlighter-rouge">npm test</code>
never typechecks, so a green <code class="language-plaintext highlighter-rouge">ci.yml</code> did not mean the console built.</p>
</blockquote>

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

<ul>
  <li><strong>A panel renders an API response; the log records what happened.</strong> When
they disagree, <code class="language-plaintext highlighter-rouge">curl</code> the endpoint and read the daemon line for the same
second before touching React.</li>
  <li><strong>Timestamps are the first suspect for “stale” data.</strong> The API returns UTC
RFC3339; a panel that slices the string shows GMT, and UTC+3 reads it
as “History stopped updating”.</li>
  <li><strong>Silent mismatches hide behind <code class="language-plaintext highlighter-rouge">?? []</code>.</strong> A mistyped envelope key and an
empty result look the same to a panel — only the API and the <code class="language-plaintext highlighter-rouge">recorder:</code>
lines can prove rows exist.</li>
  <li><strong>A badge is only as right as the receiver behind it.</strong> The symbol panels
graded a TETRA carrier with a P25 receiver; the <code class="language-plaintext highlighter-rouge">decode:</code> chip, fed by the
real counters, was right the whole time.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Local-time rendering</td>
      <td>the ONLY way a panel renders a daemon timestamp</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/lib/formatTime.ts</code> (<code class="language-plaintext highlighter-rouge">formatLocalDateTime</code>, <code class="language-plaintext highlighter-rouge">formatClock</code>)</td>
    </tr>
    <tr>
      <td>History envelope</td>
      <td><code class="language-plaintext highlighter-rouge">/api/v1/calls/history</code> answers <code class="language-plaintext highlighter-rouge">{"calls":[…]}</code>; client reads <code class="language-plaintext highlighter-rouge">r.calls ?? []</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/api/handlers.go</code>, <code class="language-plaintext highlighter-rouge">web/src/api/client.ts</code></td>
    </tr>
    <tr>
      <td>Unknown-device 404</td>
      <td>resolve the serial BEFORE the WebSocket upgrade</td>
      <td><code class="language-plaintext highlighter-rouge">internal/api/devicerrors.go</code> (<code class="language-plaintext highlighter-rouge">ErrUnknownDevice</code>, <code class="language-plaintext highlighter-rouge">rejectUnknownDevice</code>)</td>
    </tr>
    <tr>
      <td>Reconnect discipline</td>
      <td>backoff resets only after a frame arrives (or <code class="language-plaintext highlighter-rouge">OPEN_GRACE_MS</code> held)</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/api/reconnectingSocket.ts</code></td>
    </tr>
    <tr>
      <td>Symbol receiver selector</td>
      <td>per-protocol <code class="language-plaintext highlighter-rouge">symbol_proto</code> per spectrum device</td>
      <td><code class="language-plaintext highlighter-rouge">cmd/gophertrunk/spectrum_provider.go</code> (<code class="language-plaintext highlighter-rouge">symbolProtoFor</code>), <code class="language-plaintext highlighter-rouge">web/src/api/symbols.ts</code></td>
    </tr>
    <tr>
      <td>Segment keying</td>
      <td>each over’s <code class="language-plaintext highlighter-rouge">CallComplete</code> attaches to its call</td>
      <td><code class="language-plaintext highlighter-rouge">CallComplete.CallStartedAt</code>, <code class="language-plaintext highlighter-rouge">call_recordings</code></td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>What these symptoms are telling you</strong> — a panel is downstream of the log.</li>
  <li><strong>What healthy looks like</strong> — the recorder lines a History row must match.</li>
  <li><strong>Five stories, one table</strong> — symptom → log evidence → seam → fix.</li>
  <li><strong>When the panel and the log disagree</strong> — four checks, plus the CI lesson.</li>
</ul>

<h2 id="what-these-symptoms-are-telling-you">What these symptoms are telling you</h2>

<p>Every operator-facing surface is a client of the same daemon: the SPA reads
<code class="language-plaintext highlighter-rouge">/api/v1/*</code> over REST, the live feed over the WebSocket twin of the event
stream (<a href="/blog/deep-dives/operator-cockpit-04-sse-to-react/">Cockpit Part 4</a>),
the DSP scopes over <code class="language-plaintext highlighter-rouge">/api/v1/diag/symbols</code>
(<a href="/blog/deep-dives/operator-cockpit-08-constellation-eye-symbol/">Cockpit Part 8</a>).
Nothing in the browser <em>decodes</em>. A web symptom is a claim about one of
three seams — <strong>data</strong>, <strong>transport</strong>, <strong>render</strong> — and the log sits upstream
of all three. The habit: find the daemon line for the same
second, ask which seam turns <em>that</em> line into <em>this</em> panel.</p>

<h2 id="what-healthy-looks-like">What healthy looks like</h2>

<p>A History row renders a call the recorder finished; healthy means it agrees
with these lines on time, talkgroup and duration:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INF recorder: call started device=cc:same-carrier:1 wav=../recordings/Metro/805/2026-09-10T12-17-27Z_805.wav tg=805 provoice=false vocoder=imbe
INF recorder: call ended device=cc:same-carrier:1 wav=../recordings/Metro/805/2026-09-10T12-17-27Z_805.wav duration=5.6s reason=normal
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">wav=</code> path carries the UTC start; History should show it in <em>your</em>
clock with <code class="language-plaintext highlighter-rouge">duration</code> matching. The unhealthy twin from the 20 Aug DMO run — same
lines, wrong numbers (that run printed the older <code class="language-plaintext highlighter-rouge">colour=</code> field names):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INF composer: tetra DMO voice follow started — DNB TCH/S decode + ACELP vocoder serial=cc:same-carrier:1 seed_hint=0x00000000 rate_hz=18000
INF recorder: call started device=cc:same-carrier:1 wav=../recordings/Site-DMO/0/2026-08-20T19-41-02Z_0.wav tg=0 provoice=false vocoder=tetra-acelp
INF composer: tetra DMO voice follow ended serial=cc:same-carrier:1 dnb_bursts=242 speech_frames=0 bfi_count=242 seed=0x00000000 seed_known=false
INF call ended device=cc:same-carrier:1 grant=tetra-dmo tg=0 438900000Hz reason=timeout
INF recorder: call ended device=cc:same-carrier:1 wav=../recordings/Site-DMO/0/2026-08-20T19-41-02Z_0.wav duration=7.0s reason=timeout
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">speech_frames=0</code> with <code class="language-plaintext highlighter-rouge">bfi_count=242</code> is a call that decoded nothing;
<code class="language-plaintext highlighter-rouge">reason=timeout</code> follows — nothing refreshed its liveness, so hangtime tore
it down. The web row is the <em>consequence</em>.</p>

<h2 id="five-stories-one-table">Five stories, one table</h2>

<table>
  <thead>
    <tr>
      <th>Panel symptom</th>
      <th>Log evidence</th>
      <th>Root cause</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>History “stops at 00:15”; the feed shows 03:15</td>
      <td><code class="language-plaintext highlighter-rouge">recorder: call started</code> keeps coming; the <code class="language-plaintext highlighter-rouge">wav=</code> UTC stamps match the <em>feed</em></td>
      <td>six panels sliced the RFC3339 string (<code class="language-plaintext highlighter-rouge">.replace("T"," ")</code>) — always GMT</td>
      <td><code class="language-plaintext highlighter-rouge">formatLocalDateTime</code> in <code class="language-plaintext highlighter-rouge">lib/formatTime.ts</code>; History refetches on <code class="language-plaintext highlighter-rouge">call.end</code></td>
    </tr>
    <tr>
      <td>“No calls in the daemon’s call log for this filter”, every query</td>
      <td><code class="language-plaintext highlighter-rouge">recorder: call ended</code> rows exist; <code class="language-plaintext highlighter-rouge">curl /api/v1/calls/history</code> returns <code class="language-plaintext highlighter-rouge">{"calls":[…]}</code></td>
      <td>the client read <code class="language-plaintext highlighter-rouge">r.rows</code> with <code class="language-plaintext highlighter-rouge">?? []</code> — wrong key and empty result look identical</td>
      <td><code class="language-plaintext highlighter-rouge">r.calls ?? []</code> in <code class="language-plaintext highlighter-rouge">client.ts</code>; <code class="language-plaintext highlighter-rouge">History.test.tsx</code> pins the empty state</td>
    </tr>
    <tr>
      <td>A tab left open across a restart reconnects 2–4×/s forever</td>
      <td>a daemon line per attempt; upgrade, then a 1011 close</td>
      <td>a handshake that always succeeds reset the backoff in <code class="language-plaintext highlighter-rouge">onopen</code>; <code class="language-plaintext highlighter-rouge">MAX_BACKOFF</code> was dead code</td>
      <td><code class="language-plaintext highlighter-rouge">rejectUnknownDevice</code> → <strong>404</strong> before upgrade; <code class="language-plaintext highlighter-rouge">reconnectingSocket.ts</code> resets backoff only after a frame</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">symbol: poor</code> next to <code class="language-plaintext highlighter-rouge">decode: clean</code> on a TETRA rig</td>
      <td><code class="language-plaintext highlighter-rouge">tetra: decode status … bsch_ok=147 bsch_fail=0</code> — the decode was fine</td>
      <td><code class="language-plaintext highlighter-rouge">demodModeToProto("")</code> fell back to <code class="language-plaintext highlighter-rouge">p25-c4fm</code>: a C4FM receiver on a π/4-DQPSK carrier, ~9 dB “MER” ⇒ <code class="language-plaintext highlighter-rouge">&lt; 10 dB</code> poor</td>
      <td><code class="language-plaintext highlighter-rouge">symbolProtoFor</code> → <code class="language-plaintext highlighter-rouge">symbol_proto</code>; <code class="language-plaintext highlighter-rouge">autoProtoFor</code> prefers it</td>
    </tr>
    <tr>
      <td>A ~0.1 s silent WAV row after every DMO PTT</td>
      <td><code class="language-plaintext highlighter-rouge">voice follow ended … speech_frames=0 bfi_count=242</code>, <code class="language-plaintext highlighter-rouge">call ended … reason=timeout</code></td>
      <td>the voice chain fell back to colour 0 before adopting the pipeline’s; the recorder only drops <code class="language-plaintext highlighter-rouge">dataBytes==0</code> or zero-voice calls</td>
      <td>the chain now adopts the pipeline’s colour; the tiny-row side is <strong>staged, not shipped</strong>; DMO is on-air-gated (<a href="https://github.com/MattCheramie/GopherTrunk/issues/1003">#1003</a>)</td>
    </tr>
  </tbody>
</table>

<p>Three more from the same batch were really recorder or shell stories.
<strong>“Duration 30 s, recording 4 s”</strong> was segment rolls: the recorder publishes
one <code class="language-plaintext highlighter-rouge">CallComplete</code> <em>per over</em>, stamped with the over’s own <code class="language-plaintext highlighter-rouge">StartedAt</code>, and
the call log keyed on it — only the first over matched. <code class="language-plaintext highlighter-rouge">CallComplete.CallStartedAt</code>/<code class="language-plaintext highlighter-rouge">Segment</code> plus the <code class="language-plaintext highlighter-rouge">call_recordings</code>
table fix the keying, and <code class="language-plaintext highlighter-rouge">/calls/{id}/audio</code> concatenates the segments
(<code class="language-plaintext highlighter-rouge">concatRecordingSegments</code>) — the seam
<a href="/blog/deep-dives/recording-streaming-09-call-complete-seam/">Recording Part 9</a>
describes. <strong>Cyrillic folders as <code class="language-plaintext highlighter-rouge">_______</code></strong>: <code class="language-plaintext highlighter-rouge">sanitize</code> allowed
<code class="language-plaintext highlighter-rouge">[A-Za-z0-9._-]</code>; it now keeps Unicode letters, marks and digits. And the
<strong>AudioPlayer docked over the pager</strong> — <code class="language-plaintext highlighter-rouge">DataTable</code> now renders one top and
bottom.</p>

<figure class="lab-figure">
<svg viewBox="0 0 680 250" width="680" height="250" role="img" aria-label="The path from a daemon log line to a browser panel drawn as four boxes joined by arrows — recorder and engine log lines, the REST and WebSocket API, the client fetch layer, and the rendered panel — with each of the five September field reports pinned to the seam that broke it: symbol_proto at the API's device list, the 404 before upgrade at the transport, the r.calls envelope key at the fetch layer, the UTC string slice at the render, and speech_frames zero sitting upstream in the log itself.">
  <rect x="8" y="70" width="140" height="60" rx="6" fill="none" stroke="var(--accent)" />
  <text x="78" y="90" text-anchor="middle" fill="var(--accent)" font-size="10">daemon log</text>
  <text x="78" y="104" text-anchor="middle" fill="var(--fg-muted)" font-size="8">recorder: call started</text>
  <text x="78" y="116" text-anchor="middle" fill="var(--fg-muted)" font-size="8">call ended reason=…</text>
  <line x1="148" y1="100" x2="176" y2="100" stroke="currentColor" /><polygon points="174,96 182,100 174,104" fill="currentColor" />
  <rect x="182" y="70" width="140" height="60" rx="6" fill="none" stroke="currentColor" />
  <text x="252" y="90" text-anchor="middle" fill="currentColor" font-size="10">REST · WebSocket</text>
  <text x="252" y="104" text-anchor="middle" fill="var(--fg-muted)" font-size="8">{"calls":[…]} UTC RFC3339</text>
  <text x="252" y="116" text-anchor="middle" fill="var(--fg-muted)" font-size="8">devices: symbol_proto</text>
  <line x1="322" y1="100" x2="350" y2="100" stroke="currentColor" /><polygon points="348,96 356,100 348,104" fill="currentColor" />
  <rect x="356" y="70" width="140" height="60" rx="6" fill="none" stroke="currentColor" />
  <text x="426" y="90" text-anchor="middle" fill="currentColor" font-size="10">client fetch layer</text>
  <text x="426" y="104" text-anchor="middle" fill="var(--fg-muted)" font-size="8">client.ts · reconnectingSocket.ts</text>
  <text x="426" y="116" text-anchor="middle" fill="var(--fg-muted)" font-size="8">r.calls ?? []</text>
  <line x1="496" y1="100" x2="524" y2="100" stroke="currentColor" /><polygon points="522,96 530,100 522,104" fill="currentColor" />
  <rect x="530" y="70" width="142" height="60" rx="6" fill="none" stroke="var(--accent)" />
  <text x="601" y="90" text-anchor="middle" fill="var(--accent)" font-size="10">rendered panel</text>
  <text x="601" y="104" text-anchor="middle" fill="var(--fg-muted)" font-size="8">formatLocalDateTime</text>
  <text x="601" y="116" text-anchor="middle" fill="var(--fg-muted)" font-size="8">qualityVerdict pill</text>
  <line x1="78" y1="130" x2="78" y2="160" stroke="var(--fg-muted)" stroke-dasharray="3 3" />
  <text x="78" y="174" text-anchor="middle" fill="currentColor" font-size="8">speech_frames=0 →</text>
  <text x="78" y="185" text-anchor="middle" fill="currentColor" font-size="8">0.1 s silent row</text>
  <line x1="252" y1="130" x2="252" y2="160" stroke="var(--fg-muted)" stroke-dasharray="3 3" />
  <text x="252" y="174" text-anchor="middle" fill="currentColor" font-size="8">symbol_proto missing →</text>
  <text x="252" y="185" text-anchor="middle" fill="currentColor" font-size="8">P25 receiver on TETRA</text>
  <line x1="339" y1="100" x2="339" y2="30" stroke="var(--fg-muted)" stroke-dasharray="3 3" />
  <text x="339" y="20" text-anchor="middle" fill="currentColor" font-size="8">101 then 1011 → 404 before upgrade</text>
  <line x1="426" y1="130" x2="426" y2="160" stroke="var(--fg-muted)" stroke-dasharray="3 3" />
  <text x="426" y="174" text-anchor="middle" fill="currentColor" font-size="8">r.rows ?? [] →</text>
  <text x="426" y="185" text-anchor="middle" fill="currentColor" font-size="8">empty History forever</text>
  <line x1="601" y1="130" x2="601" y2="160" stroke="var(--fg-muted)" stroke-dasharray="3 3" />
  <text x="601" y="174" text-anchor="middle" fill="currentColor" font-size="8">.replace("T"," ") →</text>
  <text x="601" y="185" text-anchor="middle" fill="currentColor" font-size="8">History 3 h behind</text>
  <text x="340" y="228" text-anchor="middle" fill="var(--fg-muted)" font-size="9">every web symptom is a seam between log and screen — find the seam; the log already names the cause</text>
</svg>
<figcaption>Log → API → fetch → render: each September report broke one seam, and the daemon line for the same second was the tie-breaker.</figcaption>
</figure>

<h2 id="when-the-panel-and-the-log-disagree">When the panel and the log disagree</h2>

<p>Four checks, in order, settle almost any web report without a debugger.</p>

<p><strong>1. Compare clocks.</strong> The API returns UTC RFC3339. A panel behind by
<em>exactly</em> your UTC offset is a render bug, not staleness;
<code class="language-plaintext highlighter-rouge">formatLocalDateTime</code>/<code class="language-plaintext highlighter-rouge">formatClock</code> are the only way to render a timestamp.
Grep for <code class="language-plaintext highlighter-rouge">replace("T", " ")</code> before shipping a panel.</p>

<p><strong>2. <code class="language-plaintext highlighter-rouge">curl</code> the endpoint the panel reads.</strong> <code class="language-plaintext highlighter-rouge">/api/v1/calls/history</code> with the
same filter shows the envelope key and the rows. Rows there, panel empty ⇒
the client (History had no test; it has one now). In reverse,
an empty API answer with <code class="language-plaintext highlighter-rouge">recorder: call ended</code> lines in the log points at
the call log or its retention window
(<a href="/blog/deep-dives/recording-streaming-10-call-log-sqlite/">Recording Part 10</a>).</p>

<p><strong>3. Read the decode counters before trusting a badge.</strong> The <code class="language-plaintext highlighter-rouge">decode:</code> and
<code class="language-plaintext highlighter-rouge">symbol:</code> chips are deliberately different axes — frame-error rate versus raw
constellation — but they must look at the same receiver. On the TETRA rig
they did not: the Histogram panel with Mode forced to TETRA showed
<code class="language-plaintext highlighter-rouge">SNR (MER) —</code> and <code class="language-plaintext highlighter-rouge">balance ±5.7%</code> (<em>clean</em>) while the auto-selected panels
sat at ~9 dB on a C4FM receiver. Two receivers on one carrier is the tell; the
<code class="language-plaintext highlighter-rouge">tetra: decode status</code> line from
<a href="/blog/tutorials/field-notebook-03-tetra-decode-status/">Part 3</a>
is the authority; a <code class="language-plaintext highlighter-rouge">poor</code> pill against <code class="language-plaintext highlighter-rouge">bsch_fail=0</code> is the pill’s problem.
Every <code class="language-plaintext highlighter-rouge">/diag/symbols</code> subscriber also builds its own full-rate
<code class="language-plaintext highlighter-rouge">Downconverter</code>; nothing is pooled.</p>

<p><strong>4. Count the reconnects.</strong> A stale tab reconnecting several times a second
is a line per attempt in the log, and the mechanism was a handshake that
could not fail: the handlers resolved the serial <em>after</em> <code class="language-plaintext highlighter-rouge">upgrader.Upgrade</code>,
so an unknown device got a 101 then a 1011 close, and every client reset its
backoff in <code class="language-plaintext highlighter-rouge">onopen</code>. Three fixes were all needed — resolve the device first
and return <strong>404</strong>, reset the backoff only once a frame arrives (or the
socket held <code class="language-plaintext highlighter-rouge">OPEN_GRACE_MS</code> = 2 s), and reconcile the selected serial
against a refreshed device list. <code class="language-plaintext highlighter-rouge">reconnectingSocket.ts</code> also fixes the
<code class="language-plaintext highlighter-rouge">onerror</code>+<code class="language-plaintext highlighter-rouge">onclose</code> double-bind that scheduled two timers per failure — the
storm’s sibling from
<a href="/blog/solution-postmortem/from-the-issue-tracker-19-one-render-loop/">Issue Tracker Part 19</a>.
<code class="language-plaintext highlighter-rouge">GONE_AFTER_FAILURES</code> (6) frameless attempts ⇒ <code class="language-plaintext highlighter-rouge">gone</code>.</p>

<p>A fifth check is for whoever ships a fix. <strong>A green <code class="language-plaintext highlighter-rouge">ci.yml</code> did not mean
the console built</strong>: <code class="language-plaintext highlighter-rouge">npm test</code> (vitest) never typechecks, so three <code class="language-plaintext highlighter-rouge">TS6133</code>
errors in <code class="language-plaintext highlighter-rouge">reconnectingSocket.test.ts</code> landed on main and broke
<code class="language-plaintext highlighter-rouge">npm run build</code> (<code class="language-plaintext highlighter-rouge">tsc --noEmit &amp;&amp; vite build</code>) for an operator; the one job
that caught them, “Windows installer (PR)”, was not a required check.
<code class="language-plaintext highlighter-rouge">ci.yml</code> now runs <code class="language-plaintext highlighter-rouge">web-typecheck</code> per SPA, and any web diff runs
<code class="language-plaintext highlighter-rouge">cd web &amp;&amp; npm run typecheck</code> before pushing —
<a href="/blog/deep-dives/operator-cockpit-14-testing-uis/">Cockpit Part 14</a>
covers the tests; this was the gap they left.</p>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Likely cause</th>
      <th>Fix / read</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Tables behind by exactly your UTC offset</td>
      <td>raw RFC3339 string rendered</td>
      <td><code class="language-plaintext highlighter-rouge">formatLocalDateTime</code>; the <code class="language-plaintext highlighter-rouge">wav=</code> stamp is the UTC truth</td>
    </tr>
    <tr>
      <td>Empty table, <code class="language-plaintext highlighter-rouge">recorder:</code> lines flowing</td>
      <td>envelope key mismatch</td>
      <td><code class="language-plaintext highlighter-rouge">curl</code> the endpoint; <code class="language-plaintext highlighter-rouge">client.ts</code></td>
    </tr>
    <tr>
      <td>Reconnect every ~500 ms</td>
      <td>stale serial; old handshake-then-close</td>
      <td><code class="language-plaintext highlighter-rouge">rejectUnknownDevice</code>; <code class="language-plaintext highlighter-rouge">reconnectingSocket.ts</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">symbol: poor</code> on a decoding rig</td>
      <td>wrong receiver behind the scope</td>
      <td><code class="language-plaintext highlighter-rouge">symbol_proto</code>; trust the decode counters (<a href="/blog/tutorials/field-notebook-03-tetra-decode-status/">Part 3</a>)</td>
    </tr>
    <tr>
      <td>Recording far shorter than its duration</td>
      <td>segment rolls keyed wrong</td>
      <td><code class="language-plaintext highlighter-rouge">call_recordings</code> (<a href="/blog/deep-dives/recording-streaming-09-call-complete-seam/">Recording 9</a>)</td>
    </tr>
    <tr>
      <td>Tiny silent DMO rows</td>
      <td><code class="language-plaintext highlighter-rouge">speech_frames=0</code>, <code class="language-plaintext highlighter-rouge">reason=timeout</code></td>
      <td>a decode problem — <a href="/blog/tutorials/field-notebook-11-voice-chain-lines/">Part 11</a>, <a href="/blog/tutorials/operator-cookbook-05-tetra-dmo/">Cookbook 5</a></td>
    </tr>
  </tbody>
</table>

<h3 id="how-this-line-shapes-operator-practice">How this line shapes operator practice</h3>

<ul>
  <li><strong>Report the daemon line, not the screenshot.</strong> A History row with its
<code class="language-plaintext highlighter-rouge">recorder: call started</code> / <code class="language-plaintext highlighter-rouge">call ended reason=</code> pair is diagnosable; a
screenshot alone is not.</li>
  <li><strong>Trust the decode counters over the badge.</strong> <code class="language-plaintext highlighter-rouge">bsch_fail</code>, <code class="language-plaintext highlighter-rouge">fec_pass</code>,
<code class="language-plaintext highlighter-rouge">speech_frames</code> come from the receiver doing the work; a quality pill from
a <em>second</em> receiver may be the wrong one’s.</li>
  <li><strong>A <code class="language-plaintext highlighter-rouge">?? []</code> is a place to look, not a safety net.</strong> Where a client
defaults a missing key to empty, only the log can prove data existed.</li>
  <li><strong>Typecheck what CI doesn’t.</strong> <code class="language-plaintext highlighter-rouge">npm run typecheck</code> separates a green PR
from an operator who cannot build.</li>
</ul>

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

<p>Every story here ended the same way: a log line and the API answer for the
same second, pasted beside the symptom.
<a href="/blog/tutorials/field-notebook-13-from-log-to-issue/">Part 13</a>
makes that the whole method — what to paste, how to align a capture to the
log, which capture answers which question, and the wording that
lets an issue close as <em>verified</em> rather than merely fixed.</p>

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

<p><strong>Why is the GopherTrunk History panel hours behind the activity feed?</strong>
Because it rendered UTC. The daemon returns RFC3339 UTC strings, and several
panels sliced them as text — always GMT — while the feed formatted locally.
Tables lagging by exactly your UTC offset is that bug;
<code class="language-plaintext highlighter-rouge">web/src/lib/formatTime.ts</code> now renders every timestamp in local time.</p>

<p><strong>How do I tell a web bug from a decode bug?</strong>
Find the daemon lines for the same second — <code class="language-plaintext highlighter-rouge">recorder: call started</code>,
<code class="language-plaintext highlighter-rouge">call ended reason=…</code>, the protocol’s decode-status line — and <code class="language-plaintext highlighter-rouge">curl</code> the
endpoint the panel reads. Log and API agree, panel disagrees ⇒ a render or
client seam. Log shows <code class="language-plaintext highlighter-rouge">speech_frames=0</code> ⇒ the panel is honest.</p>

<p><strong>What does symbol: poor next to decode: clean mean?</strong>
Two receivers. The <code class="language-plaintext highlighter-rouge">decode:</code> chip reads the real decoder’s counters; the
<code class="language-plaintext highlighter-rouge">symbol:</code> chip grades a diagnostic receiver, which on TETRA and DMR rigs used
to be a P25 C4FM one. The daemon now publishes a per-device <code class="language-plaintext highlighter-rouge">symbol_proto</code> so
the scopes open the right receiver.</p>

<p><strong>Why did a browser tab hammer the daemon after I swapped SDRs?</strong>
The diag WebSocket handlers completed the handshake before checking the
device, so a stale serial got an open and an immediate close, and the
client reset its backoff on every open. The daemon now answers 404 before
upgrading, and <code class="language-plaintext highlighter-rouge">reconnectingSocket.ts</code> resets backoff only after a real frame.</p>

<p><strong>Why does History show a 0.1-second silent recording after a DMO PTT?</strong>
The voice chain decoded no speech (<code class="language-plaintext highlighter-rouge">speech_frames=0</code>) and hangtime ended the
call (<code class="language-plaintext highlighter-rouge">reason=timeout</code>); the recorder only discards zero-byte or
zero-voice calls, and ACELP is neither, so a tiny row lands. The cause is
upstream — colour adoption in the voice chain — and DMO remains on-air-gated
under #1003.</p>

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

<p><strong>Part 12 of 14</strong> · ←
<a href="/blog/tutorials/field-notebook-11-voice-chain-lines/">Part 11: Voice-Chain Lines — Frames, Colours &amp; Teardown Reasons</a>
· Next →
<a href="/blog/tutorials/field-notebook-13-from-log-to-issue/">Part 13: From Log to Issue — What to Paste, What to Capture</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="tutorials" /><category term="field-notebook" /><category term="logs" /><category term="web" /><category term="react" /><category term="troubleshooting" /><category term="tutorial" /><summary type="html"><![CDATA[Five GopherTrunk web-console complaints — History hours behind, an empty table, a reconnect storm, a permanent symbol: poor badge, a silent 0.1-second recording row — traced through the daemon log to the seam that broke: the UTC render, the r.rows key, the pre-upgrade 404, symbol_proto, and a voice chain that decoded nothing.]]></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">Beyond Voice, Part 11: LoRa — A Pure-Go Chirp Receiver</title><link href="https://gophertrunk.org/blog/deep-dives/beyond-voice-11-lora-chirp-receiver/" rel="alternate" type="text/html" title="Beyond Voice, Part 11: LoRa — A Pure-Go Chirp Receiver" /><published>2026-09-27T00:00:00-05:00</published><updated>2026-09-27T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/beyond-voice-11-lora-chirp-receiver</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/beyond-voice-11-lora-chirp-receiver/"><![CDATA[<p><em>Part 11 of <strong>Beyond Voice</strong>, a 14-part deep dive into everything GopherTrunk
decodes that is not a trunked voice call — the AFSK signalling formats,
paging, APRS, ADS-B, AIS, DSC, LoRa, M17 and the amateur digital-voice
family — and the one eleven-place wiring pattern that carries each of them
from a burst on the air to a row in the web console.
<a href="/blog/deep-dives/beyond-voice-10-dsc-marine-calling/">Part 10</a>
closed the marine pair, both narrowband FSK through a discriminator. This
part is the series’ one spread-spectrum decoder: LoRa, where a symbol is a
frequency sweep across the whole channel and the receiver’s central move is
a multiply and an FFT rather than a slicer.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> <code class="language-plaintext highlighter-rouge">internal/radio/lora</code> is a pure-Go LoRa modem (#586). A symbol is
the base upchirp (<code class="language-plaintext highlighter-rouge">GenChirp</code>) cyclically shifted by its value, N = 2^SF
chips, SF 7–12, BW 125/250/500 kHz, <code class="language-plaintext highlighter-rouge">DefaultOversample</code> 2. The
<code class="language-plaintext highlighter-rouge">dechirper</code> multiplies a window by the downchirp, FFTs, <strong>folds</strong> the osf
copies and takes the peak bin; <code class="language-plaintext highlighter-rouge">syncFrame</code> locks four stable preamble
peaks, finds the SFD, and solves <code class="language-plaintext highlighter-rouge">cfo = bd/2</code>. Bits
come out through <code class="language-plaintext highlighter-rouge">grayDecode</code> → <code class="language-plaintext highlighter-rouge">Deinterleave</code> → <code class="language-plaintext highlighter-rouge">HammingDecode4</code> →
<code class="language-plaintext highlighter-rouge">Whiten</code> (LFSR 0xB8) → <code class="language-plaintext highlighter-rouge">PayloadCRC</code> (0x1021). The header is a compact
<strong>self-consistent</strong> layout; bit-exact Semtech interop is gated on golden
vectors not yet committed. <code class="language-plaintext highlighter-rouge">lora/receiver</code> channelizes one SDR into
sub-channels, one demodulator per candidate SF, and on sync word 0x34
parses LoRaWAN 1.0.x, verifying the AES-CMAC MIC and decrypting with
operator-held keys. Output: <code class="language-plaintext highlighter-rouge">KindLoRaFrame</code> → <code class="language-plaintext highlighter-rouge">lora_log</code>
→ <code class="language-plaintext highlighter-rouge">GET /api/v1/lora/frames</code> → <code class="language-plaintext highlighter-rouge">/lora</code>. Synthetic-verified only.</p>
</blockquote>

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

<ul>
  <li><strong>Dechirp turns modulation into a bin index.</strong> Multiply by the conjugate
chirp and every symbol collapses to a tone; the folded FFT’s processing
gain decodes SF12 at 0 dB SNR in the tests.</li>
  <li><strong>Preamble and SFD together separate offset from timing.</strong> Upchirps peak
at <code class="language-plaintext highlighter-rouge">timing + cfo</code>, SFD downchirps at <code class="language-plaintext highlighter-rouge">cfo − timing</code>; realign on the first
and the second reads <code class="language-plaintext highlighter-rouge">2·cfo</code>.</li>
  <li><strong>The bit chain is exact inverse pairs, and that is also its limit.</strong>
Round-trips prove consistency; Semtech interop waits on a golden packet.</li>
  <li><strong>LoRaWAN decrypts only with keys you hold.</strong> <code class="language-plaintext highlighter-rouge">KeyStore.Decode</code> verifies
the MIC under NwkSKey and decrypts under AppSKey — no key recovery.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Chirp synthesis</td>
      <td>base up/down chirp, cyclic shift by symbol value</td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/lora/chirp.go</code> (<code class="language-plaintext highlighter-rouge">GenChirp</code>, <code class="language-plaintext highlighter-rouge">GenSymbolChirp</code>)</td>
    </tr>
    <tr>
      <td>Dechirp + sync</td>
      <td>fold-FFT peak, preamble/SFD lock, <code class="language-plaintext highlighter-rouge">cfo = bd/2</code></td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/lora/demod.go</code> (<code class="language-plaintext highlighter-rouge">peakOf</code>, <code class="language-plaintext highlighter-rouge">syncFrame</code>)</td>
    </tr>
    <tr>
      <td>Bit chain + LDRO</td>
      <td>Gray, diagonal interleave, Hamming(4+CR,4), whitening; SF−2 rows</td>
      <td><code class="language-plaintext highlighter-rouge">fec.go</code>, <code class="language-plaintext highlighter-rouge">whitening.go</code>, <code class="language-plaintext highlighter-rouge">ldro.go</code> (<code class="language-plaintext highlighter-rouge">Deinterleave</code>, <code class="language-plaintext highlighter-rouge">AutoLDRO</code>)</td>
    </tr>
    <tr>
      <td>Streaming receiver</td>
      <td>one SDR → <code class="language-plaintext highlighter-rouge">tuner.Bank</code> → demod per SF → bus</td>
      <td><code class="language-plaintext highlighter-rouge">internal/radio/lora/receiver/receiver.go</code> (<code class="language-plaintext highlighter-rouge">subChannel.bestDecode</code>)</td>
    </tr>
    <tr>
      <td>LoRaWAN</td>
      <td>MAC parse, AES-CMAC MIC, AES-CTR payload</td>
      <td><code class="language-plaintext highlighter-rouge">lorawan/mac.go</code>, <code class="language-plaintext highlighter-rouge">mic.go</code>, <code class="language-plaintext highlighter-rouge">crypt.go</code> (<code class="language-plaintext highlighter-rouge">KeyStore.Decode</code>)</td>
    </tr>
    <tr>
      <td>Bus → storage → REST → panel</td>
      <td><code class="language-plaintext highlighter-rouge">KindLoRaFrame</code> → <code class="language-plaintext highlighter-rouge">lora_log</code> → <code class="language-plaintext highlighter-rouge">/api/v1/lora/frames</code> → <code class="language-plaintext highlighter-rouge">/lora</code></td>
      <td><code class="language-plaintext highlighter-rouge">storage/loralog.go</code>, <code class="language-plaintext highlighter-rouge">api/handlers_lora.go</code>, <code class="language-plaintext highlighter-rouge">web/src/panels/LoRa.tsx</code></td>
    </tr>
  </tbody>
</table>

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

<ul>
  <li><strong>Chirps and spreading factors</strong> — what the receiver has to find.</li>
  <li><strong>Dechirp, fold, FFT</strong> — a window becomes a bin.</li>
  <li><strong>The bit chain</strong> — Gray, interleave, Hamming, whitening, CRC, LDRO.</li>
  <li><strong>One SDR, many sub-channels</strong> — the streaming receiver.</li>
  <li><strong>LoRaWAN</strong> — MIC and decryption with keys you already hold.</li>
  <li><strong>From bus event to the <code class="language-plaintext highlighter-rouge">/lora</code> panel</strong> — the eleven places, with gaps.</li>
</ul>

<h2 id="chirps-and-spreading-factors-what-the-receiver-has-to-find">Chirps and spreading factors: what the receiver has to find</h2>

<p><a href="/reference/lora/">LoRa</a> is chirp spread spectrum: a
symbol is an upchirp sweeping −BW/2 to +BW/2, and the <em>value</em> is where the
sweep starts — a cyclic shift of the base chirp by <code class="language-plaintext highlighter-rouge">v</code> of N = 2^SF chips. Bandwidth (<code class="language-plaintext highlighter-rouge">BW125</code>, <code class="language-plaintext highlighter-rouge">BW250</code>, <code class="language-plaintext highlighter-rouge">BW500</code>) sets the sweep
width; the spreading factor (7–12) sets its length and the processing gain.
(The reference page’s “Not decoded” line predates #586, which landed the
decoder in v0.3.8.)</p>

<p><code class="language-plaintext highlighter-rouge">GenChirp</code> defines the waveform at Fs = osf·BW, so it depends only on SF and
osf: <code class="language-plaintext highlighter-rouge">phase(n) = 2π(−n/2·osf + n²/2·N·osf²)</code>, conjugated for the downchirp
reference; <code class="language-plaintext highlighter-rouge">GenSymbolChirp</code> rotates it by <code class="language-plaintext highlighter-rouge">v·osf</code> samples.
<code class="language-plaintext highlighter-rouge">DefaultOversample</code> is 2 — enough to resolve the peak and a fractional
offset with a small FFT — and SF6 is out of scope (<code class="language-plaintext highlighter-rouge">MinSF</code>/<code class="language-plaintext highlighter-rouge">MaxSF</code> pin
7..12).</p>

<h2 id="dechirp-fold-fft-from-a-symbol-window-to-a-bin">Dechirp, fold, FFT: from a symbol window to a bin</h2>

<p>The kernel is <code class="language-plaintext highlighter-rouge">dechirper.peakOf</code>. Multiply a symbol-length window by the
reference chirp (downchirp for data, upchirp for the SFD), FFT the
<code class="language-plaintext highlighter-rouge">sps = N·osf</code> product, and <strong>fold</strong>: bin <code class="language-plaintext highlighter-rouge">b</code> aliases with <code class="language-plaintext highlighter-rouge">b + r·N</code> for each
osf copy, so summing their powers puts a symbol’s energy in one of N bins.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/radio/lora/demod.go (shape)</span>
<span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">window</span> <span class="p">{</span> <span class="n">d</span><span class="o">.</span><span class="n">y</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="n">window</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">*</span> <span class="n">ref</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="p">}</span>        <span class="c">// dechirp</span>
<span class="n">d</span><span class="o">.</span><span class="n">out</span> <span class="o">=</span> <span class="n">d</span><span class="o">.</span><span class="n">plan</span><span class="o">.</span><span class="n">Forward</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">cmplx128</span><span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">y</span><span class="p">))</span>                  <span class="c">// FFT, size sps</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="n">d</span><span class="o">.</span><span class="n">n</span><span class="p">;</span> <span class="n">b</span><span class="o">++</span> <span class="p">{</span>                                     <span class="c">// fold osf copies</span>
    <span class="k">for</span> <span class="n">r</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">r</span> <span class="o">&lt;</span> <span class="n">d</span><span class="o">.</span><span class="n">osf</span><span class="p">;</span> <span class="n">r</span><span class="o">++</span> <span class="p">{</span> <span class="n">sum</span> <span class="o">+=</span> <span class="n">power</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">b</span><span class="o">+</span><span class="n">r</span><span class="o">*</span><span class="n">d</span><span class="o">.</span><span class="n">n</span><span class="p">])</span> <span class="p">}</span>
    <span class="n">d</span><span class="o">.</span><span class="n">mags</span><span class="p">[</span><span class="n">b</span><span class="p">]</span> <span class="o">=</span> <span class="n">sum</span>
<span class="p">}</span>
<span class="c">/* argmax → bin; interpolation → frac; peak/runner-up → snr; peak/mean → conc */</span>
</code></pre></div></div>

<p>Two confidence figures come out with the bin: <code class="language-plaintext highlighter-rouge">snr</code>, peak over runner-up
(the panel’s <code class="language-plaintext highlighter-rouge">Frame.SNRdB</code>), and <code class="language-plaintext highlighter-rouge">conc</code>, peak over the per-bin mean — the
detection gate, because a tone straddling two bins still dwarfs the mean
where a peak/runner-up ratio would collapse.</p>

<p><code class="language-plaintext highlighter-rouge">syncFrame</code> slides one chip at a time until <code class="language-plaintext highlighter-rouge">preambleSyms</code> = 4 consecutive
windows exceed <code class="language-plaintext highlighter-rouge">concThresh</code> = 16 at the same bin (±1): the preamble
dechirps to a stable <code class="language-plaintext highlighter-rouge">bu = (timing + cfo) mod N</code>. It realigns the
grid by <code class="language-plaintext highlighter-rouge">bu·osf</code> samples, then steps symbol by symbol until a window
dechirps <em>better against the upchirp</em>: the SFD. On the realigned grid
the residual timing equals −cfo, so the SFD peak reads
<code class="language-plaintext highlighter-rouge">bd = cfo − timing = 2·cfo</code>. The header begins 2.25 symbols after the SFD,
nudged by <code class="language-plaintext highlighter-rouge">cfo</code> chips; <code class="language-plaintext highlighter-rouge">mix</code> removes the fractional offset as a phase ramp,
with
<a href="/blog/deep-dives/sdr-internals-08-equalization-diversity-fft/">SDR Internals Part 8</a>’s
FFT plan doing the heavy lifting.</p>

<p>The tests state the envelope: <code class="language-plaintext highlighter-rouge">TestModDemodClean</code> decodes every SF 7–12 at
every CR 1–4; <code class="language-plaintext highlighter-rouge">TestModDemodNoise</code> decodes SF7, 9 and 12 at <strong>0 dB SNR</strong>;
<code class="language-plaintext highlighter-rouge">TestModDemodCFO</code> survives offsets of −3, 1.5 and 4 bins;
<code class="language-plaintext highlighter-rouge">TestModDemodSFAutoDetect</code> proves an SF7 demodulator does <em>not</em> decode an
SF9 frame — what the streaming receiver’s per-SF probing relies on.</p>

<figure class="lab-figure">
<svg viewBox="0 0 680 250" width="680" height="250" role="img" aria-label="Three upchirp windows, the third shifted, feed a multiply-by-downchirp box and an FFT-and-fold box, giving a spectrum with one dominant bin labelled bin equals v; below, the sync solve from preamble bin bu to SFD bin bd equals two cfo.">
  <g stroke="currentColor" fill="none">
    <rect x="12" y="20" width="60" height="60" /><line x1="14" y1="78" x2="70" y2="22" />
    <rect x="76" y="20" width="60" height="60" /><line x1="78" y1="78" x2="134" y2="22" />
    <rect x="140" y="20" width="60" height="60" /><line x1="142" y1="50" x2="170" y2="22" /><line x1="170" y1="78" x2="198" y2="50" />
  </g>
  <text x="106" y="96" text-anchor="middle" fill="var(--fg-muted)" font-size="8">upchirps · third shifted: value v = cyclic shift</text>
  <line x1="204" y1="50" x2="232" y2="50" stroke="currentColor" />
  <rect x="236" y="30" width="96" height="40" rx="5" fill="none" stroke="var(--accent)" />
  <text x="284" y="54" text-anchor="middle" fill="var(--accent)" font-size="9">× downchirp</text>
  <line x1="332" y1="50" x2="360" y2="50" stroke="currentColor" />
  <rect x="364" y="30" width="96" height="40" rx="5" fill="none" stroke="currentColor" />
  <text x="412" y="47" text-anchor="middle" fill="currentColor" font-size="9">FFT (N·osf)</text>
  <text x="412" y="61" text-anchor="middle" fill="var(--fg-muted)" font-size="8">fold osf copies → N bins</text>
  <line x1="460" y1="50" x2="488" y2="50" stroke="currentColor" />
  <line x1="500" y1="80" x2="668" y2="80" stroke="var(--fg-muted)" />
  <line x1="590" y1="80" x2="590" y2="24" stroke="var(--accent)" stroke-width="2" />
  <text x="590" y="18" text-anchor="middle" fill="var(--accent)" font-size="9">bin = v</text>
  <text x="340" y="140" text-anchor="middle" fill="currentColor" font-size="10" font-weight="bold">syncFrame</text>
  <text x="340" y="162" text-anchor="middle" fill="currentColor" font-size="9">4 stable preamble windows → bu = (timing + cfo) mod N → realign grid by bu·osf</text>
  <text x="340" y="184" text-anchor="middle" fill="var(--accent)" font-size="9">SFD dechirped with the upchirp: bd = cfo − timing = 2·cfo → cfo = bd/2</text>
</svg>
<figcaption>A shifted upchirp times the downchirp is a tone; the folded FFT reads its bin as the symbol, and the preamble/SFD bins solve carrier offset and timing jointly.</figcaption>
</figure>

<h2 id="the-bit-chain-gray-interleave-hamming-whitening-crc">The bit chain: Gray, interleave, Hamming, whitening, CRC</h2>

<p><code class="language-plaintext highlighter-rouge">decodeFrameFromSymbols</code> decodes the header block first, learns the payload
geometry, then exactly that many payload blocks of <code class="language-plaintext highlighter-rouge">(4+CR)</code> symbols;
<code class="language-plaintext highlighter-rouge">decodeBlock</code> reverses exact-inverse transforms:</p>

<table>
  <thead>
    <tr>
      <th>Stage</th>
      <th>RX direction</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="/reference/gray-code/">Gray</a></td>
      <td><code class="language-plaintext highlighter-rouge">grayDecode</code></td>
      <td>a ±1 bin slip costs one bit</td>
    </tr>
    <tr>
      <td><a href="/reference/lora-diagonal-interleaver/">Diagonal interleave</a></td>
      <td><code class="language-plaintext highlighter-rouge">Deinterleave</code>: <code class="language-plaintext highlighter-rouge">symbol[k]</code> bit <code class="language-plaintext highlighter-rouge">(r+k) mod ppm</code> = <code class="language-plaintext highlighter-rouge">codeword[r]</code> bit <code class="language-plaintext highlighter-rouge">k</code></td>
      <td>one bad symbol, one bit per codeword</td>
    </tr>
    <tr>
      <td><a href="/reference/hamming-code/">Hamming</a>(4+CR, 4)</td>
      <td><code class="language-plaintext highlighter-rouge">HammingDecode4</code></td>
      <td>CR 1 parity, CR 2 detect, CR 3 correct, CR 4 SEC-DED</td>
    </tr>
    <tr>
      <td><a href="/reference/lora-whitening/">Whitening</a></td>
      <td><code class="language-plaintext highlighter-rouge">Whiten</code> — LFSR 0xB8, seed 0xFF</td>
      <td>self-inverse XOR</td>
    </tr>
    <tr>
      <td>CRC</td>
      <td><code class="language-plaintext highlighter-rouge">PayloadCRC</code> — 0x1021, init 0</td>
      <td><code class="language-plaintext highlighter-rouge">Frame.CRCOK</code></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">TestHammingSingleErrorCorrection</code> flips every bit of every CR 3/4 codeword
and expects the nibble back. The <strong>explicit header</strong> is one block at
<code class="language-plaintext highlighter-rouge">HeaderCR</code> = 4 — the payload’s rate is not yet known — of four nibbles:
payload length, <code class="language-plaintext highlighter-rouge">CR | hasCRC&lt;&lt;3</code>, an XOR checksum (<code class="language-plaintext highlighter-rouge">ParseHeader</code>). The
source calls this “a compact, self-consistent header layout”; a bit-exact
Semtech header is a follow-up, and the same caveat covers the interleaver’s
rotation sign, whitening sequence and CRC seed — “validated separately
against captured golden vectors”, none committed. In
<a href="/blog/deep-dives/from-spec-to-shipping-03-literal-vectors/">From Spec to Shipping Part 3</a>’s
terms: round-trips, no literal vectors.</p>

<p><strong>LDRO</strong> is the one place the payload geometry changes. At high SF clock
drift can smear the peak across the two lowest bins, so Low Data Rate
Optimize carries <code class="language-plaintext highlighter-rouge">ppm = SF − 2</code> bits per symbol in bins that are multiples
of four (<code class="language-plaintext highlighter-rouge">reducedRateShift</code>). <code class="language-plaintext highlighter-rouge">AutoLDRO</code> applies Semtech’s Ts ≥ 16 ms rule
(SF11/SF12 at 125 kHz, SF12 at 250 kHz), <code class="language-plaintext highlighter-rouge">low_data_rate_optimize</code> overrides
it, and <code class="language-plaintext highlighter-rouge">TestLDROMismatchFailsToDecode</code> proves the mode changes the wire
format — the header still parses, the payload does not.</p>

<h2 id="one-sdr-many-sub-channels-the-streaming-receiver">One SDR, many sub-channels: the streaming receiver</h2>

<p><code class="language-plaintext highlighter-rouge">lora/receiver</code> is the only decoder in this series that owns its own tuner
bank. <code class="language-plaintext highlighter-rouge">newBank</code> picks a <code class="language-plaintext highlighter-rouge">tuner.NewChannelizerBank</code> at
<code class="language-plaintext highlighter-rouge">channelizerThreshold</code> = 7 or more sub-channels and a <code class="language-plaintext highlighter-rouge">tuner.NewDDCBank</code>
otherwise — the <a href="/reference/channelizer/">channelizer</a>
versus per-tap DDC trade-off — and each tap delivers narrowband IQ at
osf·BW to a <code class="language-plaintext highlighter-rouge">subChannel</code>.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/radio/lora/receiver/receiver.go (shape)</span>
<span class="n">sfs</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">int</span><span class="p">{</span><span class="n">cfg</span><span class="o">.</span><span class="n">SF</span><span class="p">}</span>
<span class="k">if</span> <span class="n">cfg</span><span class="o">.</span><span class="n">SF</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span> <span class="k">for</span> <span class="n">sf</span> <span class="o">:=</span> <span class="n">lora</span><span class="o">.</span><span class="n">MinSF</span><span class="p">;</span> <span class="n">sf</span> <span class="o">&lt;=</span> <span class="n">lora</span><span class="o">.</span><span class="n">MaxSF</span><span class="p">;</span> <span class="n">sf</span><span class="o">++</span> <span class="p">{</span> <span class="n">sfs</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">sfs</span><span class="p">,</span> <span class="n">sf</span><span class="p">)</span> <span class="p">}</span> <span class="p">}</span>
<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">sf</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">sfs</span> <span class="p">{</span>
    <span class="n">sc</span><span class="o">.</span><span class="n">demods</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">sc</span><span class="o">.</span><span class="n">demods</span><span class="p">,</span> <span class="n">lora</span><span class="o">.</span><span class="n">NewDemodulatorMode</span><span class="p">(</span><span class="n">sf</span><span class="p">,</span> <span class="n">osf</span><span class="p">,</span> <span class="n">bw</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">LDRO</span><span class="p">))</span>
<span class="p">}</span>
<span class="n">sc</span><span class="o">.</span><span class="n">minDecode</span> <span class="o">=</span> <span class="n">maxSps</span> <span class="o">*</span> <span class="m">700</span>   <span class="c">// a 255-byte CR 4/8 frame must be fully buffered</span>
</code></pre></div></div>

<p>A sub-channel with <code class="language-plaintext highlighter-rouge">spreading_factor: 0</code> runs six demodulators and
<code class="language-plaintext highlighter-rouge">bestDecode</code> tries each, preferring a CRC-valid frame — “this trades CPU for
coverage; pinning a single SF per sub-channel in config avoids the parallel
probes.” <code class="language-plaintext highlighter-rouge">decodeReady</code> decodes only while <code class="language-plaintext highlighter-rouge">minDecode</code> samples sit past the
cursor, so a truncated frame is never mistaken for a corrupt one.
<code class="language-plaintext highlighter-rouge">TestWidebandTwoChannels</code> sums an SF7 packet at −200 kHz and an SF9 packet
at +180 kHz into one 1 MS/s stream and expects both on the bus with the
right SF; <code class="language-plaintext highlighter-rouge">TestWidebandLDROChannel</code> proves the override reaches the
demodulator.</p>

<p>On a waterfall the diagonal ramps are unmistakable — the signature
<a href="/blog/tutorials/signal-lab-08-naming-the-unknown/">Signal Lab Part 8</a>
names in the wideband survey and
<a href="/blog/deep-dives/the-hunt-04-classifying-a-signal/">The Hunt Part 4</a>
classifies. This receiver turns that identification into rows.</p>

<h2 id="lorawan-mic-and-decryption-with-keys-you-already-hold">LoRaWAN: MIC and decryption with keys you already hold</h2>

<p>When a sub-channel’s <code class="language-plaintext highlighter-rouge">sync_word</code> is <code class="language-plaintext highlighter-rouge">0x34</code> (LoRaWAN; <code class="language-plaintext highlighter-rouge">0x12</code> is private),
<code class="language-plaintext highlighter-rouge">emit</code> hands the payload to <code class="language-plaintext highlighter-rouge">KeyStore.Decode</code>. <code class="language-plaintext highlighter-rouge">lorawan.Parse</code> reads the
<a href="/reference/lorawan/">LoRaWAN</a> 1.0.x MAC layer:
MHDR’s top three bits are the <code class="language-plaintext highlighter-rouge">MType</code>; data frames carry DevAddr, FCtrl,
FCnt, FOpts, optional FPort and FRMPayload, then the 4-byte MIC; a
join-request carries JoinEUI, DevEUI and DevNonce. Without keys for the DevAddr it stops there; the cleartext fields still
publish.</p>

<p>With keys, <code class="language-plaintext highlighter-rouge">VerifyMIC</code> recomputes the
<a href="/reference/lorawan-mic/">MIC</a>: AES-CMAC (RFC 4493)
over a <code class="language-plaintext highlighter-rouge">B0</code> block (<code class="language-plaintext highlighter-rouge">0x49</code>, direction, DevAddr, FCnt, length) plus
<code class="language-plaintext highlighter-rouge">MHDR|FHDR|FPort|FRMPayload</code>, keyed under NwkSKey, compared in constant
time. <code class="language-plaintext highlighter-rouge">TestAESCMACVectors</code> pins
the primitive against RFC 4493 §4’s vectors — the package’s one
literal-vector test — and <code class="language-plaintext highlighter-rouge">TestLoRaWANRoundTrip</code> decrypts <code class="language-plaintext highlighter-rouge">temp=21.5C</code> and
confirms a wrong NwkSKey fails the MIC. <code class="language-plaintext highlighter-rouge">DecryptFRMPayload</code> is the §4.3.3
AES-CTR-style scheme: encrypt <code class="language-plaintext highlighter-rouge">A</code> blocks (<code class="language-plaintext highlighter-rouge">0x01</code>, direction, DevAddr, FCnt,
block index) into a keystream and XOR, under AppSKey for FPort &gt; 0 and
NwkSKey for FPort 0.</p>

<p><code class="language-plaintext highlighter-rouge">FCnt32</code> assumes the upper 16 bits are zero (no session state is kept), and
join-accept bodies are left raw. The posture is the trunking one from
<a href="/blog/deep-dives/trunking-engine-11-encrypted-mode/">Trunking Engine Part 11</a>:
“GopherTrunk decrypts only with keys the operator already holds and is
authorised to use — it performs no key recovery.” Keys arrive as
<code class="language-plaintext highlighter-rouge">lorawan_keys</code> (<code class="language-plaintext highlighter-rouge">dev_addr</code>, <code class="language-plaintext highlighter-rouge">nwk_skey</code>, <code class="language-plaintext highlighter-rouge">app_skey</code>); the <code class="language-plaintext highlighter-rouge">lora_log</code> row
carries <code class="language-plaintext highlighter-rouge">mic_ok</code>, <code class="language-plaintext highlighter-rouge">decrypted</code> and the plaintext as <code class="language-plaintext highlighter-rouge">decoded</code>.</p>

<h2 id="from-bus-event-to-the-lora-panel">From bus event to the <code class="language-plaintext highlighter-rouge">/lora</code> panel</h2>

<p>The <a href="/blog/deep-dives/beyond-voice-01-eleven-place-pattern/">Part 1</a>
pattern, walked for LoRa — with two places the walk finds empty:</p>

<ol>
  <li><strong>Front end + framer</strong>: <code class="language-plaintext highlighter-rouge">lora/receiver</code> and <code class="language-plaintext highlighter-rouge">lora</code>.</li>
  <li><strong>Bus event</strong>: <code class="language-plaintext highlighter-rouge">events.KindLoRaFrame</code> (<code class="language-plaintext highlighter-rouge">"lora.frame"</code>), payload
<code class="language-plaintext highlighter-rouge">storage.LoRaFrame</code>.</li>
  <li><strong>Storage</strong>: <code class="language-plaintext highlighter-rouge">storage.LoRaLog</code> → <code class="language-plaintext highlighter-rouge">lora_log</code>, indexed on <code class="language-plaintext highlighter-rouge">(received_at)</code>
and <code class="language-plaintext highlighter-rouge">(dev_addr, received_at)</code>.</li>
  <li><strong>REST</strong>: <code class="language-plaintext highlighter-rouge">GET /api/v1/lora/frames?limit=N</code>.</li>
  <li><strong>Panel</strong>: <code class="language-plaintext highlighter-rouge">LoRa.tsx</code> polls every 5 s — SF/CR/BW, SNR, CFO, a <code class="language-plaintext highlighter-rouge">crc</code>
badge, the LoRaWAN envelope.</li>
  <li><strong>Config</strong>: <code class="language-plaintext highlighter-rouge">lora.channels[]</code> — <code class="language-plaintext highlighter-rouge">serial</code>, <code class="language-plaintext highlighter-rouge">center_hz</code>, <code class="language-plaintext highlighter-rouge">bandwidth</code>,
<code class="language-plaintext highlighter-rouge">sub_channels[]</code>, <code class="language-plaintext highlighter-rouge">lorawan_keys[]</code>.</li>
  <li><strong>Field help</strong>: <code class="language-plaintext highlighter-rouge">LoRaConfig.*</code> through <code class="language-plaintext highlighter-rouge">LoRaWANKeyConfig.*</code>.</li>
  <li><strong>Route tests</strong>: <code class="language-plaintext highlighter-rouge">"/lora"</code> in <code class="language-plaintext highlighter-rouge">web/src/nav/registry.test.ts</code>.</li>
  <li><strong>Preflight</strong>: <strong>absent</strong> — <code class="language-plaintext highlighter-rouge">preflight.go</code> names every other message
decoder as needing <code class="language-plaintext highlighter-rouge">storage.path</code>, not lora.</li>
  <li><strong>Retention</strong>: <strong>absent</strong> — <code class="language-plaintext highlighter-rouge">lora_log</code> is not in
<code class="language-plaintext highlighter-rouge">storage.decoderLogTables</code>, so <code class="language-plaintext highlighter-rouge">retention.log_days</code> never sweeps it.</li>
  <li><strong>config.example.yaml</strong>: the commented <code class="language-plaintext highlighter-rouge">lora:</code> block.</li>
</ol>

<p>A third soft spot: the web Config Builder has no typed LoRa editor —
<code class="language-plaintext highlighter-rouge">webRoundTripAllow</code> in <code class="language-plaintext highlighter-rouge">configbuilder/webschema_test.go</code> records
<code class="language-plaintext highlighter-rouge">Config.LoRa</code> as round-tripping through “the generic index-signature editor;
a bespoke typed editor in types.ts is a follow-up.” Such gaps are what the
pattern exists to make visible.</p>

<p>Verification is where Parts 9 and 10 left it: synthetic only. Every LoRa
test drives <code class="language-plaintext highlighter-rouge">LoRaModulate</code> into the package’s own demodulator; the one
independent anchor is RFC 4493’s CMAC vectors. No captured LoRa packet is
committed.</p>

<h3 id="how-lora-shaped-the-go-code">How LoRa shaped the Go code</h3>

<ul>
  <li><strong>A modem, not just a receiver.</strong> <code class="language-plaintext highlighter-rouge">LoRaModulate</code> and <code class="language-plaintext highlighter-rouge">Demodulator</code> are
exact inverses in one package; every transform is round-trip testable.</li>
  <li><strong>The dechirper is the reusable kernel.</strong> One struct owns the chirps, FFT
plan and scratch buffers per (SF, osf); offline <code class="language-plaintext highlighter-rouge">Demodulator</code> and
streaming <code class="language-plaintext highlighter-rouge">subChannel</code> both run on it.</li>
  <li><strong>Confidence travels with the symbol.</strong> <code class="language-plaintext highlighter-rouge">peak</code> carries <code class="language-plaintext highlighter-rouge">bin</code>, <code class="language-plaintext highlighter-rouge">frac</code>,
<code class="language-plaintext highlighter-rouge">snr</code> and <code class="language-plaintext highlighter-rouge">conc</code>; sync gates on concentration, the panel shows SNR.</li>
  <li><strong>Decoders never guess with keys.</strong> <code class="language-plaintext highlighter-rouge">HasKeys</code>, <code class="language-plaintext highlighter-rouge">MICOK</code> and <code class="language-plaintext highlighter-rouge">Decrypted</code> are
separate flags; a frame with no keys still reaches the log.</li>
</ul>

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

<p>LoRa was data with no voice at all. The next decoder brings voice back,
open-source: M17’s 4FSK link layer, its Link Setup Frame, Golay and
convolutional coding, and the Codec 2 payload.
<a href="/blog/deep-dives/beyond-voice-12-m17-open-digital-voice/">Part 12</a>
follows it from the 4800-baud carrier to stream and packet modes.</p>

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

<p><strong>Which LoRa settings does GopherTrunk decode?</strong>
Spreading factors 7 through 12 (auto-detected when <code class="language-plaintext highlighter-rouge">spreading_factor: 0</code>),
coding rates 4/5 to 4/8 from the explicit header, bandwidths 125, 250 and
500 kHz, with or without payload CRC, and Low Data Rate Optimize by the
Semtech rule or an override. SF6 and implicit headers are out of scope.</p>

<p><strong>Does GopherTrunk decode real Semtech LoRa packets bit-exactly?</strong>
Not yet verified. The chain is round-trip tested against its own modulator,
and the source marks the header layout, interleaver rotation sign, whitening
sequence and CRC seed as calibration steps gated on captured golden vectors,
none committed. Treat interop as synthetic-verified only.</p>

<p><strong>How does the receiver estimate carrier offset?</strong>
From two dechirp peaks. The repeated preamble upchirps peak at
<code class="language-plaintext highlighter-rouge">timing + cfo</code>; after realigning the grid on that bin, the SFD downchirps
peak at <code class="language-plaintext highlighter-rouge">2·cfo</code>. Halving it gives the offset, which <code class="language-plaintext highlighter-rouge">mix</code> removes before
demodulation.</p>

<p><strong>Can GopherTrunk decrypt LoRaWAN traffic?</strong>
Only for devices whose session keys the operator supplies under
<code class="language-plaintext highlighter-rouge">lorawan_keys</code> (DevAddr, NwkSKey, AppSKey). The MIC is verified by AES-CMAC
under NwkSKey and the payload decrypted under AppSKey (NwkSKey for FPort 0).
No key recovery; frames without keys publish their cleartext header fields.</p>

<p><strong>Why does <code class="language-plaintext highlighter-rouge">lora_log</code> grow without limit?</strong>
Because <code class="language-plaintext highlighter-rouge">lora_log</code> is not in the retention sweeper’s <code class="language-plaintext highlighter-rouge">decoderLogTables</code>
list, <code class="language-plaintext highlighter-rouge">retention.log_days</code> never deletes its rows — an omission the
eleven-place walk surfaces. Until it is added, prune it by hand.</p>

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

<p><strong>Part 11 of 14</strong> · ←
<a href="/blog/deep-dives/beyond-voice-10-dsc-marine-calling/">Part 10: DSC — Marine Digital Selective Calling</a>
· Next →
<a href="/blog/deep-dives/beyond-voice-12-m17-open-digital-voice/">Part 12: M17 — The Open Digital Voice Link Layer</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="beyond-voice" /><category term="lora" /><category term="lorawan" /><category term="chirp" /><category term="iot" /><category term="go" /><summary type="html"><![CDATA[How GopherTrunk decodes LoRa without Semtech silicon — chirp spread spectrum as cyclic shifts, the dechirp-and-fold FFT that reads a symbol as a bin, preamble and SFD sync that separates carrier offset from timing, the Gray, interleave, Hamming and whitening chain, LDRO, and LoRaWAN MIC verification and decryption with operator-held keys.]]></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>