Part 1 of Beyond Voice, 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. It stands on three earlier series: the bus of SDR Internals, the SQLite of Recording, Composition & Streaming and the one-contract surface of The Operator’s Cockpit. This opener asks why a scanner bothers with data at all, then teaches the pattern once — with Kenwood FleetSync, the most recent landing, as the worked example — so the next twelve parts can spend their words on physics.
TL;DR: A conventional analog channel tells you nothing about who is talking — until a 1200-baud FFSK burst at the head of the transmission does. GopherTrunk decodes those bursts and a dozen other non-voice signals with one shape: a DSP front end (
internal/radio/fleetsync/afsk) → a callback-only framer (fleetsync.Framer) → a busKind(events.KindFleetSyncMessage = "fleetsync.message") → a genericeventLog[T]drain into SQLite (storage.FleetSyncLog,fleetsync_log) → a503-when-unwired route (GET /api/v1/fleetsync/messages) → a polling panel (/fleetsync) — plus the config struct, both config editors, thedoctorpreflight andconfig.example.yaml. Eleven places, and three tests police them:TestFieldHelpCoverage,TestConfigSchemaCoveredByWebBuilderand the webregistry.test.ts/App.panels.test.tsxroute lists. FleetSync is capture-verified (#1184); its live Kenwood run is still open.
Key takeaways
- Data decoders exist because analog voice is anonymous. MDC1200 and FleetSync give an FM channel the radio-ID column digital systems carry natively; APRS, AIS and ADS-B give it a map. None is a call.
- Every decoder is the same three packages. A protocol core that frames bits through a callback and never imports the bus, a DSP front end that owns IQ-to-bits and publishes, and (in older decoders) a thin orchestrator between them.
- The bus is the seam; everything downstream is a subscriber. The front
end publishes one
Kind; storage, REST and the panel never learn the decoder exists. - The pattern is enforced, not documented. Three tests fail the build on a config field with no help, a web schema missing a field, or a panel route with no nav entry.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Protocol core | sync hunt → capture → DecodeFrame, callback-only |
internal/radio/fleetsync/framer.go (Framer.Push) |
| DSP front end | FM → resample → FFSK → MM timing → slicer, publishes | internal/radio/fleetsync/afsk/receiver.go (Options.Bus) |
| Bus kind | the one event every subscriber keys on | internal/events/bus.go (KindFleetSyncMessage) |
| Storage | generic drain → INSERT INTO fleetsync_log |
internal/storage/fleetsynclog.go, eventlog.go, sqlite.go |
| REST | GET /api/v1/fleetsync/messages?limit=N, 503 unwired |
internal/api/handlers_fleetsync.go, server.go |
| Web panel | 5 s poll: fleet, unit, FS-I/FS-II, block check | web/src/panels/FleetSync.tsx, nav/registry.ts |
| Config + editors | struct, Builder section, field help | internal/config/config_peripherals.go, internal/configbuilder/fieldmeta.go |
In this post
- Why a scanner decodes data — the anonymous analog channel and what rides on it.
- The three-package shape — core, front end, orchestrator across three decoders.
- The eleven places, verified in the tree — FleetSync’s landing as the example.
- The three policing tests — and the two places only policy guards.
- What the pattern buys — the principle, and how it shaped the Go code.
Why a scanner decodes data
Point a receiver at a conventional analog VHF channel and you get voice and
nothing else: no talkgroup, no radio ID, none of what a control channel hands
the
trunking engine.
The fleets that run those channels solved this decades ago by putting a short
data burst inside the audio. Motorola’s answer is MDC1200; Kenwood’s is
FleetSync. Both are 1200-baud FFSK — audio tones at 1200 and 1800 Hz — keyed
for a fraction of a second at the head of each PTT and carrying the radio’s
identity. The mdc1200 package doc says what an operator wants from it: to
see which radio is transmitting, and to surface emergency and status events
“on systems that are otherwise just FM voice.”
That is why a trunking scanner grows a data path, and the same machinery
then decodes everything else that is bits rather than a vocoder frame:
pagers (POCSAG, FLEX), APRS over AX.25, ship and aircraft transponders (AIS,
ADS-B), marine distress calling (DSC), LoRa chirps and the M17 link layer.
None is a call — no grant, no voice device, no recorder — so none touches
internal/trunking. Each needs only a way to get a typed message from a burst
on the air to a row an operator can read; GopherTrunk built that once, and the
shape is the running thread from here to Part 14.
The three-package shape
Put the three AFSK decoders in the tree side by side and the shape jumps out:
| Layer | APRS (internal/radio/aprs) |
MDC1200 (internal/radio/mdc1200) |
FleetSync (internal/radio/fleetsync) |
|---|---|---|---|
| Protocol core | ax25, hdlc, aprs.go |
mdc1200.go (DecodeFrame) |
fleetsync.go + framer.go |
| Bit orchestrator | aprs/receiver (Push(bit), publishes) |
mdc1200/receiver (Push(bit), publishes) |
none — Framer is callback-only |
| DSP front end | aprs/afsk (1200/2200 Hz, NRZI) |
mdc1200/afsk (1200/1800 Hz, NRZ) |
fleetsync/afsk (1200/1800 Hz, publishes) |
The protocol core knows bits and nothing else. fleetsync.Framer is the
purest specimen: Push(bit) slides a 40-bit register hunting a 24-bit
alternating preamble plus the 16-bit sync word 0xA23E (or its complement),
captures 260 payload bits, calls DecodeFrame and invokes the one callback
NewFramer was given. No bus import, no storage import, no clock — a
callback-based framer, the package doc says, “lets it be unit-tested in
isolation.” The older decoders put a thin orchestrator between core and bus
(mdc1200/receiver, aprs/receiver); FleetSync moved the publish into the
front end instead.
The DSP front end owns IQ-to-bits. All three run the identical chain —
demod.FM → dsp.RealResampler to 9600 Hz → demod.FFSK at the protocol’s
tone pair → sync.MuellerMuller at 8 samples per bit → a slicer — and differ
only in tone pair, line code and slicer threshold,
Part 2’s
subject. It is the “IQ → symbols → state machine” split
Protocol Decoders Part 1
described for control channels, applied to bursts that never become calls.
The eleven places, verified in the tree
CLAUDE.md’s note on the FleetSync landing says adding a decoder “touches ELEVEN places and three tests police them.” Here is that count, every place named from the tree, following one burst from antenna to browser:
| # | Place | Where FleetSync landed |
|---|---|---|
| 1 | Protocol core | internal/radio/fleetsync/{fleetsync,framer}.go — DecodeFrame, Framer |
| 2 | DSP front end | internal/radio/fleetsync/afsk/receiver.go — New(Options{Bus, BaudHz, DropBadCRC}) |
| 3 | Bus kind | internal/events/bus.go — KindFleetSyncMessage = "fleetsync.message" |
| 4 | Config schema | internal/config/config_peripherals.go — FleetSyncConfig; config.go — KnownUITabs["fleetsync"] |
| 5 | Daemon wiring | cmd/gophertrunk/daemon.go — construct, spawn, open the log, inject the provider |
| 6 | Storage | internal/storage/fleetsynclog.go, sqlite.go (fleetsync_log), retention.go (decoderLogTables) |
| 7 | REST | internal/api/handlers_fleetsync.go — FleetSyncProvider, GET /api/v1/fleetsync/messages |
| 8 | Web panel | web/src/panels/FleetSync.tsx, App.tsx route, nav/registry.ts entry |
| 9 | Config editors | web/configbuilder/src/sections/FleetSync.tsx, api/types.ts; internal/configbuilder/fieldmeta.go |
| 10 | Preflight | cmd/gophertrunk/preflight.go — needs = append(needs, "fleetsync") |
| 11 | Paper trail | config.example.yaml, docs/fleetsync.md, docs/api-events.md |
Three of these are where the decoupling lives.
The publish. The framer callback is the only place the decoder meets the
bus. It honours DropBadCRC for the bus alone — an OnMessage caller still
sees every burst — and converts the protocol Message into the payload the
rest of the chain shares:
// internal/radio/fleetsync/afsk/receiver.go (shape)
func (r *Receiver) onFrame(m fleetsync.Message) {
if r.onMessage != nil { r.onMessage(m) }
if r.bus == nil { return }
if !m.CRCOK && r.dropBadCRC { r.burstsDropped.Add(1); return }
r.bus.Publish(events.Event{Kind: events.KindFleetSyncMessage,
Timestamp: time.Now(), Payload: MessageToStorage(m, time.Now())})
}
The drain. Every per-domain log writer used to carry a byte-identical
copy of subscribe → filter one Kind → insert → close. They now embed one
generic, eventLog[T] (internal/storage/eventlog.go), so a decoder’s
storage layer is an insert and a Recent; the subscription is taken at
construction so nothing published before Run is lost.
The read surface. internal/api never imports storage’s concrete type.
The daemon adapts the log into a one-method FleetSyncProvider, and with none
injected the handler answers 503 naming the fix: “fleetsync subsystem not
enabled (set storage.path in config to persist and view decoded messages)” —
the optional-provider rule of
Operator Cockpit Part 1.
The three policing tests
Eleven places would rot if they relied on memory. Three tests guard three ways the chain silently breaks.
TestFieldHelpCoverage (internal/configbuilder/fieldmeta_test.go)
walks every struct reachable from config.Config by reflection and fails on
any exported field whose FieldMeta has an empty Help. Both editors — the
Bubbletea TUI form and the web Config Builder — source help from that one
registry
(Operator Cockpit Part 13).
FleetSync’s five entries are what the test demanded, down to BaudHz’s
“0 = 1200 baud (FleetSync); 2400 is also accepted.”
TestConfigSchemaCoveredByWebBuilder (internal/configbuilder/webschema_test.go)
compares the same walk against the web builder’s hand-typed TypeScript schema,
web/configbuilder/src/api/types.ts, and fails when a Go field has no
counterpart — “a config change that the web Config Builder would silently
drop on save/load.” The FleetSyncChannelConfig interface exists because it
refused to pass without one.
registry.test.ts and App.panels.test.tsx (web/src) guard a routed
panel with no nav entry, or one that mounts nothing: the first asserts “a nav
entry for every routed panel (no orphans)” against a ROUTED_PATHS list that
now carries /fleetsync; the second mounts every route with the API clients
mocked, the net
Operator Cockpit Part 14
built.
Two places are guarded by policy instead. The doctor preflight list —
paging, aprs, ais, dsc, mdc1200, fleetsync, m17 — exists because
issue #565 had a
POCSAG operator staring at a 503 until they learned about storage.path;
a new decoder joins it by hand. And config.example.yaml is CLAUDE.md’s
4 Sep rule: a key that ships in code but not in the example is a key an
operator guesses at their rig.
What the pattern buys
The principle underneath is the one
SDR Internals Part 11
stated for the engine — publish, never call outward — pushed one level
further. The engine at least knows what a grant is; a FleetSync burst is
invisible to it. The decoder publishes one Kind and stops; the daemon is the
only file that knows every subscriber exists, and its knowledge is a
construction loop and a spawn per receiver — named
fleetsync-<serial>-<hz>, marked non-essential, looking up
d.iqBrokers[spec.serial], calling SetCenterFreq and handing a
single-channel IQ subscription to rcv.Process. Non-essential is
load-bearing: a missing SDR logs WRN fleetsync: SDR not found, skipping
receiver and the trunking pipeline keeps running; a malformed
fleetsync.channels entry becomes a startup warning and a nil slot
(TestDaemonWiresFleetSyncChannels). Retention rides along free:
fleetsync_log is one string in decoderLogTables, so
the sweeper
deletes its rows under retention.log_days (TestFleetSyncLogSweptByRetention).
What the pattern does not buy is on-air truth. Every layer is tested — the
bus path against a committed real-air slice
(TestReceiverPublishesRealAirBurstsOnBus: the FleetSync-II capture reaches
the bus as Fleet 107 / Unit 1772), storage, REST and panel with fakes. But the
daemon wiring has not seen a live Kenwood fleet; CLAUDE.md files that under
the #764/#771 discipline (“synthetic + offline ≠ on air”), and the reporter’s
lab test is the open gate.
Part 4
tells that story in full.
How the pattern shaped the Go code
- Framers take a callback, front ends take a bus.
fleetsync.NewFramerpanics withoutonMsgand imports nothing abovemath/bits;afsk.NewrequiresOnMessageorBus(TestNewRequiresASink). - One generic drain, per-table SQL only.
eventLog[T]owns subscribe, filter, insert and close;FleetSyncLogis aninsertand aRecent. - Providers are one-method interfaces the daemon fills.
api.FleetSyncProviderhasRecentFleetSyncMessages(limit); the adapter lives indaemon.go, sointernal/apicompiles without storage’s types. - Failure is a warning and a nil slot. Receivers are index-aligned with their specs so a skipped entry keeps its position; a data decoder can never cost an operator their calls.
Where this goes next
The pattern is fixed; the physics varies. Every AFSK decoder above asks the
same question — which of two audio tones is present in this bit period — and
Part 2
answers it: how demod.FFSK mixes, filters and discriminates a tone pair, why
FFSK’s integer-cycle tones differ from Bell 202’s, and the slicer lesson that
cost a round.
FAQ
Why does a trunking scanner decode non-voice data like MDC1200 or APRS? Because analog conventional channels carry no identity of their own. In-band bursts like MDC1200 and FleetSync add the transmitting radio’s ID; APRS, AIS and ADS-B add positions. GopherTrunk decodes them as messages on the events bus, never as calls.
What are the eleven places a new decoder touches in GopherTrunk?
Protocol core, DSP front end, bus Kind, config struct, daemon wiring,
storage log and table and retention list, REST provider and route, web panel
with nav entry, Config Builder section with TypeScript schema and field help,
the doctor preflight list, and config.example.yaml plus the docs page.
Which tests fail if a decoder is only half-wired?
TestFieldHelpCoverage fails on a config field with no help text;
TestConfigSchemaCoveredByWebBuilder fails when the web builder’s types.ts
lacks a field; registry.test.ts and App.panels.test.tsx fail on a routed
panel with no nav entry or one that does not mount.
Why does the REST route return 503 instead of an empty list?
Because the log writer only exists when storage.path is set. Without it the
receiver still decodes and publishes, but nothing persists, so the handler
answers 503 with the fix in the message rather than pretending the channel
is silent.
Is the FleetSync decoder verified on air? Partly. It decodes the two SDR# captures from issue #1184 — FleetSync-I 5 of 6 bursts, FleetSync-II 8 of 8, Fleet 107 / Unit 1772 — and channelized slices are committed fixtures. A live run on the reporter’s Kenwood radios is still the open gate.
Series navigation
Part 1 of 14 · Next → Part 2: AFSK & FFSK — Two Tones, One Bit