Part 1 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. Where TETRA End to End followed one European carrier, this series follows the protocol GopherTrunk locks most often — and its running thread is that P25 is a *family of twins: Phase 1 and Phase 2, C4FM and CQPSK, single-channel and wideband, live and replay. Every twin pair is a place where a fix can land on one side and miss the other — the Two Pipelines lesson, applied layer by layer. This opener is the physical layer: what a P25 carrier actually is, and why information riding in amplitudes — not phase transitions — shapes everything downstream.*
TL;DR: P25 Phase 1 is C4FM: four-level FSK at 4800 symbols/s in a 12.5 kHz channel — one dibit per symbol, carried as a deviation of ±600 or ±1800 Hz. An FM discriminator turns that into a four-level waveform, a matched filter cleans it, and a slicer maps {−3, −1, +1, +3} to dibits (
phase1.SymbolToDibit). GopherTrunk channelizes the 4800-baud family to 48 kHz — 10 samples/symbol (ddcTargetForProtocol,internal/scanner/ccdecoder/ddc.go), and the receiver (internal/radio/p25/phase1/receiver) is a discriminator → spec matched filter → CoarseAFC → Mueller-Müller → slicer chain sized entirely from that rate. One early surprise: the matched filter is not an RRC — real P25 shapes with a raised-cosine plus inverse-sinc, and modelling it as RRC was self-consistent in tests while leaving residual ISI on every real capture (issue #275).
Key takeaways
- The dibit lives in the amplitude, not the transition. After the discriminator a C4FM symbol is a level — an absolute question, where TETRA asks a relative one. That decides what the chain needs: level calibration, offset control and an AFC, rather than a rotation-tolerant differential decode.
- C4FM is not an RRC matched-pair system. The transmitter shapes with a
raised-cosine cascaded with an inverse-sinc; the correct receive filter is
a sinc (
demod.P25C4FMRxTaps). The original RRC model passed every synthetic test and failed real captures — the series’ first self-consistency trap. - A carrier offset is a slicer bias, not a nuisance. The discriminator
maps frequency to amplitude, so tuner error becomes a DC shift that pushes
inner symbols over outer thresholds — why this chain carries a
CoarseAFCstage TETRA’s differential path never needed. - 48 kHz is a design rate, not a detail. Ten samples per symbol sizes the matched filter, the AFC time constants and the Mueller-Müller loop. Feed the receiver raw 2.048 MHz IQ and the frame sync word never correlates — the failure mode issue #275 started from.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| FM discriminator | IQ → per-sample frequency (rad/sample) | internal/dsp/demod/fm.go (demod.FM) |
| Matched filter | spec C4FM receive filter (sinc, not RRC) | internal/dsp/demod/c4fm_p25.go (P25C4FMRxTaps, NewC4FMP25) |
| Carrier-offset removal | tracks/subtracts the discriminator DC bias | internal/dsp/demod/afc.go (demod.CoarseAFC) |
| Symbol timing | Mueller-Müller clock recovery on the real waveform | internal/dsp/sync (sync.MuellerMuller) |
| 4-level slicer + dibit map | {−3,−1,+1,+3} → dibits per TIA-102.BAAA | demod.C4FM.Slice; phase1.SymbolToDibit (sync.go) |
| Channel rate | 48 kHz for the 4800-baud C4FM family | internal/scanner/ccdecoder/ddc.go (ddcTargetForProtocol) |
| The composed chain | IQ → dibits, both demod paths | internal/radio/p25/phase1/receiver/receiver.go (Receiver.Process) |
In this post
- The amplitude question — C4FM vs π/4-DQPSK, the TETRA table with the roles reversed.
- The deviation ladder — ±600/±1800 Hz, and how four frequencies become dibits.
- The filter that isn’t an RRC — the transmit/receive pair real P25 actually uses.
- 48 kHz and ten samples per symbol — why the channel rate is a named constant.
- The receiver in one pass — the chain from IQ to dibits, and the hooks later parts use.
The amplitude question
TETRA End to End Part 1 opened with a table contrasting the two great demodulation families from TETRA’s side. Here it is with the roles reversed — this time the C4FM column is the protagonist:
| Axis | P25 Phase 1 (C4FM) | TETRA TMO |
|---|---|---|
| Modulation | 4-level FSK (frequency → amplitude) | π/4-DQPSK (differential phase) |
| Symbol rate | 4800 sym/s | 18000 sym/s |
| Channel width | 12.5 kHz | 25 kHz |
| Channel bit rate | 9.6 kbps | 36 kbps before slot muxing |
| The demod question | “what amplitude is this symbol?” | “how far did the phase move?” |
| Sensitive to | carrier offset (slicer bias), level error | phase wander between symbols |
| GT channel rate | 48 kHz (10 sps) | 144 kHz (8 sps) |
| Vocoder | IMBE (MBE family) | ACELP (EN 300 395-2) |
A TETRA demodulator asks a relative question, so a constant phase rotation
cancels in s·conj(last). A C4FM slicer asks an absolute one: after the FM
discriminator each symbol is a level, and the receiver must know where the
four levels sit. That difference explains most of what this chain carries
that TETRA’s doesn’t — a symbol AGC to calibrate levels, a CoarseAFC to
remove the DC bias a tuner error injects, and slicer thresholds that are
physical quantities derived from the deviation. The
demodulation primer
covers both families in the abstract, and the
DSP learning track builds the theory up
from IQ; here we care about what the absolute question costs on air.
The deviation ladder
C4FM — Compatible 4-level FM — encodes each dibit as one of four deviations from the carrier: outer symbols at ±1800 Hz, inner symbols at ±600 Hz. The mapping is fixed by TIA-102.BAAA and lives in one place in the tree:
// internal/radio/p25/phase1/sync.go (shape)
// C4FM symbol-to-dibit mapping per TIA-102.BAAA: +3→01, +1→00, -1→10, -3→11.
func SymbolToDibit(sym int8) uint8 {
switch sym {
case 1:
return 0 // +600 Hz
case 3:
return 1 // +1800 Hz
case -1:
return 2 // −600 Hz
case -3:
return 3 // −1800 Hz
}
return 0
}
Note the ordering: the dibit values do not climb the ladder with the
deviation. +1 maps to dibit 00 and +3 to dibit 01 — the high bit is the
sign, the low bit selects inner/outer, so the most likely slicer error
(inner ↔ outer on the same side) corrupts only one bit.
The figure also shows the trap. The discriminator maps frequency linearly to
amplitude, so a static tuner error of Δf shifts every level by the same
amount — and against ±600 Hz inner deviations, offsets ≥ ~1 kHz start pushing
inner symbols across outer thresholds (issue #402’s territory). That is the
structural reason this chain carries a CoarseAFC tracking and subtracting
the discriminator’s DC bias, and why later parts keep returning to carrier
offset as a first-class failure mode.
The filter that isn’t an RRC
Between the discriminator and the slicer sits the matched filter, and here P25 holds the series’ first self-consistency lesson. The obvious model — the one GopherTrunk originally shipped — is a root-raised-cosine pair. Textbook. Also wrong for P25:
// internal/dsp/demod/c4fm_p25.go (shape)
// P25 C4FM is NOT a root-raised-cosine matched-pair system. The transmit
// baseband filter is a raised-cosine (α=0.2) cascaded with an inverse-sinc
// compensation; the receive filter is a sinc (a one-symbol-period
// integrate-and-dump). The transmit inverse-sinc and the receive sinc
// cancel, so the cascade transmit×receive is a plain raised-cosine —
// ISI-free at the symbol instants.
func NewC4FMP25(sampleRate, deviation float64) *C4FM {
return NewC4FMWithTaps(P25C4FMRxTaps(sampleRate), deviation)
}
The RRC model was self-consistent: GopherTrunk’s synthetic C4FM modulator
shaped with an RRC too, so every round-trip test passed while real captures
carried residual inter-symbol interference the receiver could never remove
(issue #275). The fix cross-checked the filter against OP25’s
c4fm_const.py and now generates the taps from the spec transfer functions —
raised-cosine flat to 1920 Hz rolling off to 2880 Hz, times the inverse-sinc,
on transmit; sinc(f/4800) on receive (P25C4FMRxTaps). This is the pattern
the whole series keeps meeting: a test whose encoder and decoder share an
assumption validates the assumption against nothing. The villain returns in
Part 3 (a byte-offset bug) and Part 13 (the testing playbook), wearing
different clothes each time.
One subtlety worth planting: P25C4FMRxTaps is normalised to a DC gain of
sps, so the matched-filtered symbol centres land at ±2π·1800/48000 ≈ 0.2356
rad/sample — a physical level the slicer thresholds are calibrated against,
with a symbol AGC bridging the gap when real signal levels drift.
48 kHz and ten samples per symbol
GopherTrunk channelizes every protocol to a fixed per-protocol rate, chosen in one function — the same one the TETRA opener showed from the other side:
// internal/scanner/ccdecoder/ddc.go (shape)
func ddcTargetForProtocol(p trunking.Protocol) float64 {
switch p {
case trunking.ProtocolTETRA, trunking.ProtocolTETRADMO:
return tetraDDCTargetRateHz // 144_000 — 8 sps at 18000 baud
case trunking.ProtocolMotorola:
return motorolaDDCTargetRateHz // 18_000 — 5 sps at 3600 baud
}
return ddcTargetRateHz // 48_000 — the 4800-baud C4FM family
}
At 4800 sym/s, 48 kHz is exactly 10 samples per symbol, and everything in
the receiver is sized from it: the matched-filter span (13 symbols per the
OP25 reference), the CoarseAFC time constant, the Mueller-Müller loop gain,
the AGC’s ~256-symbol settling. The constant’s doc comment records what
happens without it: feed the receiver a raw 2.048 MHz SDR stream and you get
≈427 samples per symbol, a matched filter spanning a ±1 MHz swath, and a frame
sync word that never correlates (issue #275). The exported DDCTargetRateHz
exists so the replay subcommand builds an identical down-converter — a
replay path that channelizes differently from the live path is a twin pair
waiting to drift, and Part 11 covers the time exactly that happened between
the single-channel Downconverter and the wideband DDCBank.
The receiver in one pass
internal/radio/p25/phase1/receiver composes the chain. On the default
DemodC4FM path, Receiver.Process runs: an optional pre-discriminator DC
blocker (voice chains only — never the control-channel DDC path) → FM
discriminator → spec C4FM matched filter → CoarseAFC → Mueller-Müller
symbol clock
(timing recovery in general form)
→ symbol AGC → 4-level slicer → SymbolToDibit. The output dibit stream, in
the TIA-102.BAAA convention, feeds a DibitSink (the control-channel state
machine, Part 2) and/or an LDU assembler (the voice path, Part 8).
Three hooks for later parts:
- There is a second demod path.
Options.DemodModeselectsDemodCQPSK: a complex RRC → Gardner → blind-equalizer → differential-decode chain for P25 sites transmitting a linear/LSM waveform, on which the FM discriminator produces near-random dibits. Same dibits out, entirely different physics — the first twin pair this series tracks, and Part 6’s whole subject. - The soft information already flows.
SoftSinksurfaces per-symbol soft samples,EyeSinkthe oversampled eye, andBitLLRSinktwo log-likelihood ratios per dibit — sign-axis distance for the high bit, inner/outer-threshold distance for the low — feeding the soft-decision TSBK path. All nil by default, all free when unused. - The dibit stream is demod-agnostic by contract. FSW detection, NID parsing and the TSBK trellis (Parts 2–3) never know which physics produced their dibits — one control-channel state machine serves both paths, a seam Protocol Decoders Part 2 surveyed in brief.
How the amplitude question shaped the Go code
- Levels are calibrated, not assumed.
Options.DeviationHzderives the slicer scale, the matched filter’s DC gain restores it, and the symbol AGC holds mean|x| at the slicer’s expected value — a calibration chain a differential decoder doesn’t need. - Carrier offset gets a dedicated stage.
CoarseAFCexists because frequency error is slicer bias here; its decision-directed refinement (issue #402) ships opt-in behindEnableDecisionDirectedAFCbecause it can stably false-lock. - One mapping, one owner.
SymbolToDibitis the single source of truth for the ladder, exactly asTetraBitsToDibitsis for TETRA’s Gray map — deliberately separate functions, because mixing the conventions produces garbage that looks like a demod bug.
Where this goes next
A dibit stream at 4800/s is a firehose with no punctuation.
Part 2
adds the structure: the 48-bit frame sync word, the BCH-protected NID naming
each frame’s NAC and type, the status symbols interleaved every 36th dibit —
and what GopherTrunk requires before it logs control channel locked.
FAQ
Is C4FM the same thing as 4FSK? It’s a constrained form of it: four-level FSK with a specific pulse shaping (raised-cosine + inverse-sinc at the transmitter) chosen so the signal fits a 12.5 kHz channel and demodulates on a simple FM discriminator. The “compatible” in the name is the point — the same family of waveforms also receives as a phase modulation, the door Phase 1’s CQPSK/LSM twin path (Part 6) walks through.
Why does GopherTrunk demodulate with an FM discriminator instead of coherently? It’s simple, robust and sufficient on a clean channel: frequency maps straight to amplitude and a slicer finishes the job, with no carrier-phase loop to lose lock. The honest cost is Part 12’s subject — the discriminator path has no equalizer and hard-decision FEC, and on weak or simulcast-smeared signals that is the gap between GopherTrunk and better hardware.
What does a P25 carrier look like on a spectrum display?
A roughly 8–10 kHz-wide hump inside 12.5 kHz spacing — no sub-carriers, no
TDMA slot rhythm on Phase 1 (the control channel transmits continuously;
voice channels key up per call). The four deviation levels are invisible in
the spectrum; they only appear in the demodulated eye diagram, which is why
the receiver exposes an EyeSink fold for the diagnostics panels.
Why 48 kHz and not something lower? Ten samples per symbol is a comfortable operating point for the Mueller-Müller loop, and every loop constant in the receiver was tuned at 10 sps. The rate is also shared by the whole 4800-baud family (DMR, NXDN, dPMR), so one DDC design serves five protocols.
Does the receiver care about the ±600/±1800 numbers, or just their ratio?
Both. The 3:1 ratio fixes the slicer geometry (thresholds at ±2/3 of the
outer level); the absolute values calibrate the slicer scale in physical
units via DeviationHz — and the small inner deviation is what makes a
~1 kHz carrier offset dangerous here while TETRA, whose information rides in
phase differences, shrugs off far more.
Series navigation
Part 1 of 14 · Next → Part 2: Frame Sync, the NID & What ‘Locked’ Means