Part 6 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 5 turned channel numbers into hertz; every layer so far rode Part 1’s FM discriminator. This part is the series’ first full twin pair: the same Phase 1 protocol, transmitted as a linear modulation a discriminator turns into near-random dibits. It is where the equalizer lives, where the carrier-recovery lessons were paid for, and where GopherTrunk’s own docs were once the bug.
TL;DR: A minority of P25 Phase 1 sites transmit LSM/CQPSK — a π/4-DQPSK-family linear modulation — instead of C4FM. GopherTrunk’s opt-in
DemodCQPSKpath (internal/radio/p25/phase1/receiver/cqpsk.go) runs coarse carrier seed → NCO → complex RRC matched filter → AGC → Gardner T/2 timing →equalizer.FSE, a fractionally-spaced blind CMA equalizer (thefsefield) → Costas fine tracking → differential decode →lsmDibitRemap, emitting dibits interchangeable with the C4FM path’s. Getting it working took four acts on issue #492 (0/8 → 8/8 locks on real captures). And the selection rule is empirical: simulcast does not imply LSM — forcing CQPSK on a C4FM simulcast site kills the decode (issue #935).
Key takeaways
- Same dibits out, entirely different physics. The CQPSK path ends in
lsmDibitRemapso FSW detect, NID parse and the TSBK trellis never know which demodulator ran — one state machine, two physical layers, a standing twin-drift risk. - A linear channel is an invertible channel. Simulcast multipath is a linear distortion of complex symbols, so an equalizer can undo it — the structural reason this path carries a T/2 CMA equalizer while the discriminator path has none.
- Differential decode removes phase, not rotation. A residual carrier offset spins the constellation a constant angle per symbol, and 4800 baud breaks at 3.75× less offset than TETRA’s 18000 — why this path needed a coarse seed, an NCO and a Costas loop.
- Choose
cqpskempirically, never by “the site is simulcast.” LSM is transmitter coordination, not a modulation; emission designators cover both; and a genuinely three-tower simulcast system decodes fine in C4FM (issue #935).
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Mode selection | "" / c4fm / fm vs cqpsk / lsm / linear |
internal/radio/p25/phase1/receiver/modes.go (ParseDemodMode) |
| The chain | seed → NCO → RRC → AGC → Gardner → FSE → Costas → decode | cqpsk.go (cqpskDemod.process) |
| Blind equalizer | T/2 fractionally-spaced CMA, span 6 symbols | cqpsk.go (fse, equalizer.NewFSE) |
| Coarse carrier seed | multi-lag fit + multipath modulus-CV gate | cqpsk.go (robustSeedHz, seedModulusCV) |
| Fine tracking | QPSK Costas loop, 120 Hz BW at 4800 baud | cqpsk.go (sync.NewQPSKCostas) |
| Dibit convention | LSM constellation → TIA-102.BAAA dibits | cqpsk.go (lsmRotation, lsmDibitRemap) |
| Voice-chain wiring | grant’s demod mode string → receiver mode | internal/voice/composer/p25p1_voice.go (resolveP25Phase1DemodMode) |
In this post
- Why a linear path exists — what simulcast does to a discriminator.
- The chain in one pass — raw IQ to remapped dibits.
- The equalizer C4FM never got — the T/2 FSE and what it opens.
- The seed that lies on simulcast — carrier recovery and its gate.
- Two paths, one truth — twin drift, and the myth the docs taught.
Why a linear path exists
“Compatible” is the load-bearing word in C4FM’s name: the waveform family was designed to be receivable either as four-level FM or as a phase modulation. Some operators — often, but not only, on simulcast systems — transmit LSM (Linear Simulcast Modulation), a π/4-DQPSK-family linear waveform, because a linear signal survives multi-transmitter overlap better. When several towers transmit the same bits with small differential delays, the receiver hears the sum of delayed copies. For a linear modulation that sum is just a linear channel — inter-symbol interference an equalizer can invert (the ISI story in general form). For an FM discriminator the sum is a disaster: FM demodulation is nonlinear, and overlapping carriers produce artifacts no downstream filter can cleanly remove.
Push an actually-linear LSM signal through a quadrature FM discriminator and you get near-random dibits and a frame sync word that never matches (the issue #275 symptom). So the receiver carries a second demod mode:
DemodC4FM (default) |
DemodCQPSK |
|
|---|---|---|
| Front half | FM discriminator (real waveform) | complex RRC matched filter |
| Timing | Mueller-Müller | Gardner (mandatory, T/2 output) |
| Carrier error | DC bias → CoarseAFC |
rotation → NCO seed + Costas |
| Equalizer | none | T/2 fractionally-spaced CMA |
| Decision | 4-level slicer | differential quadrant + remap |
| Right for | the large majority of sites — including most simulcast | sites actually transmitting a linear waveform |
The last row is the one the project long got wrong; we return to it below.
The chain in one pass
cqpskDemod (cqpsk.go) wraps the same demod.PiOver4DQPSK primitive
TETRA uses — rotation π/4, the TIA-102.BAAA LSM constellation — and
composes the rest around it:
// internal/radio/p25/phase1/receiver/cqpsk.go (shape) — process
func (c *cqpskDemod) process(iq []complex64) []uint8 {
if !c.seeded {
c.seedBuf = append(c.seedBuf, iq...) // accumulate ≥2048 samples
/* … robustSeedHz → nco.SetOffset; reset Costas + FSE … */
}
c.rotated = c.nco.Mix(c.rotated, iq) // remove coarse offset
c.matched = c.dq.MatchedFilter(c.matched, c.rotated)
c.matched = c.agc.Process(c.matched, c.matched) // gain-independence
c.twoSps = c.gardner.Process2x(c.twoSps, c.matched) // [mid, on-time]
for i := 0; i < nSym; i++ {
y, e := c.fse.Process(c.twoSps[2*i], c.twoSps[2*i+1]) // T/2 CMA
c.cmaErr = e
c.symbols = append(c.symbols, c.costas.Update(y)) // fine carrier
}
c.dibits = c.dq.Decode(c.dibits, c.symbols)
for i, d := range c.dibits {
c.dibits[i] = lsmDibitRemap[d&3] // → canonical TIA-102 dibits
}
return c.dibits
}
Read the last loop first — it is the twin-pair contract: after the remap,
the CQPSK path’s dibit values match what SymbolToDibit produces from
C4FM, so everything from
Part 2’s
FSW detector onward is demod-agnostic. The AGC matters more than it looks:
the Gardner timing-error detector and the CMA weight update both use
un-normalised, amplitude-dependent error terms, so without it the path
locked only in a narrow RTL-SDR gain window — a regression issue #275’s
reporters actually measured.
The equalizer C4FM never got
The fse field is the lever the default C4FM voice path lacks (that gap
is Part 12’s
whole subject). Post-Gardner symbols are unit-modulus π/4-DQPSK points, so
the Constant Modulus Algorithm applies — a blind equalizer needing no
training sequence. Two design choices carry the weight:
- Fractionally spaced (T/2), not symbol spaced. Gardner emits the
on-time and half-symbol samples, and the equalizer runs two taps per
symbol (
cqpskFSESymbolSpan = 6). A T/2 equalizer synthesizes the receive matched filter implicitly, so it opens both simulcast multipath ISI and the C4FM-transmit-vs-RRC-receive pulse mismatch — what left a real C4FM-shaped signal’s constellation closed through the linear path (issue #492). A symbol-spaced equalizer can’t fix a pulse mismatch; it only sees the symbols the wrong filter already produced. - Brisk step, leaky taps.
cqpskFSEStep = 0.025because the blind equalizer must open the constellation within a few hundred symbols of lead-in before the FSW arrives;cqpskFSELeak = 5e-4pulls the FSE’s larger null space toward identity so the taps don’t wander on clean, ISI-free input.
The safety analysis is the one the
TETRA equalizer work
made a series-wide rule: a continuously-adapting filter ahead of a
differential decoder is only safe with its phase constrained — here the
Costas loop after the FSE pins the constellation to the decision grid
symbol by symbol. The receiver also retains cmaErr, the equalizer’s
|y|²−R² convergence proxy, for replay diagnostics: “is the equalizer
converged” should be answerable from a log, not a debugger.
The seed that lies on simulcast
The differential decode removes a constant carrier phase but not a constant per-symbol rotation: a residual offset Δf turns every symbol by 2π·Δf/4800, and the FSW never correlates. (TETRA gets away without carrier recovery only because 18000 baud rotates 3.75× less per symbol.) So the path seeds an NCO from a one-shot coarse estimate, then lets a 120 Hz Costas loop track the residual.
The war story is in the gate on that estimate. The bare lag-1 (Kay)
autocorrelation estimator reads simulcast multipath as a spurious carrier
offset — ~650–750 Hz on issue #492’s captures — because a deep simulcast
null shifts the spectral centroid, a bias no autocorrelation estimator can
tell from a real offset. Mis-tune the NCO by it and the Costas loop rails.
robustSeedHz therefore sharpens the estimate with a multi-lag phase-ramp
fit, then checks its own answer: de-rotate by the candidate, matched
filter, and measure the coefficient of variation of the symbol modulus
(seedModulusCV). A true offset only rotates a constant-modulus
constellation (low CV); multipath blurs the modulus (high CV, gated at
cqpskSeedMaxModulusCV = 0.24). A rejected seed leaves the NCO identity
and lets the loop acquire the necessarily in-range true offset itself.
Issue #492 took four acts to reach that design — no carrier recovery at all, then the poisoned seed, then the FSE, then a brute-force BCH hot path that only surfaced once locking worked — and the score went 0/8 → 3/8 → 8/8 locks on the reporter’s real captures. The full postmortem, including the diagnostic line reading the wrong demodulator’s state, is From the Issue Tracker Part 6.
Two paths, one truth
Twin paths drift, and this pair drifted twice in ways worth naming.
First, selection folklore. GopherTrunk’s docs, config comments and UI
labels once taught “simulcast ⇒ CQPSK.” Issue #935 disproved it on air: a
genuinely three-tower simulcast site decodes reliably in C4FM — in
GopherTrunk and SDRTrunk alike — and forcing CQPSK kills it. LSM is
transmitter coordination, not a modulation, and licensing metadata (the
site’s 10K1D7W emission designator covers both) can’t decide either. The
DemodMode doc in modes.go now carries the corrected rule permanently:
C4FM is the default; cqpsk is warranted only when a strong, clean
signal will not lock in C4FM. The full story of unwinding the project’s
own guidance is
From the Issue Tracker Part 7.
(The reporter’s actual problem was gain entered in the wrong units; the
myth just kept everyone looking elsewhere.)
Second, plumbing drift. The demod mode is a per-system (and
per-channel) string that must survive three hand-offs: config → CC
pipeline → grant → voice chain. The composer’s resolveP25Phase1DemodMode
(internal/voice/composer/p25p1_voice.go) parses it off the grant,
warn-logs unknown values, falls back to C4FM — and the voice chain now
prints composer: p25p1 voice chain started demod_mode=… precisely
because a field log once showed the CC locked on cqpsk with no way to
verify the voice chain wasn’t silently still on c4fm (issue #356
follow-up). The wideband pipeline, meanwhile, stamped no mode onto its
grants at all until #935 — CQPSK was decorative on that whole path. Same
lesson as the
Two Pipelines postmortem:
when a property exists on one side of a twin pair, audit the other side
for the entire class.
How the linear-channel principle shaped the Go code
- The equalizer sits where the channel is linear. The FSE runs on complex symbols before the differential decode — the only domain where simulcast delay is a clean convolution — with the Costas loop pinning phase so the differential product stays safe.
- Every estimator gates its own output.
robustSeedHzrejects its estimate when de-rotation doesn’t restore constant modulus; a wrong seed held back is recoverable, a wrong seed applied rails the loop. - The twin contract is enforced at the boundary.
lsmDibitRemapconverts to canonical dibits inside the demod, so no downstream code branches on which physics ran. - Mode strings parse in one place.
ParseDemodModereturnsok=falseon typos so callers warn and fall back — a config mistake degrades to the default, never to silence.
Where this goes next
CQPSK is Phase 1 wearing linear clothes; the next twin is a different protocol generation. Part 7 takes on Phase 2 TDMA: H-DQPSK at 6000 symbols/s carrying two voice slots per 12.5 kHz carrier, the differential primitive it shares with TETRA one π/8 apart, and the FEC-knob hand-off issue #882 proved can silently default to zero on one pipeline.
FAQ
How do I know if my P25 site needs CQPSK mode? Empirically, only: a strong, clean signal that will not lock in C4FM — near-random dibits, no frame sync — is the CQPSK signature. Do not infer it from “the site is simulcast” (most simulcast is C4FM) or from licensing data (emission designators cover both). See issue #935.
Why does the CQPSK path need carrier recovery when TETRA’s π/4-DQPSK doesn’t? Baud rate. A residual offset rotates the differential constellation by 2π·Δf per symbol interval; at 4800 baud the same hertz of error costs 3.75× more rotation than at TETRA’s 18000. The linear path crosses the quadrant boundary at offsets an ordinary tuner routinely has, so it seeds an NCO and tracks with a Costas loop.
What is the difference between LSM and CQPSK?
None the receiver cares about: LSM is the TIA-102.BAAA linear simulcast
waveform, CQPSK the modulation family it belongs to, and GopherTrunk
accepts cqpsk, lsm and linear as aliases for the same DemodCQPSK
path. The distinction that matters is LSM-the-waveform versus
simulcast-the-deployment — only the former needs this demodulator.
Can the CQPSK path decode a normal C4FM site? Often, yes — “compatible” 4-level FM is receivable as a phase modulation, and the T/2 equalizer exists partly to absorb the C4FM-vs-RRC pulse mismatch. But it is the harder road: more stages, more acquisition time, no benefit on a discriminator-friendly signal. So C4FM stays the default and CQPSK stays opt-in.
Why is the equalizer only on this path and not on C4FM voice? Because a discriminator’s output isn’t a linear function of the channel — after nonlinear FM demodulation there is no clean convolution to invert. Equalizing P25 means either the linear path (here) or porting an equalizer ahead of the C4FM decision chain — the open weak-signal work Part 12 lays out, gated on a real capture.
Series navigation
Part 6 of 14 · ← Part 5: Channel Identifiers & Band Plans — From Channel Number to Hertz · Next → Part 7: Phase 2 TDMA — Two Voices per Carrier