Part 1 of Protocol Decoders, a 12-part deep dive into how GopherTrunk turns raw symbols into structured trunking events. Every protocol — P25, DMR, NXDN, TETRA, EDACS, and the rest — runs through the same eight-stage shape, and this opener is a map of that shape. It also plants a mystery that runs the length of the series: one Motorola field that decodes to garbage, and stubbornly stays that way until Parts 10 and 11.
TL;DR: A control-channel decoder is a pipeline with a fixed skeleton: IQ → symbols → sync/framing → FEC → PDU → opcode dispatch → a typed event on the bus. GopherTrunk factors that skeleton into one interface (
ProtocolPipeline) and one registry (amap[trunking.Protocol]PipelineFactory), so a dozen protocols share the connector, the down-converter, and the event plumbing — and only differ in the middle. The daemon and the offline Signal Lab drive the same pipelines through the same factory map, which is why lab results transfer to the air.
Key takeaways
- Every decoder is the same eight stages; protocols differ only in how each stage is done, not in the order or the contract.
- The pipeline contract is three methods —
Process,Reset,Close— and nothing else. The connector doesn’t know which protocol it’s driving. - A factory registry keyed on
trunking.Protocolis the single source of truth for “which protocols can decode,” used by the daemon, the picker, and the test harness alike. - The decoder publishes
KindCCLockedandKindGrant; it never calls the trunking engine. That grant is the hand-off to the Trunking Engine series.
Cheat sheet
| Stage | What it does | Where it lives |
|---|---|---|
| Down-convert | raw SDR IQ → narrowband channel (~48 kHz) | ccdecoder/decoder.go (Downconverter) |
| Demod + timing | channel IQ → symbols (dibits or bits) | internal/radio/<proto>/receiver |
| Sync / framing | find the frame sync word, slice a burst | e.g. p25/phase1/sync.go |
| FEC | correct channel errors (trellis, BCH, Golay…) | e.g. p25/phase1/trellis.go |
| PDU parse | bytes → a structured block | e.g. p25/phase1/tsbk.go |
| Opcode dispatch | block → a specific message type | e.g. p25/phase1/opcodes.go |
| Event | publish KindGrant / KindCCLocked |
internal/events |
| Registry | pick the pipeline for a protocol | ccdecoder/pipelines.go |
In this post
- The eight stages every decoder walks, and why they’re always in that order.
- The pipeline contract — the three-method interface that hides the protocol.
- The registry — one map that answers “what can we decode?”
- The connector — how one IQ stream feeds whichever pipeline is active.
- The mystery — a Motorola alias field that decodes to noise, on purpose.
The shape every decoder shares
Point GopherTrunk at a P25 control channel and a DMR Tier III control channel and the code paths look nothing alike in the middle — different sync words, different FEC, different PDUs. But zoom out and they are the same program. Both take a stream of recovered symbols, hunt for a frame sync pattern, run forward error correction, parse the corrected bits into a protocol data unit, dispatch on an opcode, and emit a structured event. The protocol picks the details; the pipeline picks the order, and the order never changes.
The reason to name the shape is that it tells you where to look. A capture that demods clean but never locks is a framing problem. One that locks but throws CRC errors is FEC or PDU. One that decodes blocks but produces no calls is opcode dispatch. The Signal Lab dashboard reads exactly these stage boundaries, and the rest of this series walks them protocol by protocol.
The pipeline contract
The connector that owns the SDR’s IQ stream does not import P25, DMR, or any protocol package. It talks to one interface:
// internal/scanner/ccdecoder/pipelines.go (shape)
type ProtocolPipeline interface {
Process(iq []complex64) // consume one chunk of channel IQ
Reset() // clear symbol-domain state on re-sync
Close() error // release resources (idempotent)
}
Three methods. Process is the hot path — a chunk of down-converted IQ goes in,
and somewhere at the bottom of the call stack a KindGrant may come out on the
bus. Reset flushes symbol-clock and framing state when the stream re-syncs.
Close tears the pipeline down on a retune. That’s the whole surface. Whether the
protocol is 4-level C4FM at 4800 baud or π/4-DQPSK TETRA at 18000 baud is
invisible here — the pipeline swallows the difference.
Construction is where a protocol’s identity enters, through a small options struct the connector fills in the same way for everyone:
// internal/scanner/ccdecoder/pipelines.go (shape)
type PipelineOptions struct {
Bus *events.Bus
Log *slog.Logger
SystemName string
FrequencyHz uint32
SampleRateHz float64 // the *channelized* rate, not the SDR rate
System trunking.System // per-protocol config rides here
SymbolTap func(symbols []uint8, isBits bool, baseIdx int)
// …FECObserver, CarrierBiasHz
}
Note SampleRateHz is the decimated channel rate, not the raw SDR rate. The
connector down-converts every chunk to a narrowband stream (~48 kHz for the
4800-baud C4FM family, wider for TETRA) before the pipeline sees it, and each
per-protocol receiver sizes its matched filter to that rate. This is what makes
the decode path rate-invariant to the capture rate — a property the DSP notes in
this repo lean on hard. SymbolTap is a pure observation hook: production leaves
it nil, and the offline toolkit wires it to count symbols and grade signal quality
for every protocol without re-building the receiver.
The registry: one map, one truth
“Which protocols can GopherTrunk actually decode?” has exactly one answer in the codebase, and it’s a map:
// internal/scanner/ccdecoder/pipelines.go (shape)
var factories = map[trunking.Protocol]PipelineFactory{
trunking.ProtocolP25: newP25Phase1Pipeline,
trunking.ProtocolP25Phase2: newP25Phase2Pipeline,
trunking.ProtocolDMR: newDMRTier3Pipeline,
trunking.ProtocolNXDN: newNXDNPipeline,
trunking.ProtocolTETRA: newTETRAPipeline,
trunking.ProtocolEDACS: newEDACSPipeline,
trunking.ProtocolMotorola: newMotorolaPipeline,
// …dPMR, LTR, MPT1327, YSF, D-STAR, DMR Tier 1/2
}
type PipelineFactory func(PipelineOptions) (ProtocolPipeline, error)
Each value is a PipelineFactory — a function that wires a protocol’s receiver to
its control-channel state machine and returns something satisfying
ProtocolPipeline. The map is populated once at package load and treated as
immutable for the process lifetime. Three small accessors read it:
NewPipeline (build one), HasFactory (can we decode this?), and
RegisteredProtocols (list them, sorted). The Signal Lab protocol picker, a
gen→decode test sweep, and the live daemon all go through those same three doors.
How that principle shaped the Go code
- The connector is protocol-agnostic.
ccdecoder’sDecoderimportsinternal/eventsandinternal/trunking— not P25 or DMR. It looks up a factory bytrunking.Protocoland drives whatever comes back. Adding a protocol is adding a map entry plus a factory, never editing the pump loop. - One rate, sized once. Because factories receive the channelized
SampleRateHz, a receiver’s matched filter is correct whether the SDR ran at 2.048 MS/s or 10 MS/s. The DDC absorbs the capture rate. - A factory may decline. Returning an error means “this protocol isn’t wired
end-to-end yet,” and the connector leaves the system in
huntingrather than decoding noise into spurious events. The registry encodes capability honestly. - The same map powers offline tools.
NewPipelineis the out-of-package entry point the offline toolkit uses, so what the lab decodes is byte-for-byte what the daemon decodes.
The connector, in one loop
ccdecoder.Decoder is the long-lived object gluing the SDR to the registry. It
subscribes to KindHuntProgress (the CC hunter saying “I’m trying system X on
frequency F now”), and on each transition it rebuilds the down-converter for that
protocol’s channel rate, looks up the factory, and swaps the active pipeline:
// internal/scanner/ccdecoder/decoder.go (shape) — handleProgress
factory, ok := factories[sys.Protocol]
if !ok {
d.clearActive() // no decoder for this protocol; stay hunting
return
}
d.ensureDownconverterLocked(ddcTargetForProtocol(sys.Protocol))
p2, err := factory(PipelineOptions{
Bus: d.bus, SystemName: sys.Name,
FrequencyHz: p.AttemptedFreqHz, SampleRateHz: d.pipelineRateHz,
System: sys,
})
// …then, under lock: d.active = p2
From there, every IQ chunk goes d.ddc.Process(...) then d.active.Process(...).
One subtlety is worth a callout: a HuntProgress that repeats the same
(system, frequency) must not tear down and rebuild the pipeline, or a
single-candidate system that re-hunts every dwell resets acquisition every few
seconds and never converges. So the swap is idempotent — same target, same
pipeline, keep decoding. Small rule, big consequence for lock stability.
The output is never a function call into the engine. The pipeline’s state machine
Publishes KindCCLocked when it first locks and KindGrant when it decodes a
voice-channel grant — the seam between this series and the
Trunking Engine
series: we produce the Grant; the engine turns it into a recorded call.
The mystery we’re planting
⚠️ One field that won’t decode. On a Motorola P25 Phase 1 system, radios can broadcast a talker alias — the display name of the transmitting unit — reassembled from Link Control words (
LCO 0x15header,0x17data blocks) and passed through a proprietary per-byte substitution cipher. GopherTrunk reassembles the blocks perfectly and runs the decode, and it comes out as garbage. The code even knows it:decodeAliaswill never report an alias as reliable, becausemotorola.CipherVerifiedisfalse— the substitution table is unconfirmed (issue #773). It is, quite literally, the cipher we haven’t cracked. We’ll pass this field lightly through the middle of the series, then hunt it down for real in Part 10 and Part 11. It is the same emitter the Crypto Lab series calls Mercury.
Keep it in the back of your mind. Everything up to Part 9 is the machinery you need to even see that field arrive intact; Parts 10 and 11 are where we try to read it.
Where this goes next
Part 2 drops into the first protocol in detail: P25 Phase 1’s physical layer — the NAC/NID, the 48-bit frame sync word, the C4FM 4-level symbols, and the trellis + Hamming FEC that together act as the lock gate. From there we climb the stack: TSBKs and link control (Part 3), Phase 2 TDMA (Part 4), then out through the DMR, NXDN/dPMR, TETRA, and EDACS/LTR/MPT families before the two-part alias hunt.
FAQ
What is a control-channel decoder, exactly?
It’s the component that turns a trunked system’s continuous signalling channel into
structured events — chiefly “talkgroup T is now on frequency F” (a grant). In
GopherTrunk it’s a pipeline from IQ samples to a KindGrant event on the bus,
built per protocol but sharing one interface and one registry.
Do all protocols really share the same pipeline?
They share the same contract (Process/Reset/Close) and the same connector,
down-converter, and event plumbing. The middle stages — sync, FEC, PDU, opcode —
are protocol-specific implementations wired together by a per-protocol factory.
How does GopherTrunk pick which decoder to run?
The CC hunter publishes a HuntProgress event naming the system and frequency it’s
attempting; the connector looks up that system’s trunking.Protocol in the factory
map and builds the matching pipeline. If no factory is registered, it stays hunting.
Where do grants go after decoding?
Onto the event bus as KindGrant. The decoder never calls the trunking engine
directly — the engine is a subscriber. That decoupling is covered in the
Trunking Engine series.
What’s the talker-alias mystery? A Motorola talker-alias field that reassembles correctly but decodes to noise because its per-byte cipher is unverified. It threads through the series and is paid off in Parts 10–11; it’s the same emitter Crypto Lab calls Mercury.
Series navigation
Part 1 of 12 · Next → Part 2: P25 Phase 1 Physical Layer