Patrick Lidstone
Self-hosted

DMR (ETSI TS 102 361): native Tier I/II reception

A self-contained, clean-room receiver for DMR (Digital Mobile Radio) Tier I/II: the C4FM 4-level modem is shared verbatim with P25, the 264-bit burst is split into payload / sync-or-embedded / payload, data and control bursts protect 96 information bits with a BPTC(196,96) product code and a Golay(20,8) slot type, the full link-control word is Reed-Solomon-masked, CSBKs are CRC-masked, and voice bursts carry three AMBE+2 channel frames each — six bursts to a superframe, with the full LC dribbled across the middle four under 16-bit EMB words. The whole decodable path runs in-repo on NumPy; only the AMBE+2 → PCM vocoder step is delegated (see AMBE).

Rafe project · app/radio/dmr.py · in-repo NumPy


Abstract

DMR (ETSI TS 102 361) is the European digital land-mobile-radio standard for licensed PMR, amateur and commercial fleets. It is a two-slot TDMA system in a 12.5 kHz channel: the RF carrier is 4-level FSK (C4FM) at 4800 sym/s, the same continuous-phase 4-level baseband as P25, so Rafe's P25 modem (app/radio/p25/demod.py) is reused unchanged. Each 30 ms timeslot carries a 264-bit burst — 108 payload | 48 sync-or-embedded | 108 payload — optionally prefixed by a 24-bit CACH inserted by a repeater. Data and control bursts carry 96 information bits protected by BPTC(196,96) (Hamming(15,11) on rows crossed with Hamming(13,9) on columns) plus a Golay(20,8) slot-type header; voice bursts carry three 72-bit AMBE+2 channel frames, six bursts A–F to a superframe, with the 72-bit full link-control word embedded across bursts B–E under 16-bit EMB words.

This document specifies Rafe's native DMR receiver in full, from the code in app/radio/dmr.py. It is a clean-room implementation from TS 102 361-1: burst geometry, the four 48-bit sync words, the BPTC dimensions and interleave step, the slot-type / LC / CSBK layouts and their CRC masks are the published values. The EMB (16,7) generator, the embedded-LC fragment interleave, the CACH TACT interleave and the RS(12,9) generator are structurally faithful and round-trip self-consistently; their exact on-air bit orderings are the interoperability checks (see §8). The decode chain — demod → sync → BPTC/Golay/RS/CRC → LC/CSBK metadata and the 18-frame AMBE parameter set of a voice superframe — is fully native. Only the AMBE+2 channel-frame → speech vocoder is delegated to the AMBE parameter codec + MBE vocoder.


1. Motivation and scope

DMR is the most common amateur digital-voice mode after D-STAR and, on commercial bands, ubiquitous. A monitor receiver wants two things from a DMR signal: the call metadata — colour code, source and destination IDs, the CSBK control traffic — and the voice. The first needs only the modem and the block codes; the second additionally needs the AMBE+2 vocoder. Rafe's app/radio/dmr.py moves the entire decodable part in-house — everything from the 4-level baseband to the recovered link control and the 18 AMBE parameter frames of a voice superframe — leaving only the AMBE+2 channel-frame → PCM step to the shared vocoder:

                     NATIVE  (app/radio/dmr.py + p25/demod.py, p25/fec.py)
  FM-disc audio ─▶ C4FM demod ─▶ sync search ─▶ burst dispatch ┐
     (48 kHz)     p25/demod.py    find_sync    (voice vs data) │
                  (reused as-is)                               │
                                    ┌─ data/control ─▶ BPTC ─▶ slot type ─▶ LC(+RS) / CSBK(+CRC) ─▶ metadata
                                    └─ voice A–F ─▶ split ─▶ AMBE channel_to_hr ×18 ─▶ embedded-LC/EMB
                                                                     │
                                                                     └▶ AMBE params ─▶ [AMBE codec] ─▶ [MBE vocoder] ─▶ PCM

The design split mirrors the P25 package: the DSP + protocol layers (demod, framing, FEC, LC/CSBK extraction, voice-frame de-framing) are fully native; only the AMBE+2 vocoder step is external. The module header states the honesty line explicitly — burst geometry, sync words, BPTC dimensions/interleave and the slot-type/LC/CSBK layouts and CRC masks are published values; the EMB generator, embedded-LC interleave, CACH TACT interleave and RS(12,9) generator are structurally faithful and self-consistent, with their exact on-air bit orderings flagged as the interop checks (app/radio/dmr.py docstring).

