Patrick Lidstone
Self-hosted

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, ±3 transmitted 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: M0SUP00 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) = 488 coded → 368 kept. Applied in encode_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) = 296 coded → 272 kept. Applied in encode_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) ) )
  • _interleave applies 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, ….
  • _randomize XORs the 368 interleaved bits with _RAND_BITS — the MSB-first expansion of the 46-byte _RAND sequence, starting 0xD6, 0xB5, 0xE2, … (first bits 1 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_symbols maps 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 drives modulate_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.

  1. Matched filter (_mf): convolve the 48 kHz audio with the same RRC taps used on TX.
  2. Symbol-timing search: for each of the sps = 10 phases, downsample s = mf[phase::sps], then RMS-normalise to √5/rms so the symbols sit at ≈ ±1, ±3 (for equiprobable levels E[s²] = (1+9)/2 = 5).
  3. Anchor on the LSF sync (_sym_syncs): slide the 8-symbol SYNC_LSF pattern and keep positions where the normalised cross-correlation ≥ 0.9 (local maxima). At each candidate, _calib gain- and inversion-normalises the following 184 payload symbols (scale = ⟨known,rx⟩/⟨rx,rx⟩ — this corrects both amplitude and FM-discriminator sign flips), then decode_lsf_symbols recovers the LSF; if its CRC checks, that position and LSF become the anchor.
  4. Late join: if no valid LSF is found, anchor instead on a SYNC_STR correlation offset back one frame, so a receiver tuning in mid-over still decodes voice (recovering the call from the LICH within six frames).
  5. Step the grid: from anchor + 192, at each 192-symbol step check _str_corr > 0.7, _calib the payload, and decode_stream_payload → (LICH-96, FN, 128 voice bits). Keep the voice frame only if its LICH Golay-decodes (decode_lich not None).
  6. 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_streamdecode_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 libcodec2 via ctypes — the one runtime dependency. Without it, M17Voice.start reports "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, though codec2_native supports mode 1600.
  • Voice/stream path only. SYNC_PKT is 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_stream sets 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_stream brute-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_syncs correlation 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

  1. 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.
  2. 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.
  3. M17-Project/M17_Implementations (m17-mod, m17-demod) — the reference modulator/demodulator used for on-air interop validation.
  4. D. Rowe, Codec2 — the open low-bitrate speech codec (mode 3200) that M17 and this bridge use: https://github.com/drowe67/codec2.
  5. M. J. E. Golay, "Notes on digital coding," Proc. IRE 37 (1949) — the (24,12) extended Golay code protecting the LICH.
  6. G. D. Forney, "The Viterbi algorithm," Proc. IEEE 61 (1973) — the maximum- likelihood decoder for the K=5 convolutional code.