NXDN: native NXDN Forum common air interface reception
A self-contained, clean-room receiver for the NXDN Forum common air interface (Icom IDAS / Kenwood NEXEDGE): 4-level FSK demodulation at either channel rate, the 20-bit frame-sync search, the LICH bit-duplication code, the two punctured rate-1/2 convolutional chains (SACCH / FACCH1) decoded with an erasure-tolerant Viterbi, CRC-6 / CRC-12 integrity, VCALL / TX_RELEASE call-message parsing, and the AMBE+2 half-rate voice frames — all in-repo, no external NXDN binary.
Rafe project · app/radio/nxdn.py · in-repo NumPy
Abstract
NXDN is the digital land-mobile / private-mobile-radio (PMR) protocol developed jointly by Icom (marketed as IDAS) and Kenwood (NEXEDGE) under the NXDN Forum. It is a narrowband FDMA system: one conversation per 6.25 kHz or 12.5 kHz RF channel, carried by a 4-level FSK waveform — the same continuous-phase C4FM family as P25 and YSF. The 6.25 kHz channel runs at 4800 bit/s (2400 symbols/s); the 12.5 kHz channel at 9600 bit/s (4800 symbols/s).
Above the symbol layer NXDN is a rigid 384-bit frame: a 20-bit frame sync word, a 16-bit LICH (Link Information CHannel — 8 information bits, each sent twice), a 60-bit SACCH (Slow Associated Control CHannel), and 288 bits of body that carry either four 72-bit AMBE+2 voice channel frames or two 144-bit FACCH1 (Fast Associated Control CHannel) blocks. SACCH and FACCH1 are protected by punctured rate-1/2, K=5 convolutional codes with CRC-6 and CRC-12 respectively; the decoder depunctures by re-inserting erasures and runs the same erasure-aware Viterbi that the YSF module already provides.
This document specifies Rafe's native NXDN receiver in app/radio/nxdn.py in full, end to end: 4-level baseband → dibits → frame sync → LICH/SACCH/FACCH1 FEC → call metadata and AMBE+2 voice parameters. The C4FM modem is reused verbatim from P25 with only its symbol rate parameterised; the voice frames route through the shared AMBE half-rate channel codec and the native MBE vocoder. It is a clean-room transcription of the published NXDN structure. Per the honesty stance carried in the module docstring: the frame geometry, field sizes, the punctured-conv + CRC chains and the LICH duplication are the published structure; the FSW value, the puncture patterns, the CRC polynomials' exact seeds and the LICH / functional-type encodings are self-consistent transcriptions — the on-air interop checks — and are not claimed bit-exact against real NXDN signals. See §8.
1. Motivation and scope
Like the other native digital-voice modules, nxdn.py exists to move the decodable part of an NXDN transmission — everything from 4-level baseband to recovered call metadata and AMBE+2 voice parameters — in-house, so the receive chain runs on in-repo NumPy rather than shelling out to an external DSD-style binary. The module deliberately shares three building blocks that already exist in the tree:
NATIVE (app/radio/nxdn.py + shared blocks)
FM-disc audio ─▶ 4FSK demod ─▶ FSW sync ─▶ LICH ─▶ func dispatch ┐
(48 kHz) p25/demod.py nxdn.py nxdn.py │
(symrate ├─▶ VCH ─▶ AMBE+2 chan ─▶ MBE vocoder ─▶ PCM
parameterised) │ ambe.py mbe.py
│
├─▶ SACCH ─▶ depunct+Viterbi ─▶ CRC-6 ─▶ SR/RAN/payload
│ ysf.viterbi
└─▶ FACCH1 ─▶ depunct+Viterbi ─▶ CRC-12 ─▶ VCALL/TX_REL
ysf.viterbi
- The C4FM modem is
app/radio/p25/demod.py, imported unchanged; NXDN only needs its symbol rate made a parameter (symrate) so the same root-raised-cosine matched filter and max-eye-opening timing recovery serve both 2400 and 4800 sym/s (§3, §5.1). - The rate-1/2 K=5 convolutional code and its erasure-aware Viterbi are imported from
app/radio/ysf.py(conv_encode,viterbi) — the same code YSF uses for its FICH/DCH chains. NXDN reuses them for the punctured SACCH and FACCH1 codes; the "received value ≥ 2 is an erasure" rule in that Viterbi is precisely what makes a punctured code decodable (§5.3). - The voice frames are AMBE+2 half-rate channel frames handled by
app/radio/ambe.py(hr_to_channel/channel_to_hr) and synthesised by the native MBE vocoder — the identical 49-bit parameter frame DMR and YSF DN-mode carry.
This spec covers the conventional voice path implemented in the module: a VCALL header, a run of voice frames, and a TX_RELEASE trailer. Trunked signalling (control-channel messaging beyond the VCALL/TX_RELEASE call-message forms), data services and encryption are out of scope and not implemented (§8).
2. Background
2.1 NXDN in the digital-PMR landscape
NXDN is a 6.25 kHz-capable digital PMR protocol and it is FDMA — its headline feature is running a full digital channel in 6.25 kHz, twice the spectral packing of a 12.5 kHz analogue or DMR channel. It defines two system types: Type-C (conventional) — simplex / repeater with no dedicated control channel, the mode this receiver targets — and Type-D (trunked), where a control channel signals grants/registrations (analogous to P25's TSBK). Both share the same physical layer and 384-bit frame skeleton; only the SACCH/FACCH messages differ.
2.2 The two channel rates
NXDN is 4-level FSK with two operating points, selected by channel bandwidth:
| Channel BW | Gross bit rate | Symbol rate | Two bits/symbol |
|---|---|---|---|
| 6.25 kHz | 4800 bit/s | 2400 sym/s | ✓ |
| 12.5 kHz | 9600 bit/s | 4800 sym/s | ✓ |
Both carry the identical 384-bit frame; at 4800 bit/s a frame is 80 ms, at 9600 bit/s it is 40 ms (§3). The 12.5 kHz / 9600 bit/s mode is essentially the 6.25 kHz waveform clocked twice as fast, which is exactly why the modem only needs its symbol rate parameterised.
2.3 RAN and the logical channels
- RAN — Radio Access Number (6 bits, 0–63). NXDN's system-colour code: like P25's NAC or a CTCSS tone, it lets co-channel systems ignore each other. It rides in the SACCH information field and is surfaced on every decoded frame.
- LICH — Link Information CHannel (16 on-air bits). A tiny, heavily-protected field that says what kind of frame this is (voice vs control), its RF-channel type, options and direction. Decoded on every frame before anything else, because it selects the body layout.
- SACCH — Slow Associated Control CHannel (60 on-air bits). A low-rate signalling channel present in every frame; carries the SR structure, the RAN, and a fragment of a longer message spread across successive frames.
- FACCH1 — Fast Associated Control CHannel (144 on-air bits). A higher-capacity signalling block that replaces the voice body when the frame carries a call message (VCALL / TX_RELEASE) instead of speech.
- VCH — Voice CHannel. Four AMBE+2 half-rate voice frames per 384-bit frame.
2.4 dPMR — the ETSI cousin
dPMR (ETSI TS 102 658) shares NXDN's 2400-symbol 4FSK physical layer — the same modulation and symbol rate — but wraps it in its own framing. The module header notes this explicitly: dPMR "shares this 2400-sym 4FSK PHY; its own framing is a separate rung." dPMR framing is not implemented here; it is a natural future increment that would reuse this module's modem front end (§8).
3. Signal and symbol structure — exact numbers
Every value below is a constant in app/radio/nxdn.py (or in the shared modem / codec it imports) and cited to its module.
Symbol layer — the reused C4FM modem (app/radio/p25/demod.py)
NXDN modulates and demodulates through the P25 C4FM modem unchanged except for the symbol rate. modulate_call / decode pass symrate = rate // 2 into demod.modulate / demod.demodulate (nxdn.py modulate_call, decode):
- Channel bit
rate→symrate = rate // 2:4800 → 2400 sym/s,9600 → 4800 sym/s(nxdn.py). - Two bits per symbol (one dibit), four levels {+3, +1, −1, −3} from
_LEVEL = {1: 3, 0: 1, 2: -1, 3: -3}(p25/demod.py). - Samples per symbol at the default
fs = 48000:sps = fs // symrate— 20 at 2400 sym/s, 10 at 4800 sym/s (p25/demod.py). - Matched/shaping filter: root-raised-cosine, roll-off α = 0.2, span 8 symbols each side →
2·8·sps + 1taps = 321 taps at 2400 sym/s, 161 at 4800 sym/s (p25/demod.py_rrc). The filter shape is P25's; NXDN reuses it. - Timing recovery: single max-eye-opening offset search over the whole buffer (
p25/demod.py); no fractional interpolation or tracking loop. Amplitude is normalised from the top half of sampled magnitudes to level ≈ 3, then sliced at +2 / 0 / −2 into dibits.
Frame layer (nxdn.py)
- Frame length:
FRAME_BITS = 384bits = 192 symbols → 80 ms at 4800 bit/s, 40 ms at 9600 bit/s. - Frame sync word:
FSW = 0xCDF5A, 20 bits, held MSB-first inFSW_BITS = [(FSW >> (19 - i)) & 1 for i in range(20)](nxdn.py). - Field byte-offsets, as
parse_frameslices them (nxdn.py): FSW[0:20]· LICH[20:36]· SACCH[36:96]· body[96:384]= 20 + 16 + 60 + 288 = 384. ✓ - Body: 288 bits = either 4 × 72-bit AMBE+2 voice channel frames (VCH), or 2 × 144-bit FACCH1 blocks (control) (
nxdn.pybuild_voice_frame,build_control_frame).
Coding-chain sizes (nxdn.py)
| Channel | info | + CRC | + tail | coded (rate 1/2) | punctured (on-air) |
|---|---|---|---|---|---|
| LICH | 7 + 1 parity | — | — | — (each bit ×2) | 16 |
| SACCH | 26 | +6 (CRC-6) | +4 | 72 | drop every 6th → 60 |
| FACCH1 | 80 | +12 (CRC-12) | +4 | 192 | drop every 4th → 144 |
- SACCH: 26 info + 6 + 4 = 36 input bits → conv → 72 → puncture
keep_of=6drops 12 → 60 (sacch_encode). - FACCH1: 80 info + 12 + 4 = 96 input bits → conv → 192 → puncture
keep_of=4drops 48 → 144 (facch1_encode).
Voice layer (ambe.py) — each voice frame is a 49-bit AMBE+2 half-rate parameter frame carried as a 72-bit channel frame (ambe.CHANNEL_BITS = 72, hr_to_channel): Golay(24,12) over the 12 most-sensitive bits, a PN-scrambled Golay(23,12) over the next 12, and 25 unprotected bits. Details in ambe.md.
4. The 384-bit frame
bit 0 20 36 96 384
┌────────┬──────────────┬─────────────────────┬────────────────────────────────────────────────┐
│ FSW │ LICH │ SACCH │ body │
│ 20 bit │ 16 bit │ 60 bit │ 288 bit │
│0xCDF5A │ 8 info ×2 │ 26 info + CRC-6, │ VCH : 4 × 72-bit AMBE+2 channel frames │
│ (FSW) │ (duplicated) │ conv+punct 72→60 │ (or) FACCH1 : 144-bit block, transmitted twice │
└────────┴──────────────┴─────────────────────┴────────────────────────────────────────────────┘
parse_frame slices: [0:20] · [20:36] · [36:96] · [96:384]
The LICH func field selects the body layout: FUNC_VCH = 2 → four voice channel frames; FUNC_FACCH = 1 → the FACCH1 control block (sent twice for diversity). The frame skeleton (FSW | LICH | SACCH) is identical either way — only the 288-bit body differs. A voice frame is built by build_voice_frame and a control frame by build_control_frame (nxdn.py).
5. Decode — step by step
Input: real FM-discriminator audio at 48 kHz. Output: a list of NXDN events — vcall, voice (with AMBE+2 frames), tx_release — each with its SACCH metadata. The top-level driver is decode (nxdn.py).
5.1 Frame sync search (decode)
decode first demodulates to a bit stream — bits = demod.dibits_to_bits(demod.demodulate(audio, fs=fs, symrate=rate // 2)) — then slides a 20-bit window looking for the FSW within Hamming distance max_err (default 2):
for j in range(pos, n - 19):
if sum(bits[j+k] != FSW_BITS[k] for k in range(20)) <= max_err:
i = j; break
An exact match is deliberately not required — a few sync bits flip under 4-level noise. On a hit, parse_frame(bits[i:i+FRAME_BITS]) decodes the frame; if it returns a dict the event is kept and the cursor advances a full frame (pos = i + FRAME_BITS); if parse_frame returns None (bad LICH parity) the cursor advances only 20 bits and the search resumes — a mis-detected sync costs one re-scan, not a whole frame (nxdn.py decode).
5.2 LICH — bit-doubling + even parity (parse_lich)
The LICH is the smallest, most-protected field, decoded first because it selects the body. Its 7 information bits are rf(2) + func(2) + opt(2) + dir(1), plus 1 even-parity bit over those 7, giving 8 bits; each of the 8 bits is transmitted twice → 16 on-air bits (build_lich):
b = rf(2) + func(2) + opt(2) + [dir] # 7 info bits
b.append(sum(b) & 1) # even parity -> 8 bits
return [x for bit in b for x in (bit, bit)] # each bit twice -> 16
Decode (parse_lich) collapses each duplicated pair and checks parity:
for each of 8 pairs (bits16[2i], bits16[2i+1]):
b[i] = value if the two copies agree, else the FIRST copy # 2-copy guess
if (sum(b[:7]) & 1) != b[7]: return None # parity reject
return {"rf": b0..1, "func": b2..3, "opt": b4..5, "dir": b6}
With only two copies there is no majority vote, so on a disagreement the decoder takes the first copy and leans on the parity bit to detect an uncorrected error. This tolerates a single flipped copy per bit-pair when the surviving copies still satisfy parity — the test_lich_roundtrip test flips one half of a duplicated bit and still recovers the field exactly (§7). A parity failure rejects the whole frame (parse_frame returns None).
5.3 SACCH — punctured conv + erasure Viterbi + CRC-6
The coding chain (sacch_encode):
SR(2) RAN(6) payload-fragment(18) = 26 info bits
│
├── + CRC-6 (poly 0x27, width 6) ── +6
├── + tail (0 0 0 0, trellis flush) ── +4
▼
36 bits ── rate-1/2 K=5 conv (G0=0x13, G1=0x1D, ysf.conv_encode) ──▶ 72 coded bits
│
▼
puncture keep_of=6: drop every 6th bit (index % 6 == 5) ── 12 dropped ──▶ 60 on-air bits
Why puncture? The base convolutional code is rate 1/2 — every input bit becomes two coded bits, doubling the channel occupancy. That is more redundancy than the link budget needs, and 72 bits will not fit the SACCH's 60-bit slot. Puncturing simply deletes some of the coded bits before transmission according to a fixed pattern. Here _puncture(bits, keep_of=6) keeps 5 of every 6 coded bits (drops the 6th), so 72 × 5⁄6 = 60 bits go on air. Fewer channel bits now carry the same 36 input bits, so the effective code rate rises from 1/2 to (1/2)/(5⁄6) = 3/5 — less redundancy, more throughput, at the cost of some error-correcting strength.
Why it still decodes — erasures. A Viterbi trellis needs a received value at every coded-bit position; it cannot simply skip the deleted ones. So the decoder puts the missing bits back as erasures — a third symbol value, 2, that means "unknown" — with _depuncture(bits60, 6, 72):
for i in range(72):
out.append(2 if i % 6 == 5 else next input bit) # 2 = erasure at every dropped position
The imported ysf.viterbi is written to honour this: its branch metric is
m = metric[st] + (r0 < 2 and c0 != r0) + (r1 < 2 and c1 != r1)
so a received value ≥ 2 fails the r < 2 guard and charges no branch penalty, whatever the trellis branch's expected code bit c is. The erased positions are effectively free; the path-metric competition is decided entirely by the surviving un-punctured bits, and the Viterbi's global most-likely-path search recovers the message across the gaps. This is the whole trick of a punctured convolutional code: transmit fewer bits, fill the holes with "don't-knows," let the trellis do the rest.
Decode (sacch_decode):
dec = viterbi(_depuncture(bits60, 6, 72)) # 72 values -> 36 decoded bits
info, crc = dec[:26], _frombits(dec[26:32]) # 26 info + 6 CRC
if _crc6(info) != crc: return None # CRC-6 integrity gate
return {"sr": info[0:2], "ran": info[2:8], "payload": info[8:26]}
CRC-6 uses polynomial 0x27 (_crc6). The test_sacch_punctured_conv test injects two bit errors through the puncture and recovers sr/ran/payload exactly, then corrupts every even position and confirms the CRC-6 rejects (returns None) — the FEC corrects a couple of errors, the CRC catches the rest (§7).
5.4 FACCH1 — punctured conv + CRC-12
Same shape, higher capacity and a stronger drop pattern (facch1_encode):
80 info bits
├── + CRC-12 (poly 0x80F, width 12) ── +12
├── + tail (0 0 0 0) ── +4
▼
96 bits ── rate-1/2 K=5 conv (ysf.conv_encode) ──▶ 192 coded bits
▼
puncture keep_of=4: drop every 4th bit (index % 4 == 3) ── 48 dropped ──▶ 144 on-air bits
Keeping 3 of every 4 coded bits gives 192 × 3⁄4 = 144 on-air bits and an effective rate of (1/2)/(3⁄4) = 2/3. Decode is the mirror (facch1_decode):
dec = viterbi(_depuncture(bits144, 4, 192)) # 192 values -> 96 decoded bits
info, crc = dec[:80], _frombits(dec[80:92]) # 80 info + 12 CRC
return info, (_crc12(info) == crc) # (80-bit message, crc_ok)
CRC-12 uses polynomial 0x80F (_crc12). The test_facch1_vcall test flips three scattered bits and still returns crc_ok, then flips every third bit and confirms CRC-12 rejects (§7).
5.5 Call-message parse (parse_vcall)
The 80-bit FACCH1 information field is a call message (build_vcall):
type(8) opt(8) src(16) dst(16) pad(32) = 80 bits
MSG_VCALL = 1 opens a call; MSG_TX_REL = 8 (TX_RELEASE) closes it. parse_vcall slices the fields back out:
{"mtype": bits[0:8], "opt": bits[8:16], "src": bits[16:32], "dst": bits[32:48]}
src and dst are the 16-bit unit IDs. A control frame carries the FACCH1 block twice (build_control_frame returns ... + f + f); parse_frame tries the first half body[:144], and if its CRC-12 fails, the second half body[144:] — accepting the first block that passes:
for half in (body[:144], body[144:]):
info, ok = facch1_decode(half)
if ok:
msg = parse_vcall(info)
out["type"] = {MSG_VCALL: "vcall", MSG_TX_REL: "tx_release"}.get(msg["mtype"], "facch")
out.update(msg); out["facch_ok"] = True; break
else:
out["type"] = "facch"; out["facch_ok"] = False
So the two on-air copies of the block provide simple frame-level diversity: either copy decoding cleanly yields the message.
5.6 Voice body → AMBE+2 (parse_frame, VCH branch)
When lich["func"] == FUNC_VCH, the 288-bit body is four 72-bit AMBE+2 channel frames back to back. parse_frame splits them and runs each through ambe.channel_to_hr (Golay-24 / Golay-23-PN de-protection, ambe.md) to recover the 49-bit half-rate parameter frame:
for k in range(4):
f49, _ = ambe.channel_to_hr(body[k*72:(k+1)*72])
frames.append(f49)
out["type"] = "voice"; out["frames"] = frames
The 49-bit frames are the input to the native MBE vocoder (ambe.synthesize_hr) for PCM. The decoder surfaces the parameter frames whether or not the vocoder is invoked, so call metadata (RAN, src/dst from the surrounding VCALL) is available with the modem + FEC alone.
5.7 The transmit side and the full call (modulate_call)
For test-signal generation and round-trips, modulate_call builds a complete NXDN transmission and modulates it (nxdn.py):
bits = build_control_frame(ran, build_vcall(src, dst)) # VCALL header
for each group of 4 AMBE frames:
bits += build_voice_frame(ran, group) # voice frames
bits += build_control_frame(ran, build_vcall(src, dst, mtype=MSG_TX_REL)) # TX_RELEASE
return demod.modulate(_bits_to_dibits([0]*24 + bits + [0]*24), # 24-bit lead/trail pad
fs=fs, symrate=rate // 2)
decode inverts the whole sequence to the event list [vcall, voice, …, tx_release]. The 24-bit zero pads on each side give the modem's block-at-a-time timing recovery lead-in/out room.
6. Constants and tables
All values verbatim from app/radio/nxdn.py unless another module is named.
6.1 Frame sync and geometry (nxdn.py)
FSW = 0xCDF5A # 20-bit frame sync word, MSB-first in FSW_BITS
FRAME_BITS = 384 # FSW(20) + LICH(16) + SACCH(60) + body(288)
6.2 Function and message types (nxdn.py)
FUNC_FACCH = 1 # LICH func -> body is a FACCH1 control block
FUNC_VCH = 2 # LICH func -> body is 4 AMBE+2 voice channel frames
MSG_VCALL = 1 # call message: voice call setup
MSG_TX_REL = 8 # call message: TX_RELEASE (end of transmission)
6.3 Field layouts (nxdn.py)
LICH info (7): rf(2) func(2) opt(2) dir(1) + even-parity(1) -> 8 bits, each ×2 = 16
SACCH info (26): SR(2) RAN(6) payload-fragment(18)
FACCH1 info (80): call message (below)
VCALL (80): type(8) opt(8) src(16) dst(16) pad(32)
6.4 CRC generators (nxdn.py)
_crc6: poly 0x27, width 6, init 0 # SACCH
_crc12: poly 0x80F, width 12, init 0 # FACCH1
_crc(bits, poly, width, init) is a standard MSB-first shift-register CRC: for each bit, r ^= bit << (width-1), then r = (r<<1) ^ poly if top-bit set else r<<1, masked to width bits.
6.5 Puncture patterns (nxdn.py)
_puncture(bits, keep_of): drop bits where i % keep_of == keep_of-1 # every keep_of-th
_depuncture(bits, keep_of, total): re-insert value 2 (erasure) at those positions
SACCH: keep_of = 6 -> 72 coded, 12 dropped -> 60 on-air (eff. rate 3/5)
FACCH1: keep_of = 4 -> 192 coded, 48 dropped -> 144 on-air (eff. rate 2/3)
6.6 Shared convolutional code (ysf.py, imported)
rate-1/2, constraint length K=5 (16 states)
G0 = 0b10011 = 0x13 G1 = 0b11101 = 0x1D
conv_encode: reg=((reg<<1)|b)&0x1F; out += [parity(reg&G0), parity(reg&G1)]
viterbi: hard-decision, 16 states, expects a 4-bit (0000) tail flush;
a received value >= 2 is an ERASURE and charges no branch penalty
6.7 C4FM modem (p25/demod.py, imported, symrate parameterised)
symrate = rate // 2 # 4800 bit/s -> 2400 sym/s ; 9600 bit/s -> 4800 sym/s
_LEVEL = {1: 3, 0: 1, 2: -1, 3: -3} # dibit value -> level {+3,+1,-1,-3}
RRC: alpha = 0.2, span = 8 -> 2*8*sps + 1 taps (321 @ sps=20, 161 @ sps=10)
6.8 AMBE+2 voice frame (ambe.py, imported)
hr_to_channel(bits49) -> 72-bit channel frame (Golay24 + PN-scrambled Golay23 + 25 raw)
channel_to_hr(bits72) -> (49-bit parameter frame, corrected-bit count)
7. Interoperability and validation
7.1 The test suite (test_nxdn.py)
Seven tests exercise every native layer; all use fixed RNG seeds and are self-consistent (build → corrupt → decode):
| test | what it proves | key assertions |
|---|---|---|
test_modem_symbol_rate_parameterised |
the reused modem clocks at either NXDN rate | modulate/demodulate at symrate=2400 returns the interior dibits exactly (NXDN 4800 bit/s channel); the default P25 symrate=4800 path is intact too |
test_lich_roundtrip |
LICH bit-doubling + parity | build_lich→parse_lich round-trips {rf, func, opt, dir}; flipping one half of a duplicated bit still recovers the field |
test_sacch_punctured_conv |
SACCH punctured conv + erasure Viterbi + CRC-6 | 60-bit output; 2 errors through the puncture corrected to exact sr/ran/payload; corrupting every even bit makes CRC-6 reject (None) |
test_facch1_vcall |
FACCH1 punctured conv + CRC-12 + VCALL parse | 144-bit output; 3 scattered flips still crc_ok, parse_vcall → {mtype: VCALL, opt: 0, src: 1234, dst: 2000}; every-3rd-bit flip makes CRC-12 reject |
test_call_end_to_end |
a full call at 4800 bit/s | 8 AMBE frames → events ["vcall","voice","voice","tx_release"]; src=1234, dst=2000; voice frames round-trip exactly; every event's sacch.ran == 17; trailer src == 1234 |
test_call_at_9600 |
the 9600 bit/s channel (4800 sym/s) | modulate_call(..., rate=9600) / decode(..., rate=9600) → ["vcall","voice","tx_release"]; voice frames round-trip |
test_call_through_noise |
robustness under AWGN | a full call at 30 dB SNR recovers ≥ 7 of 8 AMBE frames and still surfaces vcall with src == 1234 |
The two modem tests together are the evidence the symbol-rate parameterisation is correct: the same p25/demod.py recovers a 2400-sym stream (NXDN 6.25 kHz) and a 4800-sym stream (P25 / NXDN 12.5 kHz) from the identical code path, and the end-to-end test_call_at_9600 proves the whole framer works unchanged at the higher rate. test_call_through_noise is the strongest single check — a complete VCALL→voice→TX_RELEASE call, AWGN-corrupted to 30 dB, decoding to the right events with nearly all voice frames intact.
7.2 On-air reference
The suite validates the framing / FEC / puncture / CRC transcription against itself (encode → corrupt → decode). The remaining interoperability check — decoding a genuine off-air NXDN signal against a reference decoder such as DSD-FME or Kenwood/Icom equipment — has not been performed for this module, and the values flagged in §8 would need that confirmation. The physical layer is shared with the P25 C4FM modem, which is exercised against dsd-fme + mbelib (digivoice.py), so the modem front end has independent corroboration; the NXDN-specific framing above it does not yet.
8. Limitations
Stated honestly, and preserving the distinction the module docstring draws between published structure and self-consistent transcription:
- Published structure vs. on-air interop values. Spec-accurate and safe to rely on: the frame geometry (384-bit frame, FSW/LICH/SACCH/body split), the field sizes, the punctured-conv + CRC chain shapes, and the LICH bit-duplication. Transcribed and self-consistent, but not verified bit-exact against real NXDN signals: the
FSW = 0xCDF5Avalue itself, the puncture patterns (which coded bit is dropped per group), the exact CRC-6 / CRC-12 polynomials and seeds (0x27,0x80F, init 0), and the LICH / functional-type encodings (FUNC_*,MSG_*). These round-trip perfectly with themselves, but an off-air transmitter may use different constants — do not read the tests as on-air conformance. - Conventional voice path only. VCALL / TX_RELEASE call messages and the voice superframe are implemented; Type-D trunked control signalling, data services (PDU-equivalent), status/short-message and SACCH multi-frame reassembly beyond a single 18-bit fragment are not.
- FACCH2 / other logical channels not implemented. Only SACCH and FACCH1 are decoded; other NXDN logical-channel forms are out of scope.
- dPMR framing not implemented. dPMR (ETSI TS 102 658) shares this 2400-sym 4FSK PHY but has its own framing; only the shared modem front end would be reused (§2.4). It is a future rung.
- LICH has no true error correction. With only two copies per bit the decoder guesses (first copy on disagreement) and relies on the parity bit to detect, not correct, an error; a bad-parity LICH rejects the whole frame.
- Block-at-a-time modem timing. Inherited from
p25/demod.py: one max-eye offset search over the buffer, no fractional interpolation or tracking loop — fine for clean captures, weaker on drifting or very weak signals. - AMBE+2 audio native but not bit-exact. Voice routes through the in-repo AMBE codec + MBE vocoder; the DVSI quantiser tables are structurally-correct transcriptions, so recovered audio is intelligible-but-approximate rather than bit-identical to a hardware NXDN radio (see ambe.md, mbe.md).
- Encryption not handled. There is no scrambling / key handling for encrypted NXDN.
None of these change the architecture — they are the natural next increments on a working native receive chain.
9. References
- NXDN Technical Specifications — NXDN Forum — Common Air Interface (CAI), TS 1-A — the 4-level FSK physical layer, the 6.25/12.5 kHz channels, the 384-bit frame, the LICH / SACCH / FACCH / VCH logical channels and RAN. The published structure; the specific constants here are transcriptions (§8).
- ETSI TS 102 658 — dPMR — the ETSI protocol sharing NXDN's 2400-sym 4FSK physical layer (framing not implemented, §2.4).
- In-repo implementation:
app/radio/nxdn.py; tests intest_nxdn.py(§7). - Shared building blocks, each with its own spec:
- P25 Phase 1 — the C4FM 4-level modem (
app/radio/p25/demod.py) reused here withsymrateparameterised. - YSF — the rate-1/2 K=5 convolutional code and the erasure-aware Viterbi (
app/radio/ysf.pyconv_encode,viterbi) that decode the punctured SACCH / FACCH1 chains. - AMBE — the AMBE+2 half-rate channel codec (
app/radio/ambe.pyhr_to_channel/channel_to_hr) carrying the 49-bit voice frames. - MBE vocoder — the Multi-Band Excitation analysis/synthesis that turns the recovered AMBE+2 parameters into PCM.
- P25 Phase 1 — the C4FM 4-level modem (