JT65: native Reed–Solomon(63,12) / 65-FSK weak-signal codec
A self-contained in-repo implementation of the classic WSJT-X JT65 protocol: a 72-bit message protected by a Reed–Solomon(63,12) code over GF(64), interleaved and merged with a fixed pseudo-random sync vector into 126 tones of 65-FSK.
Rafe project · app/radio/jtx/jt65.py, pack.py, unpack.py · encoder + decoder, validated against WSJT-X jt65code / jt65sim
Abstract
JT65 is the 1999-vintage Franke/Taylor weak-signal mode that made EME (moonbounce) and deep-fading HF QSOs routine. Rafe carries a native, clean-room reimplementation: no jt9 binary, pure NumPy at runtime. A standard two-callsign message is packed to 72 bits (pack.py), treated as the 12 six-bit information symbols of a Reed–Solomon(63,12) code over GF(64), and systematically encoded to 63 symbols by Phil Karn's codec parameters init_rs(6, 0x43, 3, 1, 51) (jt65.py). The 63 code symbols are 7×9 block-interleaved, Gray-coded, and merged with a fixed 126-bit pseudo-random synchronising vector (NPRC, jt65.py): 63 of the 126 slots carry a data tone, the other 63 carry the sync tone. The result is 126 channel symbols keyed as continuous-phase 65-FSK at 2.6917 baud (jt65.py), occupying ≈46.8 s of a 60 s transmit slot. The receiver is a matched-filter (per-symbol DTFT) demodulator: a 2-D coarse search over start time and base frequency locks the sync tone, a sub-bin refine pins f0 to 0.25 Hz, 64-way per-symbol argmax recovers the data tones, and a hard-decision Berlekamp–Massey + Chien + Forney RS decoder corrects up to 25 symbol errors before unpacking (unpack.py). The encoder is bit-exact against WSJT-X jt65code; the encode→air→decode chain round-trips through the native decoder and decodes jt65sim reference audio. The novelty for the project is doing all of this — a non-binary block code over a Galois field, plus the matched-filter demod — in a few hundred lines of dependency-free Python that runs on the prod box.
1. Motivation
Rafe is moving every digital mode off external binaries onto in-repo native code (see ../native-digimodes.md). JT65 was historically decoded by WSJT-X's jt9. Because WSJT-X is GPLv3 and this project is PolyForm Noncommercial, the port is clean-room from the published protocol, not translated from WSJT-X source: the constant tables (the GF(64) field, the RS generator, the NPRC sync vector, the 72-bit packing) are transcribed as protocol facts and confirmed behaviourally against WSJT-X's own reference utilities jt65code (prints packed bits and channel symbols) and jt65sim (renders reference .wav files). This is the same "validate against the oracle" discipline used for FT8 against gen_ft8.
JT65 is worth the effort because it is the archetype of the non-binary block-code branch of the WSJT-X family: unlike FT8/FST4 (LDPC) or WSPR/JT9/JT4 (K=32 convolutional), JT65's FEC is a Reed–Solomon code over GF(64), which maps one-to-one onto its 64-ary FSK alphabet. Getting a hard-decision RS(63,12) decoder — field arithmetic, Berlekamp– Massey, Chien search, Forney — correct and dependency-free is the interesting part.
2. Background
2.1 Reed–Solomon over GF(64)
A Reed–Solomon code treats each message as a sequence of symbols drawn from a finite field GF(q); JT65 uses q = 64 = 2⁶, so every symbol is exactly 6 bits. RS(n, k) with n = q − 1 = 63 and k = 12 sends 12 information symbols followed by n − k = 51 parity symbols, computed so that the whole 63-symbol codeword is a multiple of a fixed generator polynomial whose roots are 51 consecutive powers of the field's primitive element α. A hard-decision RS decoder corrects any pattern of up to t = ⌊(n−k)/2⌋ = 25 symbol errors: the sync/data structure means one badly-received tone corrupts at most one 6-bit symbol, so RS's symbol-level correction is a natural fit for FSK.
GF(64) is built as polynomials over GF(2) modulo the primitive polynomial x⁶ + x + 1 (GFPOLY = 0x43, jt65.py). Powers of α = x generate every non-zero element; the code precomputes an antilog table alpha_to[i] = αⁱ and its inverse discrete-log table index_of (_init_gf, jt65.py). All multiplication is then "add the logs mod 63"; the sentinel A0 = 63 (jt65.py) stands in for "log of 0" (the additive identity, which has no logarithm).
2.2 65-FSK
JT65 modulates one of 65 tones per symbol. Tone 0 is the synchronising tone; the 64 data tones are indices 2…65 (index 1 is deliberately skipped — a two-bin guard between sync and data). A 6-bit code symbol therefore selects one of 64 data tones. Adjacent tones are spaced by the keying rate BAUD (submode A), so the modulation is orthogonal over a symbol period: a matched filter (a full-symbol DTFT at each candidate tone frequency) cleanly separates them. The three historical submodes A/B/C widen the spacing by ×1/×2/×4 for progressively more Doppler-spread-tolerant EME work.
2.3 Sync-tone + data interleave
Weak-signal decoding needs the receiver to find where in time and frequency the signal sits before it can demodulate. JT65 solves this by time-multiplexing a known sync pattern with the data: a fixed pseudo-random 126-bit vector NPRC decides, slot by slot, whether each of the 126 symbols is a sync tone (tone 0) or the next data tone. Half the symbols (63) are sync; correlating the received sync-tone energy against NPRC gives a sharp 2-D (time × frequency) acquisition peak. The 63 data symbols, meanwhile, are 7×9 block-interleaved before transmission so that a fading burst — contiguous in time — is scattered into isolated symbol errors that the RS code corrects independently.
3. Signal, frame, and symbol structure
message text
│ pack.py (legacy 72-bit packjt: 2×28-bit calls + 16-bit grid/report)
▼
dgen[12] ── 12 six-bit information symbols = 72 bits
│ RS(63,12)/GF(64) systematic encode jt65.py
▼
sent[63] ── 12 data + 51 parity symbols (parity first: [0..50], data [51..62])
│ 7×9 block interleave jt65.py
▼
inter[63]
│ 6-bit Gray code s ^ (s>>1) jt65.py
▼
gray[63] ── the 63 "information-carrying channel symbols"
│ merge with NPRC sync vector: data→gray+2, sync→0 jt65.py
▼
tones[126] ── 63 data tones (values 2..65) + 63 sync tones (0)
│ continuous-phase 65-FSK synthesis jt65.py
▼
PCM (60 s slot, 1 s lead-in, s16le mono)
Exact numbers (all from jt65.py):
| quantity | value | source |
|---|---|---|
| Information symbols (RS message) | 12 × 6-bit = 72 bits | NN - NROOTS = 63 − 51 |
| RS code length | 63 symbols (NN) |
jt65.py |
| RS parity symbols | 51 (NROOTS) |
jt65.py |
| Channel symbols | 126 (NSYM) |
jt65.py |
| — of which sync (tone 0) | 63 | NPRC has 63 ones |
| — of which data | 63 | NPRC has 63 zeros |
| Tone alphabet | 65 tones: 0 (sync), 2…65 (data) | jt65.py |
Keying rate BAUD |
11025 / 4096 = 2.691650 Hz | jt65.py |
| Symbol duration | 4096 / 11025 = 0.371519 s | 1 / BAUD |
| Signal duration | 126 × 0.371519 = 46.811 s | 126 / BAUD |
Slot length SLOT_SECONDS |
60 s | jt65.py |
| Default lead-in | 1.0 s | encode_pcm, jt65.py |
Default carrier f0 |
1270.5 Hz (WSJT-X JT65 sync ref) | jt65.py |
| Default sample rate | 12000 Hz | jt65.py |
3.1 Submode tone spacing
The nominal frequency of channel symbol with tone value v is f0 + v · BAUD · submode (synth, jt65.py). The submode factor selects the three classic JT65 variants:
| submode arg | mode | tone spacing | data-band width (65 × spacing) |
|---|---|---|---|
| 1 | JT65A (HF) | 2.6917 Hz | ≈ 175 Hz |
| 2 | JT65B (EME) | 5.3833 Hz | ≈ 350 Hz |
| 4 | JT65C (EME) | 10.7666 Hz | ≈ 699 Hz |
Both the synthesiser and the decoder honour all three: decode_slot(..., submode=) takes 1/2/4 (A/B/C) and scales the data-tone grid to BAUD*submode (jt65.py). Sync detection keys on the base tone at f0 and is submode-independent. Round-trip-tested for A/B/C (test_jt65_submodes).
3.2 The pseudo-random sync vector NPRC
NPRC (jt65.py) is the 126-element vector that places sync vs data. NPRC[j]==1 → symbol j is the sync tone (tone 0); NPRC[j]==0 → symbol j carries the next data tone. It is fixed (a protocol constant, transcribed from WSJT-X). The 63 sync positions are:
0 3 4 8 9 10 11 12 13 15 17 21 23 24 27 31 32 33 36 37 38 39 41 42 44 45
46 47 51 52 54 56 58 59 62 63 65 67 69 72 79 80 88 89 91 94 96 97 99 101 103
106 107 110 113 118 119 120 121 122 123 124 125
The remaining 63 positions are data. The sync tone is always tone 0; data tones are always ≥ 2, so tone 0 occurs at exactly the 63 sync positions (a decoder invariant, and a unit-test assertion: s.count(0) == 63, test_jt.py).
4. Encoder — step by step
The public entry is channel_symbols(message) (jt65.py); encode_pcm (jt65.py) renders it to a 60-second PCM slot. Worked example throughout: message CQ K1ABC FN42, whose every intermediate is verified against jt65code (test_jt.py).
Step 1 — Pack to 72 bits. packmsg(message) (pack.py) produces the 12 six-bit information words dgen[12]. For the example:
dgen = [62, 32, 32, 49, 39, 55, 3, 2, 14, 5, 33, 40] # == jt65code "Packed message"
This is the legacy 72-bit packjt scheme (two 28-bit callsign fields + one 16-bit grid/report field, 28+28+16 = 72), summarised in §4.1 — not the newer 77-bit pack77 used by FT8/FST4. See wsjtx-message-packing.md for the message-packing family; the JT65/JT9/JT4 codecs share this older codec via pack.py.
Step 2 — RS(63,12) systematic encode. rs_encode(dgen) (jt65.py) returns sent[63]. The wrapper first reverses the 12 information symbols into WSJT-X's dat1 order (dat1[i] = dgen[11-i], jt65.py), runs the core LFSR encoder _encode_rs (jt65.py) to get 51 parity symbols, then lays out the systematic codeword with parity first, data last:
sent[50 - i] = parity[i] for i in 0..50 # positions 0..50 = parity
sent[i + 51] = dat1[11 - i] for i in 0..11 # positions 51..62 = data
_encode_rs is the standard Berlekamp LFSR division by the generator polynomial (§6.3): for each of the 12 data symbols it forms feedback = index_of[data ^ bb[0]] and, when feedback != A0, XORs alpha_to[(feedback + genpoly[·]) mod 63] into the parity register bb, shifting left each step (jt65.py). The register bb after 12 iterations is the 51 parity symbols. For the example:
sent = [49,56,14,57,15,14,14,19,28,36,49,47,0,6,39,2,38,16,8,6,34,37,57,46,1,4,
30,47,18,14,59,14,13,3,16,32,34,42,61,59,24,58,17,4,30,5,52,35,59,0,26,
62,32,32,49,39,55,3,2,14,5,33,40]
(the last 12 entries [62,32,…,40] are the reversed data, confirming systematic form.)
Step 3 — 7×9 block interleave. _interleave63(sent) (jt65.py):
out[j + 9·i] = sent[i + 7·j] for i in 0..6, j in 0..8
i.e. write the 63 symbols into a 9-row × 7-column grid column-major and read row-major. Equivalently out[k] = sent[perm[k]] with the fixed read-index permutation
[ 0 7 14 21 28 35 42 49 56 1 8 15 22 29 36 43 50 57 2 9 16 23 30 37 44 51 58
3 10 17 24 31 38 45 52 59 4 11 18 25 32 39 46 53 60 5 12 19 26 33 40 47 54 61
6 13 20 27 34 41 48 55 62 ]
Step 4 — 6-bit Gray code. Each interleaved symbol is Gray-coded s ^ (s >> 1) (jt65.py) so that adjacent tones differ by one bit, minimising bit errors when a tone is misread as a neighbour. The Gray-coded, interleaved 63 symbols are the "information-carrying channel symbols". For the example (matches jt65code and the JT65_INFO63 fixture, test_jt.py):
gray = [41,26,52,55,27,48,25,0,44,36,18,3,37,9,51,6,23,2,9,54,53,57,38,63,17,33,3,
37,41,24,1,9,35,7,48,9,8,56,12,6,11,38,46,48,7,9,0,5,17,2,20,50,41,49,9,5,
51,56,24,39,38,52,60]
Step 5 — Sync merge → 126 tones. Walk the 126 slots (jt65.py). Where NPRC[j]==0, emit the next data value gray[k] + 2 and advance k; where NPRC[j]==1, emit 0 (the sync tone). Result: tones[126], values in {0} ∪ {2…65}.
Step 6 — 65-FSK synthesis. synth(tones, f0, sample_rate, submode) (jt65.py) produces continuous-phase FSK. With sps = sample_rate / BAUD samples per symbol, each output sample n belongs to symbol isym = clip(⌊n/sps⌋, 0, 125); its instantaneous frequency is f0 + tones[isym] · BAUD · submode. Phase is the running integral phase[n] = 2π · cumsum(freq)/sample_rate, and the waveform is sin(phase) (float32). Using a cumulative-sum phase accumulator guarantees phase continuity across symbol boundaries (no keying clicks).
Step 7 — Slot framing. encode_pcm (jt65.py) places the ≈46.8 s signal at offset lead_s · sample_rate (default 1.0 s) inside a zero-filled SLOT_SECONDS · sample_rate buffer (60 s), clips to ±1, scales by 32767, and emits little-endian signed-16-bit mono bytes.
4.1 Payload packing (summary)
packmsg (pack.py) handles standard messages only: two callsigns plus a grid/report/RO/RRR/73 token (prefix/suffix/free-text are not implemented — see pack.py). The three sub-fields are:
packcall(pack.py) → a 28-bit callsign field. A standard call is normalised so the third character is the digit (_norm_call,pack.py) then base-encoded across37·36·10·27·27·27 = 262 177 560(NBASE); special tokensCQ,QRZ,DE,CQ nnnmap toNBASE+1,NBASE+2,267796945,NBASE+3+nnn.packgrid(pack.py) → a 16-bit grid/report field. A 4-character Maidenhead locator is converted to lon/lat degrees (_grid2deg,pack.py) then to((lon+180)//2)·180 + lat; signalling reports (-01…-30,R-01…-30,RO,RRR,73) occupyNGBASE+1 … NGBASE+64(NGBASE = 180·180 = 32400).- The three fields are bit-sliced into the 12 six-bit words
dat[0..11](pack.py):nc1fills the top 28 bits,nc2the next 28,ngthe low 16 — exactly72bits, and these 12 words are the RS(63,12) message.
Decoding reverses this: unpackmsg(dat) (unpack.py) re-slices the three fields and inverts each (unpackcall, unpackgrid); ng ≥ 32768 flags free text and returns None.
5. Decoder — step by step
decode_slot(pcm, sample_rate=12000) (jt65.py) takes one slot of s16le mono PCM (or a float array) and returns [{snr, dt, df, msg}] or []. It is a coherent matched-filter demodulator — every "FFT" here is a per-symbol DTFT (rectangular window, one symbol long) evaluated at candidate tone frequencies.
Step 0 — Setup. Normalise PCM to float; nsps = sample_rate / BAUD (≈4458.2 samples per symbol at 12 kHz); fftlen = round(nsps); binhz = sample_rate / fftlen (≈2.6918 Hz, i.e. one FFT bin ≈ one BAUD). Split the 126 slots into the 63 sync_pos (NPRC==1) and 63 data_pos (NPRC==0) (jt65.py).
Step 1 — Coarse acquisition (time × frequency). Grid-search the start offset and the sync-tone base bin (jt65.py):
- Time:
tshiftover0 … 1.5·sample_ratein steps ofint(nsps)//3(≈⅓ symbol, ≈0.124 s) — the signal may start anywhere in the first 1.5 s. - For each
tshift, build a per-symbol power spectrum matrixmat[126, ·]: for symboli, take thefftlen-sample block attshift + round(i·nsps), zero-pad if short,rfft, and store|·|². Normalise each symbol's spectrum by its total powertot[i]. - Frequency: for each base bin
fbin[⌊200/BAUD⌋ … ⌊1400/BAUD⌋](≈200–1400 Hz), scoreΣ_{p∈sync_pos} mat[p, fb] / tot[p]— the fraction of energy that lands in the sync-tone binfbacross all 63 sync symbols. The best(score, tshift, fb)wins.
This joint peak is the classic JT65 sync: the sync tone sits at the base frequency in every one of the 63 NPRC==1 slots, so the correct (t0, fb) maximises accumulated normalised sync power. t0 = tshift, fb = base bin.
Step 2 — Sub-bin f0 refine (matched filter). The coarse fb is only good to one bin (≈2.69 Hz). Refine (jt65.py): gather the 63 sync-tone blocks; for each trial f0 from (fb−1)·binhz to (fb+1)·binhz in 0.25 Hz steps, compute the summed matched-filter power Σ_p |⟨block_p, e^{−j2π f0 n / fs}⟩|² and keep the maximiser best_f0. This is a ±1-bin, ~1/10-bin frequency lock via direct DTFT rather than FFT interpolation. No fractional-symbol t0 refine is done — timing stays at the ⅓-symbol coarse grid (§8).
Step 3 — 64-way matched-filter data demod. Build the 64 candidate data-tone frequencies tone_freqs = best_f0 + (2 … 65)·BAUD and the basis matrix basis[64, fftlen] = e^{−j2π · tone_freqs ⊗ n / fs} (jt65.py). For each of the 63 data symbols, take its block and compute the 64 tone powers |basis · block|²; the argmax is the received Gray-coded tone index (jt65.py). Un-Gray each with _inverse_gray (jt65.py) to recover the 6-bit code symbols.
Step 4 — De-interleave. _deinterleave63 (jt65.py) inverts Step 3 of the encoder: out[i + 7·j] = vals[j + 9·i], restoring the 63 symbols to sent order.
Step 5 — RS(63,12) hard-decision decode. rs_decode(sent) (jt65.py) first un-permutes back to Karn's internal order (data at [0..11], parity at [12..62], jt65.py), then calls _decode_rs (jt65.py) — Phil Karn's GF(64) hard-decision decoder, correcting in place and returning the error count, or −1 if uncorrectable:
- Syndromes
s[i] = r(α^{FCR+i}),i = 0…50, by Horner evaluation over the 63 received symbols (jt65.py). If all 51 syndromes are zero the codeword is already valid → 0 errors. - Berlekamp–Massey iterates the error-locator polynomial
λ(x): each round computes the discriminantdiscr_r, updatesλand the shift registerb, and conditionally grows the locator degree (jt65.py). - Chien search evaluates
λat every field element to find its roots — the error locations — fillingroot[]/loc[](jt65.py); if the number of roots ≠deg λ, decode fails (−1). - Forney algorithm forms the error-evaluator
Ω(x)and computes each error magnitudeΩ(X⁻¹) / λ'(X⁻¹)(with theFCR-dependent factor), XORing the correction into the received symbol (jt65.py).
The wrapper then returns the corrected 12 information symbols in dgen order (jt65.py). This corrects up to 25 symbol errors (t = 51/2). It is errors-only — no erasures are supplied — and hard-decision (§8).
Step 6 — Unpack & report. unpackmsg(dgen) (unpack.py) turns the 12 words back into text; empty/invalid → []. On success decode_slot returns one dict (jt65.py):
{"snr": "%.0f" % sync_score, # the accumulated sync score, NOT a calibrated dB SNR
"dt": "%.1f" % (t0 / sample_rate),# start-time estimate, seconds
"df": "%.0f" % best_f0, # refined sync-tone frequency, Hz
"msg": "CQ K1ABC FN42"}
Note the reported snr is the sync-correlation score, not a physical SNR in dB (§8).
6. Constants and tables
Every value below is generated or literal in jt65.py; the tables are reproduced from the running code so a reimplementation can check byte-for-byte.
6.1 RS / field parameters (jt65.py)
| symbol | value | meaning |
|---|---|---|
NN |
63 | code length n = 2⁶ − 1 |
NROOTS |
51 | parity symbols = n − k |
| k (implicit) | 12 | information symbols = NN − NROOTS |
A0 |
63 | sentinel "log(0)" |
GFPOLY |
0x43 |
primitive poly x⁶ + x + 1 |
FCR |
3 | first consecutive root exponent |
PRIM |
1 | primitive-element step |
NSYM |
126 | channel symbols |
NSPS |
4096 | JT65A samples/symbol at 11025 Hz (defines BAUD) |
SLOT_SECONDS |
60 | T/R slot length |
Equivalent to Karn's init_rs(6, 0x43, 3, 1, 51) (symsize=6, gfpoly=0x43, fcr=3, prim=1, nroots=51).
6.2 GF(64) field tables (generated, _init_gf, jt65.py)
alpha_to[i] = αⁱ (the antilog / exponential table, 64 entries), built by shifting a register and reducing modulo x⁶+x+1:
alpha_to = [ 1, 2, 4, 8,16,32, 3, 6,12,24,48,35, 5,10,20,40,
19,38,15,30,60,59,53,41,17,34, 7,14,28,56,51,37,
9,18,36,11,22,44,27,54,47,29,58,55,45,25,50,39,
13,26,52,43,21,42,23,46,31,62,63,61,57,49,33, 0 ]
index_of[x] is the inverse (discrete log; index_of[0] = A0 = 63):
index_of = [63, 0, 1, 6, 2,12, 7,26, 3,32,13,35, 8,48,27,18,
4,24,33,16,14,52,36,54, 9,45,49,38,28,41,19,56,
5,62,25,11,34,31,17,47,15,23,53,51,37,44,55,40,
10,61,46,30,50,22,39,43,29,60,42,21,20,59,57,58 ]
Field arithmetic: α^a · α^b = alpha_to[(a+b) mod 63]; addition is XOR of the 6-bit representations.
6.3 RS generator polynomial (generated, _init_genpoly, jt65.py)
g(x) = ∏_{i=0}^{50} (x − α^{FCR + i·PRIM}) = ∏_{i=3}^{53}(x − αⁱ), monic, degree 51. Stored in index (log) form, 52 coefficients g[0..51] (g[51]=0=log(α⁰) monic leading term; g[0]=42 constant term = log of the product of all roots):
genpoly(log) = [42,36,57,12, 9,41,22,21,27,39,18,41,52,19,39,21,
4,59,27,15,51,10,37,51,58,36, 8,37,37,30,10,58,
29,48,24,39, 0,25,12,52,48,32,60,55,56, 1,27, 2,
12, 1,50, 0]
6.4 Sync/data vector NPRC[126] (literal, jt65.py)
The 126-element pattern (1 = sync tone 0, 0 = data tone), exactly as in source:
1 0 0 1 1 0 0 0 1 1 1 1 1 1 0 1 0 1 0 0
0 1 0 1 1 0 0 1 0 0 0 1 1 1 0 0 1 1 1 1
0 1 1 0 1 1 1 1 0 0 0 1 1 0 1 0 1 0 1 1
0 0 1 1 0 1 0 1 0 1 0 0 1 0 0 0 0 0 0 1
1 0 0 0 0 0 0 0 1 1 0 1 0 0 1 0 1 1 0 1
0 1 0 1 0 0 1 1 0 0 1 0 0 1 0 0 0 0 1 1
1 1 1 1 1 1
63 ones, 63 zeros. Sync positions listed in §3.2.
6.5 Modulation constants
| symbol | value | source |
|---|---|---|
BAUD |
11025.0 / 4096.0 = 2.691650 Hz |
jt65.py |
| submode → spacing | 1→×1 (A), 2→×2 (B), 4→×4 (C) | synth, jt65.py |
| tone map | data → gray+2, sync → 0 |
jt65.py |
7. Interoperability and validation
The reference oracle is WSJT-X's own toolchain, present on the prod box:
jt65code <message>prints the packed message (the 12 six-bit words) and the information-carrying channel symbols (the 63 Gray-coded data symbols). The native encoder is checked bit-exact against both:packmsg("CQ K1ABC FN42")equals theJT65_DGENfixture, andinterleave→grayequals theJT65_INFO63fixture (test_jt.py). The full 126-symbol stream is range/structure-checked (0 ≤ t ≤ 65, exactly 63 sync zeros,test_jt.py).jt65simrenders reference.wavslots; the native decoder decodes them (per../native-digimodes.md, "decodes jt65sim reference"), and the encoder's output round-trips through the realjt9 -6decoder on prod.- Native round-trip (
test_jt.py):rs_decode(rs_encode(dgen)) == dgen, anddecode_slot(encode_pcm(m))recoversmforCQ K1ABC FN42,M0SUP G0ABC 73,G0ABC M0SUP -05.
Because encode is deterministic, the encoder target is exact-match; the decoder target is decode-rate parity against the oracle on identical audio. Both hold for JT65A standard messages.
7.1 Reproduction checklist
A from-scratch reimplementation is correct when, for CQ K1ABC FN42:
packmsg→[62,32,32,49,39,55,3,2,14,5,33,40](§4 Step 1).rs_encode→ the 63-symbolsentin §4 Step 2 (parity[0..50], data[51..62]).interleave→gray→ the 63-valueJT65_INFO63in §4 Step 4.- Sync-merge → 126 tones with tone 0 at exactly the 63
NPRC==1positions. rs_decode(rs_encode(dgen)) == dgen, and the audio round-trip recovers the message.
8. Limitations
Honest scope of the implementation (the protocol itself is unrestricted):
- Standard messages only.
pack.pyhandles two-callsign + grid/report/RO/RRR/73. Compound/prefix/suffix calls and free text are not implemented (pack.py,unpack.pyreturnsNoneon the free-text flag). WSJT-X's plain-text and Type-2 messages won't encode/decode. - Hard-decision, errors-only RS.
_decode_rsuses no erasures and no soft information; WSJT-X's JT65 additionally uses soft-symbol and Franke–Taylor deep-search decoding that reaches several dB deeper at low SNR. Expect this decoder to be less sensitive thanjt9on marginal signals. - No fine time refine. Timing is only searched at ⅓-symbol resolution (
int(nsps)//3,jt65.py); there is no sub-symbolt0alignment, unlike the sub-binf0refine. This costs a little SNR and slightly biases the reporteddt. snris a sync score, not dB. The reportedsnris the accumulated normalised sync-tone power (jt65.py), useful for ranking candidates but not a calibrated signal-to-noise ratio.- Single-candidate output.
decode_slotreturns at most one message (the best sync peak); it does not enumerate multiple co-slot signals or subtract-and-repeat like WSJT-X's multi-decode. - Pure-Python cost. The 2-D coarse search plus per-symbol DTFTs are CPU-heavy; the 60 s T/R period gives ample headroom but the loops are unvectorised.
None of these are protocol-level defects — they are the gap between a faithful baseline codec and WSJT-X's decade-tuned decoder, and each (soft RS, deep search, submode B/C, fine timing, multi-decode) is a bounded addition.
9. References
- J. Taylor (K1JT) and the WSJT-X project — the JT65 protocol and its
gen65.f90/jt65code/jt65simreference tools (GPLv3; used here only as a behavioural oracle, not as source). - J. Taylor, "The JT65 Communications Protocol," QEX, Sept/Oct 2005 — the original RS(63,12)/GF(64) + 65-FSK + sync-vector design.
- S. W. Franke & J. H. Taylor, "Open Source Soft-Decision Decoder for the JT65 (63,12) Reed–Solomon Code," QEX, 2016 — the soft/deep-search decoding not implemented here.
- P. Karn (KA9Q), libfec — the GF(2⁶) Reed–Solomon codec (
init_rs, Berlekamp–Massey, Chien, Forney) whose parameters and structure this decoder mirrors. - E. R. Berlekamp, Algebraic Coding Theory, 1968 — the LFSR encoder and the error-locator recursion.
- G. D. Forney, "On Decoding BCH Codes," IEEE Trans. Inf. Theory, 1965 — the error- magnitude (Forney) algorithm.
- Rafe internal:
../native-digimodes.md(native-mode plan and oracle discipline),wsjtx-message-packing.md(the message- packing family), and../rvqvoice.md(the specification template).