This spec covers Tier I/II FDMA-framed C4FM as implemented: one logical channel decoded per call. Full two-slot TDMA multiplexing, over-the-air data services beyond the CSBK/data-header type tags, and encryption are out of scope (§8).


2. Background — real DMR

2.1 Two-slot TDMA, Tier I/II

DMR divides each 12.5 kHz channel by time: two 30 ms timeslots (TS1, TS2) alternate on a single carrier, giving two independent conversations — a 2:1 capacity gain over a 12.5 kHz analogue channel and the reason DMR is often described as "12.5 kHz equivalent." Tier I is licence-exempt / simplex, Tier II is licensed conventional (and the base of Tier III trunking); both share the same physical burst and coding, which is what this receiver decodes. A repeater retransmits both slots and, to let mobiles track slot timing and signalling between bursts, inserts a CACH (Common Announcement Channel) burst ahead of each payload burst.

2.2 The burst / superframe hierarchy

Everything on air is a 264-bit burst occupying one 30 ms slot. A burst is either:

  • a data / control burst — 196 BPTC-coded bits carrying 96 information bits (a full LC header, a terminator, a CSBK, or a data header), plus a 20-bit slot-type header and one of the fixed sync patterns; or
  • a voice burst — three 72-bit AMBE+2 channel frames, with a 48-bit centre that is either a sync word (first burst of a superframe) or an EMB word wrapped around an embedded-LC fragment.

Voice bursts group into a superframe of six bursts, labelled A–F. Burst A carries the voice sync word in its centre; bursts B–E carry the four fragments of the 72-bit full LC (protected by an extended-Hamming block + checksum) under 16-bit EMB words; burst F carries a null fragment. So the LC that names the call is recovered twice: once from the LC-header data burst that precedes the voice, and again, incrementally, from the embedded fragments spread across the superframe.

This receiver surfaces, for a data/control burst, the colour code, data type and (for LC headers/terminators) the parsed link control with an RS "ok" flag, or the CSBK opcode/FID/payload with a CRC "ok" flag; for a voice superframe, the colour code, the embedded LC, and the 18 AMBE+2 parameter frames ready for the vocoder. All of it comes from the modem + block codes alone — the vocoder is only needed to turn those 18 parameter frames into speech.


3. Signal and symbol structure — exact numbers

Every value here is a constant in the code or directly derivable from one; the source is cited.

Symbol layer (app/radio/p25/demod.py, reused unchanged)

  • Modulation: continuous-phase 4-level FSK (C4FM), identical baseband to P25.
  • Symbol rate: SYMRATE = 4800 sym/s.
  • Bits per symbol: 2 (one dibit) → gross channel rate 9600 bit/s.
  • Four levels {+3, +1, −1, −3} from _LEVEL = {1: 3, 0: 1, 2: -1, 3: -3} (dibit value → level); deviations ±1800 Hz (outer) / ±600 Hz (inner).
  • Default audio rate fs = 48000 → samples/symbol sps = fs // SYMRATE = 10.
  • Matched filter: root-raised-cosine, roll-off α = 0.2, span 8 symbols.

Because the DMR baseband is C4FM, dmr.py calls demod.modulate / demod.demodulate / demod.dibits_to_bits directly — no DMR-specific modem code exists. See P25 §4 for the full demod description (RRC matched filter, maximum-eye-opening timing search, amplitude normalisation, 4-level slice).

Burst layer (app/radio/dmr.py)

  • Burst length: BURST = 264 bits = 108 payload | 48 sync-or-embedded | 108 payload (dmr.py).
  • CACH: CACH = 24 bits, prepended by a repeater (dmr.py).
  • Stride between successive bursts: STRIDE = CACH + BURST = 288 bits (dmr.py).
  • Four 48-bit sync words in SYNCS (§6.1).

Coding layer (dmr.py, app/radio/p25/fec.py) — full constants in §6: BPTC(196,96) (Hamming(15,11) rows × Hamming(13,9) columns, interleave step _BPTC_STEP = 181); Golay(20,8) slot type over colour code(4) + data type(4); full LC 72→96 bits by RS(12,9) over GF(2⁸) with a data-type mask; CSBK 80-bit body

  • CRC-16-CCITT masked 0xA5A5; EMB (16,7) generator 0x2ED over CC(4)+PI(1)+ LCSS(2); embedded LC 72+5 bits → 128-bit extended-Hamming block → four 32-bit fragments; CACH TACT Hamming(7,4) over AT/TC/LCSS + a 17-bit short-LC slot.

