DMR End to End, Part 9: Direct Mode — The Gaps That Blinded the Receiver

Part 9 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 8 finished the trunked story on a base station that never stops transmitting. This part turns to the carrier that does stop — a simplex handheld, on for 27.5 ms and off for 32.5 — and the bug that hid in those gaps for the entire life of #836. DMR’s one carrier has two cadences, and a receiver that only knows one is blind to the other.

TL;DR: A direct-mode (Tier I / simplex) DMR handheld transmits one 27.5 ms burst per 60 ms frame. In the 32.5 ms gap the FM discriminator of receiver noise is uniform over ±π rad/sample — several times the signal’s ±0.25 — and every slow tracker in internal/radio/dmr/receiver ate it: the symbol AGC’s 53 ms EMA inflated so no ±3 sync word ever sliced as ±3, the AFC decayed, the coarse acquirer’s mean halved and the Mueller-Müller loop random-walked. Fixes: carrierGate — the discriminator’s mean-removed variance (0.01–0.08 rad² in bursts, 2.3–3.9 in gaps), open below 1.0, close at 2.5, trackers held via ProcessGated, discriminator muted; feed-forward timing at every onset after a 100 ms absence (sync.EstimateSymbolPhase); and an acquirer that re-mixes its engage chunk and re-locks the clock only at ≥ 1400 Hz. On-air verified on the reporter’s 15 Sep captures; the live run on a fixed build is still open.

Key takeaways

  • A burst-mode carrier is a different signal, not a weaker one. The gaps fed noise to four trackers whose time constants were longer than a burst.
  • Gate on a statistic, never on dBFS. The discriminator variance is a phase increment — blind to IQ amplitude and carrier offset, a 30× separation between the populations at any gain.
  • Feedback loops need a seed. A Mueller-Müller loop started near a half-symbol offset takes seconds to pull in; a 96-symbol kurtosis search gives it the phase inside the first burst.
  • Green synthetic ≠ on-air. The back-to-back fixture passed for the issue’s whole life; the reporter’s clipped captures proved the fix — and the live daemon run is still the open gate.

Cheat sheet

Concern What it does Where it lives
Carrier presence running discriminator variance, hysteresis 1.0 / 2.5 rad², 4-symbol delay line internal/radio/dmr/receiver/carrier_gate.go (carrierGate)
Gated trackers AGC / AFC / timing updates hold on absent samples ProcessGated on demod.C4FMSymbolAGC, demod.CoarseAFC, sync.MuellerMuller
Onset timing kurtosis eye search, seeds SetPhase after a 100 ms absence receiver/timing_acq.go, internal/dsp/sync/timing_estimate.go
Coarse offset two agreeing 512-symbol windows above a 500 Hz deadband, frozen NCO; clock relock only ≥ 1400 Hz receiver/coarse_carrier.go (coarseAcqClockRelockHz)
Failing-first pins zero syncs ungated on a synthetic direct-mode stream receiver_burst_test.go, ccdecoder/pipelines_dmr_directmode_test.go
Real-air pins reporter’s 446.500 MHz slices, gated vs NoCarrierGate cmd/gophertrunk/dmr_directmode_realair_test.go

In this post

  • The transmission every fixture got wrong — gap noise, four poisoned trackers.
  • A squelch expressed as a statistic — the variance gate and what it holds.
  • Every keyup lands at a random phase — feed-forward timing acquisition.
  • The acquirer’s two traps — the engage chunk and the thrown-away lock.
  • What the reporter’s air proved, and what it hasn’t yet — the 15 Sep captures.

The transmission every fixture got wrong

A base-station DMR carrier is continuous — both timeslots are always transmitted — and every synthetic DMR fixture modelled exactly that, 132-dibit bursts laid back to back (see Part 3). A direct-mode MS — PMR446’s 446.500 MHz “DMR simplex”, the case of #836 — transmits one 27.5 ms burst per 60 ms TDMA frame and is off for the other 32.5 ms (ETSI TS 102 361-1 §4.2). In that gap the FM discriminator of receiver noise is uniform over ±π rad/sample — several times larger than the ±0.25 rad/sample the signal’s ±1944 Hz deviation produces at 48 kHz — in exactly the quantity every tracker averages. The symbol AGC’s 256-symbol EMA (53 ms) inflated every gap, so outer symbols sliced as inner and no sync word — all ±3 — ever matched. The post-clock CoarseAFC decayed to zero. The coarse acquirer’s 512-symbol window was ~54 % noise, so a 1.2 kHz offset read ~550 Hz, at its own 500 Hz deadband. And the Mueller-Müller error term random-walked between bursts.

Reproduced failing-first: the production Tier I and Tier II pipelines decoded zero sync words from a synthetic direct-mode stream at 27 dB SNR (TestDMRPipelinesDecodeDirectModeTransmission, TestReceiverDecodesDirectModeBurstCadence) while decoding the same bursts perfectly back to back — the self-consistent trap as a fixture and a receiver sharing a wrong world.

