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):
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):
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):
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):
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:
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