Voice layer (dmr.py, app/radio/ambe.py) — 3 × 72-bit AMBE+2 channel frames per voice burst; 6 bursts A–F per superframe → 18 channel frames = 18 × 49-bit AMBE+2 parameter frames. Each channel frame is Golay(24,12) over the 12 most-sensitive bits + PN-scrambled Golay(23,12) over the next 12 + 25 raw bits (AMBE).


4. The bursts and sync

4.1 Data / control burst (264 bits)

build_data_burst(bits96, cc, dtype, sync) (dmr.py) BPTC-encodes the 96 info bits to 196 bits, Golay-encodes the slot type to 20 bits, and interleaves them around the 48-bit sync word:

 return coded[:98] + st[:10] + SYNC(48) + st[10:] + coded[98:]

 bit  0                          98   108                155  166                        263
      ┌────────────────────────────┬─────┬──────────────────┬─────┬──────────────────────────┐
      │  BPTC payload  (98 bits)    │ ST  │   SYNC  (48b)    │ ST  │   BPTC payload (98 bits)  │
      │  coded[0:98]                │[0:10]│ 48-bit sync word │[10:20]│ coded[98:196]           │
      └────────────────────────────┴─────┴──────────────────┴─────┴──────────────────────────┘
        └──────── 108 ────────┘         └────── 48 ──────┘        └──────── 108 ─────────┘

The 196 BPTC bits are split 98 | 98 around the centre; the 20 slot-type bits are split 10 | 10 flanking the sync. parse_data_burst reverses this: slot type from bits[98:108] + bits[156:166], BPTC info from bits[:98] + bits[166:] (dmr.py).

4.2 Voice burst (264 bits)

build_voice_burst(f1, f2, f3, centre48) (dmr.py) lays three 72-bit AMBE channel frames around a 48-bit centre, splitting the middle frame f2 across it:

 return f1 + f2[:36] + centre48 + f2[36:] + f3

 bit  0            72        108               155        192            263
      ┌──────────────┬──────────┬──────────────────┬──────────┬──────────────┐
      │  AMBE  f1    │ f2[:36]  │   centre (48b)   │ f2[36:]  │   AMBE  f3   │
      │  72 bits     │  36 bits │ sync-or-EMB+frag │  36 bits │   72 bits    │
      └──────────────┴──────────┴──────────────────┴──────────┴──────────────┘

split_voice_burst reverses it: f1 = bits[:72], f2 = bits[72:108] + bits[156:192], f3 = bits[192:], centre = bits[108:156] (dmr.py).

4.3 The four sync words

DMR defines four 48-bit sync patterns — base-station vs mobile-station, voice vs data (SYNCS, dmr.py):

bs_voice = 0x755FD7DF75F7      ms_voice = 0x7F7D5DD57DFD
bs_data  = 0xDFF57D75DF5D      ms_data  = 0xD5D7F77FD757

find_sync(bits, start, max_err=4) (dmr.py) slides a 48-bit window across the bit stream and returns (index, kind) for the first position whose Hamming distance to any of the four patterns is ≤ max_err (default 4), with an early-out once the error count exceeds the budget:

for i in range(start, n - 47):
    for kind, pat in _SYNC_BITS.items():
        err = Σ_{j<48} [ bits[i+j] ≠ pat[j] ]        # break as soon as err > max_err
        if err ≤ max_err: return i, kind
return -1, None

The kind string is what dispatches the burst: *_data → data/control path, *_voice → voice superframe path (§5.7). Because the sync is 48 fixed bits, a 4-bit tolerance is far below the random-collision floor.

4.4 The stream a repeater emits

modulate_voice_call prefixes every burst with a 24-bit CACH (cach_encode(1, 0, 0)), so a full voice call on the wire is:

[CACH][LC-header data burst (dtype 1, MASK_VOICE_HEADER)]
[CACH][voice burst A] [CACH][B] [CACH][C] [CACH][D] [CACH][E] [CACH][F]
[CACH][terminator data burst (dtype 2, MASK_TERMINATOR)]

