Part 11 of P25 End to End, a 14-part deep dive that follows North America’s dominant trunking protocol through GopherTrunk — from a raw C4FM carrier to recorded, named, multi-site voice. Part 10 built the site map and roamed it one control channel at a time. This part removes the “one at a time”: pin a single SDR across a band and every site inside its IQ window decodes simultaneously, out of one capture. It is also where the series’ running thread lands hardest — the wideband tuner bank and the single-channel down-converter are twin paths, and the day a fix landed on one and not the other is the reason this repo has an issue-closing policy.
TL;DR:
role: widebandpins one dongle to a band centre; atuner.Bank(internal/dsp/tuner/) extracts a 48 kHz narrowband stream per configured channel, andinternal/scanner/widebandt2runs a per-channel P25 (or DMR/NXDN) state machine on each — multi-site P25 means listing each site’s CC as its own tap, decoded in parallel, not hunted. Voice grants ride the same capture via per-call DDC taps (internal/sdr/wbvoice,voice_taps: N). Two Bank implementations exist (DDCBankper-tap NCO+resampler,ChannelizerBankpolyphase;tuner_strategy: autopicks by tap count) — and the widebandDDCBankis a different code path from the single-channelccdecoder.Downconverterthatreplay -tune-hzand the hunting daemon use. A fix to one does not touch the other: that is how the #764 “fix” missed the #771 replay symptom.sdr.input_sample_rateadds a systemwide pre-decimation stage when the front end must run faster than you decode.
Key takeaways
- Parallel beats serial for multi-site. A hunted single channel visits sites one at a time (Part 10); a wideband bank watches every CC in the IQ window at once, and grants can be voice-tapped from the same samples without retuning anything.
- Two banks, one interface — and a second twin underneath.
DDCBankandChannelizerBankimplement oneBankcontract; below them, the wideband bank and the single-channelDownconverterare parallel DDC implementations that have provably drifted apart before. - Symbol clocks come from
OutputRateHz(), not the nominal target. A pathological input rate can land the tap a fraction of a percent off 48 kHz (issue #550) — a “baud drift” that looks exactly like a demod bug. - A wide capture spends a shared budget. One antenna, one gain, one
ADC across every tap (issue #749) — and raising the native rate can
lower in-channel quality (#764). The levers are
gain: "auto"andsdr.input_sample_rate, not more megasamples.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Wideband engine | per-channel receivers + state machines on one SDR | internal/scanner/widebandt2 (Engine) |
| Bank contract | AddTap / Process / OutputRateHz per tap | internal/dsp/tuner/tuner.go (Bank) |
| Per-tap DDC bank | one NCO mixer + rational resampler per tap | internal/dsp/tuner/ddc.go (DDCBank) |
| Polyphase bank | shared channelizer + fine-tune DDC, many taps | internal/dsp/tuner/channelizerbank.go (ChannelizerBank) |
| Voice taps | per-call virtual tuner on the wideband stream | internal/sdr/wbvoice (VirtualTuner, voice_taps) |
| The single-channel twin | live hunting daemon + replay -tune-hz DDC |
internal/scanner/ccdecoder/ddc.go (Downconverter) |
| Pre-decimation lever | native rate → decode rate at the Device boundary | sdr.input_sample_rate (internal/sdr/decimate.Device) |
In this post
- One capture, many channels — the wideband engine and multi-site P25 config.
- The bank — two implementations of one contract, and the #764 CPU story.
- Voice without retuning — per-call DDC taps as virtual tuners.
- The twin pair that made a fix miss — #764, #771, and the drift rules.
- What a wide capture costs — shared gain, CPU, and the honest limits.
One capture, many channels
A 2.4 MS/s dongle sees roughly ±1.08 MHz of usable band after guard — on
an 800 MHz system, easily several sites’ control channels plus most of
their voice channels. role: wideband pins the dongle to a centre
frequency and lists channels, each referencing a trunking.systems entry
whose protocol picks the state machine — p25 runs the Phase 1 TSBK chain
(Part 3),
p25-phase2 the MAC chain
(Part 7),
and DMR/NXDN mix freely on the same dongle. The per-channel receivers are
the same ones the rest of this series met — the twin discipline starts
with reusing them, not forking them.
For multi-site P25, each site’s control channel becomes its own
channels: entry pointing at the same system. Every tap decodes in
parallel out of the one capture — they are not hunted one at a time — so
the Part 10
site tracker fills in minutes instead of sessions.
The Hunt Part 9
tells this story from the discovery side. Sites of one system can even
differ in modulation: a per-channel p25_phase1_demod_mode override
(issue #935) lets a genuinely-linear LSM site decode on the CQPSK path
(Part 6)
while its C4FM siblings keep the default — and the config docs warn, in
bold, not to set cqpsk just because a site is simulcast: simulcast is a
transmitter-coordination technique, not a modulation.
The bank
The channelizer contract is small and both implementations honour it:
// internal/dsp/tuner/tuner.go (shape)
type Bank interface {
AddTap(offsetHz float64, sink SinkFunc) error
Process(src []complex64) // one wideband chunk in, every sink fed
InputRateHz() float64
// The rate the bank ACTUALLY emits — may differ a fraction of a
// percent from the nominal target (issue #550). Symbol clocks must
// be built from this value, not the nominal target.
OutputRateHz() float64
Reset()
}
DDCBank runs one NCO mixer plus rational polyphase resampler per tap —
O(taps × samples), simple and exact. ChannelizerBank amortises the
anti-alias work across taps with a shared polyphase filter bank plus a
fine-tune DDC per channel, which wins when tap counts grow;
tuner_strategy: auto picks ddc up to six channels and polyphase
above (SDR Internals Part 5
covers the DSP). That OutputRateHz comment in the contract is a scar: a
non-standard input rate can reduce to a pathological L/M ratio, and the old
fallback silently shifted the achieved rate — a 3.019 MS/s capture landed a
144 kHz tap at 143762 Hz, −0.165% of symbol rate, the “baud drift”
signature. The bounded bestRatioUnderCaps search (issue #550) lands
143998 Hz instead.
DDCBank also carries the CPU half of the #764 story. At 10 MS/s the old
bank ran a 208:1 single-stage decimation per tap, which starved the live
wideband pump goroutine until samples dropped at the hardware layer. The
fix is a shared integer pre-decimation stage that runs once over the
wideband stream, bringing it down to the ~2.5 MS/s regime the per-tap
resamplers were tuned for — and below that floor the path is deliberately
byte-for-byte identical to the pre-#764 behaviour, so the fix could not
silently change working configs.
Voice without retuning
A control-channel tap never moves; voice grants land wherever the band plan
puts them. internal/sdr/wbvoice closes that gap: a VirtualTuner
implements the same interfaces as a physical voice SDR
(SetCenterFreq for the
voice pool,
StreamIQ for the composer) but its SetCenterFreq allocates a per-call
DDC tap on the wideband stream instead of touching hardware. It emits
exactly 48 kHz (wbvoice.NarrowbandRateHz), so the composer’s decimator
collapses to a no-op. voice_taps: N sets how many concurrent calls one
dongle carries; a grant outside the IQ window (minus a 5% guard) returns
ErrOutOfBand, which the engine treats as “wrong tuner for this grant” and
routes to the next free device — so one wideband dongle plus one physical
voice SDR covers both the in-window common case and the out-of-window
stragglers (wideband-voice-taps
has the operator view).
The twin pair that made a fix miss
Now the hard lesson. GopherTrunk has two wideband-to-narrowband implementations, and they are not wired together:
ccdecoder.Downconverter |
tuner.DDCBank |
|
|---|---|---|
| Taps | one channel | many |
| Used by | hunting daemon’s CC path, replay -tune-hz |
role: wideband, wbvoice, siglab wideband decode |
| Rate handling | interpolates up too (sub-rate captures) | shared pre-decimation + per-tap resample |
| File | internal/scanner/ccdecoder/ddc.go |
internal/dsp/tuner/ddc.go |
When issue #764 (“P25 decodes at 2.5 MS/s but not 10 MS/s”) was first
“fixed”, the fix landed in the wideband DDCBank. The reporter’s replay
symptom lived in the single-channel Downconverter — a path the fix never
touched — so the issue was closed twice while the symptom was still live,
which is issue #771 and the origin of this repo’s issue-closing policy
(never close until a failing-first regression passes and the reporter
confirms). The full postmortem chain is
Ten Megasamples
and the
Two Pipelines finale;
the ending is worth restating because it changed the mental model. The
decode path is rate-invariant: both down-converters normalise to the
per-protocol channel rate
(Part 1’s
48 kHz), and ddc_highrate_test.go pins that a noisy channel reaches the
receiver at the same in-channel SNR whether decoded natively at 10 MS/s or
decimated to 2.5 MS/s. The reporter’s own captures then closed #764
honestly: the 10 MS/s file replayed at ≈9.5 dB demod SNR against the
2.5 MS/s file’s ≈19.7 dB, an independent resampler reproduced the same
deficit, and neither capture clipped — the ~10 dB was baked into the
samples (front-end phase noise at the Airspy’s native 10 MS/s clock),
not GT’s DSP. The decoder can only be as good as the samples.
The twins have since been converged where it counts: the #550 ratio
fallback was ported into the Downconverter explicitly to close the “two
separate DDC paths” divergence, and both files say so in comments. But the
structural rule stands — fix anything in one down-converter, go read the
other one.
What a wide capture costs
Three budgets, all shared across every tap:
- RF budget (issue #749): one antenna, one centre, one gain. A fixed
gain chosen for the strongest site leaves weak co-tenants flat at the
ADC floor; a hot site can desensitise everything. Prefer
gain: "auto"on multi-site dongles — the daemon logs a startup WARN for a fixed-gain multi-tap config — and watchgophertrunk_sdr_wideband_input_clip_ratio. A genuinely weak distant site may deserve its own dongle. - CPU budget: every tap is a full receiver. When decode falls behind,
the honest signals are the
ccdecoder: decode can't keep up with real timeWARN and, on network SDRs,soapyremoteoverrun drops — a downstream symptom, not a driver bug.sdr.input_sample_rateis the systemwide lever: the hardware runs at its happy native rate (an Airspy pinned to 10 MS/s) whiledecimate.Deviceinteger-decimates tosdr.sample_rateat the Device boundary, before the bank, the demods, and every recording tap. It must be an exact integer multiple, and it survives device reacquisition because it wraps the Device rather than threading a second rate through the daemon. - Physics budget: the lever above is a load and recording-size fix, not an RF fix — decimating 10 → 2.5 MS/s does not recover quality lost to native-clock phase noise (#764 again; The Analog Edge Part 6 is the operator’s guide to choosing rates).
Where this goes next
Wideband is P25 monitoring at its most parallel — and it still inherits the series’ weakest link: Part 12 faces the honest gap — the default C4FM voice path’s missing equalizer and soft FEC, why the fix is gated on a real capture, and what contributing one looks like.
FAQ
Can one SDR really monitor a whole P25 system?
If the system’s channels fit the dongle’s usable IQ window (sample rate
minus a 5% guard per edge), yes: every site’s CC as a wideband tap, voice
via voice_taps, a physical voice SDR as fallback for out-of-window
grants. Systems spread wider than one dongle sees need a second dongle —
each widebandt2.Engine owns one SDR and they share only the bus.
Why does only one site decode while its siblings sit at the noise floor?
The channelizer is gain-flat across taps, so that difference is real RF: a
weak or distant site at this shared centre and gain, or a strong site
overloading the shared ADC. Check the input clip ratio first — if it’s
non-zero, lower the gain or add attenuation; otherwise try gain: "auto"
(issue #749), and give a genuinely weak site its own dongle.
Should I raise the sample rate to cover more channels?
Only as far as the front end stays clean. More megasamples cost CPU per
tap and, on some hardware, in-channel quality — the #764 capture pair
measured ~10 dB worse demod SNR at the Airspy’s native 10 MS/s than at
2.5 MS/s, baked into the samples. If the hardware must run fast, set
sdr.input_sample_rate and decode at a lower sdr.sample_rate.
What’s the difference between tuner_strategy: ddc and polyphase?
Cost shape. ddc (DDCBank) pays per tap — simple and exact, fine to ~6
channels. polyphase (ChannelizerBank) pays mostly once in a shared
filter bank, winning as tap counts grow. auto picks by channel count;
both honour the same Bank contract, so decoders can’t tell them apart.
Why did the daemon decode a capture that replay wouldn’t (or vice versa)?
Historically: because they channelize through different code (DDCBank vs
ccdecoder.Downconverter) and a fix had landed on one side only — the
#764/#771 story. Today the paths are pinned by rate-invariance tests and
share the ratio math, but a daemon/replay disagreement is always worth
filing: it is a twin-path drift detector by construction.
Series navigation
Part 11 of 14 · ← Part 10: Sites, WACNs & Roaming a Multi-Site System · Next → Part 12: The Weak-Signal Gap — P1 Voice’s Missing Levers