Part 9 of TETRA End to End, a 14-part deep dive into how GopherTrunk turns one real 25 kHz TETRA carrier into clear recorded voice. Part 8 stopped throwing away the demod’s confidence, and the marginal same-carrier call got dramatically better — but not clean. What remained was not noise: the π/4-DQPSK constellation on the reporter’s concurrent-load captures was *smeared, each symbol dragged by its neighbours. That is a linear channel — multipath, band-edge group delay, ISI — and no amount of per-bit confidence fixes it, because the symbols themselves are in the wrong places. This part inverts the channel: a blind equalizer on the voice path, and the one design constraint that makes it survivable in front of a differential decoder.*
TL;DR: The residual garble after soft decision was linear channel / ISI smearing the constellation — not front-end degradation. A blind CMA equalizer (
equalizer.SnapshotCMA, wired via the receiver’sEnableEqualizerin the voice composer) between symbol-timing recovery and the differential decoder roughly doubled CRC-valid TCH/S yield across six captures — soft-decision 410→778 (~1.9×), one call 4→207, another 42→134 — with no loss on already-clean captures. The load-bearing design: adapt a tracking filter continuously, apply a FROZEN snapshot refreshed every 200 symbols, because CMA’s phase wanders as it adapts and a time-varying phase does not cancel ins·conj(prev)— streaming-adaptive CMA drives CRC yield to exactly zero. The trained siblingSnapshotLMS(per-burst midamble training in theTrafficExtractor) is staged behindGT_TETRA_LMSfor capture A/B.
Key takeaways
- CRC yield is the only trustworthy metric; EVM is a trap. A numerically-unstable CMA variant once showed differential EVM collapsing 34%→8% while CRC-valid bursts stayed 0. Blind CMA minimises modulus, not correctness — never conclude an equalizer helps from EVM.
- Never feed a continuously-adapting equalizer to a differential decoder.
CMA’s cost is rotation-invariant, so its output phase wanders with the taps;
the wandering phase corrupts every
s·conj(prev). Frozen snapshots impose only a constant phase, which the differential cancels. - Normalise by a constant, not a local EMA. The CMA update scales with |x|³; an EMA tracking the TDMA downlink’s slot-to-slot power swings gives a moving modulus target and CMA converges to garbage. Cumulative-mean normalisation plus a divergence guard keeps it sane.
- The equalizer can only help. Center-spike initialisation means the output is the unmodified input until CMA converges — clean captures measure no regression.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Blind equalizer | CMA(2,2) tracking + frozen applied snapshot | internal/dsp/equalizer/snapshot_cma.go (SnapshotCMA) |
| Receiver wiring | insert between timing recovery and diff decode | internal/radio/tetra/receiver/receiver.go (Options.EnableEqualizer) |
| Defaults | 11 taps, µ=6e-3, snapshot every 200 symbols | receiver.go (DefaultEqualizerTaps / Mu / Snapshot) |
| Voice-chain enable | composer turns it on for every TETRA follow | internal/voice/composer/tetra_voice.go (EnableEqualizer: true) |
| Divergence guard | re-seed to pass-through if taps blow up | snapshot_cma.go (snapshotDivergeGuard) |
| Trained sibling | per-burst midamble-trained LMS, frozen taps | internal/dsp/equalizer/snapshot_lms.go (SnapshotLMS) |
| LMS wiring | train on NTS midamble, re-derive soft LLRs | internal/radio/tetra/traffic.go (EnableLMSEqualizer, equalizedBurstDiffs) |
| Proof | ISI recovery + clean-channel no-harm + capture sweep | snapshot_cma_test.go, receiver_equalizer_test.go, traffic_lms_test.go |
In this post
- Diagnosing ISI, not noise — why the residual garble had structure.
- Why plain CMA kills a differential decoder — the phase-wander failure.
- The snapshot trick — adapt continuously, apply frozen.
- Normalisation and the divergence guard — the two quiet failure modes.
- The trained sibling: LMS on the midamble — staged, not yet default.
Diagnosing ISI, not noise
After Part 8, the reporter’s concurrent-load captures still garbled. The tempting diagnosis was the one #764 taught us — front-end degradation baked into the captured samples. But the signature was different. A signal-limited capture is noisy: the constellation is a fuzzy cloud centered on the right points. These captures showed points dragged into crescents and smears — each received symbol a weighted sum of its neighbours. That is a linear channel: multipath reflections, band-edge group delay from the channel filter, ISI. The distinction matters because the remedies are disjoint — you cannot equalize thermal noise, and you cannot out-gain ISI. The general taxonomy is Weak-Signal Engineering Part 3; here it meant one thing: the fix was a filter, and it had to be blind, because a voice follow has no training preamble before the speech starts.
Why plain CMA kills a differential decoder
The classic blind equalizer is CMA — the constant modulus algorithm.
π/4-DQPSK symbols all sit on the unit circle, so drive an adaptive FIR to make
its output’s modulus constant and it must be inverting whatever smeared the
ring: J = E[(|y|² − R²)²]. The package’s plain CMA does exactly this and
works fine ahead of a coherent slicer.
Ahead of a differential decoder it is fatal, and the reason is worth engraving.
CMA’s cost function only sees modulus — it is rotation-invariant. Nothing
constrains the equalizer’s output phase, so as the taps adapt, that phase
wanders freely. A coherent receiver’s carrier loop tracks the wander out. But
the differential decode forms s[n]·conj(s[n−1]), and a phase that changes
between consecutive symbols does not cancel there — it adds directly to the
decision angle of every dibit. Measured, not argued: wiring the
continuously-adapting CMA into the TETRA voice path produced CRC yield 0.
Frozen taps produced baseline. The failure is structural, not a tuning issue —
Weak-Signal Engineering Part 4
derives it from the cost function.
The snapshot trick
The resolution is to separate adapting from applying:
// internal/dsp/equalizer/snapshot_cma.go (shape) — Process
// Output uses the frozen filter (phase-coherent between snapshots).
var y complex64
for k := 0; k < n; k++ {
y += e.wApply[k] * e.buf[n-1-k]
}
// Adapt the tracking filter by CMA(2,2): e = ya·(|ya|² − 1).
var ya complex64
for k := 0; k < n; k++ {
ya += e.wAdapt[k] * e.buf[n-1-k]
}
/* … CMA tap update on wAdapt, divergence guard … */
if e.since++; e.since >= e.snapEvery {
copy(e.wApply, e.wAdapt) // refresh the frozen snapshot
e.since = 0
}
wAdapt updates every sample and its phase wanders — it is never applied.
wApply is a snapshot of it refreshed every snapEvery samples (200 by
default, ≫ a 255-symbol burst’s data span) and is the filter the output
actually sees. Held fixed, wApply imposes only a constant phase and scale —
exactly what the differential decode cancels by construction — while still
inverting the channel. The one symbol per snapshot that straddles the refresh
sees a phase step; the FEC absorbs a dibit or two per 200. And because
wApply starts as a center spike, the pre-convergence output is the unmodified
input: on a clean capture the equalizer is a near-noop, which is why the sweep
measured no loss where there was nothing to fix. The pattern is general
enough that it earned its own theory post —
Weak-Signal Engineering Part 5.
Normalisation and the divergence guard
Two quieter failure modes cost real time before the yield numbers appeared,
and both are pinned by snapshot_cma_test.go.
Normalisation. The CMA tap update scales with |x|³, so the input must be
normalised toward unit power for the R² = 1 target to mean anything. The
obvious choice — a local EMA power estimate — is wrong for TETRA specifically:
the TDMA downlink’s power swings slot to slot, an EMA tracks the swings, and a
moving modulus target makes CMA converge to garbage (CRC 0, again, even
though a global-RMS normalise gave the full win). SnapshotCMA instead divides
by a cumulative mean — cumSum/count, converging to the whole-session RMS
and then staying put. A constant scale is a constant target.
Divergence. A normalisation transient or a deep fade can blow the tracking
taps up; without a guard, one bad patch poisons every later snapshot. The guard
is blunt and effective — if any tap’s squared magnitude exceeds
snapshotDivergeGuard (|tap| > 3), the tracking filter re-seeds to a center
spike and starts over. The applied filter keeps its last sane snapshot in the
meantime. The general pattern is
Weak-Signal Engineering Part 6.
The verdict metric for all of it: CRC-valid TCH/S bursts, never EVM
(Weak-Signal Engineering Part 2
is the full indictment). Across the six reporter captures: soft-decision yield
410→778 (~1.9×), one call 4→207, another 42→134, clean captures
unchanged. The composer enables it for every TETRA voice follow
(EnableEqualizer: true in runTETRAVoiceChain), alongside the voice-only
EnableDCBlock — a distinction Part 10 returns to on the control-channel side.
The trained sibling: LMS on the midamble
Blind CMA pins only the modulus; a training sequence pins the channel inverse
exactly, phase and all. Every TETRA burst carries a known 11-dibit midamble
(NTS1/NTS2), and the #1001 follow-up wires a trained equalizer around it:
TrafficExtractor.EnableLMSEqualizer trains an equalizer.SnapshotLMS on each
burst’s midamble, freezes the taps (the same differential-safety principle),
equalizes the BKN1..BKN2 span with a taps-long FIR warm-up, and re-derives the
soft LLRs from the equalized symbols — the hard frame is untouched:
// internal/radio/tetra/traffic.go (shape) — equalizedBurstDiffs
ref := te.midambleRef(L, ntsLen) // ideal midamble from a unit anchor
rx := te.symBuf[m0 : m0+ntsLen+1] // raw symbols via StashSymbols
te.lms.Reset()
te.lms.Train(rx, ref, te.lmsPasses) // 80 passes over 11 dibits
span := te.lms.Equalize(te.lmsSpan[:0], te.symBuf[s0:s1])
/* … differential-decode consecutive equalized symbols → 216 diffs … */
The subtlety: the linear channel is a clean convolution only in the raw
symbol domain — s·conj(prev) is a nonlinear product in which it is not —
so the extractor carries raw symbols down a second parallel buffer
(SymbolSink → StashSymbols, the symbol analog of Part 8’s soft bridge). The
reference is differentially encoded from an arbitrary unit anchor, because a
constant start-phase rotation cancels downstream — the same property that makes
frozen snapshots safe. traffic_lms_test.go pins synthetic multipath through
the real extractor at raw 13% → 0% payload bit-error with no harm on clean.
It is opt-in and soft-path-only: production composers still run CMA alone, and
GT_TETRA_LMS=1 in TestTETRAMultiSlotReplay runs the capture A/B (compare
traffic_marked_crc_soft) that gates flipping it on.
Weak-Signal Engineering Part 7
owns the trained-equalization theory.
Where this goes next
The voice path now stacks three levers on a marginal carrier: soft decisions, a blind equalizer, and a staged trained one. But the control channel — the thing every voice follow depends on — was quietly running with none of them in one specific configuration, and a reporter’s one-hour session showed exactly what that costs: ~210 control-channel transitions and 11 hard sync losses. Part 10 reads that session’s forensics, rules out the compute theory, and finds the one TETRA CC path that wasn’t running SnapshotCMA.
FAQ
How do I know my garble is ISI and not a weak signal? Look at the constellation and the yield pattern. Noise fuzzes points symmetrically and degrades everything at once; ISI smears points into structured crescents and hits the differential decode hardest. Practically: if the equalizer roughly doubles CRC yield, it was ISI; if nothing moves, revisit gain and antenna (#764’s lesson — the equalizer mitigates a channel, not a front end).
Why not just use the LMS equalizer everywhere and skip CMA?
Different niches. CMA is framing-free — it runs in the receiver on the
continuous stream with no idea where bursts sit, which is what the control
channel and the pre-lock voice path need. LMS needs the midamble location, so
it lives in the extractor, per burst. And LMS is still gated on a capture A/B
(GT_TETRA_LMS=1) before it becomes a production default.
What does the equalizer cost on a clean signal?
Measurably nothing but CPU. Center-spike initialisation makes the
pre-convergence output the unmodified input, and on a clean ring CMA’s gradient
is near zero, so the taps stay near pass-through.
TestReceiverEqualizerCleanChannelNoHarm pins it — with one caveat: a
literally noise-free constant-modulus synthetic is a degenerate CMA input, so
the test adds 30 dB AWGN to be a fair fixture.
Why 200 symbols between snapshots? It must be long against a burst (so each 255-dibit burst’s data span sees one constant filter — the straddling symbol is FEC noise) and short against the channel’s drift. 200 at 18 ksym/s refreshes ~90×/s, plenty for the slowly varying multipath these captures show, and converges within a few hundred symbols from cold.
Could the equalizer hide a real RF problem? It can mask moderate ISI from a bad antenna path, which is why the health metrics still surface the underlying quality. The rule stands: raise the signal too. An equalizer buys back the linear channel, never the noise floor.
Series navigation
Part 9 of 14 · ← Part 8: Going Soft — Soft-Decision TCH/S · Next → Part 10: The Control Channel Under Stress — Sync Loss & the CC Equalizer