padded front and back with 24 zero bits, then modulated. Successive bursts are therefore STRIDE = 288 bits apart, and the decoder steps by that stride when it walks the six bursts of a superframe (§5.7).


5. Decode — step by step

The top-level entry is decode(audio, fs=48000, max_err=4) (dmr.py): demodulate to a bit stream, then repeatedly find_sync, dispatch on the sync kind, and advance. The building blocks:

5.1 BPTC(196,96) — the product code (dmr.py)

DMR's data/control payload is a Block Product Turbo Code: a 13-row × 15-column matrix (plus one pad bit = 196 bits) in which every one of columns is a Hamming(13,9) codeword and rows 0–8 are Hamming(15,11) codewords. The 96 info bits sit at row 0 cols 3–10 (8 bits, cols 0–2 reserved) + rows 1–8 cols 0–10 (88 bits) (_bptc_rows).

Interleave. The 196 matrix bits are permuted by a fixed step _BPTC_STEP = 181:

_bptc_interleave:   out[i] = m[(i*181) % 196]        # 181 is coprime to 196
_bptc_deinterleave: m[(i*181) % 196] = a[i]

Encode (bptc_encode): Hamming(15,11)-encode each of the 9 rows (row 0 is 3 reserved zeros + 8 info bits), append four all-zero rows 9–12, then for each of the 15 columns compute the Hamming(13,9) parity of rows 0–8 and write the 4 parity bits down rows 9–12. Flatten with a leading pad bit and interleave.

Decode (bptc_decode): deinterleave to the 13×15 grid, correct columns first (Hamming(13,9), single-error per column), then rows (Hamming(15,11)), and return row0[3:] + rows 1–8. Correcting columns before rows lets the product code fix a scatter of single errors that neither dimension could fix alone (the test flips 3 bits in distinct rows and columns and still recovers — §7). BPTC carries no CRC of its own; integrity is checked by the layer above (slot-type Golay, LC RS, CSBK CRC).

5.2 Slot type — Golay(20,8) (dmr.py)

The slot-type header carries colour code (4 bits) + data type (4 bits), protected by a shortened Golay(24,12): slot_type_encode(cc, dtype) packs d8 = (cc<<4)|dtype, Golay(24,12)-encodes it (the top 4 of the 12 data bits are zero), and drops those 4 leading zeros → 20 bits. slot_type_decode prepends [0,0,0,0], runs golay24_decode, and returns (cc, dtype) — or None if the Golay fails or the decoded value exceeds 8 bits. Data types (DATA_TYPES):

1: voice_lc_header    2: terminator_lc    3: csbk    6: data_header

The full LC is 72 bits, laid out (MSB-first) as FLCO(8) + FID(8) + service-options(8) + destination(24) + source(24) (build_lc / parse_lc). Note the on-air field order places the 24-bit destination before source, even though build_lc(flco, src, dst, …) takes src first in its signature.

It is protected by a Reed-Solomon(12,9) code over GF(2⁸) (primitive polynomial 0x11D), whose 3 parity bytes are XORed with a data-type-specific CRC mask so that decoding under the wrong mask deliberately fails (this is how DMR ties the LC to its slot type):

MASK_VOICE_HEADER = 0x969696       # voice LC header (dtype 1)
MASK_TERMINATOR   = 0x999999       # terminator with LC (dtype 2)
  • GF(2⁸) log/antilog tables _EXP/_LOG are stepped from α (start 1, g <<= 1, XOR 0x11D when bit 8 sets) over 255 elements, with _EXP doubled to 512 to avoid a modulo in _gmul.
  • Parity (_rs12_9_parity): the generator is built as (x−α⁰)(x−α¹)(x−α²); the 9 data bytes are fed through a systematic LFSR remainder to produce 3 parity bytes.
  • Encode (lc_encode): split the 72-bit LC into 9 bytes, compute the RS parity, XOR the mask bytes in, append → 96 bits.
  • Decode (lc_decode): un-mask the 3 parity bytes, form the 12-byte codeword, evaluate the three syndromes Sⱼ = Σ_k c_k · α^(j·(11−k)) for roots α⁰…α² and return (LC[:72], ok) where ok = (all syndromes zero). A wrong mask (or a corrupted bit) leaves non-zero syndromes → ok = False.

