FST4 and FST4W: native 4-GFSK weak-signal LDPC modes
A from-scratch, reproduction-grade specification of the in-repo FST4 (QSO) and FST4W (beacon) codec — 240-bit LDPC, CRC-24, 4-ary Gaussian FSK, a five-group sync frame — as implemented in app/radio/jtx/fst4.py.
Rafe project · app/radio/jtx/fst4.py, _tables/fst4_101_*.py, _tables/fst4_74_*.py · native encoder and decoder
Abstract
FST4 and FST4W are the WSJT-X low-signal HF modes designed for the LF/MF bands and for very slow, very weak QSO/beacon work. Both carry a small payload over a 4-ary continuous-phase Gaussian FSK waveform of exactly 160 symbols, of which 40 are known sync tones (five groups of eight) and 120 are data. FST4 is the two-way-QSO mode: it reuses the WSJT-X 77-bit message payload (the same one FT8/FT4 use), scrambles it, appends a 24-bit CRC, and protects the resulting 101 bits with a systematic LDPC(240, 101) code. FST4W is the WSPR-style beacon mode: a 50-bit callsign+grid+power payload, CRC-24, and LDPC(240, 74). Both codewords are 240 bits = 120 dibits = 120 data symbols; Gray-mapped to tones 0…3 and interleaved with the sync groups they become the 160-symbol frame. The keying rate equals the tone spacing (modulation index hmod = 1), and both are set by the T/R period: FST4/W runs at 15, 30, 60, 120 or 300-second periods (this implementation), giving baud rates from 16.67 Hz down to 0.558 Hz and occupied bandwidths of four tone-spacings.
This document specifies the real implementation in the repo — every constant, polynomial, generator and parity table is quoted from source with file:line citations — in enough detail to reimplement FST4/FST4W from nothing. The encoder is bit-exact against WSJT-X's own tools (§7); the decoder does a per-slot search over start-time and centre-frequency, a normalized sync metric, max-log soft demodulation, tanh-domain LDPC belief propagation, and a CRC-24 gate.
The payload codec (pack77) is shared with the whole WSJT-X family and is specified separately in wsjtx-message-packing.md; this document treats it as a black box and covers only the FST4-specific channel coding and waveform.
1. Motivation
The WSJT-X weak-signal modes are the backbone of modern amateur digital HF, and Rafe implements the whole family natively — no external jt9/wsprd binaries, pure NumPy at runtime (see ../native-digimodes.md). FST4 and FST4W are the slow end of that family: where FT8 spends 12.6 s per transmission, FST4 can spend up to 300 s, trading throughput for the ability to dig further into the noise on 630 m / 2200 m and other quiet, stable bands. FST4W is the FST4-era replacement for the classic WSPR beacon, using the same efficient LDPC coding instead of WSPR's old K=32 convolutional code.
The two modes are one codebase because they differ only in payload size and code book: FST4 → 77-bit message → LDPC(240,101); FST4W → 50-bit message → LDPC(240,74). Everything downstream — CRC-24, Gray map, sync frame, GFSK synthesis, and the decoder's search/demod/BP pipeline — is shared.
2. Background
2.1 4-GFSK (the waveform)
Both modes are 4-ary continuous-phase FSK with Gaussian pulse shaping — identical in construction to FT8's 8-GFSK but with four tones instead of eight. Each channel symbol is one of four tones; the instantaneous frequency is steered between tones by a Gaussian-filtered rectangular phase pulse so the phase is continuous and the spectrum compact. The modulation index is hmod = 1 (fst4.py), meaning adjacent tones are spaced by exactly one baud (Δf = R_sym), the minimum spacing for orthogonal coherent detection at index 1. The Gaussian pulse uses BT = 2.0 (fst4.py), the same time-bandwidth product FT8 uses, and spans three symbol periods (3·nsps samples). Because the pulse is wider than one symbol, successive symbols' pulses overlap-add in the phase domain, giving smooth inter-symbol transitions.
2.2 LDPC codes (the FEC)
A low-density parity-check code is a linear block code defined by a sparse parity-check matrix H (M × N, mostly zeros). A length-N vector c is a codeword iff Hc = 0 (mod 2). "Low density" means each row of H touches only a few bits and each column only a few checks, which is what makes iterative belief-propagation (BP) decoding practical and near-capacity. FST4/W both use N = 240-bit codewords:
- FST4: LDPC(240, 101) —
K = 101information bits,M = 139parity checks,N − K = 139parity bits. - FST4W: LDPC(240, 74) —
K = 74information bits,M = 166parity checks,166parity bits.
Both are systematic: the codeword is [message bits | parity bits], so the first K bits of a decoded codeword are the message directly. Encoding multiplies the message by a dense generator matrix G (stored as packed hex, §6.1); the decoder works from the sparse H expressed as two adjacency lists (§6.2).
2.3 The two variants at a glance
| FST4 (QSO) | FST4W (beacon) | |
|---|---|---|
| Payload | 77-bit WSJT-X message (pack77) |
50-bit call+grid+power |
| Scramble | XOR with RVEC (77 bits) |
— |
| CRC | CRC-24 | CRC-24 |
Message block K |
101 (= 77 + 24) | 74 (= 50 + 24) |
| LDPC | (240, 101), M=139 | (240, 74), M=166 |
| Codeword | 240 bits → 120 dibits | 240 bits → 120 dibits |
| Frame | 160 symbols (40 sync + 120 data) | 160 symbols (40 sync + 120 data) |
| Entry point | channel_symbols() |
channel_symbols_w() |
3. Signal, frame and symbol structure
3.1 The 160-symbol frame
Every FST4/W transmission is NSYM = 160 channel symbols (fst4.py), each a tone in {0, 1, 2, 3}. The 120 data symbols (from the 240-bit codeword) are split into four blocks of 30 and interleaved with five 8-tone sync groups, alternating ISYNC1/ISYNC2/ISYNC1/ISYNC2/ISYNC1 (_frame, fst4.py):
[ ISYNC1 | data[ 0: 30] | ISYNC2 | data[30: 60] | ISYNC1 |
data[60: 90] | ISYNC2 | data[90:120] | ISYNC1 ]
8 + 30 + 8 + 30 + 8 + 30 + 8 + 30 + 8 = 160
The sync groups therefore occupy symbol indices 0–7, 38–45, 76–83, 114–121, 152–159 (used verbatim by the decoder, fst4.py). The two 8-tone sync words are (fst4.py):
ISYNC1 = (0, 1, 3, 2, 1, 0, 2, 3)
ISYNC2 = (2, 3, 1, 0, 3, 2, 0, 1)
Note ISYNC2 is not a trivial permutation of ISYNC1; both are fixed 4-ary patterns chosen for good autocorrelation. The alternating pattern (1,2,1,2,1) spreads five sync anchors evenly through the frame so timing/frequency can be estimated even under fading.
3.2 Timing: period → symbol rate → bandwidth
The one free parameter is the T/R period (sub-mode). The samples-per-symbol table at the canonical 12000 Hz sample rate is (fst4.py):
NSPS = {15: 720, 30: 1680, 60: 3888, 120: 8200, 300: 21504}
Everything else derives from it. With sample_rate = 12000 and R_sym = Δf = sample_rate / nsps:
| period (s) | nsps |
baud = tone-spacing (Hz) | tsym (s) |
signal length 160·tsym (s) |
occupied BW ≈ 4·Δf (Hz) |
|---|---|---|---|---|---|
| 15 | 720 | 16.6667 | 0.06000 | 9.600 | 66.7 |
| 30 | 1680 | 7.14286 | 0.14000 | 22.400 | 28.6 |
| 60 | 3888 | 3.08642 | 0.32400 | 51.840 | 12.3 |
| 120 | 8200 | 1.46341 | 0.68333 | 109.333 | 5.9 |
| 300 | 21504 | 0.55804 | 1.79200 | 286.720 | 2.2 |
The signal is placed inside the period with a lead-in of lead_s = 0.5 s (encode_pcm, fst4.py) and the buffer zero-padded to exactly period · sample_rate samples, so the emitted slot fills the whole T/R window. FST4's default QSO sub-mode in Rafe is 60 s at f0 = 1500 Hz; FST4W's default is 120 s (app/radio/wsjtx.py).
Sub-modes 900 s and 1800 s are defined by the WSJT-X protocol but are not present in this implementation's
NSPSmap; only 15/30/60/120/300 are supported. Adding them is a one-line table extension (plus matching WSJT-Xnspsvalues) — no other code changes.
3.3 Tone frequencies and the GFSK pulse
The four tone frequencies are centred on the reported "dial + audio" offset f0, spaced by Δf = sample_rate / nsps and offset so f0 sits at the geometric centre of the 4-tone bank (synth, fst4.py; _data_syms, fst4.py):
f_tone(t) = f0 + (t − 1.5)·Δf , t ∈ {0,1,2,3}
= f0 − 1.5Δf , f0 − 0.5Δf , f0 + 0.5Δf , f0 + 1.5Δf
The Gaussian frequency-shaping pulse is the shared FT8 pulse (app/radio/ftx/encode.py), reused by FST4 with BT = 2.0:
_gfsk_pulse(n, bt):
i = arange(3n); t = i/n − 1.5; k = GFSK_CONST_K · bt
pulse = (erf(k·(t+0.5)) − erf(k·(t−0.5))) / 2
where GFSK_CONST_K = 5.336446 = π·√(2/ln2) (app/radio/ftx/constants.py). The pulse has length 3·nsps, unit area, and is the difference of two error functions — i.e. a rectangular symbol convolved with a Gaussian of the given BT. See §4.5 for how it drives the phase.
4. Encode — step by step
The FST4 encode path is channel_symbols() (fst4.py); FST4W is channel_symbols_w() (fst4.py); both feed synth() (fst4.py) via encode_pcm() (fst4.py).
4.1 Payload bits
FST4 (101-bit block). The 77-bit message is produced by the shared packer pack77 (10 bytes = 80 bits, MSB-first, the top 77 used), unpacked to a bit list by _msg77_bits (fst4.py). Those 77 bits are scrambled by XOR with the fixed 77-element vector RVEC (fst4.py):
RVEC = (0,1,0,0,1,0,1,0,0,1,0,1,1,1,1,0,1,0,0,0,1,0,0,1,1,0,
1,1,0,1,0,0,1,0,1,1,0,0,0,0,1,0,0,0,1,0,1,0,0,1,1,1,
1,0,0,1,0,1,0,1,0,1,0,1,1,0,1,1,1,1,1,0,0,0,1,0,1) # len 77
The scrambled bits are written into msgbits[0:77] of a zeroed 101-bit array; msgbits[77:101] are left 0 for the CRC step.
FST4W (74-bit block). No pack77, no scramble. channel_symbols_w builds a 50-bit WSPR-style Type-1 field itself (fst4.py):
m50 = pack28(call) [28 bits] ‖ packgrid(grid4) [15 bits]
‖ idbm [5 bits] ‖ 00 [2 type bits]
idbm = round(dBm · 3 / 10) # power quantized to a 5-bit index
using _pack28 / _packgrid / HashStore from the shared ftx.pack. It rejects the message (returns None) unless exactly three whitespace tokens are present, pack28 succeeds, and igrid4 ≤ 32399 (a 4-char Maidenhead grid, not a report). m50 occupies msgbits[0:50] of a zeroed 74-bit array.
The docstring notes this is the FT8-style
pack28/packgrid, not WSPR's own callsign packing. Interop is therefore with WSJT-X FST4W, which likewise uses the modern 50-bit source encoding.
4.2 CRC-24
A 24-bit CRC is computed over the message block (with the 24 CRC positions still zero) and written into those positions (fst4.py for FST4; fst4.py for FST4W). The generator polynomial is 0x100065B (25 bits, MSB-first), stored bit-expanded (fst4.py):
CRC24_P = (1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,1,0,1,1,0,1,1) # 0x100065B
The routine (_crc24, fst4.py) is a 25-stage bit-serial LFSR:
_crc24(mc, length): # length = 101 (FST4) or 74 (FST4W)
r = mc[0:25] # preload first 25 bits
for i in 0 .. length-25: # consume the remaining message bits
r[24] = mc[i+24] # shift next bit into stage 24
if r[0]: r = r XOR CRC24_P # 25-bit polynomial reduction
r = r[1:] + [r[0]] # cyclic left-rotate by one
return r[0:24] # low 24 bits are the CRC
Because the CRC slots are zero during the computation, this is exactly a CRC-24 over the K−24 message bits with 24 trailing zeros — the WSJT-X get_crc24 convention. On decode the identical routine is re-run over message[:K−24] + [0]*24 and compared bit-for-bit against the recovered CRC field (fst4.py).
4.3 LDPC systematic encode
The K-bit block (message ‖ CRC) is encoded to a 240-bit codeword by appending M parity bits, each the mod-2 dot product of the message with a generator row (_encode240_101, fst4.py; _encode240_74, fst4.py):
FST4 : for i in 0..138: parity[i] = (Σ_{j<101} msgbits[j] · GEN[i][j]) mod 2
codeword = msgbits[0:101] ‖ parity[0:139] # 240 bits
FST4W: for i in 0..165: parity[i] = (Σ_{j<74} msgbits[j] · GEN74[i][j]) mod 2
codeword = msgbits[0:74] ‖ parity[0:166] # 240 bits
The generator rows are unpacked once at import from the hex tables by _build_gen (fst4.py): 4 bits per hex char, MSB-first, truncated to K bits per row. See §6.1 for the tables. The result is systematic — codeword[0:K] is the message unchanged.
4.4 Dibit → Gray → interleave with sync
The 240 codeword bits are taken two at a time (MSB first) as an index is = 2·c[2i] + c[2i+1] ∈ {0,1,2,3}, then Gray-mapped to a tone (fst4.py):
_GRAY = (0, 1, 3, 2) # is → tone : 00→0, 01→1, 10→3, 11→2
itmp[i] = _GRAY[is] # i = 0 .. 119 → 120 data tones
The 120 data tones are then interleaved with the five sync groups by _frame (§3.1) to give the 160-tone symbol vector. The FST4 path inlines this (fst4.py); FST4W calls _gray_frame then _frame (fst4.py).
Golden vector.
channel_symbols("CQ K1ABC FN42")yields the full 160-tone sequence below (FST4_CQintest_jt.py), the exact reference the test suite pins against:0,1,3,2,1,0,2,3,1,0,3,3,1,1,2,3,3,0,3,1,3,1,1,0,2,2,2,1,1,3,1,1,1,3,0,2,2,1,
2,3,1,0,3,2,0,1,2,2,3,3,1,2,3,3,1,1,0,2,1,0,3,3,3,1,3,1,1,3,0,3,3,2,0,3,0,1, 0,1,3,2,1,0,2,3,3,2,1,1,3,2,2,0,2,1,0,3,3,0,2,1,0,3,0,3,2,2,3,3,1,1,1,0,0,0, 2,3,1,0,3,2,0,1,0,2,3,2,0,2,1,3,0,2,2,2,3,2,3,1,0,3,1,2,1,3,2,2,1,1,3,1,0,1, 0,1,3,2,1,0,2,3
4.5 GFSK synthesis
synth(tones, f0, sample_rate, period) (fst4.py) turns the 160 tones into a real waveform. The construction is FT8-identical:
nsps = NSPS[period]; hmod = 1; tsym = nsps/sample_rate
pulse = _gfsk_pulse(nsps, 2.0) # length 3·nsps, unit area
dphi_peak = 2π·hmod/nsps # rad/sample per unit tone
dphi = zeros((160+2)·nsps)
for j, t in enumerate(tones): # overlap-add the phase pulses
dphi[j·nsps : j·nsps + 3·nsps] += dphi_peak · pulse · t
dphi += 2π·(f0 − 1.5·hmod/tsym)/sample_rate # centre the 4-tone bank on f0
seg = dphi[nsps : 161·nsps] # drop the 1-symbol pulse ramp
phi = cumsum(seg) (phi[0]=0) # integrate freq → phase
signal = sin(phi) # 160·nsps float32 samples
Each symbol contributes a Gaussian phase pulse scaled by its tone value t; because the pulse spans three symbols the contributions overlap-add, so the phase is continuous (constant-envelope, no clicks). The constant term places tone 0 at f0 − 1.5Δf and each unit of t adds Δf (since hmod/tsym = Δf), realizing the tone map of §3.3. encode_pcm (fst4.py) then embeds signal at a 0.5 s lead into a period·sample_rate-sample buffer, clips to ±1, and quantizes to little-endian int16 PCM.
5. Decode — step by step
decode_slot(pcm, sample_rate=12000, period=60, f_lo=200, f_hi=2700, wspr=False) (fst4.py) recovers zero or one message from a full slot. The pipeline is: coarse (t0, f0) search → f0 refine → t0 refine → soft demod → LDPC BP → CRC gate → unpack.
5.1 Coarse synchronization search
Input PCM (bytes or array) is normalized to floats. With Δf = sample_rate/nsps, the decoder builds a 4-tone matched-filter basis for a candidate f0:
basis[t, n] = exp(−j·2π·(f0 − 1.5Δf + t·Δf)·n / sample_rate), t=0..3, n=0..nsps−1
It scans start time t0 = 0 … 1.0 s in steps of nsps//4 (a quarter-symbol), and centre frequency f0 = f_lo … f_hi in steps of Δf (one bin), and for each candidate evaluates a normalized sync metric over the five sync groups (fst4.py):
sync_groups = [(0,ISYNC1),(38,ISYNC2),(76,ISYNC1),(114,ISYNC2),(152,ISYNC1)]
sc = Σ_groups Σ_{k=0..7} p[ ISYNC[k] ] / ( Σ p + 1e−12 )
where p[0:4] = |basis · block|² is the 4-tone power of the nsps-sample block at that sync position. Dividing by the total block power makes the metric a per-symbol fraction of energy landing on the expected sync tone — bounded in [0,1] per symbol and, crucially, robust to silent/faded blocks (a silent block contributes ≈0, not a spurious peak). The best-scoring (sc, t0, f0) is kept.
5.2 Fine frequency and timing
Two refinements follow, both maximizing the un-normalized sync power (raw matched-filter energy on the expected sync tones):
- f0 refine (
fst4.py): searchf0 ± ΔfinΔf/6steps. - t0 refine (
fst4.py): at the refinedf0, searcht0 ± nsps/2innsps/16steps.
This yields sub-bin frequency (≈Δf/6) and sub-symbol timing (≈nsps/16) alignment — the same matched-filter refine pattern shared with the JT65 decoder.
5.3 Soft symbol demodulation (max-log LLRs)
_data_syms (fst4.py) matched-filters the 120 data symbols (all positions that are not in a sync group), returning a 120×4 array of tone powers p. Each symbol yields two LLRs via a max-log rule on s = log(p) (fst4.py):
s = log(p + 1e−12)
llr[2i] = max(s[3], s[2]) − max(s[0], s[1]) # b0 (dibit MSB)
llr[2i+1] = max(s[1], s[2]) − max(s[0], s[3]) # b1 (dibit LSB)
These follow directly from the inverse Gray map INV_GRAY = (0,1,3,2) (self-inverse of _GRAY, fst4.py): b0 = 1 for tones {3,2}, b0 = 0 for {0,1}; b1 = 1 for tones {1,2}, b1 = 0 for {0,3}. The 240 LLRs are then scaled to a fixed spread, llr = llr / std(llr) · 2.0 (fst4.py), so the BP tanh non-linearity operates in a sensible dynamic range.
5.4 LDPC belief-propagation
bp_decode(llr, MN, NM, NRW, N, M, max_iters=40) (fst4.py) is a sum-product (tanh-domain) decoder driven by the two sparse adjacency lists of §6.2:
MN[n]— the (exactly 3) checks touching variable noden(1-based).NM[m]— the variable nodes touching checkm(1-based, zero-padded).NRW[m]— the degree (row weight) of checkm.
Per iteration:
1. hard decision: cw[n] = (llr[n] + Σ_j tov[n][j] > 0)
2. syndrome: errs = Σ_m ( XOR_{k<NRW[m]} cw[NM[m][k]−1] )
if errs == 0: return cw # valid codeword
track the best (fewest-errs) cw seen
3. bits→checks: toc[m][k] = tanh( −T / 2 ), T = llr[n] + Σ_{j: MN[n][j]≠m} tov[n][j]
4. checks→bits: tov[n][j] = −2·artanh( Π_{k: NM[m][k]≠n} toc[m][k] ) (m = MN[n][j])
N = 240 always; M = 139 (FST4) or 166 (FST4W). tanh is clipped to ±20 (_fast_tanh, fst4.py) and the check-node product clamped to ±0.999999 before artanh for numerical safety. After ≤40 iterations it returns the first codeword with zero syndrome, or the lowest-syndrome candidate seen; the caller rejects anything with errs ≠ 0 (fst4.py).
5.5 CRC-24 gate and message recovery
The systematic property means cw[0:K] is the message block. The decoder:
- re-checks CRC-24 over
message[:K−24] + [0]*24against the recovered CRC field; mismatch → reject (fst4.py). This is the true error detector — LDPC can converge to a wrong codeword under noise, and only the CRC catches that. - FST4 (
fst4.py): un-scramblem77[i] = msgbits[i] ⊕ RVEC[i], pack the 77 bits back into 10 bytes MSB-first, and callunpack77. - FST4W (
fst4.py): readn28,igrid4,idbmfrom the bit slices[0:28],[28:43],[43:48];_unpack28/_unpackgrid; recover power asround(idbm · 10 / 3); format"CALL GRID dBm".
On success the decoder returns one dict: {"snr": <coarse sync score>, "dt": t0/sample_rate, "df": f0, "msg": <text>} (fst4.py).
snrcaveat. The reportedsnris the coarse sync metric of §5.1, not a calibrated dB SNR. It is a relative sync-quality figure, adequate for ranking but not comparable to WSJT-X's dB scale.
6. Constants and tables
All numeric constants used above are quoted at their source. This section gives the large matrices in full — every generator row and every parity-table entry — along with their exact shapes and formats, so the mode can be reproduced without consulting any external file. They are direct transcriptions of the WSJT-X Fortran tables (each source file's docstring cites its origin, e.g. ldpc_240_101_generator.f90); copy them verbatim.
6.1 LDPC generator matrices (encode)
FST4 — app/radio/jtx/_tables/fst4_101_gen.py. GEN_HEX is a list of 139 strings (= M), each 26 hex chars = 104 bits; _build_gen expands each to bits (MSB-first, 4 bits/char) and keeps the first 101 (= K). Shape after expansion: 139 × 101. Parity bit i = message · GEN[i] (mod 2). In full:
GEN_HEX = [ # 139 rows; 26 hex chars = 104 bits each; keep first 101 (K)
"e28df133efbc554bcd30eb1828", "b1adf97787f81b4ac02e0caff8", "e70c43adce5036f847af367560",
"c26663f7f7acafdf5abacb6f30", "eba93204ddfa3bcf994aea8998", "126b51e33c6a740afa0d5ce990",
"b41a1569e6fede1f2f5395cb68", "1d3af0bb43fddbc670a291cc70", "e0aebd9921e2c9e1d453ffccb0",
"897d1370f0df94b8b27a5e4fb8", "5e97539338003b13fa8198ad38", "7276b87da4a4d777e2752fdd48",
"989888bd3a85835e2bc6a560f8", "7ec4f4a56199ab0a8d6e102478", "207007665090258782d1b38a98",
"1ea1f61cd7f0b7eed7dd346ab8", "08f150b27c7f18a027783de0e8", "d42324a4e21b62d548d7865858",
"2e029656269d4fe46e167d21d0", "7d84acb7737b0ca6b6f2ef5eb0", "6674ca04528ad4782bf5e15248",
"118ce9825f563ae4963af7a0b0", "fb06248cc985e314b1b36ccd38", "1c478b7a5aec7e1cfc9c24eb70",
"185a0f06a84f7f4f484c455020", "98b840a3a70688cd58588e3e30", "cfb7719de83a3baf582e5b2aa0",
"9d8cc6b5a01fdbfa307a769048", "ed776a728ca162d6fcc8996760", "8d2b068128dfb2f8d22c79db50",
"bd2ba50007789ffb7324aa9190", "fd95008fe88812025e78065610", "3027849be8e99f9ef68eac1020",
"88574e1ea39d87414b15e803a8", "89365b330e76e6dde740dced08", "c83f37b913ed0f6b802aaf21d8",
"bdca7c1959caa7488b7eb13030", "794e0b4888e1ef42992287dd98", "526ac87fbaa790c6cd58864e08",
"940518ba1a51c1da55bc8b2d70", "59c5e51ebfbd02ab30ff822378", "c81fff87866e04f8f3948c7f10",
"7913513f3e2a3c0f76b69f6d68", "e43cc04da189c44803c4f740a0", "fdca7c1959ca85488b7eb13030",
"95b07fce9b7b1bf4f057ca61b8", "d7db48a86691a0c0c9305aac90", "0d50bf79a59464597c43ba8058",
"4a9c34b23fd5eaff8c9dc215e0", "3d5305a6f0427938eeb9d1c118", "55d8b6b58039f7a3a2d592a900",
"784f349ecb74c4abbdbb073b90", "5973bbb2205f9d6a5c9a55c238", "5d2ee61006fec94f69f6b0f460",
"9e1f52ef1e6589990dd0ce0cc8", "85b7b48f4b45775c9f8a36cc90", "ae1d6a0171168f6d70804b79f8",
"a467aa9aa6cdc7094677c730d8", "dcf2f56c9ae20fb57e89b916d0", "3ae98d26ae96ea714c1a5146d0",
"103c89581446805b8c71b2e638", "6783f3dfec835dd4e92131cc20", "52f88428c50f12c55876f7d8a8",
"51fcb0e56a22fa3b7140aeaa80", "07c54871155603e65325f66cd8", "a8dd4fac47a113ee5706eef180",
"f6cdc6f4cc1fa7e4db15bf86f8", "2e1c6a0171168f6d70c04a79f8", "2a90ab82bef6424db981752dc8",
"845a1db59c193249d937e889d0", "a929d379f1769cb4baa4e41e90", "0c2a5829548d82223d6f566d48",
"420087bc5c4e2f5bc139ad0220", "6df8d880ae7209fe52c69ede00", "dfbdcef29a985fd40d052d1a88",
"8567fc332342b1ed8408f5fa00", "c908feb4e1866a24ca0c702a08", "645f5ee59f9f64fd43a5f2ec30",
"bee56991e877baf3e9cf11b770", "649ea2e4194ca51be28abf3430", "90e7394c551bd58d00686d5420",
"4e3cf731f8f89e8414214afaf0", "dcbf16aa8180a7712571e94f98", "9b456c015999c52b7fbd1ab390",
"397ab76924659c4b8b3be4ac58", "4f5038c4f9da4b02bdfa178278", "4892fada978c98dd4fd363c450",
"6c8af64b426bc474431c110c98", "84a553be5ef0e57390a5af05b0", "bed4a9347c9a2064f6d63ac0f8",
"d973bbb2605f9d6a5c9a57c238", "1e3bee9a99fe10d3864ee669d8", "a590771ff185d807cb32f46000",
"9a498fc4b549d81c625f80fc90", "28b3e72878aadee7e0e2617950", "96ce025d621a91396aa8f3ec20",
"4f5a77becf838a590d6d406ea8", "52d3856dfb9fe78012f10e25c0", "b45323c2b28b4752ca0675d2e0",
"3bae5a8452a785beb35851ad18", "65098832d20d915e75bea336e8", "5eb6f3c331098e8c0fbfa3aee0",
"ef19d974a25540c8998fbf1df0", "403ea58feff08cf92d5cacc780", "6ba93204ddfa7bcb994aea8998",
"653909166aa7bead4bd9c90020", "089cb20e639bc5a44da66f17c0", "10f803949961359e994f5ade88",
"15b7ec1e6106cd55ef7d996590", "c99e99de9d85d2b999a17a95d8", "ca3e161b97148bac6dd28a6178",
"e1ab199c992cb4c22aee115358", "ea8a4d0e96d3d9f827899b6d88", "8af4992d60223f021569a8ab60",
"5087771abceb87a6d872291fe8", "d045e0812e217bb7bbdac92f30", "ccccd78ae5fa6e191f21c06908",
"54545f37df6fed4734ef6509b0", "b0780327d899cbc03d95a81a48", "a4229c31f2b85e44a322273d50",
"d182ab001c2085ea7be26a20d0", "1a82c30b4fba7dfaafb8d287a8", "d974fba598e7fb0630c1587db0",
"b5c078a8cbab3e73728659ea20", "626bbf9eed1a8715c3a7d38f60", "c1efe9aa67130865fda93d8be8",
"d39796dbce155df6306e7b77c0", "c7e7c1f032d7209b4549e84aa8", "d5799b30a1605baf6b9cd04960",
"0baf2d21051a926dfd87046d70", "da8bf7d1e305c499b573c02cc8", "0ccaa7fffb9ae3e42dd0688328",
"b951b62e18f5290ac13c195130", "79b006f001961fb233be80d0e8", "56637b6dedfd6e050f06404a48",
"e0c4bf71a15597523bbd57bde0", "1312231ffa04426a34a8fab038", "db5f6f0455d24b8358d1cbc3d8",
"d559e31b34d21f48e1f501af30",
]
FST4W — app/radio/jtx/_tables/fst4_74_gen.py. GEN_HEX is 166 strings (= M), each 19 hex chars = 76 bits, truncated to the first 74 (= K). Shape after expansion: 166 × 74. In full:
GEN_HEX = [ # 166 rows; 19 hex chars = 76 bits each; keep first 74 (K)
"de8b3201e3c59f55a14", "2e06d352ebc5b74c4fc", "2e16d6cf5a725c3244c",
"84f5587edca6d777de4", "e152b1e2b5965093ecc", "244b4828a2ccf2b5f58",
"5fbbaade810e123c730", "6b7e92a99a918df3d44", "bbcec6a63ab757a7278",
"f5f3f0b89a21ceccdb0", "a248c5f1ec2bc816290", "c84bbad839a5fe76d0c",
"ad724129bbf4c7f4570", "91adb56e7623a2575cc", "cbe995bdf156df2c9e4",
"92ff6ea492c08c150e0", "c4ddbe5a02f6a933384", "d2e9befc131dc483858",
"68567543d1eebcb080c", "21fa61d559f9baf6abc", "911c4fbbafc72e3db28",
"7c0b534af4b7d583d50", "12ce371b90ee9dfe72c", "15a604148872e251ec4",
"3a3c9f3eb0e0f96edc0", "705919ffb636f96b390", "43daaaa8163d6bc2bd4",
"96e11ea798b74b10e98", "811150609c9dee8230c", "be713f85ab34380f4b0",
"5a02c4abaaccb8f24c4", "67bdebb8863d04768cc", "5a449cd90c3dbdfe844",
"9c7a54d1c4ef7418b84", "cd82fefaaf9cd28cd8c", "ca47e847fabb0054a38",
"f0b30cef6aab9e37f98", "d948d912fbcc1708710", "cce1a7b355053d98270",
"4cf227c225a9063dd48", "2db92612e9ba1418e24", "3d215c04c762c3d6a28",
"77de65500b5624ceb0c", "fd1a1df99ded2fb9d88", "2a19392c71438410fb8",
"a9b486a9d26ed579754", "b698d244ac78d97a498", "3d7975b74d727a5e704",
"38094225a2bce0e1940", "3d3e58fae40fac342b0", "7732e839a066e337714",
"69356c082b7753a47b0", "3e868a55dc403a802ac", "a0157a14a6bf7fdbbcc",
"1ab628e11a7ab4a7c44", "9da3a2247d7449052f4", "199a8a7b114816b97f4",
"b1c5cde2542061704cc", "432fa8d3a153eafbdc8", "c4ece7e400d8a89c448",
"316ecf74e4b983f007c", "6a14fa8e713bb5e8adc", "da4b957ded8374e3640",
"0a804dba7c7e4533300", "52c342ed033f86580e0", "1667da8d6fcf4272470",
"da2f7038d550fa88d8c", "685bcbab1d9dd2c2a44", "4c93008b3156b3636bc",
"726998d6327ac797c3c", "44ece7e400d8a8dc448", "01f9add00dfe823a948",
"dbb95f5ce9e371ad720", "fc746ee5c76827a8728", "b25408029506467f4b4",
"9b5c9219e21126b7cf8", "39ae9f48ba9d1a24f04", "7de2699623eb507f938",
"b9c6e903ee91dd32934", "397510d2c6cb5e81de8", "20157a14aebf7fdbbec",
"067f76ea5817a465980", "9248f3cea0869feb994", "23cde2678004ebe5f80",
"5b81fe6848f58e3cfa8", "a9099ace96bff092904", "4afa4b0802b33215438",
"f4f740396b030360858", "fc613f77a35ee1163b8", "1a4dc27d7e8cc835ff4",
"e9b056f153b39def7ec", "b62eb777a2f953c7efc", "388ae4de514b62d238c",
"891529af40e85317160", "474f1afeb724dbd2ba8", "11d70880fd88fdd307c",
"29f26a3acb76e6a517c", "df3e902ff9cadcf776c", "e3c42da8445965c09f0",
"ce277a6aeccc316dc58", "4d7841fb71543abd9b8", "e63230d2d465fb44750",
"b6e11fa798b74b14e98", "05f189d37c5616547b4", "ebdb51a81d1e883baa8",
"bf5bc736663bcd53ae0", "2f8d1cc0936142c08fc", "436b22fc36d917b6928",
"044b482822ccf2b5f58", "37b2e839a066e3b7714", "2a9b4b765c581f0c51c",
"10a7d44cecf8e6628dc", "ad95f02df6d5502dd4c", "bbd34f8afd63deaf564",
"cabddfeb01fce632788", "66b57babeedd6124114", "7813e0454fbd462be8c",
"b6105ed6f01ea621d04", "9f68bbcec679d1c088c", "673da96e414fc7a0f40",
"5568adb935e11084abc", "f6dd308de5e5c4f6fb0", "3b49e80d40ae596c7b4",
"a3cde2478004ebe5f80", "dd8e4f309e919d5ed94", "5a4020d387757d7bc28",
"64f9e02ae32362a255c", "630d5942d392334b0dc", "0bd7e9f4229b2dee210",
"bca549a9467d3a2550c", "2fef7b1f578c5e28d04", "f35e0fdda1be4b3b35c",
"69ed575e7cc537d2394", "7dfdcfbfd5ef3093680", "b3b2921af97f251d328",
"5622d0fe90363522364", "fcd4fc7fa04a69d2ac4", "1119ea451502ed9ab34",
"970ee777ec969a41754", "688d14f8afec76783dc", "4d0b8a1028578407420",
"d3d2138d9fa268da3e8", "df1bdbff898e006394c", "8ac478a916bb0b77684",
"93881997428e2c17a94", "4aa510e746245e90c08", "e00cb8543f85a5d58b8",
"9100d8eb74031073044", "38710e4235bd1e4003c", "6aef311cac4c4dccfd4",
"58430f577f51c36b3e0", "12082fa5d4268a95b4c", "7a7435a0aca071e64d0",
"cd8250ebadc95de15b0", "debad40c852e99d64dc", "4e6caa5e7c86efef748",
"a5d4cbb97e726e3c580", "7e3a0a2c73ef8553640", "b60bfc2fd2bd8f530dc",
"32dbef097a5f84b0318", "4cc7c1cf434300be380", "896840945be8eabf7f0",
"36c9b10ec694819a0a0", "349f46a799ef95a47c8", "9bdcd4ce2563e560b74",
"b19fcd7111a335c52ec",
]
6.2 LDPC parity tables (decode)
The parity-check matrix H is stored as three Python lists in fst4_101_parity.py (FST4) and fst4_74_parity.py (FST4W). All indices are 1-based (Fortran heritage; the decoder subtracts 1). Verified shapes:
| table | FST4 (fst4_101_parity.py) |
FST4W (fst4_74_parity.py) |
|---|---|---|
MN (variable → checks) |
240 rows × 3 | 240 rows × 3 |
NM (check → variables) |
139 rows, padded to 6 (0-fill) | 166 rows, padded to 5 (0-fill) |
NRW (check degrees) |
139 ints (values 5 or 6) | 166 ints (values 4 or 5) |
ΣNRW (total edges) |
720 (= 3·240) | 720 (= 3·240) |
MN—MN[n]lists the 3 check-node numbers incident on variable noden. Every variable node has degree exactly 3 (so the codeword bit participates in 3 parity equations).NM—NM[m]lists the variable nodes incident on checkm, padded with 0 to a fixed width (6 for FST4, 5 for FST4W).NRW[m]gives the real degree so the decoder ignores the padding.NRW— the row weights: FST4 uses values 5 or 6 (139 ints), FST4W 4 or 5 (166 ints); each list sums to 720 = 3·240 total Tanner-graph edges.
The complete tables follow. FST4 — fst4_101_parity.py:
MN = [ # 240 variable nodes, each degree 3 (1-based check indices)
[57, 100, 134], [56, 99, 136], [1, 12, 15], [2, 23, 72], [3, 133, 137], [4, 93, 125],
[5, 68, 139], [6, 38, 55], [7, 40, 78], [8, 30, 84], [9, 17, 122], [10, 34, 95],
[11, 36, 138], [13, 90, 132], [14, 50, 117], [16, 57, 83], [18, 22, 121], [19, 60, 89],
[20, 98, 107], [21, 37, 61], [24, 26, 75], [25, 88, 115], [27, 49, 127], [28, 74, 119],
[29, 111, 114], [31, 91, 129], [32, 96, 104], [30, 33, 130], [35, 65, 135], [41, 42, 87],
[44, 108, 131], [45, 94, 101], [45, 46, 97], [47, 102, 134], [48, 64, 104], [19, 51, 116],
[20, 52, 67], [53, 104, 113], [12, 54, 103], [58, 66, 88], [62, 80, 124], [63, 70, 71],
[73, 114, 123], [76, 85, 128], [77, 106, 109], [46, 79, 126], [61, 81, 110], [82, 92, 120],
[86, 105, 112], [66, 100, 118], [23, 51, 136], [1, 40, 53], [2, 73, 81], [3, 63, 130],
[4, 68, 136], [5, 60, 78], [6, 72, 131], [7, 115, 124], [8, 89, 120], [9, 15, 44],
[10, 22, 93], [11, 49, 100], [13, 55, 80], [14, 76, 95], [16, 54, 111], [17, 41, 110],
[18, 69, 139], [21, 24, 116], [25, 39, 71], [26, 69, 90], [27, 101, 133], [28, 64, 126],
[29, 94, 103], [31, 56, 57], [32, 91, 102], [33, 35, 129], [34, 47, 128], [36, 86, 117],
[37, 74, 75], [38, 79, 106], [42, 82, 123], [43, 77, 99], [48, 70, 92], [50, 109, 118],
[52, 112, 119], [58, 62, 108], [59, 84, 134], [57, 65, 122], [67, 97, 113], [83, 127, 135],
[85, 121, 125], [87, 132, 137], [96, 98, 105], [73, 107, 138], [1, 83, 89], [2, 41, 70],
[3, 35, 131], [4, 111, 128], [5, 29, 99], [6, 25, 31], [7, 19, 96], [1, 39, 110],
[2, 7, 117], [3, 49, 109], [4, 81, 96], [5, 100, 108], [6, 51, 124], [2, 20, 132],
[8, 80, 137], [9, 56, 67], [10, 63, 102], [11, 16, 101], [12, 115, 122], [13, 32, 128],
[14, 15, 130], [14, 70, 99], [11, 51, 69], [17, 89, 105], [18, 83, 99], [19, 44, 79],
[20, 106, 133], [10, 21, 123], [22, 23, 61], [16, 22, 60], [24, 38, 114], [25, 37, 42],
[26, 43, 52], [27, 68, 71], [28, 65, 139], [29, 62, 69], [30, 92, 126], [31, 78, 123],
[13, 44, 78], [33, 40, 120], [7, 34, 119], [4, 35, 77], [12, 36, 52], [25, 98, 136],
[5, 24, 133], [1, 80, 91], [33, 96, 97], [34, 41, 91], [32, 37, 117], [26, 72, 125],
[19, 65, 75], [45, 131, 136], [46, 55, 70], [47, 48, 50], [6, 48, 94], [3, 74, 79],
[39, 50, 126], [23, 118, 127], [21, 36, 113], [53, 77, 134], [30, 54, 55], [17, 46, 135],
[9, 92, 102], [57, 85, 87], [58, 125, 138], [59, 76, 93], [60, 66, 107], [47, 132, 138],
[29, 85, 131], [43, 73, 108], [64, 75, 129], [28, 38, 53], [61, 106, 122], [56, 71, 114],
[27, 57, 120], [62, 67, 130], [54, 104, 118], [8, 68, 115], [72, 86, 111], [73, 74, 94],
[49, 105, 113], [42, 86, 121], [40, 59, 109], [35, 88, 95], [31, 107, 112], [58, 64, 87],
[68, 79, 104], [1, 5, 121], [15, 82, 93], [18, 88, 116], [82, 84, 119], [7, 71, 103],
[4, 80, 94], [63, 81, 84], [66, 76, 137], [83, 124, 129], [90, 112, 116], [89, 111, 134],
[6, 21, 120], [3, 16, 25], [12, 28, 131], [45, 95, 110], [17, 93, 124], [97, 121, 127],
[98, 103, 135], [8, 99, 138], [41, 101, 139], [13, 24, 105], [14, 53, 107], [10, 64, 98],
[11, 35, 78], [90, 100, 103], [9, 72, 101], [18, 74, 92], [15, 73, 87], [2, 88, 113],
[20, 55, 85], [19, 67, 110], [26, 27, 95], [22, 50, 114], [29, 49, 81], [32, 52, 83],
[30, 37, 77], [39, 128, 135], [23, 128, 130], [36, 76, 126], [33, 132, 139], [34, 89, 118],
[38, 58, 127], [31, 54, 125], [40, 70, 75], [41, 109, 116], [43, 60, 63], [44, 84, 86],
[42, 47, 62], [45, 82, 90], [43, 46, 91], [48, 112, 122], [51, 102, 133], [59, 61, 108],
[65, 117, 137], [56, 66, 96], [59, 69, 104], [39, 69, 119], [97, 115, 123], [106, 111, 129],
]
NM = [ # 139 checks; padded to width 6 with 0 (real degree = NRW[m])
[3, 52, 95, 102, 140, 182], [4, 53, 96, 103, 108, 210], [5, 54, 97, 104, 150, 194],
[6, 55, 98, 105, 136, 187], [7, 56, 99, 106, 139, 182], [8, 57, 100, 107, 149, 193],
[9, 58, 101, 103, 135, 186], [10, 59, 109, 172, 200, 0], [11, 60, 110, 157, 207, 0],
[12, 61, 111, 122, 204, 0], [13, 62, 112, 117, 205, 0], [3, 39, 113, 137, 195, 0],
[14, 63, 114, 133, 202, 0], [15, 64, 115, 116, 203, 0], [3, 60, 115, 183, 209, 0],
[16, 65, 112, 124, 194, 0], [11, 66, 118, 156, 197, 0], [17, 67, 119, 184, 208, 0],
[18, 36, 101, 120, 145, 212], [19, 37, 108, 121, 211, 0], [20, 68, 122, 153, 193, 0],
[17, 61, 123, 124, 214, 0], [4, 51, 123, 152, 219, 0], [21, 68, 125, 139, 202, 0],
[22, 69, 100, 126, 138, 194], [21, 70, 127, 144, 213, 0], [23, 71, 128, 169, 213, 0],
[24, 72, 129, 166, 195, 0], [25, 73, 99, 130, 163, 215], [10, 28, 131, 155, 217, 0],
[26, 74, 100, 132, 179, 224], [27, 75, 114, 143, 216, 0], [28, 76, 134, 141, 221, 0],
[12, 77, 135, 142, 222, 0], [29, 76, 97, 136, 178, 205], [13, 78, 137, 153, 220, 0],
[20, 79, 126, 143, 217, 0], [8, 80, 125, 166, 223, 0], [69, 102, 151, 218, 238, 0],
[9, 52, 134, 177, 225, 0], [30, 66, 96, 142, 201, 226], [30, 81, 126, 176, 229, 0],
[82, 127, 164, 227, 231, 0], [31, 60, 120, 133, 228, 0], [32, 33, 146, 196, 230, 0],
[33, 46, 147, 156, 231, 0], [34, 77, 148, 162, 229, 0], [35, 83, 148, 149, 232, 0],
[23, 62, 104, 175, 215, 0], [15, 84, 148, 151, 214, 0], [36, 51, 107, 117, 233, 0],
[37, 85, 127, 137, 216, 0], [38, 52, 154, 166, 203, 0], [39, 65, 155, 171, 224, 0],
[8, 63, 147, 155, 211, 0], [2, 74, 110, 168, 236, 0], [1, 16, 74, 88, 158, 169],
[40, 86, 159, 180, 223, 0], [87, 160, 177, 234, 237, 0], [18, 56, 124, 161, 227, 0],
[20, 47, 123, 167, 234, 0], [41, 86, 130, 170, 229, 0], [42, 54, 111, 188, 227, 0],
[35, 72, 165, 180, 204, 0], [29, 88, 129, 145, 235, 0], [40, 50, 161, 189, 236, 0],
[37, 89, 110, 170, 212, 0], [7, 55, 128, 172, 181, 0], [67, 70, 117, 130, 237, 238],
[42, 83, 96, 116, 147, 225], [42, 69, 128, 168, 186, 0], [4, 57, 144, 173, 207, 0],
[43, 53, 94, 164, 174, 209], [24, 79, 150, 174, 208, 0], [21, 79, 145, 165, 225, 0],
[44, 64, 160, 189, 220, 0], [45, 82, 136, 154, 217, 0], [9, 56, 132, 133, 205, 0],
[46, 80, 120, 150, 181, 0], [41, 63, 109, 140, 187, 0], [47, 53, 105, 188, 215, 0],
[48, 81, 183, 185, 230, 0], [16, 90, 95, 119, 190, 216], [10, 87, 185, 188, 228, 0],
[44, 91, 158, 163, 211, 0], [49, 78, 173, 176, 228, 0], [30, 92, 158, 180, 209, 0],
[22, 40, 178, 184, 210, 0], [18, 59, 95, 118, 192, 222], [14, 70, 191, 206, 230, 0],
[26, 75, 140, 142, 231, 0], [48, 83, 131, 157, 208, 0], [6, 61, 160, 183, 197, 0],
[32, 73, 149, 174, 187, 0], [12, 64, 178, 196, 213, 0], [27, 93, 101, 105, 141, 236],
[33, 89, 141, 198, 239, 0], [19, 93, 138, 199, 204, 0], [2, 82, 99, 116, 119, 200],
[1, 50, 62, 106, 206, 0], [32, 71, 112, 201, 207, 0], [34, 75, 111, 157, 233, 0],
[39, 73, 186, 199, 206, 0], [27, 35, 38, 171, 181, 237], [49, 93, 118, 175, 202, 0],
[45, 80, 121, 167, 240, 0], [19, 94, 161, 179, 203, 0], [31, 86, 106, 164, 234, 0],
[45, 84, 104, 177, 226, 0], [47, 66, 102, 196, 212, 0], [25, 65, 98, 173, 192, 240],
[49, 85, 179, 191, 232, 0], [38, 89, 153, 175, 210, 0], [25, 43, 125, 168, 214, 0],
[22, 58, 113, 172, 239, 0], [36, 68, 184, 191, 226, 0], [15, 78, 103, 143, 235, 0],
[50, 84, 152, 171, 222, 0], [24, 85, 135, 185, 238, 0], [48, 59, 134, 169, 193, 0],
[17, 91, 176, 182, 198, 0], [11, 88, 113, 167, 232, 0], [43, 81, 122, 132, 239, 0],
[41, 58, 107, 190, 197, 0], [6, 91, 144, 159, 224, 0], [46, 72, 131, 151, 220, 0],
[23, 90, 152, 198, 223, 0], [44, 77, 98, 114, 218, 219], [26, 76, 165, 190, 240, 0],
[28, 54, 115, 170, 219, 0], [31, 57, 97, 146, 163, 195], [14, 92, 108, 162, 221, 0],
[5, 71, 121, 139, 233, 0], [1, 34, 87, 154, 192, 0], [29, 90, 156, 199, 218, 0],
[2, 51, 55, 138, 146, 0], [5, 92, 109, 189, 235, 0], [13, 94, 159, 162, 200, 0],
[7, 67, 129, 201, 221, 0],
]
NRW = [ # 139 check degrees; sum = 720
6, 6, 6, 6, 6, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 6, 5, 5, 5, 6, 5, 6,
5, 5, 5, 6, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5,
5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5
]
FST4W — fst4_74_parity.py:
MN = [ # 240 variable nodes, each degree 3 (1-based check indices)
[84, 101, 144], [10, 14, 138], [87, 148, 166], [1, 50, 67], [2, 53, 74], [3, 83, 113],
[4, 90, 121], [5, 63, 128], [6, 124, 138], [8, 22, 108], [11, 28, 159], [12, 18, 142],
[13, 24, 145], [15, 131, 149], [16, 44, 93], [17, 41, 47], [19, 37, 129], [20, 33, 94],
[21, 100, 154], [23, 71, 141], [25, 89, 95], [26, 105, 153], [27, 36, 58], [29, 59, 166],
[30, 52, 126], [31, 61, 77], [32, 84, 111], [34, 97, 155], [38, 98, 127], [39, 76, 143],
[40, 55, 92], [42, 147, 158], [43, 82, 148], [45, 49, 109], [46, 70, 86], [48, 78, 139],
[51, 101, 104], [54, 63, 96], [56, 81, 125], [57, 117, 164], [60, 75, 107], [39, 62, 132],
[64, 110, 118], [24, 65, 146], [66, 80, 134], [68, 91, 114], [69, 123, 162], [72, 88, 152],
[79, 99, 130], [85, 112, 124], [99, 103, 157], [106, 115, 133], [116, 120, 140], [119, 161, 165],
[64, 122, 137], [34, 89, 135], [136, 138, 163], [93, 144, 159], [35, 130, 150], [62, 151, 164],
[104, 153, 160], [1, 106, 166], [2, 132, 152], [3, 11, 105], [4, 18, 160], [5, 53, 91],
[6, 109, 141], [7, 111, 113], [8, 54, 136], [9, 61, 92], [10, 40, 101], [12, 30, 146],
[13, 37, 82], [14, 29, 95], [1, 47, 131], [2, 8, 139], [3, 58, 130], [4, 96, 115],
[5, 119, 129], [6, 60, 148], [7, 95, 163], [2, 35, 56], [9, 67, 79], [10, 75, 122],
[11, 17, 121], [12, 137, 145], [13, 36, 152], [14, 15, 155], [15, 134, 143], [16, 106, 125],
[11, 106, 157], [18, 99, 118], [19, 50, 94], [20, 126, 158], [21, 41, 135], [22, 24, 71],
[23, 42, 136], [22, 109, 161], [25, 39, 46], [26, 45, 55], [27, 77, 82], [28, 73, 166],
[29, 69, 76], [30, 108, 150], [31, 91, 146], [14, 32, 147], [33, 35, 107], [34, 103, 111],
[8, 94, 122], [13, 70, 151], [32, 37, 142], [3, 38, 87], [25, 51, 92], [40, 57, 72],
[21, 108, 153], [23, 26, 142], [43, 44, 48], [30, 43, 62], [7, 45, 154], [16, 46, 149],
[1, 53, 75], [33, 44, 160], [49, 86, 157], [19, 80, 159], [51, 116, 138], [52, 92, 98],
[6, 12, 47], [54, 83, 101], [24, 55, 102], [56, 63, 120], [17, 57, 82], [38, 154, 162],
[59, 74, 151], [53, 144, 164], [61, 85, 117], [62, 66, 90], [48, 113, 145], [64, 65, 128],
[27, 29, 65], [58, 63, 134], [9, 74, 83], [68, 109, 113], [41, 61, 69], [36, 60, 155],
[42, 64, 144], [40, 90, 130], [28, 110, 135], [20, 59, 112], [70, 110, 124], [54, 76, 105],
[4, 77, 111], [78, 104, 143], [66, 67, 91], [80, 81, 88], [50, 101, 132], [71, 97, 120],
[72, 131, 158], [84, 133, 141], [5, 85, 99], [49, 89, 133], [87, 132, 140], [34, 88, 104],
[89, 105, 147], [6, 76, 102], [18, 31, 163], [52, 96, 140], [93, 102, 165], [79, 104, 165],
[81, 100, 126], [95, 121, 152], [97, 123, 153], [37, 98, 114], [8, 91, 155], [100, 114, 160],
[2, 26, 28], [93, 116, 150], [68, 103, 166], [78, 117, 125], [86, 107, 127], [4, 59, 136],
[9, 37, 97], [7, 30, 75], [80, 148, 153], [73, 138, 164], [10, 39, 103], [39, 146, 156],
[48, 129, 136], [5, 17, 51], [112, 149, 161], [11, 24, 126], [1, 70, 78], [14, 113, 118],
[10, 119, 141], [13, 33, 105], [19, 57, 89], [12, 25, 56], [16, 18, 54], [84, 124, 162],
[20, 41, 134], [15, 45, 82], [115, 118, 123], [128, 139, 149], [127, 156, 159], [21, 141, 152],
[23, 130, 156], [3, 160, 164], [22, 90, 110], [35, 61, 109], [31, 87, 158], [42, 60, 106],
[137, 140, 157], [27, 114, 124], [32, 62, 125], [34, 38, 128], [40, 123, 139], [29, 66, 86],
[36, 52, 161], [43, 63, 133], [46, 73, 108], [44, 135, 146], [47, 115, 127], [49, 74, 116],
[58, 102, 122], [55, 85, 132], [50, 65, 150], [67, 145, 162], [53, 71, 77], [69, 88, 142],
[68, 72, 93], [9, 64, 95], [92, 94, 111], [81, 83, 119], [98, 143, 163], [73, 79, 96],
[35, 129, 131], [99, 100, 151], [7, 112, 159], [117, 137, 156], [120, 147, 154], [107, 121, 165],
]
NM = [ # 166 checks; padded to width 5 with 0 (real degree = NRW[m])
[4, 62, 75, 121, 191], [5, 63, 76, 82, 175], [6, 64, 77, 112, 206],
[7, 65, 78, 151, 180], [8, 66, 79, 159, 188], [9, 67, 80, 127, 164],
[68, 81, 119, 182, 237], [10, 69, 76, 109, 173], [70, 83, 141, 181, 230],
[2, 71, 84, 185, 193], [11, 64, 85, 91, 190], [12, 72, 86, 127, 196],
[13, 73, 87, 110, 194], [2, 74, 88, 106, 192], [14, 88, 89, 200, 0],
[15, 90, 120, 197, 0], [16, 85, 131, 188, 0], [12, 65, 92, 165, 197],
[17, 93, 124, 195, 0], [18, 94, 148, 199, 0], [19, 95, 115, 204, 0],
[10, 96, 98, 207, 0], [20, 97, 116, 205, 0], [13, 44, 96, 129, 190],
[21, 99, 113, 196, 0], [22, 100, 116, 175, 0], [23, 101, 139, 212, 0],
[11, 102, 147, 175, 0], [24, 74, 103, 139, 216], [25, 72, 104, 118, 182],
[26, 105, 165, 209, 0], [27, 106, 111, 213, 0], [18, 107, 122, 194, 0],
[28, 56, 108, 162, 214], [59, 82, 107, 208, 235], [23, 87, 144, 217, 0],
[17, 73, 111, 172, 181], [29, 112, 132, 214, 0], [30, 42, 99, 185, 186],
[31, 71, 114, 146, 215], [16, 95, 143, 199, 0], [32, 97, 145, 210, 0],
[33, 117, 118, 218, 0], [15, 117, 122, 220, 0], [34, 100, 119, 200, 0],
[35, 99, 120, 219, 0], [16, 75, 127, 221, 0], [36, 117, 137, 187, 0],
[34, 123, 160, 222, 0], [4, 93, 155, 225, 0], [37, 113, 125, 188, 0],
[25, 126, 166, 217, 0], [5, 66, 121, 134, 227], [38, 69, 128, 150, 197],
[31, 100, 129, 224, 0], [39, 82, 130, 196, 0], [40, 114, 131, 195, 0],
[23, 77, 140, 223, 0], [24, 133, 148, 180, 0], [41, 80, 144, 210, 0],
[26, 70, 135, 143, 208], [42, 60, 118, 136, 213], [8, 38, 130, 140, 218],
[43, 55, 138, 145, 230], [44, 138, 139, 225, 0], [45, 136, 153, 216, 0],
[4, 83, 153, 226, 0], [46, 142, 177, 229, 0], [47, 103, 143, 228, 0],
[35, 110, 149, 191, 0], [20, 96, 156, 227, 0], [48, 114, 157, 229, 0],
[102, 184, 219, 234, 0], [5, 133, 141, 222, 0], [41, 84, 121, 182, 0],
[30, 103, 150, 164, 0], [26, 101, 151, 227, 0], [36, 152, 178, 191, 0],
[49, 83, 168, 234, 0], [45, 124, 154, 183, 0], [39, 154, 169, 232, 0],
[33, 73, 101, 131, 200], [6, 128, 141, 232, 0], [1, 27, 158, 198, 0],
[50, 135, 159, 224, 0], [35, 123, 179, 216, 0], [3, 112, 161, 209, 0],
[48, 154, 162, 228, 0], [21, 56, 160, 163, 195], [7, 136, 146, 207, 0],
[46, 66, 105, 153, 173], [31, 70, 113, 126, 231], [15, 58, 167, 176, 229],
[18, 93, 109, 231, 0], [21, 74, 81, 170, 230], [38, 78, 166, 234, 0],
[28, 156, 171, 181, 0], [29, 126, 172, 233, 0], [49, 51, 92, 159, 236],
[19, 169, 174, 236, 0], [1, 37, 71, 128, 155], [129, 164, 167, 223, 0],
[51, 108, 177, 185, 0], [37, 61, 152, 162, 168], [22, 64, 150, 163, 194],
[52, 62, 90, 91, 210], [41, 107, 179, 240, 0], [10, 104, 115, 219, 0],
[34, 67, 98, 142, 208], [43, 147, 149, 207, 0], [27, 68, 108, 151, 231],
[50, 148, 189, 237, 0], [6, 68, 137, 142, 192], [46, 172, 174, 212, 0],
[52, 78, 201, 221, 0], [53, 125, 176, 222, 0], [40, 135, 178, 238, 0],
[43, 92, 192, 201, 0], [54, 79, 193, 232, 0], [53, 130, 156, 239, 0],
[7, 85, 170, 240, 0], [55, 84, 109, 223, 0], [47, 171, 201, 215, 0],
[9, 50, 149, 198, 212], [39, 90, 178, 213, 0], [25, 94, 169, 190, 0],
[29, 179, 203, 221, 0], [8, 138, 202, 214, 0], [17, 79, 187, 235, 0],
[49, 59, 77, 146, 205], [14, 75, 157, 235, 0], [42, 63, 155, 161, 224],
[52, 158, 160, 218, 0], [45, 89, 140, 199, 0], [56, 95, 147, 220, 0],
[57, 69, 97, 180, 187], [55, 86, 211, 238, 0], [2, 9, 57, 125, 184],
[36, 76, 202, 215, 0], [53, 161, 166, 211, 0], [20, 67, 158, 193, 204],
[12, 111, 116, 228, 0], [30, 89, 152, 233, 0], [1, 58, 134, 145, 0],
[13, 86, 137, 226, 0], [44, 72, 105, 186, 220], [32, 106, 163, 239, 0],
[3, 33, 80, 183, 0], [14, 120, 189, 202, 0], [59, 104, 176, 225, 0],
[60, 110, 133, 236, 0], [48, 63, 87, 170, 204], [22, 61, 115, 171, 183],
[19, 119, 132, 239, 0], [28, 88, 144, 173, 0], [186, 203, 205, 238, 0],
[51, 91, 123, 211, 0], [32, 94, 157, 209, 0], [11, 58, 124, 203, 237],
[61, 65, 122, 174, 206], [54, 98, 189, 217, 0], [47, 132, 198, 226, 0],
[57, 81, 165, 233, 0], [40, 60, 134, 184, 206], [54, 167, 168, 240, 0],
[3, 24, 62, 102, 177],
]
NRW = [ # 166 check degrees; sum = 720
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 5, 5, 4,
4, 4, 5, 5, 4, 5, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 5, 5,
5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 5, 5, 5,
4, 5, 4, 4, 4, 5, 4, 5, 4, 4, 5, 5, 5, 4, 4, 5, 4, 5, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5,
4, 4, 4, 4, 4, 5, 4, 5, 4, 4, 4, 5, 4, 5, 4, 4, 5, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 5, 5, 4, 4,
4, 4, 4, 5, 5, 4, 4, 4, 5, 4, 5
]
MN and NM are the two views of the same bipartite Tanner graph; the BP loops of §5.4 walk MN for the check→bit update and NM for the bit→check update. H is consistent with the systematic G of §6.1 (encoding then syndrome-checking gives zero) — the encode round-trip and noisy decode are both exercised by the test suite (§7).
6.3 Fixed vectors and scalars (summary)
| constant | value | source |
|---|---|---|
NSYM |
160 | fst4.py |
ISYNC1 |
(0,1,3,2,1,0,2,3) | fst4.py |
ISYNC2 |
(2,3,1,0,3,2,0,1) | fst4.py |
| sync group starts | 0, 38, 76, 114, 152 | fst4.py |
RVEC |
77-bit scramble (§4.1) | fst4.py |
| CRC-24 poly | 0x100065B (25-bit) | fst4.py |
_GRAY / INV_GRAY |
(0,1,3,2) | fst4.py |
NSPS |
{15:720,30:1680,60:3888,120:8200,300:21504} | fst4.py |
hmod |
1 | fst4.py |
GFSK BT |
2.0 | fst4.py |
GFSK_CONST_K |
5.336446 (= π√(2/ln2)) | ftx/constants.py |
BP max_iters |
40 | fst4.py |
7. Interoperability and validation
The implementation is validated bidirectionally against WSJT-X's own reference tooling — the same oracle strategy used across the native WSJT-X family (../native-digimodes.md).
Encoder (bit-exact).
channel_symbols("CQ K1ABC FN42")is pinned to the full 160-tone golden vectorFST4_CQintest_jt.py. That vector is the output of WSJT-X's FST4 encoder /jt9 -7for the same message — so a reimplementation is correct iff it reproduces this sequence exactly. The frame structure (160 tones, sync groups at[0:8]/[38:46], tones ∈ {0..3}) is separately asserted (test_jt.py), and FST4W beacons are checked for the same 160-tone structure (test_jt.py).Decoder (round-trip).
test_fst4_decode_roundtrip(test_jt.py) encodes several messages withencode_pcm(..., period=60)and requiresdecode_slot(..., period=60)to return the identical text;test_fst4w_decodedoes the same atperiod=120, wspr=True(test_jt.py). The docs table records that the decoder also decodes the WSJT-Xfst4simreference signal (../native-digimodes.md), i.e. an off-air-representative waveform generated by WSJT-X, not merely its own output.
How to reproduce the oracle. Build WSJT-X and use its command-line tools: fst4sim generates a reference .wav for a given message/sub-mode/SNR (feed it to decode_slot); the WSJT-X FST4 encoder (genfst4 / jt9 -7) prints the tone sequence to pin channel_symbols against. FST4W's beacon path mirrors WSPR-style tooling. Matching the golden tone vector guarantees payload packing, scramble, CRC-24, LDPC generator, Gray map and frame interleave are all correct; the round-trip additionally exercises the whole demod + BP + CRC decode chain.
8. Limitations
Honest scope of this implementation (not of the FST4 protocol):
- Sub-modes. Only 15/30/60/120/300 s are in
NSPS; the 900 s and 1800 s ultra-slow sub-modes are absent (§3.2). - Single candidate per slot.
decode_slotreturns at most one message — it keeps the single best sync peak. It does not enumerate multiple overlapping signals in the passband the way a production multi-decoder would. - Reported
snris not dB. It is the coarse normalized sync score (§5.5), fine for ranking, not calibrated to WSJT-X's dB figure. - No a-priori / list decoding. The decoder is the generic BP+CRC path; it does not exploit hashed-callsign hints or WSJT-X's AP (a-priori) decoding that lifts sensitivity a few dB on known-partner QSOs.
- Search cost. The coarse loop is a dense (t0 × f0) grid with an inner matched-filter over 40 sync symbols — correct and simple, but O(Nt·Nf) and not FFT-accelerated; at 300 s / wide
f_lo…f_hiit is the runtime bottleneck. - Fixed BP schedule. 40 flooding iterations, min-sum-free tanh sum-product; no early-exit tuning, layered scheduling, or damping.
- FST4W source encoding uses the FT8-style
pack28/packgrid(§4.1), matching WSJT-X FST4W but not the legacy WSPR bit layout.
None of these affect wire-format interoperability — they are decoder-completeness and performance refinements on a correct, oracle-validated base.
9. References
- S. W. Franke, W. J. Somerville, J. H. Taylor (K9AN, G4WJS, K1JT), "The FST4 and FST4W Digital Modes," WSJT-X documentation / QEX — the mode definition, timing, and sub-modes.
- WSJT-X source:
genfst4.f90(encoder),ldpc_240_101_generator.f90/ldpc_240_101_parity.f90andldpc_240_74_generator.f90/ldpc_240_74_parity.f90(the generator/parity tables transcribed in_tables/),get_crc24(CRC-24), and thefst4sim/jt9 -7reference tools used as the validation oracle (§7). - R. G. Gallager, "Low-Density Parity-Check Codes," IRE Trans. Inf. Theory, 1962 — the LDPC codes and belief-propagation decoding of §2.2/§5.4.
- WSJT-X 77-bit message packing — the shared payload codec (
pack77), specified in wsjtx-message-packing.md. ../native-digimodes.md— the native WSJT-X family overview and per-mode validation status.../rvqvoice.md— the depth/structure/tone benchmark for this document.
Appendix A — file/function map
| file | role |
|---|---|
app/radio/jtx/fst4.py |
encoder + decoder (all of §3–§5) |
app/radio/jtx/_tables/fst4_101_gen.py |
FST4 LDPC(240,101) generator (139×101 hex) |
app/radio/jtx/_tables/fst4_101_parity.py |
FST4 parity MN/NM/NRW (M=139) |
app/radio/jtx/_tables/fst4_74_gen.py |
FST4W LDPC(240,74) generator (166×74 hex) |
app/radio/jtx/_tables/fst4_74_parity.py |
FST4W parity MN/NM/NRW (M=166) |
app/radio/ftx/pack.py · unpack.py |
shared pack77/unpack77, _pack28, _packgrid |
app/radio/ftx/encode.py |
shared _gfsk_pulse (BT pulse) |
app/radio/ftx/constants.py |
GFSK_CONST_K |
app/radio/wsjtx.py |
mode registration + slot routing |
test_jt.py |
golden-vector + round-trip tests |
Runtime API:
from app.radio.jtx import fst4
# FST4 QSO (60 s slot, 1500 Hz)
pcm = fst4.encode_pcm("CQ K1ABC FN42", f0=1500.0, period=60) # int16 LE bytes
res = fst4.decode_slot(pcm, period=60) # [{'snr','dt','df','msg'}] or []
# FST4W beacon (120 s slot)
pcm = fst4.encode_pcm("K1ABC FN42 30", period=120, wspr=True)
res = fst4.decode_slot(pcm, period=120, wspr=True)
# tone vectors only (no audio)
tones = fst4.channel_symbols("CQ K1ABC FN42") # 160 ints 0..3, or None
tonesw = fst4.channel_symbols_w("K1ABC FN42 30") # 160 ints 0..3, or None