Part 9 of The Hunt. By now our stray 851–869 MHz carrier is no longer a
mystery: it locked (Part 7), and its control channel named a band plan and a
list of neighbour sites — a multi-site system, not a lone repeater. The map
in the DiscoveredSystem says there are three or four control channels out
there. This post is about confirming that: how GopherTrunk watches every one of
those control channels at the same time on a single wideband dongle, so the
neighbours the CC advertised actually show up decoding.
TL;DR: A wideband monitor pins one SDR to a band centre and streams its whole IQ band continuously. A
tuner.Banksplits that band into one narrowband (48 kHz) stream per channel, and each stream feeds a protocol-specific receiver plus a per-channel control-channel state machine that publishescc.locked/granton the shared bus. So a four-site P25 system’s four control channels decode in parallel off one front end —internal/scanner/widebandt2. The catch is a shared front end: one gain, one ADC, one noise floor for every site — and the engine spends most of its diagnostics budget making the failure modes of that visible.
Key takeaways
- One dongle, N channels. The engine pins the SDR to a fixed centre and a
tuner.Bank(a per-tapDDCBankor a shared polyphaseChannelizerBank) extracts a 48 kHz stream per control channel — the same narrowband rate the single-channel decoder targets, so a wideband tap decodes identically to a dedicated dongle. - Strategy scales with channel count. At ≤6 channels a per-tap DDC wins on simplicity; above that the shared channelizer’s amortised wide-band filter is the only thing that stays real-time — a dense 71-repeater plan benches ~6× cheaper on the channelizer.
- A shared front end is the whole risk. One fixed gain can only be right for one site, so the engine warns loudly about pinned gain, ADC-clip overload, and tuner-offset — the failure modes a per-tap power gauge alone can’t see.
- The engine yields to a hunt.
Suspend/Resumelet a live hunt borrow the wideband SDR mid-run and retune it, then hand it back on-channel — so discovery and steady-state monitoring share one radio.
Cheat sheet
| Piece | What it does | Where it lives |
|---|---|---|
Engine |
owns one wideband SDR + the per-channel fan-out | internal/scanner/widebandt2/engine.go |
tuner.Bank |
band IQ → one 48 kHz stream per channel | internal/dsp/tuner (DDCBank, ChannelizerBank) |
buildChannel |
wire the right receiver + state machine per protocol | engine.go (buildChannel) |
pickStrategy |
DDC vs polyphase by channel count | engine.go (pickStrategy) |
Suspend/Resume |
lend the SDR to a live hunt and take it back | engine.go |
| Diagnostics | low-power / overload / no-sync WARNs | engine.go (maybeLogDiagnostics) |
In this post
- Why watch in parallel — and why the CC hunter alone isn’t enough.
- The tuner bank — one wide band split into a narrowband stream per channel.
- DDC vs polyphase — the strategy that scales with the plan.
- The shared front end — one gain, one ADC, and the WARNs that make it legible.
- Yielding to a hunt — how the engine lends its SDR mid-run.
Why watch in parallel
The control-channel hunter does one thing beautifully: it retunes a single radio across candidate frequencies until one locks. That is exactly right for finding a system. It is the wrong shape for watching one. A multi-site P25 system has a control channel per site, and each site’s CC advertises the others as neighbours — but you only ever see a neighbour advertised, never decoding, if you can only listen to one frequency at a time. To confirm the topology our carrier claimed — to see RFSS 2 / Site 4 actually broadcasting its own identity — we have to be on all of those frequencies simultaneously.
You could buy four dongles. Or you could notice that four control channels
spread across a couple of megahertz all fit inside one SDR’s IQ band. An
Airspy at 10 MS/s covers 10 MHz; a whole 800 MHz P25 system’s control channels
live in a fraction of that. internal/scanner/widebandt2 is the engine that
turns one wide capture into many decoders.
The tuner bank: one band, many streams
The engine’s core idea is a fan-out. One goroutine pumps the SDR’s IQ chunks;
a tuner.Bank decimates that wide stream into one narrowband stream per
configured channel; each stream drives its own receiver and state machine. The
package doc says it plainly:
// internal/scanner/widebandt2/engine.go (shape)
// The dongle is pinned to a configured centre frequency; an
// internal/dsp/tuner.Bank extracts one narrow-band IQ stream per channel
// inside the dongle's IQ band; each stream feeds a protocol-specific
// receiver and per-channel state machine that publishes cc.locked / grant
// events on the bus.
const narrowbandRateHz = 48_000.0 // per-tap output rate
That 48 kHz is not arbitrary — it is the same rate the single-channel
ccdecoder down-converter targets (≈10 samples/symbol at 4800 baud). A P25
receiver sizes its matched filter and clock loop to that rate, not the capture
rate, so a channel decoded via a wideband tap behaves identically to one on its
own dongle. This is the rate-invariance property the whole codebase leans on,
applied one level up: the bank absorbs the capture rate, the receiver never sees
it.
Each channel is added as a tap — an offset from the dongle centre plus a sink that folds power for diagnostics and feeds the receiver:
// internal/scanner/widebandt2/engine.go (shape) — New, per channel
offset := float64(ch.FrequencyHz) - float64(opts.CenterFreqHz)
ec, _ := buildChannel(sys, ch, bank.OutputRateHz(), opts.Bus, log, opts.Now)
sink := func(out []complex64) {
ec.pwr.Add(out) // window-averaged dBFS gauge
ec.receiver.Process(out) // → DibitSink → per-channel state machine
}
bank.AddTap(offset, sink)
buildChannel is where a channel’s protocol identity enters. It dispatches on
the referenced system’s protocol and wires the matching receiver to the matching
state machine — DMR Tier II conventional, DMR Tier III trunked, P25 Phase 1, or
P25 Phase 2 — behind two tiny interfaces (narrowbandReceiver.Process([]complex64)
and channelProcessor.Process(dibits, baseIdx)) so the engine treats every
channel uniformly. For the trunked protocols it also enforces a rule: the tap’s
frequency must be one of the system’s declared control_channels
(requireControlChannel) — a control-channel state machine only makes sense on a
control channel.
DDC vs polyphase: the strategy that scales
There are two ways to split the band, and the right one depends on how many
channels you are watching. pickStrategy chooses:
// internal/scanner/widebandt2/engine.go (shape)
const strategyAutoThreshold = 6 // DDC below, channelizer above
func pickStrategy(requested string, channelCount int) (kind, tag string) {
switch requested {
case "ddc": return "ddc", "ddc"
case "polyphase": return "polyphase", "polyphase"
case "", "auto":
if channelCount <= strategyAutoThreshold {
return "ddc", "auto(ddc)" // a handful of taps: linear, no bin constraint
}
return "polyphase", "auto(polyphase)" // many taps: one shared filter
}
// …explicit values honoured verbatim
}
A DDCBank runs an independent NCO + decimator per tap — perfect for a few
control channels, no bin-alignment constraints. But it scales linearly: 70 taps
means 70 reduced-rate resamplers, and the host stops keeping up. A
ChannelizerBank instead runs one shared polyphase filter/FFT across the
whole band and reads each tap out of a bin — a dense 71-repeater DMR plan benches
~6× cheaper on the channelizer than on a per-tap DDC. The cost is that a bin
has a fixed width, so a carrier that lands near a bin edge decodes at reduced
SNR. The engine keeps the bin width roughly constant (channelizerBinsFor aims
for ~150 kHz per bin, so a 10 MS/s band gets ~64 bins instead of 16), and when a
dense plan crowds taps onto bin edges it warns rather than silently
degrading — the channelizer is still the only bank that stays real-time at that
tap count, so the trade-off is made visible, not avoided.
How that principle shaped the Go code
- Two interfaces hide every protocol.
narrowbandReceiverandchannelProcessorare each one method.buildChannelis the only place that knows P25 from DMR; the pump loop drives whatever it built. - The bank sizes itself from the plan.
Newpre-computes every tap’s offset and the widest one, so aDDCBankcan size its shared decimator to keep the outermost tap in band and the channelizer can pick a bin count — the plan drives the DSP, not a fixed constant. - Rate-invariance is reused, not reinvented. Because taps emit at 48 kHz, the per-channel receivers are byte-for-byte the ones the dedicated-dongle path builds. A wideband channel is not a second decoder — it is the same decoder fed differently.
The shared front end is the whole risk
Everything above is upside. The downside is a single sentence: one dongle means one gain, one ADC, and one noise floor for every site. A control-channel hunter tunes to one site and the AGC settles on it; a wideband engine serves four sites at four signal strengths through one front end, and a single fixed gain can only be right for one of them. The strongest co-tenant sets the level and a weaker site sits below the shared ADC floor and never decodes. So the engine warns — once, at startup, before any traffic:
// internal/scanner/widebandt2/engine.go (shape) — New
if len(opts.Channels) > 1 {
if tenthDB, fixed := fixedGainTenthDB(opts.Gain); fixed {
log.Warn("widebandt2: multi-channel wideband device is pinned to a fixed " +
"gain — one gain can't serve sites at different signal strengths off a " +
"shared front end … try 'gain: auto' (AGC) on this dongle (issue #749)")
}
}
The runtime diagnostics (maybeLogDiagnostics, once per power window) round it
out, and each one localises a different fault:
- Channel power very low but the wideband input is healthy → the carrier is
outside the captured passband or mistuned, or a weak co-tenant is under a
fixed gain — the WARN suggests
gain: auto. - Wideband input clipping (ADC rail-pinned above a small ratio) → a strong site is overloading the shared ADC and burying the weak taps. The fix is less RF, not more — the WARN says so explicitly, because the instinct is always to raise gain.
- Strong in-channel signal but zero sync for three windows → the classic fingerprint of an uncorrected tuner PPM error. A narrowband 4-level demod tolerates only ~±75 Hz of carrier offset while an FM audio app still sounds fine, so a mistuned dongle “works” everywhere except here (issue #836).
None of these are decode logic — they are operator feedback, and on a shared front end they are the difference between “one site decodes and I don’t know why the others don’t” and a WARN that names the cause.
Yielding to a hunt
There is one more piece that ties this post back to the series. A live hunt needs
an SDR, and a wideband dongle is an SDR sitting right on the band you want to
survey. So the engine can lend it. Suspend makes the pump keep draining the IQ
stream (so the SDR never blocks) but process nothing; Resume reprograms the
dongle back to the engine’s centre frequency — because the borrower retuned
it — and clears the flag:
// internal/scanner/widebandt2/engine.go (shape)
func (e *Engine) Resume() {
if !e.suspended.Load() {
return
}
// The borrower retuned the shared SDR; put it back on-channel first.
if err := e.device.SetCenterFreq(e.centerHz); err != nil {
e.log.Warn("widebandt2: resume retune failed; resuming anyway", "err", err)
}
e.suspended.Store(false)
}
Without Suspend, an engine whose SDR had been retuned by a hunt would decode
off-frequency IQ — flooding the log with low-power WARNs and emitting spurious
grants whose voice taps capture silence. With it, the same dongle that watches
our system’s four control channels all day can be borrowed for a five-minute
survey and handed back on-channel, no drift. Discovery and steady-state
monitoring share one radio.
Where this goes next
We have now watched our system’s control channels in parallel and confirmed its
sites. But every result so far came off the air, live. Part 10
asks the question that makes any of this trustworthy: does a find reproduce?
It walks the offline survey path — replaying a recorded capture through the same
sweep → identify → map pipeline — and shows why what you decode from a .cfile
is byte-for-byte what you’d decode on the air.
FAQ
Is this the same decoder as a single-channel control receiver?
The middle is identical. Every tap emits at 48 kHz, the same rate the
single-channel ccdecoder down-converter targets, and the per-channel receivers
are the same P25/DMR receivers built with the same constants. Only the front —
one shared bank instead of one dedicated down-converter — differs.
When should I force polyphase instead of auto?
Rarely — auto picks the channelizer above six channels, which is the right call
for a dense plan. Force ddc only for a handful of channels where you want no
bin-alignment constraint at all; force polyphase if you have a mid-count plan
whose host is falling behind on per-tap DDC cost.
Why does one site decode and the others don’t?
Almost always the shared front end. A fixed gain suits the strongest site and
buries the weak ones below the ADC floor, or a strong site is clipping the ADC
and raising the noise floor for everyone. Try gain: auto, and check the
overload WARN — the fix there is to reduce gain, not raise it.
Can one wideband dongle both watch a system and run a hunt?
Yes — that is what Suspend/Resume are for. A live hunt borrows the SDR,
retunes it to sweep, and hands it back; Resume reprograms it to the engine’s
centre so the next chunk is on-channel again.
Does the wideband engine decode voice too? It can — published grants flow through the trunking engine’s voice-pool allocator, which can bind a grant to a per-grant DDC tap on this same wideband IQ stream (a “virtual voice tap”), so a wideband dongle can record its own voice grants without a separate voice SDR when the grant lands inside its IQ window.
Series navigation
Part 9 of 14 · ← Part 8: DMR LCN Correlation — Rebuilding a Channel Map · Next → Part 10: Offline vs Live Surveys — Hunting a Recording