MFSK16 / MFSK32: a reproduction-grade specification
A native, dependency-free port of the IZ8BLY/gmfsk keyboard mode as fldigi implements it — 16-tone M-ary FSK carrying a convolutionally-coded, diagonally interleaved, Gray-mapped IZ8BLY varicode.
Rafe project · app/radio/mfsk.py, app/radio/_tables/mfsk_varicode.py · byte-exact vs a compiled fldigi oracle
Abstract
MFSK16 and MFSK32 are HF/VHF keyboard-chat modes: you type, the far end sees text. Both send 16 tones, one at a time, at a fixed baud (15.625 sym/s for MFSK16, 31.25 for MFSK32), and both wrap the same coding chain around that M-ary FSK carrier — an IZ8BLY variable-length character code (varicode), a constraint-length-7, rate-½ convolutional FEC (generator polynomials 0x6D / 0x4F), a stateful 4×4×10 diagonal bit interleaver, and a Gray map from the interleaved symbol to the transmitted tone. The receiver is a per-symbol single-bin DFT over the 16 tone frequencies, a soft-bit demapper (Gray-decode, de-interleave), and a 64-state soft-decision Viterbi decoder (linear mettab, traceback 84) feeding a self-delimiting varicode framer.
This document is complete: every constant, polynomial, tap offset and scaling factor is written out inline — including the full 256-entry IZ8BLY varicode table (§6.5) — so nothing here requires the source to be on hand. The two easily-mis-implemented traps are called out explicitly — fldigi's backward Gray naming convention and the diagonal interleaver tap geometry. The implementation was validated so that its transmitted tone sequence is byte-identical to a compiled fldigi's for the same input (§7); getting the Gray convention, the interleaver taps and the convolutional-poly bit order exactly right is what makes that true.
The whole modem is app/radio/mfsk.py (325 lines) plus one data table, app/radio/_tables/mfsk_varicode.py. Pure NumPy at runtime; no fldigi process, no XML-RPC bridge.
1. Scope and notation
Two modes are defined, selected by a string key (app/radio/mfsk.py):
| key | symlen@8k |
symbits |
numtones |
basetone |
depth |
preamble |
|---|---|---|---|---|---|---|
mfsk16 |
512 | 4 | 16 | 64 | 10 | 107 |
mfsk32 |
256 | 4 | 16 | 32 | 10 | 107 |
Everything is defined against fldigi's internal 8 kHz processing rate (FS = 8000, app/radio/mfsk.py) and a 1000 Hz audio centre (CENTER = 1000.0, app/radio/mfsk.py). The symbol length in samples at 8 kHz is symlen; the tone spacing is derived from it as FS / symlen. The modulator and demodulator can run at any output/input sample rate sr; they scale the symbol length to symlen / FS * sr samples and synthesise/correlate at the true tone frequencies in Hz, so nothing depends on the caller's rate being 8 kHz.
Bit-order conventions used throughout, all matching the code:
- Varicode bit strings are MSB-first character strings of
'0'/'1'(app/radio/mfsk.py). - The convolutional encoder emits a 2-bit value whose bit 0 is the
POLY1parity and bit 1 is thePOLY2parity (app/radio/mfsk.py). - A tone index is
0…15;symbits = 4bits per tone.
2. Background: MFSK = FEC + varicode + interleaver over M-ary FSK
M-ary FSK. Instead of one carrier switched between two tones (2-FSK/RTTY), MFSK transmits one of M = 16 tones per symbol interval, so each symbol carries log2(M) = 4 bits. Sending one tone at a time (constant envelope, phase-continuous) is power-efficient and robust to the selective fading and multipath of HF, and the narrow per-tone bandwidth gives good weak-signal performance for the baud.
But raw 16-FSK is fragile: a single mis-detected tone corrupts 4 bits at once, and text has no redundancy. MFSK16 therefore stacks three classical layers between the characters and the tones, and this pipeline — not the FSK itself — is what has to be reproduced exactly:
Varicode (source coding). A variable-length, self-delimiting character code (IZ8BLY's) that spends few bits on common letters and is decodable from the bit stream with no external framing — the same idea as PSK31's varicode but a different table (§6.5).
Convolutional FEC (channel coding). A rate-½, K=7 convolutional code doubles the bit count and lets a Viterbi decoder correct scattered bit errors (§6.1, §5.4).
Diagonal interleaver (burst defence). HF fades hit contiguous bits; the convolutional code only fixes scattered errors. A diagonal interleaver spreads each fade across many code symbols so the Viterbi sees isolated errors (§6.2).
The transmit order is character → varicode → convolutional-encode → interleave → Gray-map → tone. The receiver inverts it: tone → Gray-decode → de-interleave → Viterbi → varicode-frame → character. MFSK32 is the same chain at double the tone spacing and half the symbol length — twice the baud, twice the bandwidth, same coding.
3. Signal structure
3.1 Tones, spacing and bandwidth
Tone frequencies are built centre-out (_tone_freqs, app/radio/mfsk.py; mirrored in modulate, app/radio/mfsk.py):
tonespacing = FS / symlen # Hz
bw = numtones * tonespacing # Hz
base = center - bw / 2 # frequency of tone 0
freq(t) = base + t * tonespacing # t = 0 … numtones-1
Evaluated exactly:
tonespacing |
occupied bw |
tone 0 (base) |
tone 15 (top) | baud (FS/symlen) |
|
|---|---|---|---|---|---|
| MFSK16 | 8000/512 = 15.625 Hz | 250.0 Hz | 875.0 Hz | 1109.375 Hz | 15.625 |
| MFSK32 | 8000/256 = 31.25 Hz | 500.0 Hz | 750.0 Hz | 1218.75 Hz | 31.25 |
All 16 tones straddle the 1000 Hz centre (they are symmetric about it because base = center - bw/2).
On basetone. The basetone value (64 for MFSK16, 32 for MFSK32, app/radio/mfsk.py) is fldigi's DFT bin index of the audio centre at 8 kHz: center / tonespacing = 1000 / 15.625 = 64 and 1000 / 31.25 = 32. It is carried in PARAMS for reference only — neither _tone_freqs nor modulate uses it; both derive tone frequencies from center and tonespacing directly. A reimplementation may ignore basetone entirely.
3.2 Symbol rate, data rate
Each symbol is symlen samples at 8 kHz, i.e. one tone held for 1/baud seconds. Per symbol: symbits = 4 channel bits (one tone → 4 bits). After the rate-½ code that is 2 payload bits per symbol; after the varicode it is roughly the varicode's average length in characters per second. Order-of-magnitude throughput: MFSK16 ≈ 42 wpm-class, MFSK32 ≈ double.
At an arbitrary output rate sr, samples per symbol is sps = symlen / FS * sr (app/radio/mfsk.py). Worked values:
| mode | sps @8k |
sps @12k |
sps @48k |
|---|---|---|---|
| MFSK16 | 512 | 768 | 3072 |
| MFSK32 | 256 | 384 | 1536 |
3.3 Carrier synthesis
Tones are cosine, phase-continuous across symbol boundaries — the phase accumulator carries over so there is no click at each tone change (app/radio/mfsk.py):
out[a:a+n] = cos(2*pi*f*t/sr + phase) # t = 0 … n-1
phase = (phase + 2*pi*f*n/sr) mod 2*pi
Output is scaled to half full-scale and written as little-endian 16-bit PCM: round(clip(out * 0.5, -1, 1) * 32767) as <i2 (app/radio/mfsk.py) — peak sample magnitude ≈ 16384.
3.4 Frame / on-air structure
One transmission (modulate, app/radio/mfsk.py) is, in order:
[ preamble//3 = 35 zero data-bits ]
[ CR STX CR ] = chars 13, 2, 13
[ the message text ]
[ CR EOT CR ] = chars 13, 4, 13
[ 160 flush zero data-bits ]
- Preamble:
preamble // 3 = 107 // 3 = 35zero data bits pushed through the encoder before anything else (app/radio/mfsk.py) — idle tone energy for the receiver's symbol-timing tracker to lock to. - Start/end framing: the text is bracketed by
CR STX CR(13, 2, 13) andCR EOT CR(13, 4, 13) (app/radio/mfsk.py). STX (2) and EOT (4) are sent through the varicode like any other character but are not printed on receive (the RX filter passes only32…126plus LF/CR — §5.6), so they act as invisible message delimiters; the CRs are printed. - Flush: 160 trailing zero data-bits (
app/radio/mfsk.py) to drain the last real bits out of the interleaver pipeline (~size*depthsymbols) and the Viterbi traceback (84) on the far end.
Note these bits enter the encoder, so each zero data-bit becomes 2 coded bits and fractions of tone symbols; the counts are in data-bit units, not symbols.
4. Encode / transmit, step by step
Reference: modulate(text, mode, sr=48000, center=CENTER), app/radio/mfsk.py. State: one _ConvEnc, one forward _Interleaver, and a running 2→4 bit assembler st = {"bs", "sh"}.
Step E1 — character → varicode bits. For each character c, look up _VENC[c & 0xFF] (the ENC table, §6.5) and feed its bits MSB-first as 0/1 into sendbit (app/radio/mfsk.py). No inter-character separator is added — the varicode is self-delimiting (§6.5).
Step E2 — convolutional encode (per bit). sendbit(bit) calls enc.encode(bit) (app/radio/mfsk.py):
reg = ((reg << 1) | bit) & 0x7F # 7-bit shift register
out = parity(POLY1 & reg) | (parity(POLY2 & reg) << 1) # 2 bits
parity(x) is the population-count parity, bin(x).count("1") & 1 (app/radio/mfsk.py). Output bit 0 = POLY1=0x6D parity, bit 1 = POLY2=0x4F parity — this ordering is load-bearing and must match the decoder's out[] table (§6.1).
Step E3 — pack 4 coded bits into a symbol. The two output bits are shifted into sh bit 0 first, then bit 1 (app/radio/mfsk.py):
for i in 0,1:
sh = (sh << 1) | ((data >> i) & 1)
bs += 1
if bs == symbits(=4):
syms.append( inlv.bits(sh & (numtones-1)) ) # emit a symbol
bs = 0; sh = 0
So each MFSK symbol packs 4 consecutive coded bits = the outputs of 2 consecutive data bits, MSB = the first (oldest) coded bit.
Step E4 — interleave. inlv.bits(v) runs the 4-bit value through the forward diagonal interleaver (fwd=True) and returns a 4-bit value (§6.2, §4.1 below).
Step E5 — Gray-map to a tone. At synthesis time each interleaved symbol s is mapped to a tone index with _grayencode (the cumulative-XOR; see the trap in §6.4): tone = _grayencode(s & (numtones-1)) (app/radio/mfsk.py).
Step E6 — synthesise. f = base + tone * tonespacing; emit sps samples of a phase-continuous cosine at f (§3.3).
4.1 The forward interleaver call
_Interleaver.bits(val) (app/radio/mfsk.py) splits val into size=4 bits MSB-first, runs the bit-level delay machinery symbols() (§6.2), then re-packs MSB-first:
syms[i] = (val >> (size - i - 1)) & 1 # i = 0 … 3, syms[0] = MSB
symbols(syms) # in place, forward taps
out = 0; for i: out = (out << 1) | syms[i]
The TX interleaver thus operates on hard bits (0/1); the RX interleaver (§5.3) runs the identical delay structure on soft bytes (0…255). Same taps, same delay — that is why de-interleave is exact.
5. Decode / receive, step by step
Reference: class MfskDecoder, app/radio/mfsk.py. It is a streaming decoder: feed(pcm) in, on_text(char) callbacks out. State: a backward _Interleaver (fwd=False), one _Viterbi, a sample buffer, a fractional symbol position _pos, a soft-symbol pairing buffer, and the varicode shift register.
Construction (app/radio/mfsk.py): sps = symlen / FS * sr; freqs, tonespacing = _tone_freqs(mode). The tone frequencies come from the module CENTER (1000 Hz), not from the center argument — the decoder stores self.center but never uses it for the DFT bins (app/radio/mfsk.py). To decode a signal centred elsewhere, shift the audio, don't pass center.
Step D1 — symbol DFT. _mags_at(start) (app/radio/mfsk.py) takes int(sps) samples from start and correlates against each tone frequency — a single-bin (Goertzel-style) DFT at the true Hz, so non-integer bins are fine:
mags[t] = | Σ_n seg[n] * exp(-2j*pi*freqs[t]*n/sr) | # t = 0 … 15
Returns None until a whole symbol is buffered.
Step D2 — symbol-timing tracking. An early/late tracker nudges _pos toward the sampling instant of best tone contrast (feed, app/radio/mfsk.py). With d = sps/8, it compares a quality metric q = mags.max() / (mags.mean()+1e-10) (_quality, app/radio/mfsk.py) at _pos-d, _pos, _pos+d; if a neighbour is better it slews _pos by ±0.4*d and re-DFTs before decoding. Then it advances _pos += sps.
Step D3 — soft demap (tone → 4 soft bits). _process_symbol(mags) (app/radio/mfsk.py):
S = mags.sum() or 1e-9
currsym = argmax(mags) # the detected tone
b[k] = 0.0 for k in 0…3
for i in 0…15: # every tone
j = _graydecode(i) # Gray-decode: tone i -> symbol value j
binmag = 2*mags[i] if i == currsym else mags[i] # winner weighted x2
for k in 0…3:
b[k] += +binmag if (j >> (3-k)) & 1 else -binmag # MSB-first bit test
softbit[k] = clip( round(128 + b[k]/S*256), 0, 255 ) # 0..255, 128 = neutral
Each of the four bit positions accumulates a magnitude-weighted vote across all 16 tones (a soft-decision APP-style metric), centred to a byte with 128 = erasure, larger = more likely a 1. The winning tone is given double weight. _graydecode is the single XOR and is the exact inverse of the TX _grayencode (§6.4).
Step D4 — de-interleave. self.inlv.symbols(softbit) runs the four soft bytes through the backward diagonal interleaver in place (§6.2). Because TX used the forward taps and RX the backward taps with the same size=4, depth=10, the pair inverts (up to the fixed pipeline delay the TX flush covers).
Step D5 — pair soft bits and Viterbi-decode. Each de-interleaved soft byte is pushed through _decodesymbol (app/radio/mfsk.py), which pairs consecutive soft symbols and, on every second one, runs one Viterbi step:
sympair = [sympair[1], symbol] # slide
symcounter toggles 0/1 each call
if symcounter: return # wait for the pair
c = vit.decode(sympair[0], sympair[1]) # sym0 = POLY1 soft, sym1 = POLY2 soft
recvbit(c)
sympair[0] is the earlier soft symbol (the POLY1 bit) and sympair[1] the later (POLY2) — matching the TX packing of §4/E3. vit.decode returns one hard decoded bit (§5.4).
Step D6 — varicode framing. _recvbit(bit) (app/radio/mfsk.py) reassembles characters from the self-delimiting varicode using a shift register datashreg:
datashreg = (datashreg << 1) | bit
if (datashreg & 7) == 1: # low three bits == 0b001
code = datashreg >> 1 # the completed varicode (ends in "00")
idx = _VDEC.index(code) # exact table lookup, DEC == int(ENC,2)
if valid: emit
datashreg = 1 # keep the leading 1 of the next code
The trigger (datashreg & 7) == 1 fires when a 1 arrives after two 0s — i.e. the first bit of the next character has been seen, so the current one is complete. This works because every IZ8BLY code starts with 1, ends in 00, and contains no internal 00 (verified over all 256 entries; §6.5). code is looked up directly in DEC (which equals int(ENC[i], 2)); on miss, idx = -1.
5.6 Output filter
Only sensible characters reach on_text (app/radio/mfsk.py): index in 0…255, not 0, and either printable 32 ≤ ch < 127 or ch in (10, 13) (LF/CR). The STX/EOT frame markers (2, 4) decode but are silently dropped, so the printed text is just the message plus its bracketing CRs.
6. Constants & tables (exact)
6.1 Convolutional code
POLY1, POLY2 = 0x6D, 0x4F # app/radio/mfsk.py
K = 7 # app/radio/mfsk.py -> 64 states
rate = 1/2
0x6D = 0b1101101, 0x4F = 0b1001111 — the NASA-standard K=7 r=½ code (also the one used by the WSJT MSK modes and by this repo's RVQ-Voice channel layer). The encoder register is masked to 7 bits (& 0x7F, app/radio/mfsk.py) and the two outputs are the parities of POLY1 & reg (→ output bit 0) and POLY2 & reg (→ output bit 1) (app/radio/mfsk.py). The Viterbi's expected-output table repeats this over all 128 register values (app/radio/mfsk.py):
out[i] = parity(POLY1 & i) | (parity(POLY2 & i) << 1) # i = 0 … 127
# out[0..7] = [0, 3, 2, 1, 3, 0, 1, 2]
Bit-order trap. If you swap POLY1/POLY2, or emit bit 1 before bit 0, or index out[] with the polys reversed, the code still self-consistently round-trips in a pure-software loopback but will not match fldigi and will fail against real MFSK. Keep POLY1=0x6D on bit 0.
6.2 Diagonal interleaver
class _Interleaver(size, depth, fwd), app/radio/mfsk.py. For MFSK it is built with size = symbits = 4, depth = 10 (forward for TX, backward for RX; app/radio/mfsk.py). Internally it is a flat delay array of size*size*depth = 4*4*10 = 160 cells (app/radio/mfsk.py), indexed t[k*size*size + i*size + j] for stage k, row i, column j (_idx, app/radio/mfsk.py).
The core is symbols(psyms) (app/radio/mfsk.py), run once per input group. For each of the depth = 10 stages k:
# 1. shift every row left by one column (drop col 0):
for i in 0…size-1: for j in 0…size-2: t[k,i,j] = t[k,i,j+1]
# 2. write the incoming group into the last column:
for i in 0…size-1: t[k,i,size-1] = psyms[i]
# 3. read the diagonal tap back into psyms:
for i in 0…size-1:
psyms[i] = t[k, i, size-i-1] if fwd # forward diagonal
= t[k, i, i] otherwise # backward diagonal (de-interleave)
Tap geometry — the trap. The delay applied to row i differs by row. Forward reads column size-1-i, so row 0 is read from the just-written column (delay 0), row 3 from column 0 (delay size-1). Backward reads column i — the complementary diagonal. Cascading depth = 10 such stages accumulates the spreading. Forward and backward taps are inverses only as this exact pair (size-i-1 vs i); swap them, transpose i/j, or change the shift direction and TX/RX no longer cancel.
The interleaver is stateful — its delay array persists across the whole transmission; you cannot reset it between characters. That is why the TX appends 160 flush bits and the RX tolerates a startup transient before text appears.
6.3 Soft-decision Viterbi (mettab, states, traceback)
class _Viterbi, app/radio/mfsk.py.
PATHMEM = 256 # app/radio/mfsk.py (ring-buffer depth)
traceback = K * 12 = 84 # app/radio/mfsk.py
nstates = 1 << (K-1) = 64 # app/radio/mfsk.py
mettab — the soft metric. A linear table over the 0…255 soft-bit range (app/radio/mfsk.py):
met[0][x] = 128 - x # branch metric if the expected bit is 0
met[1][x] = x - 128 # branch metric if the expected bit is 1
A neutral soft value 128 scores 0 for either hypothesis; a confident 0 (small x) rewards the "expected 0" branch, a confident 1 (large x) the "expected 1" branch. (fldigi derives its mettab from a Gaussian assumption; this port uses the simple linear form, which is sufficient because it decodes the same demapper's soft bytes — the byte-exact guarantee is on the transmit side.)
Butterfly. Per decoded bit, decode(sym0, sym1) (app/radio/mfsk.py) first builds the 4-entry branch-metric tuple for the received soft pair — indexed by the 2-bit expected output value (bit 0 ↔︎ sym0/POLY1, bit 1 ↔︎ sym1/POLY2):
met = ( m0[sym1]+m0[sym0], # expected output 00
m0[sym1]+m1[sym0], # expected output 01
m1[sym1]+m0[sym0], # expected output 10
m1[sym1]+m1[sym0] ) # expected output 11
then, for each of the 64 current states n, compares the two predecessors s0 = n, s1 = n + 64 (predecessor state = s>>1):
a = pm[n>>1] + met[out[n]] # via s0
b = pm[(n+64)>>1] + met[out[n+64]] # via s1
cm[n], ch[n] = (a, n>>1) if a > b else (b, (n+64)>>1)
pm/cm are the previous/current path metrics, ch the survivor predecessor. Renormalisation: if cm[0] > (1<<29) (536870912), subtract 1<<29 from every metric to prevent overflow (app/radio/mfsk.py).
Traceback (app/radio/mfsk.py): pick the best-metric state at the newest column, walk history back traceback = 84 steps through the 256-entry ring, and output the oldest bit's LSB (seq[p] & 1) — chunksize 1, one decoded bit per decode call. Too short a traceback truncates surviving paths and raises the error rate; 84 (= K*12) is the value to use.
6.4 Gray convention (the naming trap)
fldigi names its two Gray functions backwards from the textbook, and this port matches fldigi so the tones agree on the air (app/radio/mfsk.py):
_grayencode(n): m = n; while n: n >>= 1; m ^= n; return m & 0xFF # CUMULATIVE XOR
_graydecode(n): return n ^ (n >> 1) # SINGLE XOR
_grayencodeis the cumulative XOR — mathematically the standard Gray→binary decode — and is what TX applies to map an interleaved symbol to a tone (app/radio/mfsk.py)._graydecodeis the single XOR — the standard binary→Gray encode — and is what RX applies to map a tone back to a symbol value (app/radio/mfsk.py).
They are exact inverses (_grayencode(_graydecode(n)) == n), so the round trip is correct; the trap is purely the naming/direction. If you follow the textbook and put the single XOR on transmit, every tone is wrong and nothing interoperates. For reference, _grayencode(0…15) = [0,1,3,2,7,6,4,5,15,14,12,13,8,9,11,10] and _graydecode(0…15) = [0,1,3,2,6,7,5,4,12,13,15,14,10,11,9,8].
6.5 The IZ8BLY MFSK varicode
app/radio/_tables/mfsk_varicode.py — two parallel 256-entry lists indexed by byte value (ENC line 2, DEC line 3). ENC[c] is the MSB-first '0'/'1' bit string for character c; DEC[c] is that same string as an integer (DEC[c] == int(ENC[c], 2), verified for all 256). TX uses ENC (app/radio/mfsk.py); RX looks the completed shift-register value up in DEC (app/radio/mfsk.py).
Structural invariants (all 256 entries, verified — these are what make the code self-delimiting and drive the §5.6/§D6 framer):
- every code starts with
1; - every code ends in
00(often a longer run of trailing zeros); - no code contains
00before its terminal zero-run — i.e. strip the trailing zeros and the remaining "core" has no00in it (verified over all 256 entries), so the framer's001trigger can only fire at the next code's leading1; - lengths run from 3 bits (e.g. space) to 12 bits.
Representative exact entries:
| char | dec | ENC (bits) |
DEC (int) |
|---|---|---|---|
| NUL | 0 | 11101011100 |
1884 |
| STX | 2 | 11101101000 |
1896 |
| EOT | 4 | 11101110000 |
1904 |
| LF | 10 | 11110100000 |
1952 |
| CR | 13 | 10101100 |
172 |
| space | 32 | 100 |
4 |
0 |
48 | 11100000 |
224 |
A |
65 | 10111100 |
188 |
a |
97 | 10100 |
20 |
e |
101 | 1000 |
8 |
t |
116 | 1100 |
12 |
This is a hand-assigned table, not derived from any Gray/algorithmic rule, so it must be reproduced verbatim — and it is, in full, below. ENC and DEC are indexed by the byte value 0…255; DEC[i] == int(ENC[i], 2), so the two arrays are redundant and both are printed to leave nothing to look up.
# IZ8BLY MFSK varicode — index i == ASCII byte value (0x00..0xFF); DEC[i] == int(ENC[i], 2)
ENC = ['11101011100', '11101100000', '11101101000', '11101101100', '11101110000', '11101110100', '11101111000', '11101111100', '10101000', '11110000000', '11110100000', '11110101000', '11110101100', '10101100', '11110110000', '11110110100', '11110111000', '11110111100', '11111000000', '11111010000', '11111010100', '11111011000', '11111011100', '11111100000', '11111101000', '11111101100', '11111110000', '11111110100', '11111111000', '11111111100', '100000000000', '101000000000', '100', '111000000', '111111100', '1011011000', '1010101000', '1010100000', '1000000000', '110111100', '111110100', '111110000', '1010110100', '111100000', '10100000', '111011000', '111010100', '111101000', '11100000', '11110000', '101000000', '101010100', '101110100', '101100000', '101101100', '110100000', '110000000', '110101100', '111101100', '111111000', '1011000000', '111011100', '1010111100', '111010000', '1010000000', '10111100', '100000000', '11010100', '11011100', '10111000', '11111000', '101010000', '101011000', '11000000', '110110100', '101111100', '11110100', '11101000', '11111100', '11010000', '11101100', '110110000', '11011000', '10110100', '10110000', '101011100', '110101000', '101101000', '101110000', '101111000', '110111000', '1011101000', '1011010000', '1011101100', '1011010100', '1010110000', '1010101100', '10100', '1100000', '111000', '110100', '1000', '1010000', '1011000', '110000', '11000', '10000000', '1110000', '101100', '1000000', '11100', '10000', '1010100', '1111000', '100000', '101000', '1100', '111100', '1101100', '1101000', '1110100', '1011100', '1111100', '1011011100', '1010111000', '1011100000', '1011110000', '101010000000', '101010100000', '101010101000', '101010101100', '101010110000', '101010110100', '101010111000', '101010111100', '101011000000', '101011010000', '101011010100', '101011011000', '101011011100', '101011100000', '101011101000', '101011101100', '101011110000', '101011110100', '101011111000', '101011111100', '101100000000', '101101000000', '101101010000', '101101010100', '101101011000', '101101011100', '101101100000', '101101101000', '101101101100', '101101110000', '101101110100', '101101111000', '101101111100', '1011110100', '1011111000', '1011111100', '1100000000', '1101000000', '1101010000', '1101010100', '1101011000', '1101011100', '1101100000', '1101101000', '1101101100', '1101110000', '1101110100', '1101111000', '1101111100', '1110000000', '1110100000', '1110101000', '1110101100', '1110110000', '1110110100', '1110111000', '1110111100', '1111000000', '1111010000', '1111010100', '1111011000', '1111011100', '1111100000', '1111101000', '1111101100', '1111110000', '1111110100', '1111111000', '1111111100', '10000000000', '10100000000', '10101000000', '10101010000', '10101010100', '10101011000', '10101011100', '10101100000', '10101101000', '10101101100', '10101110000', '10101110100', '10101111000', '10101111100', '10110000000', '10110100000', '10110101000', '10110101100', '10110110000', '10110110100', '10110111000', '10110111100', '10111000000', '10111010000', '10111010100', '10111011000', '10111011100', '10111100000', '10111101000', '10111101100', '10111110000', '10111110100', '10111111000', '10111111100', '11000000000', '11010000000', '11010100000', '11010101000', '11010101100', '11010110000', '11010110100', '11010111000', '11010111100', '11011000000', '11011010000', '11011010100', '11011011000', '11011011100', '11011100000', '11011101000', '11011101100', '11011110000', '11011110100', '11011111000', '11011111100', '11100000000', '11101000000', '11101010000', '11101010100', '11101011000']
DEC = [1884, 1888, 1896, 1900, 1904, 1908, 1912, 1916, 168, 1920, 1952, 1960, 1964, 172, 1968, 1972, 1976, 1980, 1984, 2000, 2004, 2008, 2012, 2016, 2024, 2028, 2032, 2036, 2040, 2044, 2048, 2560, 4, 448, 508, 728, 680, 672, 512, 444, 500, 496, 692, 480, 160, 472, 468, 488, 224, 240, 320, 340, 372, 352, 364, 416, 384, 428, 492, 504, 704, 476, 700, 464, 640, 188, 256, 212, 220, 184, 248, 336, 344, 192, 436, 380, 244, 232, 252, 208, 236, 432, 216, 180, 176, 348, 424, 360, 368, 376, 440, 744, 720, 748, 724, 688, 684, 20, 96, 56, 52, 8, 80, 88, 48, 24, 128, 112, 44, 64, 28, 16, 84, 120, 32, 40, 12, 60, 108, 104, 116, 92, 124, 732, 696, 736, 752, 2688, 2720, 2728, 2732, 2736, 2740, 2744, 2748, 2752, 2768, 2772, 2776, 2780, 2784, 2792, 2796, 2800, 2804, 2808, 2812, 2816, 2880, 2896, 2900, 2904, 2908, 2912, 2920, 2924, 2928, 2932, 2936, 2940, 756, 760, 764, 768, 832, 848, 852, 856, 860, 864, 872, 876, 880, 884, 888, 892, 896, 928, 936, 940, 944, 948, 952, 956, 960, 976, 980, 984, 988, 992, 1000, 1004, 1008, 1012, 1016, 1020, 1024, 1280, 1344, 1360, 1364, 1368, 1372, 1376, 1384, 1388, 1392, 1396, 1400, 1404, 1408, 1440, 1448, 1452, 1456, 1460, 1464, 1468, 1472, 1488, 1492, 1496, 1500, 1504, 1512, 1516, 1520, 1524, 1528, 1532, 1536, 1664, 1696, 1704, 1708, 1712, 1716, 1720, 1724, 1728, 1744, 1748, 1752, 1756, 1760, 1768, 1772, 1776, 1780, 1784, 1788, 1792, 1856, 1872, 1876, 1880]
The arrays above are the complete IZ8BLY MFSK varicode, transcribed verbatim — there is no larger table anywhere else to consult.
7. Interoperability & validation
Provenance. This is an independent Python port of the IZ8BLY/gmfsk MFSK modem as expressed in fldigi's mfsk.cxx / mfskvaricode.cxx (GPL-3), reimplemented under the project's own licence; the numeric constants (polys, varicode, interleaver size/depth, tone geometry) are treated as protocol facts, and no fldigi source is copied in (see NOTICE.md). fldigi is the reference standard here, and MFSK16/32 are on-air interop modes, so "correct" means byte-identical to fldigi on the air, not merely self-consistent in loopback.
Oracle methodology. This is the same behavioural-oracle discipline the project uses elsewhere — FT8 against a compiled gen_ft8, the WSJT family against jt9code/*sim, Mercury against its param-dump binary, ARDOP's CRC against a compiled GenCRC16 (see docs/native-digimodes.md and NOTICE.md). A stock fldigi is compiled and driven as an external oracle only (never linked, never distributed): give it a known message in MFSK16/MFSK32 and capture its output; assert the native chain reproduces it exactly. Concretely, the chain
char -> ENC varicode -> conv(0x6D/0x4F) -> interleave(4x4x10) -> grayencode -> tone
was pinned so the transmitted tone-index sequence is identical to fldigi's for the same text — which is exactly the set of details that are trivial to get subtly wrong: the backward Gray naming (§6.4), the forward-diagonal interleaver taps (§6.2), the POLY1-on-bit-0 output order (§6.1), and the preamble/STX/EOT framing (§3.4). The git history records the decisive fix as "MFSK: fix Gray convention to match fldigi (interop correctness)" — before it, loopback passed but fldigi did not decode; after it, tones matched byte-for-byte.
Round-trip test. test_jt.py::test_mfsk_roundtrip (test_jt.py) modulates "CQ DE M0SUP K" in both modes at sr=8000 and asserts the streaming MfskDecoder recovers the substring — an end-to-end check of the full encode → synthesise → DFT → soft-demap → de-interleave → Viterbi → varicode-frame path. The THOR and DominoEX modems reuse _ConvEnc, _Interleaver, _Viterbi and the varicode from this module (app/radio/thor.py), so the same primitives are exercised by their tests too.
Integration. app/radio/fldigi_native.py registers mfsk as the in-repo backend for the mfsk16/mfsk32 mode keys (app/radio/fldigi_native.py); codec_backend.fldigi_native() selects native vs the legacy fldigi XML-RPC bridge (app/radio/codec_backend.py, app/radio/digimodes.py).
8. Limitations
- RX soft metric is linear, not fldigi's Gaussian
mettab. It decodes the native demapper's soft bytes fine, but is not claimed byte-identical to fldigi's receiver internals — only the transmitter is oracle-matched. A calibrated (Gaussian/LLR)mettabwould likely add a fraction of a dB of sensitivity. - Decoder
centerargument is inert. Tone bins come from the moduleCENTER(1000 Hz) via_tone_freqs; to decode an off-centre signal you must frequency- shift the audio first (§5). The modulator, by contrast, honours itscenterargument. - No AFC / no wide search. The demod correlates at fixed nominal tone frequencies; there is a symbol-timing tracker (§D2) but no automatic frequency control or coarse acquisition, so significant carrier offset degrades it. fldigi's tuning/AFC is not reproduced.
- DFT-per-tone RX is CPU-heavy — 16 complex correlations per candidate symbol position (plus up to 3 for timing) in pure Python. Fine for one keyboard-mode stream on the prod box; vectorising the DFT would give headroom.
- Framing is minimal. STX/EOT bracket the text but there is no CRC or message integrity check; a mis-decoded varicode simply prints a wrong character (or is dropped by the §5.6 filter). This matches fldigi keyboard-mode behaviour.
- Only MFSK16 and MFSK32 are defined. The other IZ8BLY MFSK rates (MFSK4/8/11/22/31/64) are not in
PARAMS; the machinery generalises but the per-mode tone/spacing/interleaver parameters would need adding.
9. References
- Nino Porcino IZ8BLY — the original MFSK16 design and varicode (2000).
- Tomi Manninen OH2BNS et al. — gmfsk, the GPL MFSK/DominoEX implementation MFSK's coding chain descends from.
- Dave Freese W1HKJ et al. — fldigi (
mfsk.cxx,mfskvaricode.cxx,viterbi.cxx,interleave.cxx), GPL-3.0 — the reference expression and the validation oracle used here. SeeNOTICE.md. - G. C. Clark & J. B. Cain, Error-Correction Coding for Digital Communications, 1981 — convolutional codes and the Viterbi algorithm.
- This repo:
docs/native-digimodes.md(the behavioural-oracle validation discipline),docs/rvqvoice.md(the depth/structure template), anddocs/protocols/README.md(the native-protocol spec index). The K=7 r=½ code0x6D/0x4Fis shared withapp/radio/rvqvoice_stream.pyand the THOR/DominoEX modems (app/radio/thor.py,app/radio/domino.py).
Appendix A — Implementation & reproduction map
| file | role |
|---|---|
app/radio/mfsk.py |
the whole modem: FEC, interleaver, Gray, Viterbi, modulator, streaming decoder |
app/radio/_tables/mfsk_varicode.py |
ENC/DEC — the 256-entry IZ8BLY varicode |
app/radio/fldigi_native.py |
registers mfsk16/mfsk32 as native modes |
app/radio/codec_backend.py |
native vs fldigi-bridge backend switch |
test_jt.py::test_mfsk_roundtrip |
end-to-end round-trip test (both modes) |
Runtime API:
from app.radio.mfsk import modulate, MfskDecoder
# TX: text -> s16le mono PCM at the given rate
pcm = modulate("CQ DE M0SUP K", mode="mfsk16", sr=48000) # bytes, <i2
# RX: streaming; call feed() with PCM (bytes s16le, or float ndarray)
out = []
dec = MfskDecoder(on_text=out.append, mode="mfsk16", sr=12000)
dec.feed(pcm_chunk) # repeat as audio arrives; characters arrive via on_text
Everything is NumPy at runtime; the only data artefact is the committed varicode table, transcribed once from fldigi's mfskvaricode.cxx and validated byte-exact against the compiled oracle.