5.4 CSBK — Control Signalling Block (dmr.py)

A CSBK is 96 bits: LB(1) + PF(1) + CSBKO(6) + FID(8) + payload(64) + CRC-16(16). build_csbk(csbko, fid, payload64) forms the 80-bit head [1,0] + CSBKO + FID + payload (LB=1, PF=0), then a CRC-16-CCITT (poly 0x1021, init 0xFFFF, MSB-first, no final inversion — _crc16) XORed with MASK_CSBK = 0xA5A5. parse_csbk recomputes the CRC over bits[:80], XORs the mask, compares to bits[80:], and returns {csbko, fid, payload, ok}.

5.5 EMB + embedded LC (dmr.py)

EMB (16,7). The 48-bit voice-burst centre (bursts B–F) is `EMB[:8] + fragment

  • EMB[8:]— a 16-bit EMB word split around a 32-bit embedded-LC fragment. The EMB carries **CC(4) + PI(1) + LCSS(2)** = 7 data bits protected by a (16,7) shortened cyclic code, generator_EMB_GEN = 0x2ED(degree 9)._emb_paritydoes binary polynomial division to get 9 parity bits; a 128-entry codeword table_EMB_TABLE[d] = (d<<9) | parityis precomputed.emb_encode(cc, pi, lcss)looks up the codeword;emb_decodedoes a **nearest-codeword** search (minimum Hamming distance, threshold < 3 → corrects up to 2 errors) and returns(cc, pi, lcss)`.

Embedded LC. embed_lc_encode(bits72) builds the block that dribbles the LC across bursts B–E:

info  = LC(72) + checksum(5)                # 77 bits = 7 rows × 11
rows  = 7 × extended-Hamming(16,11)         # _ham16_enc: Hamming(15,11) + overall parity
row 8 = column parity (XOR of the 7 rows)   # 16 bits
flat  = 8 rows × 16 = 128 bits  →  four 32-bit fragments

The 5-bit checksum _cs5 is the sum of the nine LC octets mod 31. On decode, embed_lc_decode(fragments) flattens the four 32-bit fragments, Hamming(15,11)- corrects each of the 7 data rows (the first 15 bits of each 16-bit row), extracts LC = info[:72] and checksum = info[72:77], and returns (LC, ok) where ok = (checksum == _cs5(LC)). (The 8th column-parity row is generated on encode but not consulted on decode — the per-row Hamming correction plus the checksum are what gate the result.)

5.6 CACH — Common Announcement Channel (dmr.py)

The 24-bit CACH is a 7-bit TACT codeword + a 17-bit short-LC slot (zeros here). The TACT packs AT(1) + TC(1) + LCSS(2) into 4 bits and protects them with a Hamming(7,4) code (_ham7_enc parity p = [d0⊕d1⊕d2, d1⊕d2⊕d3, d0⊕d1⊕d3]); _ham7_dec corrects a single error via a syndrome→bit table. cach_decode returns {at, tc, lcss}. (Rafe emits CACH on every burst; a real system only inserts it on repeater outbound slots — §8.)

5.7 Voice superframe assembly (dmr.py)

Encodemodulate_voice_call(src, dst, ambe_frames, cc=1, fs=48000, flco=0):

  1. Assert exactly 18 AMBE+2 parameter frames (3 per burst × 6 bursts).
  2. Build the LC (build_lc(flco, src, dst)) and its four embedded fragments (embed_lc_encode).
  3. Emit CACH + build_data_burst(lc_encode(lc, MASK_VOICE_HEADER), cc, 1) — the LC-header data burst.
  4. Convert all 18 parameter frames to channel frames (ambe.hr_to_channel).
  5. For bursts A–F (k = 0…5):
    • k = 0 (A): centre = bs_voice sync word.
    • k = 1…5 (B–F): lcss = 1 if k==1 else (2 if k<5 else 3) (first / continuation / last), frag = frags[k-1] for k ≤ 4 else a 32-bit zero fragment, centre = emb_encode(cc,0,lcss)[:8] + frag + emb_encode(...)[8:].
    • Emit CACH + build_voice_burst(ch[3k], ch[3k+1], ch[3k+2], centre).
  6. Emit CACH + build_data_burst(lc_encode(lc, MASK_TERMINATOR), cc, 2) — the terminator.
  7. Zero-pad 24 bits each side and demod.modulate(bits_to_dibits(...)).
 Voice superframe (6 bursts, centre 48 bits each)
 ┌──────┬───────────────────────────┬──────┐
 │  A   │ centre = bs_voice sync (48)│      │  ← sync, no LC fragment
 ├──────┼───────────────────────────┼──────┤
 │  B   │ EMB(8) │ frag0 (32) │ EMB(8)      │  LCSS=1  first
 │  C   │ EMB(8) │ frag1 (32) │ EMB(8)      │  LCSS=2  cont
 │  D   │ EMB(8) │ frag2 (32) │ EMB(8)      │  LCSS=2  cont
 │  E   │ EMB(8) │ frag3 (32) │ EMB(8)      │  LCSS=2  cont
 │  F   │ EMB(8) │ zeros (32) │ EMB(8)      │  LCSS=3  last (null fragment)
 └──────┴───────────────────────────┴──────┘
   each burst also carries 3 × 72-bit AMBE frames = 18 total