A squelch expressed as a statistic

The fix is an FM noise-quieting squelch written as a statistic. carrierGate keeps the running mean and mean-square of the discriminator over a four-symbol EMA window and reads the variance:

// internal/radio/dmr/receiver/carrier_gate.go (shape)
const (
    carrierGateOpenBelow = 1.0   // rad²: absent → present
    carrierGateCloseAt   = 2.5   // rad²: present → absent
)
g.mean += g.rate * (v - g.mean)
g.sq   += g.rate * (v*v - g.sq)
variance := g.sq - g.mean*g.mean
if g.open { g.open = variance < g.closeAt } else { g.open = variance < g.openBelow }

The two populations were measured on the reporter’s capture through the production DDC: 0.01–0.08 rad² inside every burst — even ADC-clipped — and 2.3–3.9 rad² in every gap. Mean removal makes it offset-independent: a ppm error is a constant bias, not variance. Scale invariance is inherent: a phase increment is blind to IQ amplitude, the coherence-over-dBFS rule MRC calibration learned the hard way. And an exactly-zero IQ sample closes the gate, since a dead input’s constant-0 discriminator would read as a quiet carrier.

direct mode: 27.5 ms burst · 32.5 ms gap · 60 ms frame burst: ±0.25 rad/sample gap: noise over ±π var 2.5 close 1.0 open present open, 4-symbol lag closes ~3 symbols late trackers HOLD · muted UPDATE HOLD · muted UPDATE
The gate reads the discriminator's variance, not the IQ level: bursts and gaps sit thirty times apart on that axis at any gain.

The decision does two things. The trackers hold — AGC, AFC, acquirer and Mueller-Müller skip absent samples through gated variants, byte-identical to plain Process when every flag is true. And the discriminator is muted: the RRC matched filter otherwise carries ±π gap noise into every burst’s first span — unmuted, half the header bursts failed BPTC. The statistic is causal, so the discriminator runs through a carrierGateSymbols · sps delay line while the decision does not, and on a continuous carrier the gated output is the ungated one shifted by exactly that lag — TestReceiverCarrierGateIsNoOpOnContinuousCarrier pins it dibit for dibit.

Every keyup lands at a random phase

The reporter’s captures then showed the next layer. The gate holds the timing loop across gaps within a transmission, but every keyup starts at a symbol phase of its own, and a Mueller-Müller loop pulls a phase error in at gain·error per symbol — from near a half-symbol offset, its unstable equilibrium, that takes seconds. Cold-started on the second PTT at ten sub-sample offsets, the receiver decoded the first burst at seven and took 1.3–2.8 s at the other three; live, the phase held from PTT 1 cost PTT 2 its entire ten-copy header train.

The fix is feed-forward acquisition in receiver/timing_acq.go. After an absence longer than timingAcqGapSymbols (480 symbols = 100 ms — a new transmission, not the next burst’s 156-symbol gap), the receiver skips timingAcqSkipSymbols = 10 symbols of filter transient, collects timingAcqSymbols = 96 symbol periods from inside the first burst, and hands them to sync.EstimateSymbolPhase: for each of the sps candidate phases it decimates and measures the kurtosis E[x⁴]/E[x²]² about the mean. A 4-level signal at its ISI-free instant takes four discrete values; between instants the ISI mixtures smear toward Gaussian. Lowest wins, a parabolic fit refines below one sample, and maxEyeKurtosis = 2.2 / minEyeContrast = 1.12 refuse a window with no eye. The moments are central, since an uncorrected carrier offset is a DC bias on the discriminator. The square-law Oerder-Meyr estimator was tried first and is useless here: at the C4FM family’s 20 % roll-off the symbol-rate line is often below the noise floor while the eye stays open. The estimate seeds MuellerMuller.SetPhase mid-chunk; the first burst’s own sync word is still lost, but DMR radios repeat their header. Acquisition arms only after an absence, so the continuous-carrier pin stays byte-identical (SDR Internals Part 7 has the loop this seeds).

The acquirer’s two traps

The coarse carrier acquirer (coarse_carrier.go) is the one frozen stage in the chain: it averages the discriminator over coarseAcqSymbols = 512 symbols, needs two consecutive windows clearing a 500 Hz deadband and agreeing within 250 Hz — a tuner offset repeats, noise doesn’t — then de-rotates once through an NCO and freezes. Fed only present samples, its halved-estimate failure is gone. Two more traps surfaced:

  • The correction applied “on the NEXT chunk”. A 4096-sample chunk is ~85 ms, a whole burst, so the rest of the engage chunk re-locked the reset loop against the still-shifted signal — 34/40 burst syncs with 10 ms chunks, 20/40 with 4096-sample ones. Process now re-mixes the engage chunk at the frozen offset.
  • clock.Reset() on engage threw away a good lock. A de-rotation moves nothing in the timing domain; at −1.2 kHz the reset dropped bursts from 129/132 to ~80/132 dibits while the loop crawled back. The clock now re-locks only at coarseAcqClockRelockHz = 1400 Hz — the #1165 notch where the sgn() error term genuinely breaks.

