Part 4 of Weak-Signal Engineering, a 14-part deep dive into decoding the marginal regime, where a receiver locks but under-decodes. Part 3 established that the smear on our thread capture is a convolution — invertible by another filter, if only we could find it. The catch: on a live control channel there’s no training sequence conveniently aligned for us, no pilot, no known symbols at all from the receiver’s point of view. This part is about finding the inverse filter *blind — using nothing but a statistical property the transmitted signal is known to have — and about the exact price that bargain exacts, which Parts 5 and 6 then pay down.*
TL;DR: PSK-family signals leave the transmitter with constant modulus — every symbol on one circle. A dispersive channel destroys that property; the Constant Modulus Algorithm exploits it by adapting FIR taps to minimise the Godard cost
J = E[(|y|² − R²)²]by stochastic gradient descent:w ← w − μ·(|y|²−R²)·y·conj(x), five lines ininternal/dsp/equalizer/cma.go. It is genuinely blind — no reference, no framing — which is why it can run inside a receiver that hasn’t synchronised yet. The costs: the cost function never mentions correctness (spurious constant-modulus minima — Part 2’s EVM trap is one), its output phase is unconstrained (fatal before a differential decoder — Part 5), the update scales with |x|³ so normalisation is load-bearing (Part 6), and a literally noise-free constant-modulus input is a degenerate case — which is why GopherTrunk’s synthetic equalizer tests deliberately add noise.
Key takeaways
- CMA needs to know one thing about the signal, and PSK guarantees it. Constant envelope is a property of the modulation, known before any decode. That’s what makes blind operation possible on an unsynchronised channel.
- The update is the cost’s own gradient, one sample at a time. No matrix inversions, no block processing: an 11-tap FIR and a rank-1 update per symbol. Cheap enough to leave on permanently.
- Center-spike initialisation makes it fail-safe. Starting as a pass-through means an unconverged (or unneeded) equalizer is a wire, not a hazard — the “no regression on clean captures” half of every result.
- Blindness has structural costs, not tuning costs. Spurious minima, phase indifference, and scale sensitivity are properties of the cost function itself. The next two parts are engineering around them, not parameter-tweaking them away.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| The classic algorithm | Godard/CMA-2 cost, gradient, caveats | internal/dsp/equalizer/cma.go (CMA, NewCMA) |
| Per-sample update | FIR output + w −= μ·err·y·conj(x) |
cma.go (Process) |
| Differential-safe wrapper | adapt always, apply frozen snapshots | internal/dsp/equalizer/snapshot_cma.go (SnapshotCMA) — Part 5 |
| Failing-first ISI regression | multipath channel: hard decode fails raw, passes equalized | snapshot_cma_test.go (TestSnapshotCMARecoversISIChannel) |
| No-harm guard | clean signal in ⇒ essentially unchanged out | snapshot_cma_test.go (TestSnapshotCMAHarmlessOnCleanSignal) |
| Concept reference | CMA in the reference library | /reference/cma-equalizer/, /reference/adaptive-filter/ |
In this post
- The property the channel can’t hide — constant modulus as prior knowledge.
- From cost to update rule — deriving the five lines in
cma.go. - Reading the real implementation — history ring, error proxy, center spike.
- The fine print — spurious minima, and why blind can mean confidently wrong.
- The degenerate case — why the synthetic tests add noise on purpose.
The property the channel can’t hide
Everything blind equalization knows about the world fits in one sentence: the transmitter emitted symbols of constant magnitude. For π/4-DQPSK, QPSK, and the rest of the PSK family, information rides entirely in phase; every transmitted symbol sits on one circle of radius R. A dispersive channel — Part 3’s convolution — sums each symbol with scaled, rotated echoes of its neighbours, and a sum of vectors of fixed length has variable length. Magnitude spread at the receiver is therefore not noise-like bad luck; it is a measurement of the ISI. Squeeze the output magnitudes back onto one circle and you have — up to the fine print below — undone the mixing.
That inversion of perspective is the elegant part: the modulation’s most boring property (we deliberately put no information in amplitude) becomes the observable that lets a receiver learn the channel with no cooperation from the transmitter, no frame sync, and no decoded bits. Which is exactly what a control-channel receiver needs, since the equalizer has to help before decoding succeeds — on the thread capture, decoding mostly isn’t succeeding.
From cost to update rule
Formalise “squeeze onto one circle” and the algorithm falls out. Let y[n]
be the equalizer output — an FIR over the received samples,
y = Σ_k w_k·x[n−k]. Godard’s CMA-2 cost penalises squared deviation of the
squared modulus from a target R²:
J = E[(|y|² − R²)²]
Differentiate with respect to the conjugate taps (the Wirtinger convention
that lms.go’s comments walk through for the trained case) and the gradient
comes out proportional to (|y|² − R²)·y·conj(x). Descend it one sample at a
time — stochastic gradient, the same move LMS makes — and you get the whole
algorithm. GopherTrunk’s cma.go states it in its doc comment exactly as the
math reads:
// internal/dsp/equalizer/cma.go (shape) — the contract
// Cost function and gradient (Godard / CMA-2):
//
// J = E[(|y|^2 - R^2)^2]
// ∂J/∂w* ∝ (|y|^2 - R^2) · y · conj(x)
// w[n+1] = w[n] - μ · (|y|^2 - R^2) · y · conj(x)
//
// Pick R^2 so the equilibrium weight scaling matches the expected
// constellation. For unit-magnitude PSK use R^2 = 1 …
Read the update rule’s anatomy, because each factor earns its keep.
(|y|² − R²) is the signed modulus error: outputs outside the circle push
taps one way, inside the other, and on the circle the update vanishes.
y·conj(x) steers the correction along the input history — the same
directional term every adaptive FIR uses. And μ is the eternal step-size
trade: fast convergence versus a noisy equilibrium. Notice what the rule never
consults: a reference symbol, a decision, a frame boundary. That absence is
the blindness — and it’s also the door the failure modes walk through.
Reading the real implementation
Process in cma.go is the derivation transcribed, plus the practicalities:
a ring buffer for the input history, and manual complex arithmetic so the hot
loop stays allocation-free:
// internal/dsp/equalizer/cma.go (shape) — Process
// Error proxy = |y|^2 - R^2.
mag2 := yr*yr + yi*yi
err := mag2 - c.target
// Weight update: w_k -= μ · err · y · conj(x[n-k]).
mu := c.stepSize
/* … walk the history ring … */
ur := yr*xr + yi*xi // y·conj(x), real part
ui := yi*xr - yr*xi // imag part
c.taps[i] = complex(tr-mu*err*ur, ti-mu*err*ui)
Two constructor-time choices matter more than any parameter. First,
center-spike initialisation: NewCMA sets taps[N/2] = 1 with all others
zero, so tap zero state is an identity filter with a fixed delay. An
unconverged equalizer passes the signal through untouched — which is why
TestSnapshotCMAHarmlessOnCleanSignal can demand a clean channel stay clean,
and why enabling the equalizer fleet-wide was a defensible default rather than
a gamble. Second, Process returns the error proxy |y|²−R² alongside the
output: watch it settle toward zero and you are watching convergence, no
decoded bits required.
One more block in cma.go deserves a mention because it previews Part 5’s
theme. The constant-modulus cost is invariant to rotating all taps by a
common phase — rotate every w_k by e^{jθ} and |y| doesn’t change — so
the gradient has a null direction and the tap phase random-walks, driven by
noise. cma.go handles the symptom that bit first (a downstream carrier loop
integrating the drift, #492)
by re-anchoring the centre tap to the positive real axis after each update.
That keeps the output phase slowly varying — good enough for a coherent
loop, and, as Part 5 will show in detail, not good enough for a
differential decoder.
The fine print: spurious minima
Here is where Part 2’s trap stops being an anecdote and becomes a theorem-
shaped fact. J is a fourth-order surface over the tap space, and it is not
convex. Its global minima are the channel inverses we want (at arbitrary
rotation and delay — all equally good). But it also has local minima that
satisfy constant modulus without inverting the channel — tap vectors whose
output sits beautifully on a circle while remaining a scrambled function of
the transmitted symbols. A finite-length FIR trying to invert a channel it
can’t perfectly represent makes more of them, and noise plus a finite step
size lets the descent wander into one and stay.
Sit with what that means operationally: CMA’s own convergence diagnostics
cannot distinguish success from a spurious minimum. The error proxy settles
near zero either way. EVM improves either way — that is precisely how a
numerically-unstable variant printed 34%→8% while decoding nothing. The only
external observer that can tell the difference is the decoder: CRC yield, the
Part 2 verdict, measured through the whole chain. This is also the honest case
for Part 7’s trained equalizer: a known reference collapses the ambiguity —
e = d − y is only zero at the right answer — which is why the midamble
path exists even with a working blind path in production.
The degenerate case: why the tests add noise
One last subtlety, discovered the practical way when a daemon-level synthetic TETRA test started exercising the CC equalizer. A synthesized, perfectly clean, perfectly constant-modulus input is a degenerate input for CMA: the modulus error is zero for the identity filter and for a continuum of other tap vectors that happen to preserve modulus on that pristine signal — the cost surface goes flat in directions that real signals would penalise, and the taps drift on numerical noise with nothing to anchor them. Blind CMA is well-defined only against a noise floor.
So GopherTrunk’s synthetic fixtures add one on purpose: the receiver-level clean-channel test runs at 30 dB SNR, and the daemon’s synthetic TETRA CC test adds 40 dB AWGN — not to make the tests harder, but to make the algorithm’s problem well-posed. It’s a pleasing inversion of the usual instinct, and a small instance of the self-consistent-synthetic trap this project keeps guarding against: a test input can be too ideal to represent the air, and “add realistic impairment” is sometimes a correctness requirement, not pessimism.
Where this goes next
We now have a filter that finds the channel inverse blind — and a cost
function that is provably indifferent to output rotation. Between snapshots of
adaptation, that indifference is fatal to exactly the decoder TETRA uses.
Part 5
is the snapshot trick: why a continuously-adapting CMA ahead of a differential
decoder measures CRC zero, why frozen taps alone measure baseline, and how
SnapshotCMA’s adapt-continuously-apply-frozen design gets both — taking the
thread capture from ~12% to ~100% BSCH in the process.
FAQ
How many taps, and what step size, does GopherTrunk actually run?
The TETRA receiver’s defaults are 11 taps, μ = 6e-3, refreshing the applied
snapshot every 200 symbols (DefaultEqualizerTaps / DefaultEqualizerMu /
DefaultEqualizerSnapshot in internal/radio/tetra/receiver/receiver.go),
validated on the reporter captures and the synthetic multipath fixtures. At
18k symbols/s, 11 symbol-spaced taps span ±5 symbols of channel memory —
generous for the delay spreads a 25 kHz land-mobile channel serves up.
Why CMA-2 (squared modulus) instead of penalising |y| − R directly?
The squared form makes the cost a smooth polynomial in the taps — the gradient
is algebraic (err·y·conj(x)), with no square roots or divisions per sample.
The classic CMA(1,2)/CMA(2,2) family trades exactly these smoothness-vs-
robustness properties; CMA-2 is the conventional choice for exactly this
gradient simplicity.
Does CMA work on C4FM or QAM?
Not as-is. cma.go’s caveat list is explicit: on non-constant-modulus signals
(FM-family, OQPSK, QAM) the cost isn’t zero at the right answer. C4FM is
four-level FSK demodulated through a discriminator — a different signal model,
and part of why P25 Phase 1’s equalizer story (Part 13) isn’t a copy-paste of
TETRA’s. QAM needs modified costs or decision-directed
LMS.
Could I detect a spurious minimum without running the decoder? Not reliably from inside the equalizer — that’s the sober conclusion of the 34%→8% incident. Modulus error, EVM, even constellation appearance are all satisfied at spurious minima. Structural defenses exist (center-spike restarts make you start at the identity, Part 6’s guards re-seed on divergence), but verification is external by nature: decode to CRC.
If blind equalization is this fragile, why not always train? Training needs known symbols at known positions — which requires burst framing, which on a control channel you’re still acquiring may not exist yet. Blind CMA works pre-sync, streaming, with no framing at all; that’s an operating regime training can’t reach. The two are complementary, not competing: blind in the receiver (Parts 5–6), trained in the extractor where midambles are locatable (Part 7).
Series navigation
Part 4 of 14 · ← Part 3: ISI & the Linear Channel — What an Equalizer Can & Can’t Fix · Next → Part 5: The Snapshot Trick — Frozen Taps & Differential Decoders