Patrick Lidstone
Self-hosted

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 decodernot 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 4.375 Hz ≈ 17.5 Hz HF / 6 m, low Doppler
JT4B 8.75 Hz ≈ 35 Hz 2 m / 70 cm EME
JT4C 17.5 Hz ≈ 70 Hz 70 cm EME
JT4D 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 is baud * submode where submode is the multiplier 1/2/4/9/18/36/72 (A–G). synth/encode_pcm/decode_slot all take a submode argument, and the four 4-FSK tones are placed/read at bins fb + 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.py calls _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 is 2·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 -05 apart 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 of 0 1 2 3 (the first digit of reports −01…−30). This fires for both … -NN and … R-NN reports; e.g. in G0ABC M0SUP -05 the - is at index 12 and is followed by 0, 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.

A coarse grid search over start time dt and base bin fb (jt4.py):

  • Time: dt from 0 to int(1.5·sample_rate) = 18000 samples in steps of NSPS//4 = 685 samples (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: fb from bin_lo to bin_hi (≈ 571 base bins), the four tone bins being fb … fb+3 (_power_matrix computes bin_hi + 6 bins 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_std only. 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 recovers G0ABC M0SUP -05 (its reported df sits one 4.376 Hz bin below the true carrier); see test_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-½ bias 0.5 bits:

    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.py

    When the hypothesised coded bit agrees with the soft value the metric is ≈ 0.5; when it disagrees the metric goes sharply negative. The +0.5 bias 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).
  • snrthe raw sync-correlation score best[0] (the §5.3 sc), not a calibrated dB SNR. It is a detection strength (e.g. ≈ 166 for a clean on-bin CQ 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 above NBASE: 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 below NGBASE = 180·180 = 32400 (pack.py). Values ≥ NGBASE encode shorthands: blank grid NGBASE+1; signal reports −01…−30 at NGBASE+1+n; R−01…R−30 at NGBASE+31+n; RO = NGBASE+62, RRR = NGBASE+63, 73 = NGBASE+64 (pack.py). Extended numeric reports −50…+49 map through KAnn/LAnn pseudo-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 golden JT4_CQ vector captured from jt4code (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) checks G0ABC M0SUP -05, G0ABC M0SUP R-05 and M0SUP G0ABC 73 all 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:

  1. Native round-trip (test_jt4_decode_roundtrip, test_jt.py): decode_slot(encode_pcm(m)) must return m for CQ K1ABC FN42, M0SUP G0ABC 73 and G0ABC M0SUP -05 — the last exercising the inverted-sync decode path end to end.
  2. Against WSJT-X audio (docs/native-digimodes.md, decoder-status table): the native decoder "round-trip[s] + decodes jt49sim reference" — i.e. it demodulates the reference .wav files that WSJT-X's jt4sim/jt49sim generator 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_slot take a submode argument and the 4-FSK tones are placed/read at bins fb + k*submode. Round-trip-tested A–G (test_jt4_submodes). The FEC/interleave/sync/source are submode-independent.
  • snr is a correlation score, not dB. The reported snr is 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, so df and 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

  1. J. Taylor K1JT, S. Franke K9AN, B. Somerville G4WJS et al., WSJT-X User Guide — JT4 mode description and submode tone-spacing / bandwidth table.
  2. WSJT-X source (GPL-3): gen4.f90 (JT4 symbol generation, the npr sync vector), packjt.f90 (72-bit packmsg/unpackmsg), jt4code/jt4sim reference utilities — used here only as behavioral oracles, not copied.
  3. 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.
  4. J. L. Massey, Threshold Decoding / the Fano metric — the soft bit metric of fec.py.
  5. docs/native-digimodes.md — the native WSJT-X-family port plan, decoder status, and the clean-room *code/*sim oracle methodology.
  6. docs/protocols/wsjtx-message-packing.md — the newer 77-bit pack77 payload codec (FT8/FT4/FST4/Q65); JT4 uses the legacy 72-bit packing of §6.5 instead.
  7. docs/rvqvoice.md — the depth/structure/tone template for these protocol specs.