Patrick Lidstone
Self-hosted

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 across 37·36·10·27·27·27 = 262 177 560 (NBASE); special tokens CQ, QRZ, DE, CQ nnn map to NBASE+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) occupy NGBASE+1 … NGBASE+64 (NGBASE = 180·180 = 32400).
  • The three fields are bit-sliced into the 12 six-bit words dat[0..11] (pack.py): nc1 fills the top 28 bits, nc2 the next 28, ng the low 16 — exactly 72 bits, 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: tshift over 0 … 1.5·sample_rate in steps of int(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 matrix mat[126, ·]: for symbol i, take the fftlen-sample block at tshift + round(i·nsps), zero-pad if short, rfft, and store |·|². Normalise each symbol's spectrum by its total power tot[i].
  • Frequency: for each base bin fb in [⌊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 bin fb across 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:

  1. 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.
  2. Berlekamp–Massey iterates the error-locator polynomial λ(x): each round computes the discriminant discr_r, updates λ and the shift register b, and conditionally grows the locator degree (jt65.py).
  3. Chien search evaluates λ at every field element to find its roots — the error locations — filling root[]/loc[] (jt65.py); if the number of roots ≠ deg λ, decode fails (−1).
  4. Forney algorithm forms the error-evaluator Ω(x) and computes each error magnitude Ω(X⁻¹) / λ'(X⁻¹) (with the FCR-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 the JT65_DGEN fixture, and interleave→gray equals the JT65_INFO63 fixture (test_jt.py). The full 126-symbol stream is range/structure-checked (0 ≤ t ≤ 65, exactly 63 sync zeros, test_jt.py).
  • jt65sim renders reference .wav slots; the native decoder decodes them (per ../native-digimodes.md, "decodes jt65sim reference"), and the encoder's output round-trips through the real jt9 -6 decoder on prod.
  • Native round-trip (test_jt.py): rs_decode(rs_encode(dgen)) == dgen, and decode_slot(encode_pcm(m)) recovers m for CQ 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:

  1. packmsg[62,32,32,49,39,55,3,2,14,5,33,40] (§4 Step 1).
  2. rs_encode → the 63-symbol sent in §4 Step 2 (parity [0..50], data [51..62]).
  3. interleave→gray → the 63-value JT65_INFO63 in §4 Step 4.
  4. Sync-merge → 126 tones with tone 0 at exactly the 63 NPRC==1 positions.
  5. 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.py handles two-callsign + grid/report/RO/RRR/73. Compound/prefix/suffix calls and free text are not implemented (pack.py, unpack.py returns None on the free-text flag). WSJT-X's plain-text and Type-2 messages won't encode/decode.
  • Hard-decision, errors-only RS. _decode_rs uses 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 than jt9 on marginal signals.
  • No fine time refine. Timing is only searched at ⅓-symbol resolution (int(nsps)//3, jt65.py); there is no sub-symbol t0 alignment, unlike the sub-bin f0 refine. This costs a little SNR and slightly biases the reported dt.
  • snr is a sync score, not dB. The reported snr is the accumulated normalised sync-tone power (jt65.py), useful for ranking candidates but not a calibrated signal-to-noise ratio.
  • Single-candidate output. decode_slot returns 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

  1. J. Taylor (K1JT) and the WSJT-X project — the JT65 protocol and its gen65.f90 / jt65code / jt65sim reference tools (GPLv3; used here only as a behavioural oracle, not as source).
  2. J. Taylor, "The JT65 Communications Protocol," QEX, Sept/Oct 2005 — the original RS(63,12)/GF(64) + 65-FSK + sync-vector design.
  3. 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.
  4. P. Karn (KA9Q), libfec — the GF(2⁶) Reed–Solomon codec (init_rs, Berlekamp–Massey, Chien, Forney) whose parameters and structure this decoder mirrors.
  5. E. R. Berlekamp, Algebraic Coding Theory, 1968 — the LFSR encoder and the error-locator recursion.
  6. G. D. Forney, "On Decoding BCH Codes," IEEE Trans. Inf. Theory, 1965 — the error- magnitude (Forney) algorithm.
  7. 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).