Decode — inside decode, when find_sync returns a *_voice kind:

  • burst0 = i - 108 (the sync sits at burst bits 108–155, so back up to the burst start).
  • For k = 0…5, take the burst at burst0 + k*STRIDE, split_voice_burst it, and ambe.channel_to_hr each of the three 72-bit frames → append 18 parameter frames.
  • For k = 1…4, collect the fragment centre[8:40] and emb_decode(centre[:8] + centre[40:]) (the last successful EMB gives the colour code).
  • If all four fragments were collected, embed_lc_decode them → LC + ok, and parse_lc populates src/dst/etc. on the event.
  • Advance pos = burst0 + 6*STRIDE.

The emitted voice event is {"type": "voice", "frames": [18 param frames], "cc": …, "src"/"dst"/… , "lc_ok": …}.

Data/control decode — for a *_data sync: parse_data_burst → slot type. If slot_type is None the burst is skipped. Data types 1/2 decode the LC under MASK_VOICE_HEADER / MASK_TERMINATOR respectively and emit voice_lc_header / terminator_lc events; data type 3 parses the CSBK; type 6 is surfaced as data_header. Each event carries the colour code.


6. Constants and tables

All values quoted verbatim from app/radio/dmr.py unless noted.

6.1 Sync words and geometry

SYNCS = {"bs_voice": 0x755FD7DF75F7, "bs_data": 0xDFF57D75DF5D,
         "ms_voice": 0x7F7D5DD57DFD, "ms_data": 0xD5D7F77FD757}    # 48 bits each
BURST  = 264      # 108 payload | 48 sync-or-embedded | 108 payload
CACH   = 24
STRIDE = CACH + BURST = 288

6.2 Data types and CRC masks

DATA_TYPES = {1: "voice_lc_header", 2: "terminator_lc", 3: "csbk", 6: "data_header"}
MASK_VOICE_HEADER = 0x969696     # full-LC RS parity mask, data type 1
MASK_TERMINATOR   = 0x999999     # full-LC RS parity mask, data type 2
MASK_CSBK         = 0xA5A5       # CSBK CRC-16 mask

6.3 BPTC(196,96)

_BPTC_STEP = 181                 # out[i] = m[(i*181) % 196]
matrix     = 1 pad bit + 13 rows × 15 columns
rows 0–8   = Hamming(15,11) codewords         (fec.hamming15_encode/decode)
columns    = Hamming(13,9) codewords          (fec.hamming13_encode/decode)
info bits  = row 0 cols 3–10 (8) + rows 1–8 cols 0–10 (88) = 96
decode     = correct columns (13,9) then rows (15,11)

hamming13_encode/decode use data columns _H13_COLS = [3,5,6,7,9,10,11,12,13] (p25/fec.py); hamming15_* use _H15_COLS = [3,5,6,7,9,10,11,12,13,14,15].

6.4 Slot type — Golay(20,8)

d8 = (cc<<4) | dtype                          # 4-bit colour code + 4-bit data type
slot_type_encode = golay24_encode(d8)[4:]     # shortened: 4 leading data zeros dropped → 20 bits
slot_type_decode = golay24_decode([0,0,0,0]+bits20)   → (cc, dtype)  (None if d>0xFF)

Golay(24,12) is p25/fec.py's perfect code (generator _G23 = 0xC75, 2048-entry syndrome table, +overall-parity extension).

