JT4 — native 4-FSK weak-signal mode (K=32 convolutional, 206 symbols)
A reproduction-grade specification of the in-repo JT4 encoder and decoder: 72-bit source packing, a rate-½ constraint-length-32 convolutional code, a bit-reversal interleaver, a 206-bit merged sync/data symbol map over 4-FSK, and a soft-decision sequential stack decoder.
Rafe project · app/radio/jtx/jt4.py, jt9.py, fec.py, pack.py, unpack.py · clean-room from the WSJT-X protocol, validated against jt4code/jt4sim
1. Abstract
JT4 is one of the WSJT-X weak-signal digimodes, designed by Joe Taylor K1JT for EME (moonbounce) and microwave work where signals sit far below the noise floor and the path may impose large Doppler spread. This document specifies the native, in-repo JT4 codec (app/radio/jtx/jt4.py), which replaces the external WSJT-X jt9 binary for this mode. The codec is pure NumPy at runtime and shares three of its four stages with the sibling JT9 codec: the same 72-bit source packing (pack.py), the same K=32 rate-½ convolutional code and sequential decoder (jt9.py/fec.py), and the same 206-bit bit-reversal interleaver (jt9.py). JT4 differs only in the modulation front-end: where JT9 maps 3 interleaved bits per 9-FSK data symbol and inserts sync as a 16-position tone-0 pattern, JT4 uses 4-FSK and merges one data bit and one sync bit into every one of its 206 symbols via tone = 2·data + sync. The sync bit stream is a fixed 206-bit pseudo-random vector (npr), optionally inverted for negative-report messages so the receiver can disambiguate the two. Decoding is an FFT-per-symbol power demod, a joint time/frequency sync search that marginalises over the unknown data bit, per-symbol soft LLRs, deinterleaving, and a soft-decision Zigangirov–Jelinek stack sequential decoder — not a Viterbi decoder, which is intractable at constraint length 32 (2³¹ states). The encoder is bit-exact against WSJT-X's own jt4code reference (the 206-symbol vector for CQ K1ABC FN42 matches to the symbol), and the full audio path round-trips through the native decoder for standard, negative-report and 73 messages.
2. Background
2.1 Where JT4 sits in the WSJT-X family
The classic WSJT "structured message" modes — JT65, JT9 and JT4 — all carry the same 72-bit source payload (two callsigns plus a grid/report, or a handful of shorthand tokens; §6.5) and differ in their channel code and modulation:
| Mode | FEC | Modulation | Data symbols | Frame |
|---|---|---|---|---|
| JT65 | Reed–Solomon(63,12) / GF(64) | 65-FSK | 63 | 126 symbols |
| JT9 | K=32 r=½ convolutional | 9-FSK | 69 | 85 symbols |
| JT4 | K=32 r=½ convolutional | 4-FSK | 206 | 206 symbols |
JT4 and JT9 therefore share their entire FEC/interleave stack (fec.py, encode232, _interleave9); only the symbol map is mode-specific. JT4 uses the narrowest per-tone alphabet of the three (4-FSK), which is what makes its submodes possible: because only 4 tones are needed, the tone spacing can be widened by large integer factors without the signal becoming impractically wide, which is exactly what a Doppler-spread EME/microwave channel needs.
All three run in a 60-second T/R sequence (SLOT_SECONDS = 60, jt4.py), transmit at a fixed 4.375-baud keying rate, and are decoded against a captured 12 kHz mono slot.
2.2 JT4 submodes A–G
JT4 defines seven submodes, A through G. They are identical in every respect specified in this document — same 72-bit source, same K=32 code, same 206-symbol frame, same npr sync, same 4.375-baud keying rate — and differ only in the 4-FSK tone spacing (equivalently, the occupied bandwidth). The spacing is an integer multiple of the base 4.375 Hz keying rate:
| Submode | Multiplier | Tone spacing | ≈ occupied BW (≈ 4× spacing) | Typical use |
|---|---|---|---|---|
| JT4A | 1× | 4.375 Hz | ≈ 17.5 Hz | HF / 6 m, low Doppler |
| JT4B | 2× | 8.75 Hz | ≈ 35 Hz | 2 m / 70 cm EME |
| JT4C | 4× | 17.5 Hz | ≈ 70 Hz | 70 cm EME |
| JT4D | 9× | 39.375 Hz | ≈ 157.5 Hz | 23 cm EME |
| JT4E | 18× | 78.75 Hz | ≈ 315 Hz | microwave |
| JT4F | 36× | 157.5 Hz | ≈ 630 Hz | microwave, high Doppler |
| JT4G | 72× | 315 Hz | ≈ 1260 Hz | 10 GHz+ EME, extreme Doppler |
(The four tones span 3× the spacing; the transmitted spectrum, including the first modulation sidelobe on either side, occupies roughly 4× the spacing — 17.5 Hz for JT4A.)
What this implementation covers. The native codec implements all submodes A–G. The keying rate is fixed at
baud = sample_rate / NSPS = 12000 / 2742 = 4.376368 Hz; the tone spacing isbaud * submodewheresubmodeis the multiplier 1/2/4/9/18/36/72 (A–G).synth/encode_pcm/decode_slotall take asubmodeargument, and the four 4-FSK tones are placed/read at binsfb + k*submode(jt4.py); the source, code, interleave and sync logic are submode-independent. All seven submodes are round-trip-tested (test_jt4_submodes).
2.3 The name "npr" and the 206/207 quirk
WSJT-X's gen4.f90 stores the JT4 sync bits in a Fortran array npr that is declared with a leading element that is never used in symbol generation. The native port preserves that shape faithfully: the NPR tuple has 207 entries (jt4.py, assert len(NPR) == 207), and symbol i consumes NPR[i+1], i.e. NPR[1..206] — the Fortran npr(2:207) slice. NPR[0] (value 0) is inert. So "207" is the length of the stored constant; the frame is 206 symbols (NSYM = 206, jt4.py) carrying 206 sync bits.
3. Signal, frame and symbol structure
All numbers below are the exact values in the running code; citations are file:line.
3.1 Timing and sampling
| Quantity | Symbol | Value | Source |
|---|---|---|---|
| Sample rate | sample_rate |
12000 Hz | jt4.py |
| Samples per symbol | NSPS |
2742 = int(12000/4.375) |
jt4.py |
| Keying rate / tone spacing (JT4A) | baud |
12000/2742 = 4.376368 Hz | jt4.py |
| Symbol period | — | 2742/12000 = 0.22850 s | derived |
| Channel symbols | NSYM |
206 | jt4.py |
| Signal duration | — | 206·2742/12000 = 47.071 s | derived |
| T/R slot | SLOT_SECONDS |
60 s | jt4.py |
| Default lead-in silence | lead_s |
1.0 s | jt4.py |
| Default audio centre (tone 0) | f0 |
1500.0 Hz | jt4.py |
The transmitted waveform is a continuous-phase 4-FSK tone stream (§4.5): 206 symbols × 0.22850 s = 47.071 s, placed at a 1.0 s offset inside the 60 s slot (jt4.py), leaving the remainder as silence.
3.2 The four-stage pipeline
text ─▶ pack72 ─▶ conv K=32 r=½ ─▶ interleave(206) ─▶ merge sync ─▶ 4-FSK
(§4.1) (§4.2) (§4.3) (§4.4) (§4.5)
72 bits 206 coded bits 206 scrambled 206 tones 47.07 s audio
bits 0..3
- Source (72 bits). Two 28-bit callsign fields + one 16-bit grid/report field (
pack.py, §6.5). - Channel code (206 bits). 72 message bits + 31 zero tail = 103 input bits through a rate-½ K=32 convolutional code → 103·2 = 206 coded bits (
jt9.py:encode232, §4.2). No puncturing; the count is exact. - Interleave (206 bits). An 8-bit-reversal permutation over indices 0…205 (
jt9.py:_interleave9, §4.3) — literally the JT9 interleaver (jt4.pycalls_interleave9; the module comment notes "interleave4 == interleave9",jt4.py). - Symbol map (206 tones). Each scrambled bit is one data bit; each frame position also has one sync bit from
NPR; the transmitted tone is2·data + sync ∈ {0,1,2,3}(jt4.py, §4.4).
3.3 The merged sync/data symbol map
This is the defining feature of JT4. For symbol i (0 ≤ i < 206):
data_i = scrambled[i] # 0 or 1 (the interleaved coded bit)
sync_i = NPR[i+1] (or 1-NPR[i+1] if inverted, §4.4) # 0 or 1
tone_i = 2 * data_i + sync_i # 0, 1, 2 or 3
So the low tone bit is the sync bit and the high tone bit is the data bit:
| tone | high bit (data) | low bit (sync) |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 0 | 1 |
| 2 | 1 | 0 |
| 3 | 1 | 1 |
Consequently, for a known sync bit s, the data bit is decided purely by which of the two tones {s, s+2} is present — and summing the power in exactly those two bins recovers all of the symbol's energy regardless of the unknown data bit. The decoder exploits both facts: it uses the {s, s+2} power sum for the sync search (§5.3) and the log-power difference log P[s+2] − log P[s] as the data LLR (§5.4).
4. Encode — step by step
Entry points: channel_symbols(message) → 206 tones (jt4.py); encode_pcm(message, f0, sample_rate, lead_s) → 60 s of s16le PCM (jt4.py).
4.1 Source packing → 72 bits
bits = msg_bits(message) (jt4.py, pack.py) parses the message into two callsign fields and one grid/report field, packs them into twelve 6-bit words (packmsg, pack.py), and emits 72 bits MSB-first per word. Returns None for messages the standard packer cannot represent (prefix/suffix/ free-text are not implemented). See §6.5 for the field layout.
4.2 Convolutional coding → 206 bits
enc = encode232(bits, 206) (jt4.py, jt9.py). A rate-½, constraint-length-32 convolutional encoder with the two 32-bit generator polynomials
CONV_POLY = (0xF2D05351, 0xE4613C47) # jt9.py, fec.py
The encoder maintains a 32-bit shift register reg, shifts in one input bit at a time (MSB-first), masks to 32 bits, and emits the even parity of reg & poly0 then reg & poly1:
reg = 0
out = []
for b in bits + [0] * 40: # message bits then an ample zero tail
reg = ((reg << 1) | b) & 0xFFFFFFFF
out.append(_parity(reg & CONV_POLY[0]))
if len(out) >= n_out: break
out.append(_parity(reg & CONV_POLY[1]))
if len(out) >= n_out: break
return out # jt9.py
_parity(x) = bin(x).count("1") & 1 (jt9.py). The loop stops at exactly n_out = 206 outputs. Because the code is rate-½, those 206 outputs consume 103 input bits: the 72 message bits followed by the first 31 of the zero-tail bits. That 31-bit tail (TAIL = 31, fec.py = K−1) flushes the register to a known all-zeros state, which the decoder relies on (§5.5). The [0]*40 padding is merely "ample tail"; only 31 tail bits are actually consumed.
Both generators have Hamming weight 15 (popcount(0xF2D05351) = popcount(0xE4613C47) = 15); in octal they are 0o36264051521 and 0o34430236107. These are the same polynomials WSPR and JT9 use (jt9.py comment: "== wsjtx conv232 npoly1/npoly2").
4.3 Interleaving → 206 scrambled bits
scr = _interleave9(enc) (jt4.py, jt9.py) applies a bit-reversal permutation. For each i in 0…255, compute n = bitrev8(i) (the 8-bit reversal of i, jt9.py); if n ≤ 205, take the next encoded bit and place it at scrambled index n:
out = [0] * 206
k = -1
for i in range(256):
n = _bitrev8(i)
if n <= 205:
k += 1
out[n] = bits206[k] # scrambled[bitrev8(i_k)] = encoded[k]
return out # jt9.py
The 50 values of i whose reversal exceeds 205 are skipped (256 − 206 = 50). The first few (encoded-index k, source i, scrambled position bitrev8(i)) triples are (0,0,0) (1,1,128) (2,2,64) (3,3,192) (4,4,32) (5,5,160) (6,6,96) (7,8,16) — note i=7 is dropped because bitrev8(7) = 224 > 205. The decoder inverts this exactly with _deinterleave9 (jt9.py).
4.4 Sync merge → 206 tones
msg = message.upper()
i1 = msg.find("-")
invert = i1 >= 8 and i1 + 1 < len(msg) and msg[i1 + 1] in "0123"
return [2 * scr[i] + ((1 - NPR[i + 1]) if invert else NPR[i + 1])
for i in range(NSYM)] # jt4.py
- The standard sync is
NPR[i+1]— the 206-bit pseudo-random vector of §6.1. - Inverted sync (
1 − NPR[i+1]) is used for negative-report messages, so the receiver can tell a report like-05apart from a grid at the same tones. The trigger is a-at message index ≥ 8 (i.e. in the report field, past both 6-char calls and their separators) immediately followed by one of0 1 2 3(the first digit of reports −01…−30). This fires for both… -NNand… R-NNreports; e.g. inG0ABC M0SUP -05the-is at index 12 and is followed by0, so the frame is transmitted with inverted sync.
The result is a list of 206 tones, each in {0,1,2,3}. For CQ K1ABC FN42 the first sixteen tones are [2,2,0,1,1,2,0,2,3,3,2,3,1,2,2,1] — bit-exact against jt4code (the full 206-tone golden vector is JT4_CQ in test_jt.py).
4.5 Waveform synthesis
synth(tones, f0, sample_rate) (jt4.py) renders continuous-phase 4-FSK: each tone occupies NSPS = 2742 samples at frequency f0 + tone·baud, baud = sample_rate/NSPS; the instantaneous frequency vector is integrated once to produce a phase-continuous sin (no per-symbol phase resets, so the spectrum stays clean):
baud = sample_rate / NSPS
freqs = np.repeat([f0 + t * baud for t in tones], NSPS)
phase = 2 * np.pi * np.cumsum(freqs) / sample_rate
return np.sin(phase).astype(np.float32) # jt4.py
encode_pcm (jt4.py) then places this 47.071 s tone stream at lead_s = 1.0 s inside a 60 s all-zeros buffer, clips to ±1, and quantises to little-endian s16 (np.round(clip*32767)), returning SLOT_SECONDS * sample_rate = 720000 samples = 1 440 000 bytes.
Note — no GFSK smoothing. JT4's transmitted tones are hard-switched continuous-phase FSK. WSJT-X applies a small amount of Gaussian smoothing to the tone transitions for a cleaner on-air spectrum; the native TX does not (same limitation noted for the native WSPR TX in
docs/native-digimodes.md). It decodes correctly; the spectral cleanliness is slightly worse than WSJT-X.
5. Decode — step by step
Entry point: decode_slot(pcm, sample_rate=12000, f_lo=200.0, f_hi=2700.0) (jt4.py), returning [{snr, dt, df, msg}] or [].
5.1 Input conditioning
s16le bytes are converted to float in ±1 (np.frombuffer(pcm, "<i2")/32768.0, jt4.py); a NumPy array is taken as-is. The FFT bin width is
dfr = sample_rate / NSPS = 12000/2742 = 4.376368 Hz # jt4.py
bin_lo = int(f_lo / dfr) = int(200/4.376) = 45 # jt4.py
bin_hi = int(f_hi / dfr) = int(2700/4.376) = 616 # jt4.py
so the search covers audio ≈ 197…2697 Hz. Two sync reference vectors are formed: sync_std[i] = NPR[i+1] and sync_inv[i] = 1 − NPR[i+1] (jt4.py).
5.2 Per-symbol power (the FFT front-end)
_power_matrix(sig, t0, nbins) (jt4.py) slices the signal into 206 consecutive NSPS-sample blocks starting at sample offset t0, zero-pads a short final block, takes a real FFT of each block, and stores the power spectrum (real² + imag²) of the first nbins bins:
mat = np.zeros((NSYM, nbins))
for i in range(NSYM):
a = t0 + i * NSPS
block = sig[a:a + NSPS] # 2742 samples
if len(block) < NSPS:
block = np.concatenate([block, np.zeros(NSPS - len(block))])
spec = np.fft.rfft(block)
mat[i] = spec.real[:nbins]**2 + spec.imag[:nbins]**2 # jt4.py
The FFT length equals the symbol length (NSPS), so each symbol maps to exactly one 4.376 Hz bin per tone; a 4-FSK symbol at base bin fb puts its energy in one of {fb, fb+1, fb+2, fb+3}. This is a non-coherent (energy) demod — no carrier phase is tracked.
5.3 Joint time/frequency sync search
A coarse grid search over start time dt and base bin fb (jt4.py):
- Time:
dtfrom 0 toint(1.5·sample_rate) = 18000samples in steps ofNSPS//4 = 685samples (a quarter-symbol, ≈ 57 ms) — about 27 hypotheses. A hypothesis is skipped if the frame would run past the buffer (dt + NSYM·NSPS > len(sig) + NSPS,jt4.py). - Frequency:
fbfrombin_lotobin_hi(≈ 571 base bins), the four tone bins beingfb … fb+3(_power_matrixcomputesbin_hi + 6bins to leave headroom,jt4.py).
For each (dt, fb) the score marginalises over the unknown data bit by summing the two bins that must hold energy under the standard sync value at each symbol, normalised by that symbol's total power:
tot = mat.sum(axis=1) + 1e-12
q = mat[:, fb:fb + 4]
good = np.array([q[i, sync_std[i]] + q[i, sync_std[i] + 2] for i in range(NSYM)])
sc = float(np.sum(good / tot)) # jt4.py
The (dt, fb) with the largest sc wins (jt4.py). This is exactly the "sum the {s, s+2} pair" trick of §3.3: for a correctly-aligned standard-sync signal, good[i] collects the whole symbol's tone energy, so sc peaks sharply at the true time and frequency.
Sync polarity in the search. The coarse search scores with
sync_stdonly. Inverted-sync (negative-report) signals still acquire: their energy lands in the complementary pair{1−s, 3−s}, which the search partly captures by locking a base bin one step low, after which the inverted-sync decode (§5.4) succeeds. Empirically the full audio round-trip recoversG0ABC M0SUP -05(its reporteddfsits one 4.376 Hz bin below the true carrier); seetest_jt.py.
5.4 Soft demodulation and the data LLR
With the winning (t0, fb, mat), _try_decode(mat, fb, sync) (jt4.py) forms one soft value per symbol. Take the 4 tone powers p = mat[:, fb:fb+4], their logs s = log(p + 1e-12), and the log-power difference between the data-1 tone (sync+2) and the data-0 tone (sync):
p = mat[:, fb:fb + 4]
s = np.log(p + 1e-12)
soft = np.array([s[i, sync[i] + 2] - s[i, sync[i]] for i in range(NSYM)]) # jt4.py
soft[i] > 0 favours data bit 1, < 0 favours 0 — a per-symbol max-log LLR that conditions on the known sync bit (only the two tones consistent with that sync value enter the comparison; the other two are ignored). These 206 soft values are then deinterleaved back to convolutional-code order:
conv = _deinterleave9(list(soft)) # jt4.py, jt9.py
5.5 Soft-decision sequential (stack) decoding
bits = stack_decode(conv, 72) (jt4.py, fec.py) recovers the 72 message bits from the 206 soft values. This is not a Viterbi decoder — a constraint-length-32 code has 2³¹ ≈ 2.1 billion states, so full trellis decoding is infeasible. Instead it is a Zigangirov–Jelinek stack sequential decoder with a soft (Fano-type) bit metric — the same class of algorithm WSJT-X uses for this code.
Setup (fec.py):
n_in = n_msg + TAIL = 72 + 31 = 103— the number of input bits to recover (message + known-zero tail).The soft values are normalised by their mean absolute value (
scale = mean(|r|),r /= scale,fec.py) so the metric is amplitude-invariant;alpha = 2.0(fec.py).Bit metric (
fec.py), the Massey/Fano metric with the rate-½ bias0.5bits:def bit_metric(val, e): # e = expected coded bit 0/1 x = alpha * val * (1 if e == 1 else -1) if x > 30: ls = 0.0 elif x < -30: ls = x / np.log(2) else: ls = -log2(1.0 + np.exp(-x)) return 0.5 + ls # fec.pyWhen the hypothesised coded bit agrees with the soft value the metric is ≈ 0.5; when it disagrees the metric goes sharply negative. The
+0.5bias makes a random path drift to ≈ 0, so the correct path rises above the field.
The search itself (fec.py) is a best-first (priority-queue) tree walk over the input-bit tree, each node carrying its accumulated path metric, depth, 32-bit register state and the bits chosen so far:
poly0, poly1 = CONV_POLY
heap = [(-0.0, 0, 0, 0, ())] # (-metric, tiebreak, depth, reg, bits)
while heap and pops < max_pops: # max_pops = 300000
neg_m, _, depth, reg, bits = heapq.heappop(heap)
metric = -neg_m
if depth == n_in:
return list(bits[:n_msg]) # 72 message bits
choices = (0,) if depth >= n_msg else (0, 1) # tail bits forced to 0
for b in choices:
reg2 = ((reg << 1) | b) & 0xFFFFFFFF
e0 = _popcount(reg2 & poly0) & 1
e1 = _popcount(reg2 & poly1) & 1
bm = bit_metric(r[2*depth], e0) + bit_metric(r[2*depth + 1], e1)
heapq.heappush(heap, (-(metric + bm), counter, depth+1, reg2, bits+(b,)))
counter += 1
return None # fec.py
Two things make this efficient. First, the known 31-bit zero tail: once the tree reaches depth ≥ n_msg (72) only the branch b = 0 is explored (choices = (0,), fec.py), so the tail is not searched. Second, the best-first ordering means the correct high-metric path is usually reached in far fewer than the max_pops = 300000 node budget; if the budget is exhausted or the heap empties, the decoder returns None (a decode failure). The re-encode inside the loop (reg2, e0, e1) mirrors §4.2 exactly, so the metric compares the soft values against the code's own parity outputs.
5.6 Unpack and result
On success the 72 bits are grouped into twelve 6-bit words (bits_to_dat, unpack.py) and decoded to text (unpackmsg, unpack.py, §6.5). decode_slot tries sync_std first and then sync_inv at the same winning cell (jt4.py) — so a single pass handles both standard and negative-report frames — and returns the first that yields a message:
for sync in (sync_std, sync_inv):
msg = _try_decode(mat, fb, sync)
if msg:
return [{"snr": "%.0f" % best[0],
"dt": "%.1f" % (t0 / sample_rate),
"df": "%.0f" % (fb * dfr),
"msg": msg}] # jt4.py
msg— decoded text.dt— frame start in seconds (t0 / sample_rate).df— carrier (tone-0) frequency in Hz (fb · dfr).snr— the raw sync-correlation scorebest[0](the §5.3sc), not a calibrated dB SNR. It is a detection strength (e.g. ≈ 166 for a clean on-binCQ K1ABC FN42, ≈ 124 for an inverted-sync-05), formatted with%.0f. Treat it as a confidence figure, not decibels (§8).
6. Constants and tables
Everything a reimplementation needs, quoted verbatim with citations.
6.1 The sync vector NPR (207 stored, 206 used)
jt4.py. Symbol i uses NPR[i+1]; NPR[0] = 0 is inert (§2.3).
NPR = (
0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0,
1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1,
0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0, 1,
0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1,
0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1,
1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1,
) # len(NPR) == 207
The 206-bit sync stream NPR[1:207] begins 0,0,0,1,1,0,0,0,1,1,0,1,1,0,0,1,…. For negative-report messages every bit is complemented (1 − NPR[i+1], §4.4).
6.2 Convolutional code
| Parameter | Value | Source |
|---|---|---|
| Constraint length K | 32 | 32-bit register mask 0xFFFFFFFF (jt9.py, fec.py) |
| Rate | 1/2 | two parity outputs per input bit (jt9.py) |
| Generator poly 0 | 0xF2D05351 = 0o36264051521 = 0b11110010110100000101001101010001 (wt 15) |
jt9.py, fec.py |
| Generator poly 1 | 0xE4613C47 = 0o34430236107 = 0b11100100011000010011110001000111 (wt 15) |
jt9.py, fec.py |
| Zero tail | 31 bits (K−1) | TAIL = 31, fec.py |
| Input bits | 103 = 72 message + 31 tail | fec.py |
| Coded bits | 206 = 103 × 2 | jt4.py (encode232(bits, 206)) |
Bit order is MSB-first into the register; each step emits parity(reg & poly0) then parity(reg & poly1) (jt9.py).
6.3 Interleaver
8-bit-reversal permutation over indices 0…205 (jt9.py). Rule: for i = 0…255, n = bitrev8(i); if n ≤ 205, the next coded bit goes to scrambled index n. 50 indices with bitrev8(i) > 205 are skipped. Inverse: _deinterleave9 (jt9.py). This is identical to JT9's interleaver (jt4.py comment).
6.4 Frame / modulation summary
| Parameter | Value | Source |
|---|---|---|
| Channel symbols | 206 | NSYM, jt4.py |
| Modulation | continuous-phase 4-FSK | jt4.py |
| Symbol map | tone = 2·data + sync |
jt4.py |
| Samples/symbol @12k | 2742 | NSPS, jt4.py |
| Tone spacing (JT4A) | 4.376368 Hz | jt4.py |
Default carrier f0 |
1500 Hz | jt4.py |
| Slot | 60 s, 1 s lead-in | jt4.py |
Decode search window dt |
0…1.5 s, step ¼ symbol (685 samples) | jt4.py |
Decode search window df |
≈197…2697 Hz (bins 45…616) | jt4.py |
| Stack decoder budget | 300000 pops | fec.py |
6.5 Source message packing (summary)
The JT4 payload is the legacy 72-bit "JT" packing (pack.py, unpack.py; ports of WSJT-X packjt.f90), shared with JT9 and JT65. It is distinct from the newer 77-bit pack77 used by FT8/FT4/FST4/Q65 — see the companion WSJT-X message packing spec for that scheme; the notes below cover the 72-bit form this mode actually uses.
A standard message is two callsigns + one grid/report, packed as 28 + 28 + 16 = 72 bits:
- Callsign field — 28 bits (
packcall,pack.py). A standard call is normalised to 6 characters (digit forced into position 3; a leading space added when the call has a 1-char prefix,_norm_call,pack.py) and packed radix-37·36·10·27·27·27(NBASE = 262177560,pack.py). Shorthand tokens use the reserved codepoints aboveNBASE:CQ = NBASE+1,QRZ = NBASE+2,CQ nnn(directed CQ)= NBASE+3+nnn,DE = 267796945(pack.py). A few DXpedition prefixes are remapped (3DA0→3D0,3X→Q…,pack.py). - Grid/report field — 16 bits (
packgrid,pack.py). A 4-char Maidenhead locator is converted to lon/lat degrees (_grid2deg,pack.py) and packed as((lon+180)//2)·180 + lat, which stays belowNGBASE = 180·180 = 32400(pack.py). Values ≥NGBASEencode shorthands: blank gridNGBASE+1; signal reports−01…−30atNGBASE+1+n;R−01…R−30atNGBASE+31+n;RO = NGBASE+62,RRR = NGBASE+63,73 = NGBASE+64(pack.py). Extended numeric reports −50…+49 map throughKAnn/LAnnpseudo-grids (pack.py).
packmsg (pack.py) assembles the three fields into twelve 6-bit words dat[0..11] with the exact bit-slicing shown at pack.py; msg_bits (pack.py) serialises them MSB-first into 72 bits. unpackmsg (unpack.py) inverts this: ng ≥ 32768 flags free-text / plain-text and returns None (not decodable by the standard unpacker). Not implemented (returns None): prefix/suffix compound calls and arbitrary free text (pack.py docstring; docs/native-digimodes.md).
7. Interoperability and validation
The mode is developed clean-room — from the published JT4 protocol description, not from GPL WSJT-X source — and every constant is confirmed behaviourally against WSJT-X's own reference utilities, which prod ships (jt4code, jt4sim, plus jt9). These are treated as behavioral oracles: observe input→output of a black box, exactly as FT8 is validated against gen_ft8 (docs/native-digimodes.md, "Clean-room via the WSJT-X *code/*sim oracles").
7.1 jt4code — the encoder oracle (bit-exact)
jt4code "<message>" prints, among other things, the 206 channel symbols WSJT-X would transmit. The native encoder is validated to reproduce that vector symbol-for-symbol:
channel_symbols("CQ K1ABC FN42")must equal the goldenJT4_CQvector captured fromjt4code(test_jt.py,test_jt4_symbols,test_jt.py). This exercises the whole encode chain: pack → K=32 conv → interleave →2·data + sync.- Inverted-sync reports are covered too:
test_jt4_inverted_sync_report(test_jt.py) checksG0ABC M0SUP -05,G0ABC M0SUP R-05andM0SUP G0ABC 73all produce 206 valid symbols in{0,1,2,3}— i.e. the §4.4 invert trigger fires (or not) as WSJT-X does.
Reproducing the golden vector from scratch (as this document's chain does) yields [2,2,0,1,1,2,0,2,3,3,2,3,1,2,2,1, …] — the same 206 symbols jt4code prints.
7.2 jt4sim / jt9 — the decoder oracle
Two complementary checks:
- Native round-trip (
test_jt4_decode_roundtrip,test_jt.py):decode_slot(encode_pcm(m))must returnmforCQ K1ABC FN42,M0SUP G0ABC 73andG0ABC M0SUP -05— the last exercising the inverted-sync decode path end to end. - Against WSJT-X audio (
docs/native-digimodes.md, decoder-status table): the native decoder "round-trip[s] + decodesjt49simreference" — i.e. it demodulates the reference.wavfiles that WSJT-X'sjt4sim/jt49simgenerator produces, proving the demod/sync/LLR/stack chain interoperates with genuine WSJT-X-shaped signals, not just its own encoder's output.
The app wires JT4 to prod's jt9 -4 -p 60 -b A -F 500 -d 3 for the external backend (wsjtx.py), so native and external can be A/B-compared on the same captured slot (RAFE_WSJTX_BACKEND = native | external, codec_backend.py).
7.3 Reproducing from this document
from app.radio.jtx.jt4 import channel_symbols, encode_pcm, decode_slot
channel_symbols("CQ K1ABC FN42")[:16]
# -> [2, 2, 0, 1, 1, 2, 0, 2, 3, 3, 2, 3, 1, 2, 2, 1]
decode_slot(encode_pcm("G0ABC M0SUP -05", f0=1500.0))
# -> [{'snr': '124', 'dt': '1.0', 'df': '1497', 'msg': 'G0ABC M0SUP -05'}]
An independent reimplementation is correct when (a) its 206-symbol vector for a suite of messages equals jt4code's, and (b) its decoder recovers those messages from jt4sim/jt49sim audio and from its own encode_pcm output, including at least one negative-report (inverted-sync) message.
8. Limitations and honest notes
Faithful to what the code does today, roughly in order of impact:
- All submodes A–G supported. The tone spacing is
baud * submode(multiplier 1/2/4/9/18/36/72);synth/encode_pcm/decode_slottake asubmodeargument and the 4-FSK tones are placed/read at binsfb + k*submode. Round-trip-tested A–G (test_jt4_submodes). The FEC/interleave/sync/source are submode-independent. snris a correlation score, not dB. The reportedsnris the raw §5.3 sync score (best[0]), not a calibrated signal-to-noise ratio; do not compare it to WSJT-X's dB figures (§5.6).- Sensitivity below WSJT-X. The decoder is single-pass: non-coherent FFT-per-symbol demod → max-log LLR → stack sequential decode. It has no sub-bin frequency fit, no frequency-drift tracking, no coherent multi-symbol processing, and no deep-search / a-priori decoding. On low-SNR EME signals WSJT-X will reach a few dB deeper (same gap noted for the native WSPR/JT9 decoders in
docs/native-digimodes.md). - Coarse frequency grid. The sync search resolves frequency only to one 4.376 Hz bin (
dfr); there is no fractional-bin interpolation, sodfand the reported carrier can be off by up to ~1 bin (visible in the inverted-sync case, §5.3). - Inverted-sync acquisition is indirect. The coarse search optimises the standard-sync score; inverted-sync frames acquire by locking an adjacent base bin (§5.3). It works (validated) but is not the tight, polarity-matched sync a from-scratch design might choose.
- No TX pulse shaping. Hard-switched continuous-phase FSK, no Gaussian smoothing (§4.5) — decodes fine, slightly wider transmitted spectrum than WSJT-X.
- Standard messages only. Prefix/suffix compound calls and free text are not packed or unpacked (§6.5); such messages return
None. - Runtime. Pure-Python FFT-per-symbol over ~27 time × ~571 frequency hypotheses plus a stack decode is CPU-heavy; the 60 s T/R period gives ample headroom, but it is not optimised.
None of these change the on-air protocol; they are sensitivity/quality refinements on a correct, interoperable base.
Implementation map
| File | Role |
|---|---|
app/radio/jtx/jt4.py |
JT4 symbol map, 4-FSK synth, FFT demod + sync search + soft LLR, decode_slot/encode_pcm |
app/radio/jtx/jt9.py |
shared encode232 (K=32 conv), _interleave9/_deinterleave9, _bitrev8 |
app/radio/jtx/fec.py |
shared stack_decode (soft sequential/stack decoder), CONV_POLY, TAIL |
app/radio/jtx/pack.py |
72-bit source packing (packcall/packgrid/packmsg/msg_bits) |
app/radio/jtx/unpack.py |
72-bit unpacking (unpackmsg/bits_to_dat) |
app/radio/wsjtx.py |
app integration; native vs external (jt9 -4) backend selection |
test_jt.py |
encoder golden vector (JT4_CQ) + inverted-sync + decode round-trip tests |
Runtime API:
from app.radio.jtx.jt4 import channel_symbols, encode_pcm, decode_slot
tones = channel_symbols("CQ K1ABC FN42") # -> 206 tones 0..3, or None
pcm = encode_pcm("CQ K1ABC FN42", f0=1500) # -> 60 s s16le @12 kHz (bytes)
res = decode_slot(pcm) # -> [{snr, dt, df, msg}] or []
9. References
- J. Taylor K1JT, S. Franke K9AN, B. Somerville G4WJS et al., WSJT-X User Guide — JT4 mode description and submode tone-spacing / bandwidth table.
- WSJT-X source (GPL-3):
gen4.f90(JT4 symbol generation, thenprsync vector),packjt.f90(72-bitpackmsg/unpackmsg),jt4code/jt4simreference utilities — used here only as behavioral oracles, not copied. - K. S. Zigangirov, "Some sequential decoding procedures," 1966; F. Jelinek, "Fast sequential decoding algorithm using a stack," 1969 — the stack sequential decoder of §5.5.
- J. L. Massey, Threshold Decoding / the Fano metric — the soft bit metric of
fec.py. docs/native-digimodes.md— the native WSJT-X-family port plan, decoder status, and the clean-room*code/*simoracle methodology.docs/protocols/wsjtx-message-packing.md— the newer 77-bitpack77payload codec (FT8/FT4/FST4/Q65); JT4 uses the legacy 72-bit packing of §6.5 instead.docs/rvqvoice.md— the depth/structure/tone template for these protocol specs.