Part 10 of DMR End to End, a 14-part deep dive that follows the world’s most widely deployed digital PMR protocol through GopherTrunk — from a 4FSK carrier to two simultaneous recorded calls, direct-mode handhelds, and decrypted Enhanced Privacy voice. Part 9 put one handheld on one dongle. This part goes to the other end of the scale: an X310 carrying two IPSC repeaters through a polyphase channelizer — and one tap going deaf for minutes while its neighbour decoded everything. Two decode paths, two ways for a tap to go quiet: the series’ running thread again.
TL;DR: The wideband tap for a 442.3875 MHz IPSC repeater logged
sync_hits=0for minutes on a 30 dB SNR carrier a DDC on the same IQ decoded continuously. Cause one: the repeater sat 0.48 bins off a channelizer bin centre, where a critically-sampled polyphase bin is −6 dB and folds the half of a 12.5 kHz channel past ±binRate/2 back onto itself.channelizer.Oversampled(Harris M/2, cutoff 0.75·Fs/M, bins at 2·Fs/M) makes the whole bin flat. Cause two, still open: the live receiver chain latches deaf in ~3-minute stretches at a steady −51 dBFS, sohealDeafTier2resets the Tier II receiver after three windows without sync within 6 dB of the level it last synced at — never an absolute dBFS. On 15 Sep that instrument named a third thing: the coarse acquirer freezing on a −20.1 kHz neighbour in every idle gap;Receiver.RejectCoarseCarrierOffsetnow rules out any engage the channel never synced under. The emitter is unidentified.
Key takeaways
- A bin edge is a channel boundary only if the prototype says so. 2x oversampling moves the Nyquist edge to ±Fs/M and the cutoff to 0.75·Fs/M, so a tap at residual 0.48 decodes like one at 0.
- Yield is the verdict, a tone is the pin. A synthetic carrier at residual 0.48 still granted through the old bank; the regression is tone flatness plus the capture harness.
- Gate health on the channel’s own level, never on dBFS. The deaf tap sat at −51 dBFS, below every absolute hint floor, so no WARN ever fired.
- Decode evidence settles what frequency cannot. A 12.5 kHz neighbour and a 45 ppm tuner error look identical to a discriminator mean; only an offset the tap decoded under is confirmed.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| 2x-oversampled channelizer | Harris M/2 polyphase: M/2 inputs per step, IDFT, per-bin rotation | internal/dsp/channelizer/oversampled.go (Oversampled) |
| Bank on top | fine-tune NCO + resampler per tap from 2·binRate | internal/dsp/tuner/channelizerbank.go (ChannelizerBank) |
| Bin-edge pin | tones at 0.45..0.51 bins leave the tap within [−1, +0.5] dB | TestChannelizerBankBinEdgeChannelIsFlat |
| Capture A/B | ChannelizerBank vs DDCBank, one tap each, same stream |
cmd/gophertrunk/dmr_ipsc_wideband_replay_test.go (GT_DMR_WB=1) |
| Deaf-tap heal | 3 windows no sync within 6 dB of last-synced level ⇒ Reset + ResyncReset |
internal/scanner/widebandt2/engine.go (healDeafTier2) |
| Coarse-offset reject | revert + ignore an engage the channel never synced under | dmrrx.Receiver.RejectCoarseCarrierOffset, coarse_reject_test.go |
| Counters | dibits, deaf_heals in the activity line |
widebandt2: channel decode activity |
In this post
- Two taps, one channelizer, one deaf — the field logs.
- The bin edge — −6 dB at the edge and a fold, measured on the capture.
- Oversampling the channelizer — Harris M/2 and what it costs.
- The deaf heal — resetting a receiver at its own decoding level.
- The −20 kHz emitter and the reject — decode evidence versus frequency.
Two taps, one channelizer, one deaf
The rig is the one
P25 End to End Part 11
described from the P25 side: role: wideband, an X310 at 6.25 MS/s,
tuner_strategy: polyphase, one channels: entry per carrier — here two
IPSC repeaters, Fire and Fire2, each driving its own dmrrx.Receiver and
Part 6’s
tier2.ConventionalChannel inside widebandt2.Engine. The bank is 32 bins
of 195.3125 kHz; Fire2 at 442.3875 MHz sits +687.5 kHz from centre —
3.52 bins, 0.48 of a bin from bin 4’s centre.
The operator’s 1200 s Signal Lab flac decodes 25 001 idle beacons and 14
grants through a DDC, and the 20-minute 12 Sep capture decodes every one of
its 26 transmissions (all 26 865 data bursts colour code 12 — not two colour
codes). The live tap logged sync_hits=0 for minutes at a time at its
normal −51 dBFS while Fire on the same channelizer decoded throughout; ten of
the 26 transmissions fell in those stretches.
The bin edge: −6 dB and a fold
TestDMRIPSCWidebandReplay re-creates the field geometry from a narrowband
capture: it interpolates the 25 kS/s slice to a wideband rate, mixes it to
the field offset, and feeds the same stream to a ChannelizerBank and a
DDCBank with one tap each. Over 300 s the old polyphase arm scored 1991
idle beacons and 2 grants against the DDC’s 6103 and 7, deaf in the same
stretches the live log showed. The mechanism is the prototype: a
critically-sampled bank emits each bin at Fs/M with a lowpass whose −6 dB
point sits on the bin edge, so
a 12.5 kHz channel centred 0.48 bins out is tilted by the roll-off and the
part past ±binRate/2 folds back across baseband:
| Tone residual (bins) | 0.45 | 0.48 | 0.50 | 0.51 |
|---|---|---|---|---|
| Old bank | −18.6 dB | −9.3 dB | −6.2 dB | −5.2 dB |
TestChannelizerBankBinEdgeChannelIsFlat now requires each within
[−1, +0.5] dB and unfolded, and fails on the old bank. What does not
work as a regression: a clean synthetic C4FM carrier at residual 0.48 still
granted through the old bank, because 200 header repeats need only a few
good bursts — a grant count is too forgiving to catch a 6 dB tilt, the
metrics-that-lie lesson
inverted.
Oversampling the channelizer
channelizer.Oversampled is the Harris M/2 polyphase structure: every
D = M/2 input samples it computes one output per bin,
// internal/dsp/channelizer/oversampled.go (shape)
// y_k[s] = e^{-j2πk·t_s/M} · Σ_r e^{+j2πkr/M} · u_r, u_r = Σ_p h[r+pM]·x[t_s−r−pM]
// proto: Kaiser prototype, cutoff 0.75·Fs/M; rot[q] = e^{-j2πq/M}
The polyphase partial sums and the M-point inverse DFT are the
critically-sampled bank’s. The new piece is the per-bin rotation
rot[(k·tmod) % M]: advancing output time by M/2 rather than M samples per
step leaves a phase the critically-sampled structure never has to correct.
Each bin emits at
2·Fs/M, its Nyquist edge moves to ±Fs/M, and with the cutoff at 0.75·Fs/M
everything within ±0.5 bin passes flat (TestOversampledBinEdgeToneIsFlat).
ChannelizerBank uses it unconditionally; its per-tap fine-tune NCO and
resampler run from binOutRateHz = 2·binRateHz, doubling per-tap cost: the
dense-71 bench moved from 1.5 to 2.8 ms per chunk against the DDCBank’s
11.8 ms — still cheaper, which is why
SDR Internals Part 5
picks polyphase above six taps. Post-fix the polyphase arm matches the DDC
arm window for window, and the old “dense plan crowds taps onto bin edges”
WARN is gone (the
polyphase filter bank
and channelizer pages have
the textbook version).
The deaf heal
Fixing the bin edge did not explain the 12 Sep log, because that tap was not
weak — it was deaf. Fire2 logged sync_hits=0 for six stretches of
180–210 s at −51 dBFS while offline replays of the same IQ — DDC,
channelizer, live-sized chunks via GT_DMR_WB_CHUNK=500 — decoded
everything. Nothing in the daemon runs on a ~3-minute timer and the coarse
acquirer never engaged on the capture; state accumulates inside the live
receiver chain, and the root cause of that latch is still open.
What the engine can do without knowing the cause is what a fresh offline
receiver does by construction — start clean. healDeafTier2 runs inline on
the pump goroutine at every diagnostics window: a channel that has synced
before and now sees no sync, FEC pass or beacon for tier2DeafHealWindows
= 3 consecutive windows (~15 s) at a power within tier2DeafHealMarginDb
= 6 dB of the level it last synced at gets Receiver.Reset() plus
tier2.ResyncReset(). The gate is the channel’s own decoding level,
never an absolute dBFS — the rule that stopped the “channel iq power very
low” WARN firing on a Tier III control channel decoding every C_ALOHA at
−56 dBFS. The deaf tap sat at −51 dBFS, below the −45 dBFS floor of the
“strong signal but no sync” hint, which is why no WARN had ever fired; an
unkeyed repeater drops well below its decoding level, so idle silence never
trips the heal. The WARN — widebandt2: conventional DMR tap deaf at its
decoding level — resetting the receiver — carries coarse_offset_hz,
agc_level, mm_mu and mm_sps: the receiver internals are the instrument
for the next log.
Two smaller defects fell out: dmrrx.Receiver.Reset left the timing loop
and demod history in place — a reset that was not a reset — and
Counters.Dibits was never incremented, so every activity line read
dibits=0. TestDeafTier2TapIsResetAtItsDecodingLevel fails against the
old engine, which never reset a Tier II receiver at all.
The −20 kHz emitter and the reject
The heal shipped, and the 15 Sep log showed it firing every ~15 s — 21 times
in 6 minutes — with coarse_offset_hz≈−20100 on a GPSDO-locked X310 that
decodes at 0 Hz. The repeater beacons in
~10 s idle-burst trains
(Part 7)
with ~5–9 s gaps; in the gap the tap’s power stays at −49 dBFS (Fire’s drops
5 dB, so it is not the repeater), and the coarse acquirer from Part 9 — a
discriminator mean with no bound and no decode check — froze on whatever
dominated the tap at −20.1 kHz, deafening the channel until the heal reset
it — in every gap.
By frequency alone the acquirer cannot tell a neighbour from a 45 ppm tuner
error — the case #836 built it for — so decode evidence decides.
healDeafTier2 confirms an offset in force for a whole window in which
the channel synced (an engage mid-window is not confirmed by a decoding
train’s tail) and rejects one the channel never synced under:
Receiver.RejectCoarseCarrierOffset reverts a live correction within
coarseAcqRejectHz = 500 Hz and ignores future candidates there, across
Reset. Pinned by TestCoarseAcquirerRejectedOffsetNeverReengages and the
TestDeafHeal* family. The WARN is now once per 10 minutes per channel.
What the −20 kHz emitter at ≈442.3675 MHz is remains unknown. The 15 Sep
capture does not cover it: the siglab grab was carved at the tuner centre
(441.7 MHz) rather than the typed 442.3875 MHz — a client-side coercion now
replaced by a strict parse plus requested_center_hz in the start line — so
it contains neither repeater. Geometry rules out the channelizer (the bin-4
image of Fire lands at +68.75 kHz). A ±100 kHz capture centred on
442.3875 MHz over one full idle cycle will name it. The reject path makes the
tap immune either way; do not add a
frequency bound to the acquirer — a 12.5 kHz adjacent channel sits inside any
bound that still serves #836. It is the two-pipelines discipline of
From the Issue Tracker Part 22
again: only an A/B on one stream could say which arm to fix.
How the bin edge shaped the Go code
- No critically-sampled mode survives.
ChannelizerBankbuildschannelizer.NewOversampledunconditionally and readsbinOutRateHz. - Pins are tones and captures, never grants. The flatness test asserts frequency and level per residual.
- Health thresholds are relative and per channel.
decodeDbFSis learned from the channel’s own syncs,acqConfirmedHzfrom a synced window. - The receiver exposes evidence hooks, not policy.
tier2DiagandcoarseRejecterare small interfaces the engine type-asserts.
Where this goes next
Everything so far has produced dibits, grants and superframes. The next part follows a DMR superframe into the vocoder — and to a bug that made every utterance after a pause start 24 dB too quiet. Part 11 diffs the AMBE+2 3600×2450 decoder frame by frame against two mbelib lineages and calibrates the unvoiced band.
FAQ
Why did one DMR tap on a wideband channelizer decode and its neighbour go deaf? Two causes. The repeater sat 0.48 bins off a bin centre, where the critically-sampled prototype is −6 dB and folds the part of the channel past the edge back onto itself. Separately, the live receiver chain latches deaf for ~3-minute stretches; that cause is still open, and the engine now heals it by reset.
What does channelizer.Oversampled change?
Each bin is emitted at 2·Fs/M instead of Fs/M, with the prototype cutoff at
0.75·Fs/M and the per-bin rotation an M/2-step structure needs. The whole
±0.5-bin span is flat within [−1, +0.5] dB, so a tap on a bin edge decodes
like one at centre, at double the per-tap fine-tune cost.
When does the wideband engine reset a DMR receiver?
After tier2DeafHealWindows = 3 consecutive diagnostics windows with no
sync, FEC pass or beacon while the channel’s power is within 6 dB of the
level it last synced at. An idle repeater falls well below that level and is
never reset.
What is coarse_offset_rejected=true in the deaf-tap WARN?
The coarse carrier acquirer had frozen on an offset the channel never decoded
under — on 15 Sep a −20.1 kHz neighbour in every idle gap — so the heal
reverted the correction and told the acquirer to ignore candidates within
500 Hz of it, across future Resets. An offset the channel synced under for
a whole window is confirmed instead.
Is the −20 kHz emitter identified? No. The 15 Sep capture was carved at the tuner centre, not the typed 442.3875 MHz, so it contains neither repeater nor the emitter; channelizer geometry rules the bank out. A ±100 kHz capture centred on 442.3875 MHz over one beacon train and gap will name it.
Series navigation
Part 10 of 14 · ← Part 9: Direct Mode — The Gaps That Blinded the Receiver · Next → Part 11: AMBE+2 Voice & the Silence-Frame Bug