6.5 Full LC + RS(12,9)

LC(72) = FLCO(8) | FID(8) | opts(8) | dst(24) | src(24)     # dst BEFORE src on air
GF(2^8) primitive polynomial = 0x11D
RS generator = (x−α^0)(x−α^1)(x−α^2)          # 3 parity bytes
lc_encode: LC(72) + (RS parity ⊕ mask bytes) → 96 bits
lc_decode: ok ⇔ syndromes S_j = Σ_k c_k·α^(j·(11−k)) all zero under this mask

6.6 CSBK

CSBK(96) = LB(1)=1 | PF(1)=0 | CSBKO(6) | FID(8) | payload(64) | CRC16(16)
CRC-16-CCITT: poly 0x1021, init 0xFFFF, MSB-first, no final XOR, then ⊕ 0xA5A5

6.7 EMB (16,7)

_EMB_GEN = 0x2ED                              # degree-9 (16,7) generator
d7 = (cc<<3) | (pi<<2) | lcss                 # CC(4) | PI(1) | LCSS(2)
_EMB_TABLE[d] = (d<<9) | _emb_parity(d)       # 128 codewords
emb_decode = nearest codeword (Hamming distance < 3 ⇒ corrects ≤ 2)

6.8 Embedded LC

info  = LC(72) + checksum(5)                  # checksum = Σ(nine LC octets) mod 31
7 rows of extended Hamming(16,11)  [Hamming(15,11) + overall parity bit]
+ 1 column-parity row              → 128 bits → 4 × 32-bit fragments
decode: per-row Hamming(15,11) correct + checksum gate
LCSS across B–F = 1 (first), 2, 2, 2 (cont), 3 (last, null fragment)

6.9 CACH

CACH(24) = TACT(7) + short-LC(17, zeros here)
TACT = Hamming(7,4) over  AT(1) | TC(1) | LCSS(2)
_ham7 parity = [d0⊕d1⊕d2, d1⊕d2⊕d3, d0⊕d1⊕d3]

6.10 AMBE+2 channel frame (AMBE)

hr_to_channel(bits49) → 72 bits: Golay(24,12) over C0(12) + PN-scrambled
                        Golay(23,12) over C1(12) + 25 unprotected bits
channel_to_hr(bits72) → (49-bit parameter frame, corrected-bit count) | (None, -1)

7. Interoperability and validation

7.1 The test suite (test_dmr.py)

Eight tests exercise every native layer; all use fixed RNG seeds and are self-consistent (encode → corrupt → decode). All pass.

test what it proves key assertions
test_bptc_roundtrip_and_correction BPTC(196,96) round-trip + product-code correction encode → 196 bits, decode == info; 3 errors placed in distinct rows and columns are all corrected
test_slot_type_golay20 Golay(20,8) slot type encode(5,3) → 20 bits, decodes to (5,3); still (5,3) after 3 random bit flips
test_sync_detection_with_errors error-tolerant sync search a bs_voice pattern with 3 flipped bits, padded, is found at the right index with the right kind
test_full_lc_rs_masks RS(12,9)-masked LC round-trips FLCO/FID/opts/dst/src exactly; decoding under the wrong mask fails; a single flipped bit fails the syndrome
test_embedded_lc embedded-LC block 4 × 32-bit fragments; one error injected into three different rows still recovers the exact 72-bit LC
test_csbk CSBK CRC-mask + parse csbko=61, fid=16, payload=0xDEADBEEF12345678 round-trips with ok; a flipped bit clears ok
test_voice_call_end_to_end full call assembly + decode events == [voice_lc_header, voice, terminator_lc]; LC ok on all three; all 18 AMBE frames recovered exactly; cc == 1
test_voice_call_through_noise call through AWGN (≈30 dB) exactly one voice event; ≥ 16 of 18 AMBE frames match; LC header still reads src correctly

The end-to-end test is the strongest evidence the framing is internally correct: it generates 18 real AMBE+2 parameter frames, modulate_voice_calls a complete header + superframe + terminator, runs the whole modem+decode chain, and requires bit-exact recovery of all 18 parameter frames plus correct LC and colour code. The noise test adds AWGN to ≈30 dB SNR and still recovers ≥ 16/18 frames and the header LC — the BPTC/Golay/RS/embedded-Hamming correction working end-to-end.

