Part 9 of Protocol Decoders. Eight episodes have followed control channels into grants. This one covers the four decode paths that *aren’t a single trunked CC: fixed-frequency conventional scanning, one dongle watching many carriers at once, learning a DMR band plan from thin air, and the symbol scope that lets you see a modulation before you trust it. That last tool is exactly what you reach for when a capture like Mercury refuses to name itself — which is where Parts 10–11 pick up.*
TL;DR: Not every signal is a trunked control channel. GopherTrunk decodes conventional channels (fixed frequency, IQ-power squelch, optional CTCSS/DCS tone gate) via a scan-list state machine; monitors many carriers on one wideband dongle with a tuner
Bank(including P25 Phase 2 and per-grant voice taps); learns a DMR Tier III LCN→frequency band plan by correlating grants with the carriers that key up; and exposes a symbol scope that reuses the production DSP to stream pre-slicer soft symbols and dibits to the web console.
Key takeaways
- Conventional decode has no grants — it’s a squelch + tone state machine that manufactures synthetic calls for the engine.
- The wideband engine channelizes one SDR into many narrowband streams and can decode its own voice grants with per-grant taps, no second radio.
- The DMR LCN Learner discovers a band plan at runtime and hot-swaps it into the live control channel.
- The symbol scope is a read-only diagnostic that reuses the production down-converter and receiver taps — it never touches live decode.
Cheat sheet
| Path | Package | What it does |
|---|---|---|
| Conventional | internal/scanner/conventional/ |
scan a fixed-frequency list, squelch + CTCSS/DCS, synth calls |
| Wideband multi-carrier | internal/scanner/widebandt2/ |
one dongle → many channels via tuner.Bank; P25 P1/P2, DMR |
| DMR LCN learning | internal/scanner/dmrlcn/ |
learn Tier III LCN→Hz from grants + onsets, hot-swap resolver |
| Symbol scope | internal/scanner/symbolscope/ |
stream pre-slicer soft symbols + dibits to the web console |
In this post
- Conventional — squelch, tone gating, and the synthetic call.
- Wideband Phase 2 — one dongle, many carriers, and voice taps.
- DMR LCN learning — reconstructing a band plan from evidence.
- The symbol scope — a microscope that reuses production DSP.
Conventional: no grant, just a carrier
A conventional channel is the simplest radio there is: a fixed frequency with analog FM voice and no trunking at all. There’s nothing to decode in the control-channel sense — the decode question is just is someone talking right now? The scanner answers it with an IQ-power squelch and an optional sub-audible tone gate:
// internal/scanner/conventional/scanner.go (shape)
type Channel struct {
Label string
FrequencyHz uint32
Mode string // "fm" or "nfm" (narrows the audio LPF)
SquelchDbFS float64 // carrier-present threshold, e.g. -50 dBFS
Hangtime time.Duration // below-threshold time before the call ends
Priority int
Tone ToneConfig // optional CTCSS / DCS gate
}
The scanner is a three-state machine — SCANNING, DWELL, HELD — that hops the
list, measures IQ power in a short window per channel, and opens on a channel whose
power crosses SquelchDbFS. When a ToneConfig is set, the channel only counts as
“carrier present” while both the power squelch is open and the configured
CTCSS frequency (a Goertzel bin) or DCS code is detected — the classic way to share
a frequency between systems. CTCSS is fully wired; DCS parses and validates but its
detector is a tracked follow-up, and the code says so rather than pretending
otherwise.
The clever part is what happens on squelch-open: the scanner doesn’t have its own recording path. It manufactures a synthetic grant and hands it to the trunking engine, which records it exactly like a trunked call:
// internal/scanner/conventional/scanner.go (shape) — the Engine seam
type Engine interface {
HandleSyntheticCall(g trunking.Grant, deviceSerial string)
EndSyntheticCall(deviceSerial string, reason trunking.EndReason) bool
Touch(deviceSerial string)
}
So a conventional channel reuses the entire recorder, call-log and metrics stack by pretending to be a one-call trunked system. This is the same decoupling lesson from Part 1, applied from the other side: instead of a new consumer subscribing to the engine, a new producer speaks the engine’s grant vocabulary.
Wideband: one dongle, many carriers
A wideband dongle sees several MHz at once, which is wasteful to spend on a single
control channel. The widebandt2 engine pins one SDR to a centre frequency and
uses a dsp/tuner.Bank to extract one narrowband IQ stream per configured
channel inside the dongle’s band — each stream decimated to a 48 kHz per-tap rate
and fed to its own protocol receiver and state machine:
Its supported channels are the modern digital control channels: DMR Tier II
conventional, DMR Tier III trunked, P25 Phase 1, and P25 Phase 2 (the
radio/p25/phase2 state machine consuming superframes and emitting grants from the
MAC PDU chain covered in
Part 4).
The payoff is voice taps: because the voice channel is often already inside the
dongle’s IQ window, a grant can be followed by spinning up a per-grant DDC on the
same wideband stream (internal/sdr/wbvoice) instead of allocating a physical
voice SDR — falling back to a real radio only when the grant lands outside the band
or every tap is busy.
DMR LCN learning: a band plan from evidence
DMR Tier III grants reference a voice channel by LCN (Logical Channel Number),
not a frequency, so without a band plan those grants are undecodable
(decode.error stage=no-bandplan). Usually you configure the plan by hand. The
dmrlcn.Learner discovers it automatically, and it’s a small gem of correlation
engineering: it watches the wideband IQ for carrier onsets (a channel keying
up) and the event bus for KindDMRGrantObserved events, and pairs them in
time. An onset that lines up with a grant for LCN n is a candidate LCN→frequency
mapping.
Because a temporal coincidence can be a fluke, each candidate is decode-confirmed off the main loop — a bounded set of DDC tap probes actually decode the suspected frequency and vote — before it’s fed to a band-plan fitter:
// internal/scanner/dmrlcn/dmrlcn.go (shape)
func (l *Learner) handlePair(p Pair) {
l.pairs = append(l.pairs, p)
res, ok := FitBandPlan(l.pairs, l.fitOpts) // linear or table fit
if !ok { return }
l.locked = true
l.apply(res) // hot-swap tier3.ResolverFromPlan into the live CC
}
FitBandPlan tries a linear model first (base frequency + spacing + offset —
most real DMR systems are linear) and falls back to an explicit table. Once it
locks, apply hot-swaps the resolver into the running Tier III control channel
via SetResolver, publishes KindDMRBandPlanLearned, and optionally persists the
plan to config. The learner then goes quiet. Bounded probe workers keep a ~700 ms
DDC confirmation tap from ever stalling the IQ pump into the onset detector — the
same “slow work off the hot path” discipline the engine uses.
The symbol scope: a microscope, not a decoder
The last tool in this episode isn’t a decoder at all — it’s the diagnostic you use
when a decode fails and you need to know why. The symbolscope package recovers a
live stream of the demodulated symbols — the pre-slicer soft waveform plus the
sliced dibit decisions — from a wideband feed, for the web console’s “Symbol” scope
(GopherTrunk’s answer to OP25’s Symbol plot).
The design rule is the one that makes it trustworthy: it reuses the production
DSP rather than re-implementing demod. The same ccdecoder.Downconverter the live
decoder channelizes with feeds the same protocol receiver, and the receiver’s
existing SoftSink / DibitSink taps surface the symbols. It runs as a separate
Engine on an iqtap broker subscription, so production control-channel decode is
never touched:
// internal/scanner/symbolscope/scope.go (shape)
type Frame struct {
SymbolRateHz float64
Soft []float32 // pre-slicer soft waveform (empty on CQPSK)
SymI, SymQ []float32 // complex symbol-decision points (CQPSK constellation)
Dibits []uint8 // sliced decisions, aligned index-for-index with Soft
BaseIdx int // absolute symbol index, so a client can detect gaps
}
For P25 Phase 1, the C4FM path emits the FM-discriminator soft waveform aligned
index-for-index with the sliced dibits — a four-level eye — while the CQPSK path
emits the complex symbol-decision points (SymI/SymQ), the true constellation
clustering at the four ±45°/±135° positions. The two views answer different
questions: the eye tells you timing and level, the constellation tells you phase.
When a signal produces valid dibits but no lock, the scope usually shows you which
one is wrong at a glance — a far faster diagnosis than staring at a decode-error
counter.
Where this goes next
Part 10 turns from decoding messages to decoding a cipher. The P25 talker-alias field — one of the most obscure corners of the standard — carries an encrypted display name, and framing it correctly is the whole first half of the puzzle the series has been hinting at since Part 1. For the standards here, the conventional radio, DMR Tier III and P25 Phase 2 reference pages go deeper.
FAQ
How does GopherTrunk record a conventional (non-trunked) channel?
The conventional scanner detects a carrier with an IQ-power squelch (and optional
CTCSS/DCS tone gate), then manufactures a synthetic trunking.Grant and hands it
to the engine via HandleSyntheticCall. From the engine’s side it’s an ordinary
one-call system, so the whole recorder and call-log stack is reused unchanged.
Can one SDR dongle decode multiple systems at once?
Yes. The widebandt2 engine pins one dongle to a centre frequency and uses a tuner
bank to extract a narrowband stream per channel, each feeding its own receiver.
With voice taps enabled it can even decode its own voice grants from the same IQ,
so a single dongle covers both control and voice within its band.
What is DMR LCN learning? A runtime discovery of the Tier III LCN→frequency band plan. The learner correlates carrier onsets on the wideband IQ with observed grants, decode-confirms the candidates with bounded DDC probes, fits a linear-or-table band plan, and hot-swaps it into the live control channel — so a system you didn’t pre-configure starts following voice.
Is the symbol scope the same decoder as the live path? It reuses the same production down-converter and the receiver’s soft/dibit taps, but runs as a separate engine on an IQ broker subscription so it can’t perturb live decode. What it renders is faithful to what the live decoder sees; it just doesn’t share the same instance.
Series navigation
Part 9 of 12 · ← Part 8: EDACS, LTR & MPT-1327 · Next → Part 10: The Alias Hunt I