What the reporter’s air proved, and what it hasn’t yet

Everything above could still have been another self-consistent fixture. What settled #836 was Alvin’s three 20 s cs16 captures of 15 Sep — 446.500 MHz, 2.4 MS/s, gain auto / 200 / 250, −1.6..−1.9 kHz off the carrier, and still 36–40 % ADC-clipped by a handheld in the same room. Two 48 kHz slices are committed as cmd/gophertrunk/testdata/dmr-directmode-446500-*.cs16, and TestDMRDirectModeRealAirKeyup runs them through the production receiver and Tier II state machine: exactly one grant — tg 99 / src 3024109 / cc 1 — 34–39 voice superframes, zero uncorrectable AMBE frames, and zero sync words with NoCarrierGate — the real-air pin of the gate itself. TestDMRDirectModeRealAirOnsetAtEveryPhase demands first sync within 0.15 s at all ten start phases of the second PTT.

Two more defects fell out of the same captures. Part 5’s 0.25 s re-key rule (headerRekeyDibits = 1200) was measured from the first header copy — but this radio repeats its Voice LC Header ten times over 0.6 s, so every keyup produced a phantom release and re-grant; the anchor now follows the last copy (TestConventionalHeaderTrainIsOneKeyup). And direct-mode voice must slice at the 288-dibit cadence: the 132-cadence decoder sliced the gaps, so trunking.DMRVoiceCadenceDetected now defaults every DMR protocol to the cadence-detecting decoder.

On clipping: the 14 Sep capture at ~40 % with gain: auto was undecodable by construction — capture, replay and the wideband no-sync WARN now say “front-end overload” instead of “set sdr.ppm” — yet the 15 Sep files at 36–40 % decode, so “clipped ⇒ undecodable” was too strong (The Analog Edge Part 4, Nineteen Dibits). What is not verified: the reporter’s live daemon run on a build with these fixes — chunking, pool, composer and recorder — and #836 stays open until that recording lands.

How the gaps shaped the Go code

  • Gated variants, never gated callers. Each tracker grew a ProcessGated(…, present []bool) whose nil-present path is its old Process.
  • The gate lives on the calibrated path only. newCarrierGate is built when DeviationHz > 0 && !NoCarrierGate.
  • Measured constants carry their measurements. carrierGateOpenBelow and coarseAcqClockRelockHz cite the capture numbers that set them.
  • Fixtures model a PTT, not a loop. dmHeaderBursts cycles source IDs — one burst repeated for seconds carries a symbol-mean bias the AFC reads as drift.

Where this goes next

Direct mode is one handheld on one dongle. The other end of the scale is a wideband X310 carrying two IPSC repeaters through a polyphase channelizer — and one tap went deaf for minutes while its neighbour decoded everything. Part 10 follows that tap to the bin edge, the self-healing guard, and the still unnamed −20 kHz emitter.

FAQ

Why did GopherTrunk decode DMR repeaters but never a simplex handheld? A repeater transmits continuously; a direct-mode handheld sends one 27.5 ms burst per 60 ms frame. In the 32.5 ms gaps the FM discriminator of receiver noise swings over ±π rad/sample and inflated the symbol AGC, decayed the AFC and random-walked the timing loop.

What is the DMR carrier gate and does it use a power threshold? It is carrierGate in internal/radio/dmr/receiver: the running mean-removed variance of the discriminator output, open below 1.0 rad² and closed at 2.5 rad², with a four-symbol delay line. No dBFS anywhere — a phase increment is blind to IQ level and carrier offset, so bursts and gaps separate at any gain.

Why does the first PTT after start-up decode late while later ones decode from the first burst? With a tuner offset above ~1 kHz the coarse acquirer needs two agreeing 512-symbol windows of present samples (~0.5 s of bursts) before it freezes its correction. Set sdr.ppm from gophertrunk capture’s measured line and the first PTT decodes too.

Is the direct-mode fix verified on air? Offline, yes: the reporter’s 15 Sep 446.500 MHz captures decode through the production receiver and Tier II state machine (tg 99, radio 3024109, 34–39 superframes, zero uncorrectable AMBE) and yield zero sync words with the gate off. The reporter’s live daemon run on a fixed build is still the open gate on #836.

Can a 36–40 % ADC-clipped capture really decode? On these captures it did — the discriminator variance stays inside the gate’s signal population even when clipped — which refuted “clipped ⇒ undecodable” as a rule. Clipping still costs margin; the 14 Sep capture at ~40 % with gain: auto did not decode.

Series navigation

Part 9 of 14 · ← Part 8: Tier III Trunking — C_ALOHA, Grants & LCNs · Next → Part 10: Wideband DMR — Bin Edges & the Deaf Heal