7.2 On-air reference

These tests validate the transcription against itself (encode → corrupt → decode round-trips). The published quantities — burst geometry, the four 48-bit sync words, the BPTC dimensions and 181-step interleave, the slot-type / LC / CSBK layouts and the RS/CRC masks — are taken directly from ETSI TS 102 361-1 and can be checked against any DMR reference. The structurally-faithful quantities — the EMB (16,7) generator, the embedded-LC fragment interleave, the CACH TACT interleave and the RS(12,9) generator construction — round-trip self-consistently but their exact on-air bit orderings are not yet verified against a real DMR signal (§8). The final interoperability check — decoding an off-air DMR capture — would compare against an external reference decoder (e.g. DSD/dsd-fme), the same role mbelib/dsd-fme play for the P25 path.


8. Limitations

Stated honestly, roughly in order of impact. The module docstring draws the line explicitly; this section preserves it.

  • The interop-flagged items are self-consistent, not bit-exact on air. The EMB (16,7) generator (0x2ED), the embedded-LC fragment interleave, the CACH TACT bit interleave and the RS(12,9) generator are structurally the DMR codes and round-trip perfectly against themselves, but their exact on-air bit orderings have not been confirmed against a real DMR transmission. A decoder built to this spec will interoperate with Rafe's own modulator; matching a commercial radio bit-for-bit on these four items is the outstanding interop check.
  • AMBE+2 audio is not bit-exact. The decodable path recovers the 18 AMBE+2 parameter frames exactly, but turning them into speech goes through the AMBE codec + MBE vocoder, whose quantiser tables, PN seed and inter-frame prediction are structurally faithful rather than DVSI-exact — intelligible-but-approximate audio until those are transcribed (same stance as the P25/IMBE path).
  • Single logical channel, no TDMA multiplexing. The decoder locks a sync and walks one call's bursts; it does not de-multiplex the two 30 ms TDMA timeslots or track slot timing across a repeater's interleaved TS1/TS2 stream. CACH is emitted on every burst by the modulator (a real system inserts it only on repeater outbound slots).
  • LCSS/EMB semantics are approximate. Burst F is marked LCSS = 3 (last) while carrying a null fragment; the single-burst LCSS = 0 case and PI-bit handling are not exercised. Colour code is taken from whichever EMB decodes last in the superframe.
  • Limited data/control coverage. Data type 6 is surfaced as data_header with no payload parse; only CSBK (type 3) and the LC header/terminator (types 1/2) are decoded to fields. Confirmed/unconfirmed data, Rate 1/2 and 3/4 data-burst FEC, and Tier III trunking messages are out of scope.
  • No encryption handling. Encrypted voice/data is decoded at the FEC layer but not deciphered; there is no key management.
  • Block-at-a-time timing. The shared C4FM demod does a one-shot max-eye-opening symbol-timing search with no tracking loop (see P25 §4); fine for clean captures, weaker on drifting or very weak signals.

None of these change the architecture — they are the natural next increments on a working native DMR receive chain.


9. References

  1. ETSI TS 102 361-1Digital Mobile Radio (DMR) Systems; Part 1: DMR Air Interface (AI) protocol — the 264-bit burst, the four 48-bit sync patterns, BPTC(196,96), the Golay(20,8) slot type, the full-LC/RS masks, CSBK/CRC, EMB and the embedded-LC block. (Parts 2–4 cover the voice/data services, trunking and conformance.)
  2. ETSI TS 102 361-2 / -3 — DMR voice and generic services / DMR data protocol — context for the AMBE+2 voice superframe and CSBK/data-header traffic.
  3. In-repo implementation: app/radio/dmr.py (this spec's subject) and its tests test_dmr.py.
  4. Shared modem and block codes: app/radio/p25/demod.py (the C4FM 4-level modem, reused unchanged) and app/radio/p25/fec.py (Golay(24,12)/(23,12), Hamming(15,11)/(13,9)). See P25 Phase 1 for the full modem and Golay/Hamming description.
  5. Voice vocoder front end: app/radio/ambe.py — the AMBE+2 half-rate channel codec (hr_to_channel/channel_to_hr), documented in AMBE; the underlying model layer is the MBE vocoder.
  6. External reference decoders (DSD / dsd-fme) — the off-air interoperability yardstick for the interop-flagged items above.