P25 Phase 1: native APCO Project 25 C4FM reception
A self-contained, clean-room receiver for the P25 Phase 1 (TIA-102) common air interface: C4FM 4-level demodulation, frame-sync + BCH-corrected NID recovery, the Golay, Hamming and BCH(63,16) block codes, the 1/2-rate trellis for trunking control, the IMBE voice-frame FEC — and, since the native IMBE parameter codec + MBE vocoder landed, the IMBE→PCM step too: the whole voice path runs without mbelib.
Rafe project · app/radio/p25/{demod,fec,frame,trunk,voice}.py + radio/imbe.py, radio/mbe.py · in-repo NumPy (mbelib remains a selectable fallback hook)
Abstract
APCO Project 25 (P25) is the digital land-mobile-radio standard used by North American public-safety and utility fleets. Its Phase 1 common air interface (CAI) is a continuous-phase 4-level FSK waveform — "C4FM" — carrying 9600 bit/s in a 12.5 kHz FM channel by transmitting 4800 four-level symbols per second, two bits per symbol. Everything above the symbol layer is a rigid frame structure: a fixed 48-bit frame-sync pattern, a 64-bit Network Identifier (NID) carrying the 12-bit Network Access Code and 4-bit Data Unit ID, and then one of a handful of data units — a voice superframe (LDU1/LDU2, nine IMBE voice frames each), a header, a terminator, or a trunking signalling block (TSBK).
This document specifies Rafe's native P25 Phase 1 receiver in full. It is a clean-room implementation from TIA-102: the demodulator (demod.py) recovers dibits from FM-discriminator audio with a root-raised-cosine matched filter and a maximum-eye-opening symbol-timing search; the framer (frame.py) finds the sync pattern by error-tolerant correlation and BCH-decodes the NID; the FEC primitives (fec.py) are the perfect Golay(24,12,8) / (23,12,7) code built from its own syndrome table, systematic Hamming(15,11) / (10,6), and a full BCH(63,16) decoder (GF(2⁶) syndrome / Berlekamp-Massey / Chien) that error-corrects the NID; the trunking path (trunk.py) inverts the P25 1/2-rate trellis with a four-state Viterbi decoder and parses TSBK opcodes to talkgroup/radio-ID metadata; and the voice path (voice.py) undoes the per-frame IMBE FEC — Golay on the four most-significant vectors, Hamming on three more, one raw — and de-scrambles the PN sequence seeded from the corrected u0. IMBE→PCM, historically delegated to mbelib, is now native too: the corrected u-vectors feed the IMBE parameter codec and the MBE vocoder (the IMBE patents have expired and the project is non-commercial, so no gate is needed; mbelib remains selectable). Everything the receiver needs to surface call metadata — NAC, DUID, talkgroup, source/destination IDs — decodes with the modem alone, no vocoder required.
1. Motivation and scope
Rafe already bridges to P25 through an external stack: digivoice.py shells out to dsd-fme + mbelib, feeding it discriminator audio and scraping NAC/talkgroup from its stdout (app/radio/digivoice.py). That works but it is an outside binary doing the entire DSP-and-protocol job opaquely. The native app/radio/p25/ package moves the decodable part in-house — the whole receive chain from 4-level baseband to recovered IMBE bits and trunking metadata — leaving only the encumbered vocoder as an external dependency:
NATIVE (app/radio/p25/ + radio/imbe.py, radio/mbe.py)
FM-disc audio ─▶ C4FM demod ─▶ frame sync ─▶ NID ─▶ DUID dispatch ┐
(48 kHz) demod.py frame.py frame.py │
├─▶ LDUx ─▶ IMBE FEC ─▶ IMBE codec ─▶ MBE vocoder ─▶ PCM
│ voice.py imbe.py mbe.py
└─▶ TSBK ─▶ trellis ─▶ parse ─▶ metadata
trunk.py (NAC/TG/ID)
The design split is deliberate and stated in the package header (app/radio/p25/__init__.py): "The decode chain (C4FM demod → framing → FEC → voice-frame/trunking extraction) is DSP + protocol and fully native. Only the IMBE/AMBE vocoder → PCM step is patent-encumbered." Trunking metadata "needs no vocoder at all," so a P25 control channel decodes completely with the native code.
This spec covers Phase 1 FDMA only. Phase 2 (TDMA, two-slot H-DQPSK / H-CPM with the AMBE+2 half-rate vocoder) is out of scope and not implemented.
2. Background
2.1 C4FM FDMA
P25 Phase 1 is FDMA: one conversation per 12.5 kHz RF channel. The modulation is C4FM — compatible 4-level FM — a continuous-phase 4-level frequency-shift keying. The transmitter maps each dibit (2 bits) to one of four nominal frequency deviations and shapes the symbol stream with a Nyquist filter so the occupied bandwidth fits the channel. Because it is plain FM at the RF level, a P25 Phase 1 signal can be received by any FM receiver and demodulated to a 4-level baseband by an ordinary FM discriminator — which is exactly the input this implementation expects (demod.py, "Working from FM-discriminator audio").
The four deviations and their symbol assignments (demod.py):
| dibit (bits) | dibit value | deviation | symbol level |
|---|---|---|---|
01 |
1 | +1800 Hz | +3 |
00 |
0 | +600 Hz | +1 |
10 |
2 | −600 Hz | −1 |
11 |
3 | −1800 Hz | −3 |
Two bits per symbol × 4800 symbols/s = 9600 bit/s gross channel rate.
2.2 The frame hierarchy
Every P25 frame is a rigid concatenation:
[ 48-bit frame sync ] [ 64-bit NID ] [ data unit — type selected by DUID ]
- Frame sync — a single fixed 48-bit pattern (24 dibits) that marks the start of every frame and is used to lock symbol/frame timing. In P25 the sync is transmitted using only the two outer levels (±3, ±1800 Hz) for robustness.
- NID — 64 bits: the 12-bit NAC (Network Access Code, like a CTCSS tone — distinguishes systems sharing a frequency), the 4-bit DUID (Data Unit ID), and BCH(63,16) parity + an overall parity bit protecting them.
- DUID selects the data unit that follows: header, voice, terminator, or one of the data/control units (
frame.py).
2.3 The data units (DUIDs)
| DUID | Name | Role |
|---|---|---|
| 0 | HDU | Header Data Unit — precedes voice (encryption MI, algorithm/key ID, talkgroup) |
| 3 | TDU | Terminator Data Unit (no link control) — ends a transmission |
| 5 | LDU1 | Logical link Data Unit 1 — 9 IMBE voice frames + link-control fragments |
| 7 | TSBK | Trunking Signalling Block — the control-channel unit |
| 10 | LDU2 | Logical link Data Unit 2 — 9 IMBE voice frames + encryption-sync fragments |
| 12 | PDU | Packet Data Unit — data services |
| 15 | TDU-LC | Terminator with Link Control |
This table is the code's DUID map verbatim (frame.py).
2.4 Control channel vs. voice channel
A P25 trunked system runs a dedicated control channel that streams TSBKs continuously: channel grants ("talkgroup 1234 is now on channel N"), status broadcasts, registration, etc. A receiver camped on the control channel needs only the modem + trellis + CRC to follow who is talking to whom on which channel — no vocoder. When a grant fires, the receiver retunes to the granted voice channel, where LDU1/LDU2 superframes carry the IMBE-coded speech. This is why the implementation cleanly separates trunk.py (metadata-only, fully native) from voice.py (IMBE FEC native; final IMBE→PCM external).
3. Signal and symbol structure — exact numbers
Everything in this section is a constant in the code or derivable from one; the source module is cited.
Symbol layer (demod.py)
- Symbol (baud) rate:
SYMRATE = 4800sym/s (demod.py). - Bits per symbol: 2 (one dibit). Gross bit rate 4800 × 2 = 9600 bit/s.
- Four levels: {+3, +1, −1, −3}, from
_LEVEL = {1: 3, 0: 1, 2: -1, 3: -3}(demod.py), i.e. dibit-value → level. - Nominal deviations: ±1800 Hz (outer, ±3) and ±600 Hz (inner, ±1) (
demod.py). - Default audio sample rate:
fs = 48000Hz → samples/symbolsps = fs // SYMRATE = 10(demod.py). - Matched/shaping filter: root-raised-cosine, roll-off α = 0.2, span = 8 symbols each side → 2·8·10 + 1 = 161 taps, unit-energy normalised (
demod.py). α = 0.2 is the P25 Nyquist filter roll-off from TIA-102.
Frame layer (frame.py)
- Frame sync: 48 bits,
FRAME_SYNC = 0x5575F5FF77FF(frame.py). - NID: 64 bits = NAC(12) + DUID(4) + BCH(63,16) parity(47) + overall parity(1) (
frame.py).decode_nidBCH-corrects before it slices NAC/DUID. - NAC occupies NID bits 63..52; DUID occupies bits 51..48 (systematic, at the top of the 64-bit word) (
frame.py).
Voice layer (voice.py)
- IMBE parameter frame: 88 bits, vectors
u0..u7of sizesU_SIZES = [12,12,12,12,11,11,11,7](voice.py); 12·4 + 11·3 + 7 = 88. - Coded IMBE frame: 144 bits,
_ENC = [23,23,23,23,15,15,15,7](voice.py); 23·4 + 15·3 + 7 = 144. - Superframe: 9 IMBE frames per LDU1 or LDU2 (
voice.py). - Packed IMBE frame for the vocoder: 88 bits → 11 bytes (
voice.py; testtest_p25.py).
Trunking layer (trunk.py)
- TSBK: 96 bits on air = 80 payload + 16 CRC (
trunk.py). The implemented CRC/parse operate on a 10-byte block: 8 payload bytes + a 16-bit CRC in bytes 8–9 (trunk.py) — see §5.4 for the exact field map and the payload-width note. - Trellis: 1/2-rate, 4 states, input dibit + 2-bit state → 4-bit constellation symbol (
trunk.py). Each 4-bit symbol is carried by two C4FM dibits, so 2 information bits → 4 channel bits (rate 1/2).
On-air frame sizes (TIA-102 background). The code parses per-unit content but not the full on-air LDU byte-layout (status-symbol insertion, embedded LC/LSD interleave). For orientation: a P25 Phase 1 LDU is 1728 channel bits = 180 ms at 9600 bit/s, of which 9 × 144 = 1296 bits are the coded IMBE voice frames; the remainder is sync, NID, embedded link control / low-speed data and status symbols. These figures are from TIA-102.BAAA and are context, not constants in this code.
4. Symbol and bit recovery — step by step (demod.py)
Input: real FM-discriminator audio at 48 kHz (a noisy 4-level baseband). Output: a list of dibit integers (0–3) for the framer.
4.1 Root-raised-cosine matched filter
The demodulator first convolves the audio with the RRC pulse (demod.py):
x = np.convolve(audio, _rrc(sps), "same")
_rrc(sps, alpha=0.2, span=8) (demod.py) builds the standard RRC impulse response on the sample grid t = n/sps, n ∈ [−80, 80]:
h(0) = 1 − α + 4α/π (t = 0)
h(±1/4α) = (α/√2)·[ (1+2/π)·sin(π/4α) + (1−2/π)·cos(π/4α) ] (|4αt| = 1)
h(t) = [ sin(πt(1−α)) + 4αt·cos(πt(1+α)) ]
/ [ πt·(1 − (4αt)²) ] (otherwise)
then normalises to unit energy: h /= sqrt(Σ h²). This is both the transmit pulse (in modulate, used for test-signal generation) and the receive matched filter.
4.2 Symbol-timing recovery — maximum eye opening
With 10 samples per symbol there are 10 candidate sampling phases. The code tries every integer offset and keeps the one whose sampled points have the largest mean absolute value — the offset that samples nearest the pulse peaks, i.e. the widest- open eye (demod.py):
for off in range(sps):
idx = off + arange(nsym)*sps ; idx = idx[idx < len(x)]
e = mean(|x[idx]|) # energy of the sampled instants
if e > best: best, best_off = e, off
s = x[best_off + arange(nsym)*sps] # the recovered symbol samples
There is no fractional interpolation or tracking loop; the phase is estimated once over the whole buffer. For the block-at-a-time decode the receiver runs on, this is sufficient (the timing recovery test is exact at clean SNR — §7).
4.3 Amplitude normalisation
FM-discriminator gain is unknown, so the four levels arrive at an arbitrary scale. The code estimates the outer (±3) level as the mean magnitude of the top half of the sampled-symbol magnitudes (the levels are ~equiprobable, so the larger half are the ±3 symbols) and rescales so that level ≈ 3 (demod.py):
mag = sort(|s|)
outer = mean(mag[len(mag)//2:]) # ≈ the ±3 magnitude
s *= 3.0 / (outer + 1e-9)
4.4 Four-level slicing → dibits
Each normalised sample is sliced against thresholds at +2, 0, −2 (_slice, demod.py):
level ≥ +2 → dibit 1 (+3, bits 01)
level ≥ 0 → dibit 0 (+1, bits 00)
level ≥ −2 → dibit 2 (−1, bits 10)
else → dibit 3 (−3, bits 11)
The result is the dibit list. dibits_to_bits (demod.py) expands each dibit MSB-first into two bits for the framer:
dibit d → [ (d >> 1) & 1 , d & 1 ]
so dibit 1 → 01, 2 → 10, 3 → 11, 0 → 00 — the inverse of the level map in §2.1. (_SLICE at demod.py is a leftover threshold-ordering helper and is not used by the slicer.)
5. Decode — step by step
5.1 Frame sync search (frame.py)
The sync is stored MSB-first as a 48-element bit array _FS_BITS = [(FRAME_SYNC >> (47−i)) & 1 for i in range(48)] (frame.py). find_sync slides a 48-bit window across the bit stream and returns the first index whose Hamming distance to the pattern is ≤ max_err (default 4):
for i in range(start, n − 48 + 1):
err = Σ_{j<48} [ bits[i+j] ≠ _FS_BITS[j] ] # early-out once err > max_err
if err ≤ max_err: return i
return −1
Exact match is deliberately not required — "C4FM noise flips a few sync bits, so exact match is too strict" (frame.py). Because the sync is 48 bits of a fixed pattern, a 4-bit tolerance is far from the ~24-bit random-collision floor.
5.2 NID → NAC + DUID, BCH(63,16)-corrected (frame.py, fec.py)
The 64-bit NID is a systematic BCH(63,16) codeword in bits 63..1 plus an overall even-parity bit in bit 0. The 16 information bits — NAC(12) in the top, DUID(4) below it — are the codeword's most-significant bits. decode_nid BCH-decodes (correcting up to 11 bit errors), then slices NAC/DUID from the corrected info word rather than reading them raw:
info16, nerr = bch63_16_decode(nid64 >> 1) # BCH-correct the 63-bit codeword
if nerr < 0: info16 = (nid64 >> 48) & 0xFFFF # uncorrectable -> raw slice, flagged
nac = (info16 >> 4) & 0xFFF # 12-bit Network Access Code
duid = info16 & 0xF # 4-bit Data Unit ID
return {"nac": "%03X" % nac, "duid": duid, "type": DUID.get(duid, "?"),
"nid_errs": nerr} # nerr = corrected-bit count, or -1
make_nid(nac, duid) is the exact inverse: BCH(63,16)-encode the 16-bit (nac<<4)|duid, then append the overall even-parity bit — cw = bch63_16_encode(info16); nid = (cw << 1) | (popcount(cw) & 1).
The BCH(63,16) codec is implemented natively in fec.py — a full syndrome / Berlekamp-Massey / Chien-search decoder over GF(2⁶), not a stub:
- Field. GF(2⁶) is built from the primitive polynomial x⁶ + x + 1 =
0x43. The antilog/log tables_GF6_EXP/_GF6_LOGare generated by stepping the primitive element α (start 1,g <<= 1, and XOR0x43whenever bit 6 sets) for 63 steps; α⁶³ = α⁰ = 1._gf6_muladds logs mod 63;_gf6_inv(a) = α^((63 − log a) mod 63). - Generator.
_bch63_generator()forms g(x) as the LCM of the minimal polynomials of α¹…α²², walking each cyclotomic conjugate set {c·2ᵏ mod 63} so a minimal polynomial is folded in exactly once. The computed result is a degree-47 binary polynomial:
Since 63 − 16 = 47 parity bits and the 22 = 2t consecutive roots α¹…α²² give a designed distance correcting t = 11 errors._BCH63_GEN = 0xCD930BDD3B2B # deg 47; BCH(63,16), t = 11 - Systematic encode (
bch63_16_encode): shift the 16 info bits up by 47 (msg = info16 << 47), take the remainder ofmsgmod g(x) by binary polynomial long division, and OR it back in — info in the top 16 bits, 47 parity bits below. - Decode (
bch63_16_decode): (1) syndromes Sⱼ = r(αʲ) = Σ_{i: bitᵢ=1} α^(j·i mod 63) for j = 1…22 (all-zero ⇒ return the top 16 bits with 0 errors); (2) Berlekamp-Massey over GF(2⁶) builds the error-locator λ(x) from the discrepancy δ = Sₙ + Σ λᵢ·Sₙ₋ᵢ; (3) a Chien search evaluates λ at α^(63−i) for every position i, a root marking a bit error. If the root count ≠ L the frame is flagged uncorrectable (nid_errs = -1); otherwise the marked bits are flipped and (info16, n_errors) returned.
Verified: make_nid/decode_nid round-trip NAC/DUID over 200 random words, and a NID hit by 5 bit errors still decodes to NAC 293, DUID 5 with nid_errs = 5 — the BCH correction repairs a badly-corrupted NID, not merely a clean one (§7.1).
5.3 DUID dispatch
The recovered DUID selects the downstream path: DUID 5/10 → voice LDU (IMBE FEC, §5.5); DUID 7 → TSBK (trellis + parse, §5.4); DUID 0/3/12/15 → header/terminator/ data units. The dispatch is the DUID map (frame.py); the two paths that are implemented end-to-end are voice-frame FEC and TSBK trunking.
5.4 Trunking: 1/2-rate trellis + TSBK parse (trunk.py)
Trellis decode. TSBK content is protected by the P25 1/2-rate trellis code. The encoder state is simply the previous input dibit; each (state, input-dibit) pair emits a 4-bit constellation point from a 16-entry table (trunk.py):
_CONST = [0, 15, 12, 3, 4, 11, 8, 7, 13, 2, 1, 14, 9, 6, 5, 10] # [state*4 + dibit]
state = 0
for d in dibits: emit _CONST[state*4 + d]; state = d
The decoder is a four-state Viterbi (trunk.py). Path metrics start at [0, ∞, ∞, ∞] (known start state 0). For each received 4-bit symbol and each (state s, input d) it adds the Hamming distance between the symbol and the expected constellation point, keeps the best predecessor per next-state (next-state = d), then traces back from the minimum-metric final state:
branch metric m = pm[s] + popcount( sym ⊕ _CONST[s*4 + d] )
next state ns = d
This corrects the near-totality of single-bit symbol errors (test: ≥ 190/200 — test_p25.py).
CRC-16. TSBK integrity is a CRC-16-CCITT (trunk.py): polynomial 0x1021, initial register 0xFFFF, MSB-first, no final inversion:
reg = 0xFFFF
for each byte b:
reg ^= b << 8
×8: reg = ((reg << 1) ^ 0x1021) & 0xFFFF if reg & 0x8000 else (reg << 1) & 0xFFFF
tsbk_crc_ok(tsbk10) checks crc16(tsbk10[:8]) == (tsbk10[8]<<8 | tsbk10[9]) (trunk.py) — i.e. the implemented block is 10 bytes: an 8-byte body followed by the 16-bit CRC in bytes 8–9. (The module header quotes the on-air TSBK as 96 bits = 80 payload + 16 CRC; the implemented CRC covers the first 8 bytes and the field parser indexes bytes 0–7, so the practical payload here is 8 bytes.)
TSBK parse (trunk.py): the first byte carries flags + opcode; group voice-channel-grant opcodes expose channel/talkgroup/source:
last = tsbk[0] >> 7 # LB (last-block) flag
opcode = tsbk[0] & 0x3F # 6-bit opcode (bit 6 = protected flag, ignored)
mfid = tsbk[1] # manufacturer ID
if opcode in (0x00, 0x02): # GRP_V_CH_GRANT / _UPD
channel = tsbk[3]<<8 | tsbk[4]
talkgroup = tsbk[5]<<8 | tsbk[6]
source = tsbk[7]<<16 | tsbk[2]<<8 # 24-bit source, spread across bytes
Opcodes are named from _OPCODE (trunk.py): 0x00 GRP_V_CH_GRANT, 0x02 GRP_V_CH_GRANT_UPD, 0x04 UU_V_CH_GRANT, 0x3A RFSS_STS_BCST, 0x3B NET_STS_BCST; unknown opcodes render as 0xNN.
5.5 Voice: IMBE frame FEC (voice.py)
Each 88-bit IMBE parameter frame is protected to 144 transmitted bits by a mix of codes, with a PN scramble over everything except the first codeword. Decode (decode_imbe, voice.py) is the exact inverse of encode:
u0first, unscrambled. The leading 23 bits are a Golay(23,12) codeword; decode →u0. If it fails to decode, the frame is dropped (voice.py).u0must be recovered first because it seeds the descrambler.- De-scramble the remaining 121 bits.
_pn(u0)(voice.py) is a 15-bit LFSR seeded from the correctedu0(or0xFFFifu0 == 0), producing 121 scramble bits that are XORed offbits[23:]:reg = (u0 & 0xFFF) or 0xFFF # 15-bit register, non-zero seed for i in range(121): b = ((reg >> 14) ^ (reg >> 13)) & 1 # feedback taps at bits 14,13 out[i] = b reg = ((reg << 1) | b) & 0x7FFF - Decode the rest. Walk the descrambled bits in the
_ENCfield order:u1..u3are Golay(23,12) (23 bits each),u4..u6are Hamming(15,11) (15 bits each),u7is 7 raw bits (voice.py). AnyNonefrom a Golay decode fails the whole frame. - Pack for the vocoder.
imbe_frame_bytes(u)(voice.py) concatenatesu0..u7MSB-first perU_SIZESandnp.packbitses the 88 bits into 11 bytes.
The FEC (spread across four Golay + three Hamming codewords) corrects several bit errors per frame — the test flips bits at positions 3/30/60/100, one in each code region, and still recovers the frame exactly (test_p25.py).
5.6 IMBE → PCM: the vocoder (voice.py + imbe.py + mbe.py)
Vocoder is native by default: decode() takes an 11-byte (88-bit) IMBE parameter frame, splits it into u-vectors, runs the IMBE parameter codec to \((f_0, \{v_\ell\}, \{A_\ell\})\), synthesises through the MBE vocoder, and returns 160 samples of peak-normalised s16 PCM — no external library. Constructed with native=False it reverts to the old behaviour, a lib-gate that looks for mbelib via ctypes.util.find_library("mbe") and raises NotImplementedError until mbe_processImbe is bound — kept as an A/B reference, not a dependency. (IMBE patents have expired; the project is non-commercial.) The end-to-end test injects 8 channel bit errors into a FEC-protected voice frame and gets exact u-vectors plus finite, non-silent native PCM out.
6. Constants and tables
All values below are quoted verbatim from the source module; large derived tables (the Golay syndrome map, the Hamming column sets) are described with the code that builds them.
6.1 Frame sync (frame.py)
FRAME_SYNC = 0x5575F5FF77FF # 48 bits, MSB-first
As 12 nibbles / 24 dibits / 24 C4FM levels (using §2.1's map — note the sync is all outer levels, ±3 / ±1800 Hz):
hex 5 5 7 5 F 5 F F 7 7 F F
bits 0101 0101 0111 0101 1111 0101 1111 1111 0111 0111 1111 1111
dibit 1 1 1 1 1 3 1 1 3 3 1 1 3 3 3 3 1 3 1 3 3 3 3 3
level +3+3 +3+3 +3−3 +3+3 −3−3 +3+3 −3−3 −3−3 +3−3 +3−3 −3−3 −3−3
6.2 C4FM level and slice maps (demod.py)
_LEVEL = {1: 3, 0: 1, 2: -1, 3: -3} # dibit value -> transmit level
_slice: thresholds at +2, 0, -2 # sample -> dibit (see §4.4)
6.3 DUID map (frame.py)
DUID = {0: "HDU", 3: "TDU", 5: "LDU1", 7: "TSBK", 10: "LDU2",
12: "PDU", 15: "TDU-LC"}
6.4 Golay(23,12,7) / (24,12,8) (fec.py)
Generator polynomial (fec.py):
_G23 = 0xC75 # x^11 + x^10 + x^6 + x^5 + x^4 + x^2 + 1
Syndrome is the 11-bit remainder of polynomial long division (_syndrome23, fec.py): for i = 22 … 11, if bit i is set, r ^= _G23 << (i − 11); the low 11 bits are the syndrome.
Because Golay(23,12) is a perfect code, every error of weight ≤ 3 has a unique syndrome. _build_golay_table (fec.py) enumerates all weight-1, -2 and -3 error patterns over 23 positions and stores {syndrome: error}; the table has exactly 2048 = 2¹¹ entries (asserted at test_p25.py). Decode is a table lookup then XOR (fec.py):
golay23_encode(d12) = (d12 << 11) | _syndrome23(d12 << 11) # systematic [23,12]
golay24_encode(d12) = (golay23_encode(d12) << 1) | parity # + overall even parity
golay24_decode(cw24) : drop the parity bit, look up _GOLAY_TBL[_syndrome23], XOR, take top 12
golay23_decode(cw23) : look up + XOR + take top 12 (or None if no entry)
golay24_decode corrects up to 3 errors in 24 bits (test: 300 random words, three random flips each — test_p25.py).
6.5 BCH(63,16) NID code (fec.py)
Field GF(2⁶), primitive polynomial x⁶ + x + 1 = 0x43; the log/antilog tables _GF6_EXP / _GF6_LOG are stepped from α (start 1, g <<= 1, XOR 0x43 when bit 6 sets) over 63 elements. Generator polynomial (computed) — the LCM of the minimal polynomials of α¹…α²², degree 47:
_BCH63_GEN = 0xCD930BDD3B2B # deg 47; BCH(63,16), t = 11, 47 parity bits
bch63_16_encode is systematic (info16 << 47, then remainder mod g(x)); bch63_16_decode is syndrome (S₁…S₂₂) → Berlekamp-Massey → Chien search over GF(2⁶), correcting up to t = 11 bit errors and returning the corrected error count (or −1 for uncorrectable). Wired into the NID by decode_nid / make_nid (§5.2).
6.6 Hamming codes (fec.py)
Two distinct Hamming facilities exist:
(a) Generic single-error correction (hamming_correct, fec.py). Column sets are built by _hamming_tables(n, k) (fec.py) as the nonzero (n−k)-bit syndromes in natural order; for (15,11) and (10,6) these are just 1 … n. _ham_syndrome XORs the columns of the set bits; a nonzero syndrome that matches a column flips that single bit. Used standalone (test test_p25.py).
(b) Systematic Hamming for the IMBE FEC (fec.py). Data-bit column sets:
_H15_COLS = [3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15] # (15,11): 11 data cols, m=4 parity
_H10_COLS = [3, 5, 6, 7, 9, 11] # (10,6): 6 data cols, m=4 parity
Encode appends 4 parity bits p = XOR of cols[i] over set data bits (_ham_enc, fec.py); decode recomputes the syndrome and, if it matches a data column, corrects that data bit (_ham_dec, fec.py — a flipped parity bit yields a pure power-of-two syndrome, which is not in the column set, so data is returned uncorrected-but-correct). hamming15_* / hamming10_* (fec.py) are the wrappers the voice FEC calls.
6.7 IMBE frame layout (voice.py)
U_SIZES = [12, 12, 12, 12, 11, 11, 11, 7] # 88-bit parameter frame (u0..u7)
_ENC = [23, 23, 23, 23, 15, 15, 15, 7] # -> 144 coded bits
# Golay(23,12) ×4 | Hamming(15,11) ×3 | u7 raw
PN descrambler: 15-bit LFSR, feedback taps at bits 14 and 13, seeded by the error-corrected u0 (_pn, voice.py), 121 output bits covering c1..c7.
6.8 Trellis constellation (trunk.py)
_CONST = [0, 15, 12, 3, 4, 11, 8, 7, 13, 2, 1, 14, 9, 6, 5, 10]
# index = state*4 + input_dibit -> 4-bit constellation symbol
4 states × 4 input dibits = 16 entries; branch metric = Hamming distance between the received 4-bit symbol and the table entry.
6.9 TSBK CRC-16 and opcodes (trunk.py)
CRC-16-CCITT: poly 0x1021, init 0xFFFF, MSB-first, no final XOR
_OPCODE = {0x00: GRP_V_CH_GRANT, 0x02: GRP_V_CH_GRANT_UPD, 0x04: UU_V_CH_GRANT,
0x3B: NET_STS_BCST, 0x3A: RFSS_STS_BCST}
7. Interoperability and validation
7.1 The test suite (test_p25.py)
Nine tests exercise every native layer; all use fixed RNG seeds and are self-consistent (encode → corrupt → decode):
| test | what it proves | key assertions |
|---|---|---|
test_golay_24_12 |
perfect Golay, 3-error correction | table = 2048 entries; 300× decode clean + with 3 random flips |
test_hamming_15_11 |
generic Hamming single-error | 300× 1-flip corrected |
test_frame_sync_and_nid |
sync search, noise tolerance | sync found at index 137; still found with 2 injected bit errors |
test_nid |
NID BCH round-trip | make_nid→decode_nid gives NAC 293, DUID 5, type LDU1, nid_errs = 0 |
test_nid_bch_correction |
BCH(63,16) error correction | a NID hit by 1, 3 and 5 injected bit errors still decodes to NAC 293, DUID 5 |
test_golay23_and_hamming_systematic |
23-bit Golay + systematic (15,11)/(10,6) | 200× encode→1-flip→decode exact |
test_imbe_voice_frame_fec |
full IMBE FEC + PN | 120× clean + 4-position-flip corrected; 88 bits → 11 bytes; vocoder lib-gated |
test_trellis_and_tsbk |
Viterbi + TSBK CRC/parse | clean trellis exact; ≥190/200 1-bit corrected; talkgroup 0x1234 parsed from a CRC-valid grant |
test_c4fm_demod_and_sync |
end-to-end modem | modulate→demodulate zero errors clean, <2 % BER at 30 dB SNR; sync locks at the expected dibit index |
The demod test is the strongest evidence the modem is correct: it modulates 400 random dibits, adds AWGN to 30 dB SNR (n * sqrt(mean(n²))/10**1.5), and requires the recovered dibit-error rate under 2 % (test_p25.py); at clean SNR the interior symbols are recovered exactly. It also constructs the frame sync as dibits, modulates a stream with it embedded, demodulates, and confirms find_sync(dibits_to_bits(rec)) locks at the correct offset.
7.2 On-air reference
The self-consistency tests validate the FEC/framing/trellis transcription against themselves. The final interoperability check — decoding a real off-air P25 signal — is done against the external reference stack the app already ships: dsd-fme + mbelib (digivoice.py), which decodes the same discriminator audio and prints NAC/talkgroup. trunk.py's metadata can be compared to dsd-fme directly; for voice, the native chain now produces PCM itself (IMBE codec + MBE vocoder), so an off-air capture compares native audio against mbelib's — with the caveat that the exact IMBE amplitude tables are still to be transcribed for bit-exact audio (see the IMBE spec §3). The governing standard is TIA-102 — .BAAA for the C4FM CAI, .AABC for the trunking control messages, .BABA for the IMBE vocoder (cited in each module header).
8. Limitations
Stated honestly, roughly in order of impact:
- IMBE audio is native but not yet bit-exact. The vocoder path is fully in-repo (
imbe.py+mbe.py), but the published IMBE amplitude quantiser tables and inter-frame DCT prediction are still to be transcribed — native audio from real off-air frames will be intelligible-but-approximate until then (see imbe.md §3). mbelib remains selectable for A/B. - Simplified TSBK block. The implemented CRC covers 8 payload bytes with the CRC in bytes 8–9 (a 10-byte block), whereas the on-air TSBK is 96 bits (80 payload + 16 CRC). The parser reads only the group-voice-grant fields; most opcodes are surfaced by number only (
trunk.py). - No on-air LDU deinterleave.
voice.pyundoes the per-frame IMBE FEC on an already-assembled 144-bit frame; the full LDU byte-layout — status-symbol removal, the embedded link-control / low-speed-data interleave, and the on-air bit interleave — is not implemented (voice.pynotes the interleave is "transcribed from the spec and validated self-consistently"). Likewise the TSBK on-air interleave preceding the trellis is not applied;trellis_decodeexpects the symbol sequence in order. - Block-at-a-time timing, no tracking. Symbol timing is a one-shot max-eye search over the buffer with no fractional interpolation or per-symbol tracking loop (
demod.py); fine for clean captures, less so for drifting or very weak signals. - Phase 2 / TDMA not covered. Only Phase 1 FDMA C4FM is implemented; Phase 2 (two-slot TDMA, H-DQPSK/H-CPM, AMBE+2) is out of scope.
- Encryption not handled. Encrypted (ALGID ≠ 0x80) voice is decoded at the FEC layer but not deciphered; there is no key handling.
None of these change the architecture — they are the natural next increments on a working native receive chain.
9. References
- TIA-102.BAAA — Project 25 FDMA Common Air Interface — the C4FM waveform, frame sync, NID, and Golay/Hamming FEC (cited
demod.py,fec.py,frame.py). - TIA-102.AABC — Project 25 Trunking Control Channel Messages — TSBK format and opcodes, the 1/2-rate trellis (cited
trunk.py). - TIA-102.BABA — Project 25 Vocoder Description — the IMBE 88-bit parameter frame, its 144-bit FEC, and the PN scramble (cited
voice.py). - TIA-102.BAAA §NID / block codes — the BCH(63,16) code protecting the NID (GF(2⁶), primitive poly
0x43) and the perfect Golay(23,12,7)/(24,12,8) code used for the IMBE most-significant vectors. - In-repo implementation:
app/radio/p25/{demod,fec,frame,trunk,voice}.py,app/radio/p25/__init__.py; tests intest_p25.py; external-decoder bridge and the mbelib/dsd-fmereference inapp/radio/digivoice.py. - mbelib — the open IMBE/AMBE vocoder library the
Vocoderhook links to for the patent-encumbered IMBE→PCM synthesis step.