M17: a native 4-FSK / Codec2 digital-voice coding chain
A clean-room, bit-exact implementation of the M17 open digital-voice protocol — base-40 callsigns, CRC-16/M17, the Golay(24,12)-protected LICH, the K=5 rate-½ convolutional code with puncturing, the quadratic interleaver and the randomiser, LSF and stream frame assembly, and the RRC-filtered 4-level PAM waveform at 4800 sym/s — bridged to David Rowe's Codec2 vocoder for the actual speech.
Rafe project · app/radio/m17.py, app/radio/m17_voice.py, app/radio/codec2_native.py · constants transcribed from M17-Project/libm17, interop-validated against m17-mod/m17-demod
Abstract
M17 is an open (GPL, patent-free) digital-voice and data protocol for VHF/UHF amateur radio, designed as a licence-clean alternative to the proprietary AMBE/IMBE-based modes (DMR, D-STAR, System Fusion). On the air it is 4-FSK: a root-raised-cosine-filtered four-level baseband PAM signal, transmitted at 4800 symbols/s (9600 bit/s) through an ordinary FM transmitter, carrying Codec2-3200 voice inside a convolutionally-coded, interleaved and randomised frame. Callsigns are packed into a compact 48-bit base-40 address.
This module is the bit-exact coding chain, written from the libm17 reference constants (functional facts — polynomials, tables, generator matrices) rather than any copied code. It implements every layer between speech samples and 4-FSK symbols: the base-40 address codec, CRC-16/M17, the extended Golay(24,12) that protects the Link Information Channel, the constraint-length-5 rate-½ convolutional code with its two puncturing schedules, the quadratic permutation-polynomial interleaver, the 368-bit randomiser, and the assembly of Link Setup Frames (LSF) and stream frames to and from PAM-4 symbols. On top sits an RRC modulator/matched-filter demodulator (§3, §5) and a thin ctypes bridge to libcodec2 for the vocoder (§4.6). The unit suite round-trips every layer — callsign, CRC, Golay single-bit correction, punctured Viterbi under symbol errors and AWGN, and full modulate→demodulate — and the on-air waveform is validated interop-correct against the M17 reference m17-mod/m17-demod binaries on the prod box.
The value for Rafe is a complete, in-repo M17 voice modem with no m17-demod subprocess: only libcodec2 (LGPL, dynamically linked, so interop-legal) is external.
1. Background
1.1 What M17 is
M17 replaces the vocoder and the whole air interface of legacy amateur DV. Its three defining choices:
- Codec2 voice. Instead of a proprietary AMBE chip, M17 uses David Rowe's open Codec2 at 3200 bit/s (mode
3200): 20 ms frames, 64 bits each. This module carries two Codec2 frames per M17 stream frame (40 ms, 128 voice bits). - A single 4-FSK waveform. One modulation for voice and data: 4-level PAM at 4800 Bd, root-raised-cosine shaped (β = 0.5), fed to an FM modulator — so any 9600-baud-capable FM radio can carry it. The four levels are ±1, ±3.
- base-40 addressing. Source and destination callsigns are encoded in a radix-40 alphabet and packed into 48 bits, carried in the Link Setup Frame and dribbled alongside every voice frame in the LICH so a late-joining receiver recovers the call within six frames (240 ms).
1.2 The coding chain at a glance
Every M17 payload — LSF or stream — is protected by the same three-stage inner chain before it becomes symbols:
| stage | purpose | this module |
|---|---|---|
| convolutional FEC | K=5, r=½ code (G1=0x19, G2=0x17); recover from random bit errors |
_conv_encode (m17.py), _viterbi |
| puncturing | drop coded bits on a fixed schedule to fit the 368-bit budget (P1 for LSF, P2 for stream) |
_puncture, _depuncture |
| interleave | quadratic permutation π(i)=(45i+92i²) mod 368 — scatter a fading burst into isolated errors | _interleave, _INTRL |
| randomise | XOR the 368-bit block with a fixed pseudo-random sequence — break up long runs that bias the FM/DC path | _randomize, _RAND |
The LICH (the LSF fragment inside a stream frame) is the exception: it is protected by Golay(24,12), not the convolutional code, and is prepended to the coded voice before the interleave/randomise stage (§4.4).
1.3 Module surface and scope
m17.py is the coding chain plus a baseband modem; m17_voice.py is the app-facing streaming handler (M17Voice, a drop-in for the external-decoder DigiVoice); codec2_native.py is the vocoder binding. What is implemented versus what the M17 spec defines:
- Implemented: preamble, LSF frame, STREAM frame, EOT marker; base-40, CRC, Golay/LICH, conv-code+puncture+interleave+randomise, RRC 4-FSK, Codec2-3200 voice bridge, full TX and RX.
- Defined but unused:
SYNC_PKT(0x75FF) is declared (m17.py) but the packet-mode framer is not built here — this is the voice/stream path. - Spec-only, not present: the BERT sync word (
0xDF55), M17 1600-bit/s voice (Codec2-1600), and AES/scrambler encryption. See §8.
2. Notation
- dibit — a 2-bit symbol value 0…3; the pair of type-4 bits that selects one PAM level.
- PAM-4 level / symbol — one of
±1, ±3transmitted at 4800 Bd. - type-1…type-4 bits — M17's names for the data at each stage: type-1 = raw payload (LSF bytes / FN+voice), type-2 = convolutionally coded, type-3 = punctured (the 368-bit block that is interleaved+randomised), type-4 = the final bits mapped to symbols.
- LSF — Link Setup Frame (the 30-byte call/type/meta header). LICH — Link Information Channel (a ⅙ fragment of the LSF carried in each stream frame). FN — 16-bit frame number.
- Bit order is MSB-first throughout (byte→bit extraction, syncword→symbol, Golay codeword→bits), matching the M17 spec.
3. Signal and frame structure
3.1 The 4-FSK PAM-4 waveform
The transmitted baseband is real four-level PAM at 4800 sym/s. The dibit→level map (_SYMBOL_MAP, m17.py) is the M17-standard assignment:
| dibit | 00 | 01 | 10 | 11 |
|---|---|---|---|---|
| level | +1 | +3 | −1 | −3 |
Two bits per symbol → 9600 bit/s on the air. The receiver's slicer uses the ascending level order _SYMBOL_LIST = (-3, -1, 1, 3) for nearest-level decisions, then maps the chosen level back through _SYMBOL_MAP to recover the dibit (symbols_to_bits).
Pulse shaping. Symbols are upsampled by _SPS = 10 (m17.py; 48 kHz ÷ 4800) and convolved with a root-raised-cosine filter (_rrc_taps): span 8 symbols, roll-off β = 0.5, 81 taps (N = span·sps = 80, N+1 taps), energy-normalised by 1/√sps. This is the FM modulating signal; the M17 specification maps the four PAM levels to RF frequency deviations in a 3:1 ratio (outer ±3 at the nominal ±2.4 kHz peak deviation, inner ±1 at ±0.8 kHz), occupying roughly a 9 kHz mask inside a 12.5 kHz channel. The module emits the normalised baseband — the RF deviation scaling is applied by the radio's FM front end (the RX docstring calls its input "48 kHz FM audio", i.e. the discriminator output).
3.2 The 192-symbol / 384-bit frame
Every LSF and stream frame is a fixed 192 symbols on the air:
[ 8-symbol sync burst | 184 payload symbols ]
16 bits 368 bits = 384 bits = 192 symbols
At 4800 Bd that is exactly 40 ms per frame (_FRAME_SYM = 192, m17.py; SYM_PER_PLD = 184). The payload carries 368 type-4 bits (184 × 2). (Note: M17 stream frames are 40 ms, carrying two 20 ms Codec2 frames — not 25 ms.)
The 8-symbol (16-bit) sync burst identifies the frame type. Syncwords are constants (m17.py) turned into 8 PAM symbols by _syncword_symbols:
| frame | syncword | symbols (±3/±1) |
source |
|---|---|---|---|
| LSF (Link Setup) | 0x55F7 |
+3 +3 +3 +3 −3 −3 +3 −3 |
SYNC_LSF |
| STREAM (voice) | 0xFF5D |
−3 −3 −3 −3 +3 +3 −3 +3 |
SYNC_STR |
| PACKET (data) | 0x75FF |
+3 −3 +3 +3 −3 −3 −3 −3 |
SYNC_PKT (declared, unused) |
| EOT (end-of-tx) | 0x555D |
+3 +3 +3 +3 +3 +3 −3 +3 |
EOT_MRKR |
| BERT (spec only) | 0xDF55 |
−3 +3 −3 −3 +3 +3 +3 +3 |
not implemented |
All syncwords resolve to the outer levels ±3 only (their dibits are 01/11), giving them maximum Euclidean separation from the inner-level-heavy payload for a sharp correlation peak.
3.3 Transmission layout
A complete over-the-air transmission (modulate_stream, m17.py):
[ PREAMBLE 192 sym ][ SYNC_LSF 8 | LSF payload 184 ]
([ SYNC_STR 8 | STREAM payload 184 ]) × N_voice
[ EOT 8 sym ]
The preamble _PREAMBLE = [3, -3] × 96 (m17.py) is 192 symbols of alternating +3/−3 — a 2400 Hz square-ish tone that lets the receiver's clock and AGC settle before the LSF sync arrives. The whole symbol stream is upsampled and RRC-shaped in one np.convolve.
3.4 LSF payload layout (30 bytes / 240 bits)
build_lsf (m17.py) assembles a 28-byte body plus a 2-byte CRC:
| field | bytes | bits | meaning |
|---|---|---|---|
| DST | 0–5 | 48 | destination callsign, base-40 (or ALL = 0xFFFFFFFFFFFF) |
| SRC | 6–11 | 48 | source callsign, base-40 |
| TYPE | 12–13 | 16 | stream/packet + data-type + encryption + CAN (§3.5) |
| META | 14–27 | 112 | metadata / encryption IV (default all-zero) |
| CRC | 28–29 | 16 | CRC-16/M17 over bytes 0–27 |
3.5 The TYPE field
16 bits (build_lsf packs it MSB-first at bytes 12–13). The bit assignment M17 uses, as exercised by this module (m17_voice.py sets 1 | (2 << 1) = 5):
| bit(s) | field | value used here |
|---|---|---|
| 0 | packet(0) / stream(1) | 1 |
| 1–2 | data type: 01=data, 10=voice(3200), 11=voice+data | 10 |
| 3–4 | encryption type (00=none) | 00 |
| 5–6 | encryption subtype | 00 |
| 7–10 | CAN (channel access number) | 0 |
| 11–15 | reserved | 0 |
So TYPE = 0x0005 ("stream + 3200 voice, unencrypted") is the value for a plain voice transmission.
3.6 Stream payload layout (368 type-3 bits)
Each STREAM frame's 368-bit block (before interleave/randomise) is:
[ LICH 96 bits (Golay-coded LSF fragment) | coded(FN 16b + voice 128b) 272 bits ]
The LICH (§4.4) is 96 already-Golay-coded bits; the 272 trailing bits are the punctured convolutional coding of the 144-bit (16-bit FN + 128-bit voice) type-1 payload (§4.5). 96 + 272 = 368.
4. Encode / transmit, step by step
4.1 base-40 callsign → 48 bits
encode_callsign (m17.py). The alphabet is _CHAR_MAP = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/." — exactly 40 symbols: index 0 = space, 1–26 = A–Z, 27–36 = 0–9, 37 = -, 38 = /, 39 = .. The call is upper-cased and clipped to 9 characters (40⁹ < 2⁴⁸), then evaluated as a radix-40 number least-significant character first:
v = 0
for c in reversed(cs):
v = v*40 + _CHAR_MAP.index(c) # unknown chars → 0 (space)
and packed big-endian into 6 bytes. "ALL" or the empty string encode to the broadcast address 0xFFFFFFFFFFFF. Worked example: M0SUP → 00 00 02 85 FD 05 (decimal 42 335 493). decode_callsign inverts it, returning "ALL" for the all-ones value and "" for any value ≥ 40⁹ (reserved).
4.2 CRC-16/M17
crc_m17 (m17.py) is a textbook MSB-first CRC-16 with polynomial _CRC_POLY = 0x5935, initial value 0xFFFF, no input/output reflection and no final XOR:
crc = 0xFFFF
for b in data:
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x5935) & 0xFFFF if (crc & 0x8000) else (crc << 1) & 0xFFFF
This is CRC-16/M17; the regression check crc_m17(b"123456789") == 0x772B (test_m17.py) pins it. In build_lsf it is taken over the 28-byte body and appended big-endian (m17.py).
4.3 Golay(24,12) for the LICH
golay_encode (m17.py) is the systematic extended Golay code: the codeword is (data12 << 12) | checksum, where the 12-bit checksum is the XOR of the generator rows _ENCODE_MATRIX selected by the set data bits:
checksum = 0
for i in range(12):
if data12 & (1 << i):
checksum ^= _ENCODE_MATRIX[i]
_ENCODE_MATRIX = (0x8eb, 0x93e, 0xa97, 0xdc6, 0x367, 0x6cd, 0xd99, 0x3da, 0x7b4, 0xf68, 0x63b, 0xc75) — the 12 rows of the parity submatrix B of the M17 Golay generator [I | B]. golay_decode is a hard-decision decoder that corrects up to 3 errors (Golay's minimum distance is 8): compute the syndrome parity ⊕ B·data; if zero, accept; else try each single data-bit flip (syndrome ⊕ row must weigh ≤ 2), then parity-only errors (popcount ≤ 3), then each data-bit pair (⊕ two rows weighs ≤ 1); otherwise return None (uncorrectable). The test flips every one of the 24 codeword bits across a sweep of data words and requires exact correction (test_m17.py).
4.4 LICH: spreading the LSF across six stream frames
encode_lich(lsf30, cnt) (m17.py) carries one ⅙ of the 30-byte LSF per stream frame — a 5-byte fragment (5/30 = 1/6) — so after 6 frames (cnt = 0…5) the full LSF is reassembled at the receiver. Each LICH is a 48-bit unit: a 5-byte LSF fragment plus one counter byte whose top 3 bits hold cnt ((cnt & 7) << 5):
chunk[0..4] = lsf30[cnt*5 : cnt*5+5] chunk[5] = (cnt & 7) << 5
The 48 bits are split into four 12-bit words and each is Golay(24,12)-encoded, MSB-first, into 24 bits → 96 LICH bits:
word0 = chunk0<<4 | chunk1>>4 word1 = (chunk1&0xF)<<8 | chunk2
word2 = chunk3<<4 | chunk4>>4 word3 = (chunk4&0xF)<<8 | chunk5
modulate_stream calls encode_lich(lsf30, i % 6) for stream frame i (m17.py), cycling the six fragments. decode_lich reverses it: four Golay decodes → the 5 LSF bytes and the counter (or None if any word is uncorrectable).
4.5 The convolutional code, puncturing, interleave, randomise
_conv_encode (m17.py) is a constraint-length-5 (K=5), rate-½ code. It pads the data with 4 zero bits at each end (4 to preset the initial state, 4 to flush/terminate to state 0) and emits two bits per step:
G1 = u[i+4] ⊕ u[i+1] ⊕ u[i] # taps {0,3,4} → 0b10011 = 0x19
G2 = u[i+4] ⊕ u[i+3] ⊕ u[i+2] ⊕ u[i] # taps {0,1,2,4}→ 0b10111 = 0x17
i.e. generators G1 = 0x19, G2 = 0x17 (octal 031/027; G1 = 1+D³+D⁴, G2 = 1+D¹+D²+D⁴), the M17-standard polynomials. A payload of n bits produces 2(n+4) coded bits.
Puncturing (_puncture) keeps only the coded bits where a repeating pattern is 1:
- LSF uses
_P1(m17.py), a 61-entry pattern (46 ones): 240 data →2(240+4) = 488coded → 368 kept. Applied inencode_lsf_symbols. - STREAM uses
_P2 = (1,1,1,1,1,1,1,1,1,1,1,0), a 12-entry pattern (drops every 12th coded bit): 144 data →2(144+4) = 296coded → 272 kept. Applied inencode_stream_payload.
The 368-bit type-3 block (LSF: the 368 punctured bits; STREAM: LICH-96 ‖ coded-272) is then finalised by _finalize (m17.py):
_finalize(coded368) = bits_to_symbols( _randomize( _interleave(coded368) ) )
_interleaveapplies the quadratic permutation_INTRL[i] = (45·i + 92·i²) mod 368— a QPP bijection over 368 that spreads adjacent coded bits far apart, so an FM fade or click damages scattered bits the convolutional code can fix. First indices:0, 137, 90, 227, 180, …._randomizeXORs the 368 interleaved bits with_RAND_BITS— the MSB-first expansion of the 46-byte_RANDsequence, starting0xD6, 0xB5, 0xE2, …(first bits1 1 0 1 0 1 1 0 …). This is the M17 randomiser/scrambler: a fixed 368-bit pseudo-random mask (the spec derives it from an LFSR; the code stores the resulting table) that de-correlates the bitstream so long constant runs don't bias the FM discriminator or DC path.bits_to_symbolsmaps each dibit to a PAM level via_SYMBOL_MAP→ 184 symbols.
4.6 Codec2 voice bridge
codec2_native.py is a ctypes binding to libcodec2 (LGPL, dynamically linked so interop-legal; Codec2.available() reports whether it loaded). Mode 3200 (_MODES["3200"] = 0) gives 64 bits per 20 ms frame (8 bytes, 160 samples @ 8 kHz). M17's 128-bit voice payload is therefore two Codec2-3200 frames = 40 ms = 320 samples:
speech_to_voice_bits(speech320, c2)(m17.py) encodes samples 0–159 and 160–319 into two 8-byte frames → 128 bits (_bytes_to_voice_bits).encode_voice(lsf30, speech_i16_8k, c2)slices the 8 kHz speech into 320-sample frames, builds the 128-bit voice payloads, and drivesmodulate_stream→ 48 kHz float baseband.
M17Voice.feed_tx (m17_voice.py) is the streaming TX: 48 kHz mic → decimated ×6 to 8 kHz → accumulated into whole 320-sample frames → encode_voice → peak- normalised to 0.7 → int16 modem audio.
4.7 Full TX assembly
modulate_stream(lsf30, voice_frames) (m17.py) emits, in order: the 192-symbol preamble, SYNC_LSF + the encoded LSF (encode_lsf_symbols), then for each voice frame SYNC_STR + encode_stream_payload(encode_lich(lsf30, i%6), i, voice) (note FN = frame index i), and finally the EOT marker. Upsample ×10, RRC-convolve, done.
5. Decode / receive, step by step
decode_stream(audio_48k) (m17.py) is the demodulator. It exploits the fact that frames are a rigid 192 symbols apart: it anchors on a decoded sync and then steps the grid, ignoring off-grid spurious correlations.
- Matched filter (
_mf): convolve the 48 kHz audio with the same RRC taps used on TX. - Symbol-timing search: for each of the
sps = 10phases, downsamples = mf[phase::sps], then RMS-normalise to√5/rmsso the symbols sit at ≈ ±1, ±3 (for equiprobable levelsE[s²] = (1+9)/2 = 5). - Anchor on the LSF sync (
_sym_syncs): slide the 8-symbolSYNC_LSFpattern and keep positions where the normalised cross-correlation ≥ 0.9 (local maxima). At each candidate,_calibgain- and inversion-normalises the following 184 payload symbols (scale =⟨known,rx⟩/⟨rx,rx⟩— this corrects both amplitude and FM-discriminator sign flips), thendecode_lsf_symbolsrecovers the LSF; if its CRC checks, that position and LSF become the anchor. - Late join: if no valid LSF is found, anchor instead on a
SYNC_STRcorrelation offset back one frame, so a receiver tuning in mid-over still decodes voice (recovering the call from the LICH within six frames). - Step the grid: from
anchor + 192, at each 192-symbol step check_str_corr > 0.7,_calibthe payload, anddecode_stream_payload→ (LICH-96, FN, 128 voice bits). Keep the voice frame only if its LICH Golay-decodes (decode_lichnotNone). - Best phase wins: repeat over all 10 timing phases and return the LSF plus the voice-frame list from the phase that decoded the most frames.
decode_stream_payload (m17.py) inverts §4.5: _unfinalize = _deinterleave(_randomize(symbols_to_bits(symbols184))) — slice to dibits, XOR-derandomise (the mask is self-inverse), deinterleave → 368 bits. Split off the first 96 (LICH) and the trailing 272 (punctured stream); _depuncture reinserts None erasures at the dropped P2 positions → 296 slots; _viterbi decodes 144 bits = 16-bit FN + 128 voice bits.
decode_lsf_symbols (m17.py): _unfinalize → 368 bits, _depuncture against P1 → 488 slots, _viterbi → 240 bits = 30 LSF bytes; return the bytes and crc_m17(lsf[:28]) == (lsf[28]<<8 | lsf[29]).
The Viterbi decoder (_viterbi, m17.py) is a 16-state (2⁴, K=5), hard-decision, erasure-aware decoder. Each trellis step consumes a (g1, g2) pair; a None (punctured) entry simply contributes no branch-metric penalty (neutral), which is how puncturing is handled without soft bits. Branch metrics are Hamming distances; the survivor history is traced back from state 0 (the encoder was flushed to zero), yielding the n_data decoded bits. The state/output arithmetic mirrors the encoder exactly (g1 = u ⊕ h1 ⊕ h0, g2 = u ⊕ h3 ⊕ h2 ⊕ h0, next state h1 | h2<<1 | h3<<2 | u<<3). test_stream_fec_survives_errors (test_m17.py) flips one symbol and nudges another and still recovers FN and all 128 voice bits.
Voice → speech (voice_bits_to_speech, m17.py): pack the 128 bits into 16 bytes and Codec2-decode the two 8-byte halves → 320 int16 samples (40 ms @ 8 kHz). M17Voice.feed (m17_voice.py) buffers ~1 s of RX audio, decodes, emits the LSF callsigns once (decode_callsign(lsf[6:12]) → decode_callsign(lsf[:6]), i.e. SRC → DST), and streams newly-decoded frames resampled ×6 to 48 kHz.
6. Constants and tables
All quoted verbatim from app/radio/m17.py. These are the reproduction-critical values — get any one wrong and interop breaks.
6.1 Sync / marker words (m17.py)
SYNC_LSF, SYNC_STR, SYNC_PKT, EOT_MRKR = 0x55F7, 0xFF5D, 0x75FF, 0x555D
(BERT 0xDF55 is spec-defined but not present.) Their 8-symbol expansions are in the §3.2 table.
6.2 Symbol maps (m17.py)
_SYMBOL_MAP = (1, 3, -1, -3) # dibit 0..3 -> TX level
_SYMBOL_LIST = (-3, -1, 1, 3) # ascending level order (RX slicer)
6.3 Convolutional code + puncture patterns
Generators G1 = 0x19, G2 = 0x17 (K=5, r=½; derived in §4.5 from _conv_encode, m17.py). Puncture schedules (m17.py):
_P1 = (1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1,
1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1,
1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1) # 61 entries, 46 ones: LSF 488->368
_P2 = (1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0) # STREAM 296->272 (drops every 12th)
6.4 Golay(24,12) generator rows (m17.py)
_ENCODE_MATRIX = (0x8eb, 0x93e, 0xa97, 0xdc6, 0x367, 0x6cd,
0xd99, 0x3da, 0x7b4, 0xf68, 0x63b, 0xc75)
6.5 Interleaver (m17.py)
_INTRL = [(45*i + 92*i*i) % 368 for i in range(368)]
# π(i): 0, 137, 90, 227, 180, 317, 270, 39, 360, 129, 82, 219, … (a bijection)
Quadratic permutation polynomial over ℤ₃₆₈. _interleave writes bit i to slot π(i); _deinterleave reads slot π(i) back to i.
6.6 Randomiser sequence (m17.py)
The 46-byte (368-bit) M17 randomiser mask, XORed over the interleaved block:
_RAND = (0xD6,0xB5,0xE2,0x30,0x82,0xFF,0x84,0x62,0xBA,0x4E,0x96,0x90,
0xD8,0x98,0xDD,0x5D,0x0C,0xC8,0x52,0x43,0x91,0x1D,0xF8,0x6E,
0x68,0x2F,0x35,0xDA,0x14,0xEA,0xCD,0x76,0x19,0x8D,0xD5,0x80,
0xD1,0x33,0x87,0x13,0x57,0x18,0x2D,0x29,0x78,0xC3)
_RAND_BITS = [(_RAND[i//8] >> (7 - i%8)) & 1 for i in range(368)] # MSB-first
6.7 CRC-16/M17 (m17.py)
Polynomial _CRC_POLY = 0x5935, init 0xFFFF, no reflection, no final XOR; crc_m17(b"123456789") = 0x772B.
6.8 base-40 alphabet (m17.py)
_CHAR_MAP = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/." # 40 symbols
Radix-40, least-significant-char-first, packed big-endian into 6 bytes; ALL/empty → 0xFFFFFFFFFFFF.
6.9 Waveform (m17.py)
_SPS = 10 # 48 kHz / 4800 sym/s
_RRC = _rrc_taps(sps=10, span=8, beta=0.5) # 81 taps, /sqrt(sps)
_PREAMBLE = [3, -3] * 96 # 192 symbols
SYM_PER_PLD = 184 ; _FRAME_SYM = 192
7. Interoperability and validation
7.1 The in-repo test suite (test_m17.py)
Nine tests exercise every layer as an internal round-trip:
| test | what it pins |
|---|---|
test_callsign_base40 |
M0SUP, G0XYZ-9, ALL, 2E0ABC, DL1ABC/P encode→decode exactly |
test_crc_deterministic |
crc_m17(b"123456789") == 0x772B (regression) |
test_golay_encode_decode_and_correct |
round-trip + every single-bit error corrected |
test_stream_payload_roundtrip |
96-bit LICH + FN + 128 voice bits → 184 symbols → back |
test_stream_fec_survives_errors |
a flipped symbol + a nudged symbol → Viterbi still exact |
test_lsf_roundtrip_and_crc |
LSF symbols round-trip, CRC valid, DST/SRC decode |
test_lich_roundtrip_all_chunks |
all 6 LICH fragments reassemble the LSF |
test_full_stream_modem_roundtrip |
modulate_stream→decode_stream for 1/4/10 frames |
test_stream_modem_survives_awgn |
full modem at ~18 dB SNR recovers LSF + 6 voice frames |
These validate the coding chain and the baseband modem against themselves (bit-exact TX↔︎RX). They do not embed an external known-answer vector.
7.2 Interop against the M17 reference
The constants are transcribed from M17-Project/libm17 (the reference C library) — the CRC/Golay/conv-code/interleaver/randomiser/syncwords are functional facts, not copied code. Per the module docstrings (m17.py, m17_voice.py) the on-air waveform is validated against the reference m17-mod/m17-demod binaries on the prod box: audio produced by modulate_stream decodes in m17-demod, and audio from m17-mod decodes here. M17Voice registers as the native M17 backend (selected by M17_BACKEND=native), a drop-in for the m17-demod-subprocess DigiVoice path.
8. Limitations
Honest scope boundaries of this implementation:
- Codec2 is external. The vocoder is
libcodec2via ctypes — the one runtime dependency. Without it,M17Voice.startreports "libcodec2 not installed" and bows out (m17_voice.py). (This is deliberate: interop requires the exact Codec2 frames, and the LGPL library is legally linkable where copying it is not.) - 3200 voice only. The bridge is hard-wired to Codec2
3200(TYPE = voice). M17's 1600 bit/s voice mode (Codec2-1600) is not wired in, thoughcodec2_nativesupports mode1600. - Voice/stream path only.
SYNC_PKTis declared but the packet framer, BERT mode (0xDF55), and encryption (AES / scrambler, TYPE bits 3–6) are not implemented. - No end-of-stream FN bit.
modulate_streamsets FN = frame index and appends an EOT marker, but does not set the M17 "last-frame" high bit of FN on the final stream frame; RX relies on the EOT/grid running out. - RX is a research demodulator.
decode_streambrute-forces all 10 timing phases and correlates syncs in the symbol domain with fixed thresholds (0.9 LSF, 0.7 STR) — robust in tests (survives ~18 dB-SNR AWGN,test_m17.py) and interop, but it is a batch decoder over a buffered ~1 s window (m17_voice.py), not a sample-by-sample PLL/timing-recovery loop. The_sym_syncscorrelation is an O(N·8) slide, adequate for voice but not optimised. - Baseband, not RF. The module emits/consumes the RRC-shaped 4-level baseband (the FM modulator input / discriminator output); the RF deviation and FM modulation are the radio's job.
None of these are architectural; each is a bounded addition on a working, interop-validated base.
9. References
- M17 Protocol Specification — the authoritative source for the frame layout, syncwords, TYPE field, base-40 alphabet, Golay/conv-code/interleaver/ randomiser, and Codec2 usage: https://spec.m17project.org.
- M17-Project/libm17 — the reference C library whose functional constants (
_ENCODE_MATRIX,_P1/_P2,_INTRL,_RAND, syncwords, CRC/base-40) this module transcribes: https://github.com/M17-Project/libm17. - M17-Project/M17_Implementations (
m17-mod,m17-demod) — the reference modulator/demodulator used for on-air interop validation. - D. Rowe, Codec2 — the open low-bitrate speech codec (mode 3200) that M17 and this bridge use: https://github.com/drowe67/codec2.
- M. J. E. Golay, "Notes on digital coding," Proc. IRE 37 (1949) — the (24,12) extended Golay code protecting the LICH.
- G. D. Forney, "The Viterbi algorithm," Proc. IEEE 61 (1973) — the maximum- likelihood decoder for the K=5 convolutional code.