JS8: a native keyboard-to-keyboard weak-signal messaging mode
An FT8-derived 8-FSK waveform (LDPC(174, 87) + CRC-12 + triple-Costas sync) carrying a JS8Call-compatible varicode/dictionary chat layer, at four on-air speeds.
Rafe project · app/radio/js8/ · clean-room port of JS8Call (Jordan Sherer KN4CRD, GPL-3.0)
Abstract
JS8 is a conversational HF/VHF digital mode: the robust, LDPC-coded 8-FSK physical waveform of FT8 married to a keyboard-to-keyboard message layer that compresses free-text chat, callsigns, grids and directed commands into a stream of fixed 72-bit frames. This document specifies the complete native implementation in app/radio/js8/ — a pure-NumPy clean-room port whose numeric tables are machine-generated from the JS8Call reference source, and which is validated bit-, symbol- and audio-exact against JS8Call's own genjs8/decoder (§7). Structurally JS8 keeps FT8's channel skeleton — 79 symbols, 58 data + 3×7 Costas sync, 174-bit LDPC codeword — but changes almost everything around it: the code is the WSJT-X 1.9-era LDPC(174, 87) (87 information bits, not FT8's 91), the integrity check is CRC-12 (poly 0xC06, result XORed with 42), there is no Gray map on the tones, the sync is two Costas sets (one with three distinct 7-symbol arrays), and the timing comes in four submodes — slow / normal / fast / turbo — spanning 3.125 to 20 baud and 6–30 s transmit slots. On top of that waveform sits a message layer with no analogue in FT8: six frame types, a 44-symbol Huffman varicode, a 262 144-word JSC (s,c)-dense dictionary compressor, 28- and 50-bit callsign packers, Maidenhead grid packing, a table of directed commands, and a multi-frame reassembler that stitches long messages back together across successive slots. The result is a self-contained, dependency-free JS8Call-interoperable codec. This document is self-contained: every constant, table and array below is reproduced in full (or, for the 262 144-word JSC dictionary, given with a complete regeneration recipe), so no source file is needed to read a value.
1. Background
1.1 JS8 versus FT8 — same skeleton, different everything
FT8 (see ft8-ft4.md) is a structured beacon mode: 15-second slots, a fixed 77-bit message grammar (call + call + grid/report), one shot per slot, no free text worth the name. JS8 keeps FT8's channel physics because they are excellent — 8-FSK is cheap to generate and to demodulate incoherently, the Costas arrays give a sharp sync, and the rate-½ LDPC(174, 87) code pulls signals out well below the noise floor — but repurposes them for conversation. A JS8 transmission is not a single self-describing message; it is one frame of a possibly longer chat line, and the human-visible message is reassembled from a run of frames sent over consecutive slots (§4.1, §5.8). That is the sense in which JS8 is "keyboard-to-keyboard": you type a line, it is chopped into frames, each frame rides one slot, and the far end concatenates them back into text.
The concrete divergences from the FT8 port, each detailed later:
| aspect | FT8 (app/radio/ftx) |
JS8 (app/radio/js8) |
|---|---|---|
| LDPC | (174, 91), CRC-14 0x2757 |
(174, 87), CRC-12 0xC06 XOR 42 |
| information bits | 91 = 77 msg + 14 CRC | 87 = 72 msg + 3 i3 + 12 CRC |
| tone map | Gray (0,1,3,2,5,6,4,7) |
none — direct binary |
| Costas sync | one array ×3 | two sets; set 2 has 3 distinct arrays |
| waveform | GFSK, BT = 2.0 | GFSK, BT = 2.0 (Gaussian-shaped 8-FSK) |
| slot / baud | 15 s / 6.25 baud (one speed) | 4 speeds, 6–30 s, 3.125–20 baud |
| payload | fixed 77-bit grammar | 6 frame types + varicode + JSC dictionary |
| a message is | one slot | one or more frames across slots |
The two codes are deliberately incompatible on air: the CRC-12 result is XORed with the constant 42 (crc.py) precisely "so plain FT8v1 frames never check out" (crc.py), and the differing sync arrays and slot lengths keep the submodes apart (test_js8.py).
1.2 The four speeds (submodes)
JS8Call ships four active submodes plus one disabled one. They differ only in the samples-per-symbol (hence baud, tone spacing and slot length) and in which Costas set they use; the frame structure, code and message layer are identical across all of them. All timing is defined at a 12 kHz working sample rate (constants.py):
| submode | name | samples/sym @12 k | baud = tone spacing | symbol period | transmit signal | slot | start delay | Costas set |
|---|---|---|---|---|---|---|---|---|
| E | slow | 3840 | 3.125 Hz | 320 ms | 25.28 s | 30 s | 0.5 s | 2 |
| A | normal | 1920 | 6.25 Hz | 160 ms | 12.64 s | 15 s | 0.5 s | 1 |
| B | fast | 1200 | 10 Hz | 100 ms | 7.90 s | 10 s | 0.2 s | 2 |
| C | turbo | 600 | 20 Hz | 50 ms | 3.95 s | 6 s | 0.1 s | 2 |
| I | ultra | 384 | 31.25 Hz | 32 ms | — | 4 s | 0.1 s | 2 |
The source table SUBMODES maps each letter to (samples/symbol @12 kHz, slot period s, start delay s, Costas set): A (1920, 15.0, 0.500, 1), B (1200, 10.0, 0.200, 2), C (600, 6.0, 0.100, 2), E (3840, 30.0, 0.500, 2), I (384, 4.0, 0.100, 2).
Submode A ("normal") is the default and the only one using Costas set 1; it is the closest analogue to FT8 (same 15-s slot, same 6.25 baud, same 160 ms symbol). The transmit-signal length is 79 × nsps / 12000 seconds; the remaining slot time is silence, and the signal is delayed by the "start delay" from the top of the slot (§4.6). Submode I ("ultra", 31.25 Hz) is present in the table but "disabled in JS8Call" (constants.py); the port carries it for completeness but does not exercise it. Occupied bandwidth is 8 × spacing: 25 Hz (E), 50 Hz (A), 80 Hz (B), 160 Hz (C).
Faster submodes trade sensitivity for turnaround: turbo's 3.95-s signal in a 6-s slot lets two stations exchange short frames far more quickly than normal mode's 12.64-s signal in a 15-s slot, at the cost of needing a stronger signal. The message layer is speed-aware in exactly one place: fast submodes (anything but A) pack free text as fast data frames that spend the whole 72-bit payload on JSC compression and move the data flag into the transmission header, where normal mode reserves two payload bits for a Huffman/JSC selector (§4.1, messages.py, frames.py).
1.3 The reference and the port
The waveform, code, sync, frame grammar and text codecs are all JS8Call's, ported clean-room. Every numeric table in constants.py is machine-generated by scripts/gen_js8_constants.py, which parses the reference source array literals directly (no hand transcription): the LDPC generator/parity from lib/ft8/ldpc_174_87_params.f90 and lib/ft8/bpdecode174.f90, the Costas arrays from lib/js8/genjs8.f90, the varicode/command/callsign tables from varicode.cpp, and the 262 144-word JSC dictionary from jsc_map.cpp / jsc_list.cpp (gen_js8_constants.py). The tables are protocol facts under the project's clean-room policy; see docs/native-digimodes.md and NOTICE.md. Validation against the reference binaries is §7.
2. The codec chain
TX: chat text ─▶ build_message_frames ─▶ [(msg72, i3), …] (message layer)
one line, greedily packed: heartbeat│compound│directed│data
│
per frame: frame_bits ─▶ 87-bit info = msg72(72) i3(3) CRC12(12)
│
encode174 ─▶ 174 channel bits = parity(87) ⊕ COLORDER ‖ message(87)
│
js8_tones ─▶ 79 tones = Costas(7) D(29) Costas(7) D(29) Costas(7)
│
synth_gfsk ─▶ GFSK-shaped 8-FSK (BT=2.0), per-submode timing ─▶ PCM
│
[ air ]
│
RX: PCM ─▶ build_waterfall ─▶ find_candidates (Costas sync search) │
│ │
_extract_llr (8-FSK max-log soft bits) ─▶ _normalize_logl │
│
bp_decode (LDPC sum-product, 25 iters) ─▶ CRC-12 gate ─▶ (msg72, i3)
│
render_frame ─▶ text ; FrameAssembler ─▶ reassembled message
The physical layer (crc.py, ldpc.py, encode.py, decode.py) is a near-twin of the FT8 port; the message layer (frames.py, text.py, messages.py) is entirely JS8's. codec.py is the app-facing boundary: encode_pcm(text, …) renders one or more full slots, decode_slot(pcm, submode) returns rendered frames, and messages.FrameAssembler reassembles multi-frame messages a slot at a time.
3. Signal, frame and symbol structure
3.1 The 79-symbol channel frame
Identical in layout to FT8 (constants.py):
JS8_NN = 79 channel symbols total
JS8_ND = 58 data symbols (58 × 3 bits = 174 codeword bits)
JS8_NS = 21 sync symbols (3 groups × 7 Costas)
JS8_KK = 87 information bits (72 msg + 3 frame-type/i3 + 12 CRC)
The 79 symbols are laid out as three 7-symbol Costas sync groups interleaving two 29-symbol data blocks (encode.py):
symbol: 0 ───── 6 │ 7 ──────────── 35 │ 36 ──── 42 │ 43 ──────────── 71 │ 72 ──── 78
Costas A │ data block 0 (29) │ Costas B │ data block 1 (29) │ Costas C
(7 sync) │ = codeword[0:87] │ (7 sync) │ = codeword[87:174]│ (7 sync)
Each of the 58 data symbols carries 3 bits, MSB first, with no Gray coding: tone = (cw[bi]<<2) | (cw[bi+1]<<1) | cw[bi+2] (encode.py). Data block 0 (symbols 7–35) carries codeword bits 0–86; data block 1 (symbols 43–71) carries bits 87–173. Tones are integers 0…7; the three Costas groups are fixed sync patterns that never carry data.
3.2 Costas synchronisation — two sets
FT8 uses one 7-symbol Costas array three times. JS8 uses one of two sets, selected by submode, and set 2's three positions are distinct arrays (constants.py):
COSTAS[1] = ( (4,2,5,6,1,3,0), (4,2,5,6,1,3,0), (4,2,5,6,1,3,0) ) # submode A
COSTAS[2] = ( (0,6,2,3,5,4,1), (1,5,0,2,3,6,4), (2,5,0,6,4,1,3) ) # submodes B,C,E
Each 7-tuple is a permutation of 0…6 (a Costas array — sharp 2-D autocorrelation). Set 1 (normal mode) repeats a single array at symbols 0/36/72, FT8-style. Set 2 (fast/turbo/slow) places three different arrays at the start, middle and end, which both improves time-sync robustness and — together with the different slot lengths — keeps the submodes mutually non-decodable, so a normal-A signal cannot be mistaken for a fast-B one (test_js8.py).
3.3 The information word — 72 + 3 + 12 = 87 bits
Where FT8 packs 77 message bits + 14 CRC = 91, JS8 packs (encode.py, constants.py):
info87 = [ msg72 : 72 bits ][ i3 : 3 bits ][ CRC-12 : 12 bits ] (MSB → LSB)
└──────── the 75-bit "payload" the CRC is taken over ─────┘
msg72— the 72-bit frame payload, one of six frame types (§4.1).i3— a 3-bit transmission header carried outside the 72-bit payload but inside the CRC. Its bits are flags, not a type selector (constants.py):TX_FIRST = 1(bit 0, first frame of a message),TX_LAST = 2(bit 1, last frame),TX_DATA = 4(bit 2, a fast-mode data frame).TX_ANY = 0is a plain continuation frame. This is a key JS8/FT8 difference: FT8'si3selects the message grammar; JS8'si3carries message-assembly flags plus the fast-data marker.
3.4 LDPC(174, 87)
The code is the WSJT-X 1.9-era FT8 LDPC code: 87 information bits, 87 parity bits, 174 channel bits, rate ½. It is described by four tables (all in constants.py, all machine-generated; reproduced in full in §6.2):
LDPC_GENERATOR_HEX— 87 rows of 22 hex chars. Each row is 88 bits, of which the top 87 are used (the LSB is discarded:_GEN = int(h,16) >> 1,ldpc.py). Parity bit i is the mod-2 inner product of the 87-bit message with row i.LDPC_COLORDER— the 174-entry column permutation applied after concatenating parity‖message. Its tail is the identity (COLORDER[i] = iforiin 87…173), so the message occupies channel bits 87…173 unchanged and the parity is scattered over channel bits 0…86 (ldpc.py).LDPC_NM— for each of the 87 checks, the 6 or 7 (1-based) variable nodes it XORs to zero; 6-entry checks are padded with a trailing0.LDPC_MN— for each of the 174 variables, the exactly-3 checks it participates in.LDPC_NRW— the true row length (5, 6 or 7) of eachLDPC_NMrow, so the pad0s are never read.
LDPC_NM/LDPC_MN are the two adjacency views of the same Tanner graph; the encoder uses only LDPC_GENERATOR_HEX + LDPC_COLORDER, the decoder uses only LDPC_NM/LDPC_MN/LDPC_NRW. All four tables are given in full in §6.2.
3.5 CRC-12
Twelve-bit CRC, polynomial 0xC06 (i.e. x¹² + x¹¹ + x¹⁰ + x² + x = full poly 0x1C06), computed in "boost augmented" style over the 75-bit payload zero-extended into an 11-byte (88-bit) block, then XORed with 42 (constants.py, crc.py):
frame_crc(bits75):
v = bits75 << 13 # left-align the 75 payload bits in 88 bits
buf = the 11 bytes of v
return crc12(buf, 88) ^ 42 # poly 0x1C06, MSB-first; XOR 42
The parameters are CRC_POLY = 0xC06, CRC_WIDTH = 12, CRC_XOR = 42. The XOR-42 marker is what makes a JS8 frame fail an FT8v1 CRC and vice versa (§1.1). CRC generation and checking are detailed in §4.3 and §5.6.
3.6 The 8-FSK waveform
The transmitted signal is a GFSK-shaped 8-FSK tone train (synth_gfsk, encode.py), the same modulation the reference JS8Call/FT8 transmitter uses. Per symbol the nominal instantaneous frequency is f0 + tone × spacing, where spacing = 12000 / nsps equals the baud rate, so adjacent tones are exactly orthogonal over a symbol. Rather than stepping abruptly at symbol boundaries, the frequency trajectory is Gaussian-filtered: each symbol contributes a 3-symbol-wide raised-Gaussian frequency pulse p(t) = ½·[erf(k·(t+½)) − erf(k·(t−½))] with k = GFSK_CONST_K × BT, BT = 2.0, and a peak phase step of 2π/n_spsym per symbol (hmod = 1). The shaping constant is GFSK_CONST_K = 5.336446 = π·√(2/ln 2) (constants.py). Phase is the cumulative sum of the shaped per-sample frequency, so it is continuous and its derivative is smooth — this suppresses the spectral skirts a boxcar FSK step would otherwise produce. A short raised-cosine amplitude ramp of n_spsym/8 samples is applied at each end of the burst (encode.py) to suppress key clicks.
(encode.py also carries a plain boxcar-FSK synth_fsk for reference — abrupt tone steps, no Gaussian pulse — but the codec's TX path codec.encode_frame_pcm → synth_slot → synth_gfsk always uses the GFSK modulator.) Interop audio tests (§7) confirm this GFSK waveform is decoded by the reference JS8Call decoder and that the reference waveform is decoded here, in both directions and all four submodes.
4. Encode — step by step
4.1 Chat text → frames (messages.build_message_frames)
The message layer turns one line of chat into a list of (msg72, i3) frames (messages.py). The line is upper-cased; the caller's own callsign prefix is stripped; if a "selected call" is set for directed chat it is auto-prepended unless the line already starts with a base/CQ/HB token or a callsign. Then the line is consumed left-to-right, at each step packing the longest frame that fits, trying the frame types in priority order:
- Heartbeat / CQ (
pack_heartbeat,frames.py) — a leadingHB/HEARTBEAT/CQ …token, optionally with a grid:"CQ CQ CQ EM73". - Compound (
pack_compound_message,frames.py) — a leading back-tick compound-callsign frame:"`KN4CRD/P EM73". - Directed (
pack_directed,frames.py) —TO+ command (± number):"J1Y SNR?","J1Y SNR -12". - Data — free text. Normal mode uses
pack_data(best of Huffman or JSC over the 70 payload bits after a 2-bit header,frames.py); fast submodes usepack_fast_data(JSC over the full 72 bits,frames.py).
Heartbeat/compound/directed frames are only attempted while no directed or data frame has yet been emitted for the line (messages.py). Once a directed frame is packed, if its command is buffered (e.g. MSG), a 16- or 32-bit text checksum is appended to the remaining text (messages.py, §4.2). When the line is exhausted, TX_FIRST is OR-ed into the first frame's i3 and TX_LAST into the last (messages.py). Fast data frames additionally carry TX_DATA in i3 (§4.1 item 4).
Frame-type field layouts (all fields MSB-first inside msg72; helpers split msg72 into a 64-bit value and an 8-bit rem, frames.py):
Heartbeat / Compound / Compound-directed share pack_compound_frame (frames.py):
value(64) = [ ftype:3 ][ packed_callsign:50 ][ num>>5 : 11 ]
rem(8) = [ (num & 31):5 ][ bits3:3 ]
where ftype ∈ {0 heartbeat, 1 compound, 2 compound-directed}, packed_callsign is a 50-bit alphanumeric pack (pack_alphanumeric50, frames.py), num is a 16-bit "extra" (a packed grid, or NUSERGRID + packed_command, or NMAXGRID = none), and bits3 carries the CQ/HB variant. Heartbeat sets bit 15 of num for CQ-type ("is_alt") frames (frames.py).
Directed (pack_directed, frames.py):
value(64) = [ FRAME_DIRECTED=3 : 3 ][ from:28 ][ to:28 ][ cmd%32 : 5 ]
rem(8) = [ portable_from:1 ][ portable_to:1 ][ num:6 ]
from/to are 28-bit callsign packs (§4.1a); cmd is a 5-bit directed-command id (§6.1). Example: pack_directed("J1Y SNR?", "KN4CRD") → msg72 = 0x71717ebdedc10da000, decomposing as ftype=3, from=146325342 (KN4CRD), to=258869357 (J1Y), cmd=0 (SNR?).
Data (normal, pack_data): msg72 bits [1][H][…payload…][0][1…], where the top bit is 1, the next bit H selects JSC (1) or Huffman (0), the payload is the compressed bitstream, and the tail is the standard JS8 "one 0 then all 1s" padding (frames.py, _bits_to_msg72). Fast data spends all 72 bits on JSC with the data flag in i3 instead.
4.1a Callsign packing. Standard calls pack into 28 bits (pack_callsign, frames.py) by matching a 6-character [A-Z0-9 ] template and mixed-radix encoding: ((((c0·36 + c1)·10 + c2)·27 + c3−10)·27 + c4−10)·27 + c5−10 over the alphabet ALPHANUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ /@" (constants.py), with the 3rd character forced numeric. Special/group calls (@ALLCALL, @JS8NET, …) occupy the reserved space above NBASECALL = 262177560 (= 37·36·10·27·27·27, constants.py, BASECALLS constants.py); 3DA0…→3D0… (Swaziland) and 3X…→Q… (Guinea) prefixes are special-cased. Compound calls that cannot fit 28 bits pack as the sentinel <....> and are sent via the compound-callsign handshake instead. The 50-bit pack_alphanumeric50 holds up to an 11-character [A-Z0-9 /@] word (grouped 3/1/3/1/3 with two / flags) for compound/heartbeat frames.
4.1b Grid packing. Four-character Maidenhead grids pack into 15 bits via deg2grid/grid2deg and pack_grid (frames.py): pack_grid = ((ilong+180)//2)·180 + ilat, with NBASEGRID = 32400 (= 180·180), NUSERGRID = 32410, NMAXGRID = 32767 = "no grid". Values in [NUSERGRID, NMAXGRID) instead encode a directed command in a compound frame.
4.2 Text checksums for buffered commands
Buffered directed commands (e.g. MSG, QUERY, relays) carry a text integrity checksum appended to the message body (messages.py, validated on RX messages.py). Two sizes, both rendered in a base-41 alphabet ALPHABET41 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?" (constants.py; 41³ = 68921 ≥ 65536):
- 16-bit: CRC-16/Kermit (reflected poly
0x8408) → 3 base-41 chars (checksum16,frames.py). - 32-bit: CRC-32/BZIP2 (poly
0x04C11DB7, init/xor0xFFFFFFFF) → 6 base-41 chars (checksum32,frames.py).
4.3 Frame → 87-bit information word (CRC-12)
frame_bits(msg72, i3) builds the 87-bit word (encode.py):
bits75 = (msg72 << 3) | (i3 & 7) # 75-bit payload
info87 = (bits75 << 12) | frame_crc(bits75)
frame_crc (§3.5) left-aligns the 75 payload bits in an 11-byte block, takes the CRC-12 remainder under poly 0x1C06, and XORs with 42.
4.4 87-bit word → 174-bit codeword (LDPC encode)
encode174(msg87) (ldpc.py):
for i in 0..86: parity[i] = popcount(GEN[i] & msg87) & 1 # 87 parity bits
for j in 0..86: message[j] = (msg87 >> (86-j)) & 1 # 87 message bits, MSB first
itmp = parity(0..86) ++ message(0..86) # 174 bits
for i in 0..173: codeword[COLORDER[i]] = itmp[i] # column permute
Because COLORDER's tail is the identity, the 87 message bits land verbatim in channel positions 87…173 and the 87 parity bits scatter across 0…86.
4.5 Codeword → 79 tones (js8_tones)
js8_tones(msg72, i3, submode) (encode.py) selects the submode's Costas set, writes the three 7-symbol sync groups into positions 0–6 / 36–42 / 72–78, and fills the 58 data symbols (7–35, 43–71) with successive 3-bit groups of the codeword, MSB first, direct binary (no Gray):
tone = (cw[bi] << 2) | (cw[bi+1] << 1) | cw[bi+2]
4.6 Tones → audio (synth_gfsk, synth_slot)
synth_gfsk (encode.py) renders GFSK-shaped 8-FSK at the requested carrier f0 and sample rate: n_spsym = round(nsps × rate/12000) samples per symbol, nominal per-symbol frequency f0 + tone × spacing, spacing = 12000/nsps, each symbol convolved with the 3-symbol-wide Gaussian frequency pulse (BT = 2.0, GFSK_CONST_K; §3.6), phase via cumsum, sin(phi), with an n_spsym/8-sample raised-cosine amplitude ramp at both ends. synth_slot (encode.py) places the GFSK burst at the submode start delay into a zeroed slot of slot_s × rate samples. codec.encode_frame_pcm (codec.py) calls synth_slot, clips to [−1, 1] and quantises to s16le mono PCM (48 kHz for the app; 12 kHz internally). A message spanning several frames is rendered as several consecutive slots by encode_pcm (codec.py).
4.7 Worked reference vector
encode_frames("CQ CQ CQ EM73", "KN4CRD", "EM73", "", "A") → one frame:
msg72 = 0x0a2fb3a3ee2ee6ea58 i3 = 3 (TX_FIRST | TX_LAST)
info87 = 000010100010111110110011101000111110111000101110
111001101110101001011000011000111011110 (72 msg | 3 i3 | 12 CRC)
CRC-12 = 0x1de
tones(79) = 4256130 40632072007407207412362012277 4256130
02427663507670567156513030736 4256130
The frame is a heartbeat-type frame (top 3 bits of msg72 = 0) with the CQ ("is_alt") bit set; it renders on RX as KN4CRD: @ALLCALL CQ CQ CQ EM73 (§5.7). Note the three identical 4256130 Costas groups — this is submode A, so Costas set 1. This vector is asserted bit- and symbol-exact against JS8Call's genjs8 in the interop suite (§7).
5. Decode — step by step (the DSP)
decode.decode_slot(pcm, submode) (decode.py) takes a captured slot at the working rate (12 kHz) and returns a list of raw (msg72, i3) frames with snr/dt/df/score. It is the same pipeline as the FT8 port, retimed and retabled for JS8.
5.1 The waterfall (Hann-windowed oversampled STFT)
build_waterfall (decode.py) builds a time×frequency magnitude array with 2× oversampling in both axes (TIME_OSR = FREQ_OSR = 2, decode.py):
block_size = rate × periodsamples per symbol;subblock = block_size/2gives the 2 half-symbol time phases;nfft = block_size × 2gives 2× frequency oversampling.- The analysis window is a Hann via sin²:
window[i] = (2/nfft)·sin²(π i/nfft)(decode.py). - For each block and each of the 2 time phases, a running
lastbuffer is slid bysubblockand FFT'd; magnitude² → dB (10·log10), scaled2·dB + 240, clipped to 0…255 and stored as int16 (decode.py). - Only bins for
F_MIN = 200 … F_MAX = 3000Hz are kept (decode.py); the bin grid is in units of1/periodHz (6.25 Hz for A) with the FREQ_OSR ×2 refinement.
The output wf[block, time_sub, freq_sub, bin] is the JS8 waterfall for one slot.
5.2 Costas synchronisation search
find_candidates (decode.py) searches for the three Costas groups. _sync_blocks(costas) (decode.py) lists the 21 (block_offset, expected_tone, k, length) entries — block offsets 36m + k for group m ∈ {0,1,2} and k ∈ 0…6, i.e. sync at symbols 0…6, 36…42, 72…78 — using the submode's Costas set (so it looks for the right array at each of the three positions, which for set 2 differ). For every time phase, frequency phase and time offset toff ∈ [−10, 20):
- a local-contrast score is accumulated over all 21 sync symbols: at each sync symbol the magnitude of the expected tone minus the magnitudes of its neighbours — the adjacent tones (
sm±1) and the adjacent time blocks (±1 symbol) — summed over the frequency-offset axisL = num_bins − 8 + 1(decode.py); - the score is normalised by the number of neighbour terms and thresholded at
MIN_SCORE = 10(decode.py).
All passing (score, toff, foff, time_sub, freq_sub) tuples are collected, sorted by descending score, and the top MAX_CANDIDATES = 140 (decode.py) are returned for full decoding.
5.3 Soft symbol metrics (8-FSK demod → bit LLRs)
_extract_llr (decode.py) turns a candidate's 58 data symbols into 174 bit log-likelihoods. For each data symbol kk:
- the channel symbol index is
kk + (7 if kk < 29 else 14)— skipping the first Costas group for data block 0 and the first two for data block 1 (decode.py); - the 8 tone magnitudes are read from the waterfall and de-scaled
s = 0.5·wf − 120(decode.py); - three bit metrics are formed as max-log differences over the tone groups implied by each bit's place value, in direct binary (no Gray,
decode.py):
bit2 (MSB): max(s4,s5,s6,s7) − max(s0,s1,s2,s3) # tone ≥ 4
bit1 : max(s2,s3,s6,s7) − max(s0,s1,s4,s5) # (tone>>1)&1
bit0 (LSB): max(s1,s3,s5,s7) − max(s0,s2,s4,s6) # tone&1
This is the exact inverse of the direct-binary tone map used on encode (§4.5), and it is the point where JS8 differs from FT8, which would apply the FT8 Gray map here.
5.4 LLR normalisation
_normalize_logl (decode.py) rescales the 174 LLRs so their variance matches a target of 24: it computes the sample variance and multiplies by sqrt(24/var), giving the belief-propagation decoder consistently scaled log-likelihoods regardless of signal level.
5.5 LDPC belief propagation (sum-product)
bp_decode (decode.py) runs up to LDPC_ITERS = 25 (decode.py) sum-product iterations on the Tanner graph (LDPC_NM/LDPC_MN/LDPC_NRW, converted to 0-based at decode.py):
- State:
tov[174, 3]variable→check messages,toc[87, 7]check→variable messages. - Hard decision each iteration from
llr + Σ tov; the syndrome is checked by_ldpc_check(decode.py), which XORs each check's variable bits and counts unsatisfied checks. The best (fewest-error) hard decision is retained; zero errors ends the loop early (decode.py). - Check update:
toc[m][ni] = fast_tanh(−T/2)over the incoming variable messages; variable update:tov[n][mi] = −2·fast_atanh(∏ toc)over the incoming check messages (decode.py). _fast_tanh/_fast_atanh(decode.py) are Padé rational approximations (tanh clipped to ±4.97) — the same fast nonlinearities the FT8 port uses, avoiding per-edge transcendental calls.
It returns the best hard-decision codeword and its residual parity-error count; zero errors means a valid codeword.
5.6 CRC gate and payload recovery
_decode_candidate (decode.py): only codewords with zero LDPC errors proceed. The 87 message bits are read straight from channel positions 87…173 (COLORDER tail is the identity), giving v. Split v = [bits75 : 75][CRC : 12]; recompute frame_crc(bits75) and require equality with the received 12 bits (decode.py). On a match, return (msg72, i3) = (bits75 >> 3, bits75 & 7). The CRC's XOR-42 (§3.5) guarantees a stray FT8 frame cannot pass this gate.
5.7 Metrics, dedup and rendering
decode_slot (decode.py) de-duplicates identical (msg72, i3) frames and attaches (decode.py):
snr = score × 0.5 − 24
dt = (toff + time_sub/2) × period seconds
df = (min_bin + foff + freq_sub/2) / period Hz
codec.decode_slot (codec.py) then calls render_frame (messages.py) to turn each raw frame into display text by frame type — heartbeat → "CALL: @ALLCALL CQ …" / "CALL: @HB HEARTBEAT …", compound → "CALL: ", directed → "FROM: TO CMD NUM ", data → the decompressed text — and returns UI-shaped dicts (snr/dt/df strings plus the raw fields).
5.8 Multi-frame reassembly (FrameAssembler)
Because a chat line spans several slots, messages.FrameAssembler (messages.py) stitches frames back together. Frames are bucketed by audio frequency (BUCKET_HZ = 10); a TX_FIRST frame opens a buffer, plain frames append, a TX_LAST frame closes it and the parts are concatenated. A FIRST|LAST frame passes straight through. For buffered directed commands the trailing text checksum (§4.2) is validated and stripped, and checksum_ok is reported (messages.py).
5.9 JSC text decompression
Data-frame payloads carry JSC-compressed text (text.jsc_decompress, text.py), the inverse of §6.4's compressor. The bitstream is read as 4-bit groups; a group value ≥ s (=7) is a continuation (accumulate j = j·c + (g−s)), a group < s is a terminator (j = j·s + g + base[k], where base[] is the cumulative (s,c)-dense offset table [0, 7, 70, 637, 5740, 51667, 465010, 4185097] for k = 0…7); each terminator is followed by one word-separator bit that, when set, inserts a space. The recovered index looks the word up in the 262 144-entry dictionary (§6.4). Huffman (varicode) data uses huff_decode (text.py) instead, a greedy longest-match over the 44-symbol table (§6.1).
6. Constants & tables
All tables live in app/radio/js8/constants.py and are auto-generated by scripts/gen_js8_constants.py from a JS8Call checkout under refs/js8call_src (§1.3); the JSC dictionary is shipped separately as app/radio/js8/_tables/jsc_words.txt.gz. Regenerate with python scripts/gen_js8_constants.py after cloning the reference. The generator parses the reference array literals, asserts every shape (e.g. all Costas tuples are permutations of 0…6, the LDPC matrices have the right dimensions, the JSC map is self-indexed and has unambiguous match strings), and is deterministic, so a re-clone reproduces constants.py and the dictionary exactly. Every value is reproduced in full below; §6.2 gives the four LDPC tables complete, and §6.4 gives the JSC dictionary's parameters plus its regeneration recipe (the 262 144 words themselves are external reference data).
6.1 Small tables (given in full)
| Name | Value | File |
|---|---|---|
| Channel sizes | NN 79, ND 58, NS 21, KK 87 |
constants.py |
COSTAS[1] |
((4,2,5,6,1,3,0),×3) |
constants.py |
COSTAS[2] |
((0,6,2,3,5,4,1),(1,5,0,2,3,6,4),(2,5,0,6,4,1,3)) |
constants.py |
CRC_POLY / WIDTH / XOR |
0xC06 / 12 / 42 |
constants.py |
LDPC_N / K / M |
174 / 87 / 87 |
constants.py |
GFSK_CONST_K |
5.336446 (= π·√(2/ln 2); GFSK pulse constant, §3.6) |
constants.py |
SUBMODES |
A(1920,15.0,0.5,1) B(1200,10.0,0.2,2) C(600,6.0,0.1,2) E(3840,30.0,0.5,2) I(384,4.0,0.1,2) |
constants.py |
ALPHABET72 |
0123456789A-Za-z-+/?. (67 chars; debug/display only — see below) |
constants.py |
ALPHABET41 |
0123456789A-Z+-./? (41 chars, base-41 checksum text) |
constants.py |
ALPHANUMERIC |
0123456789A-Z + <space>/@ (39 chars, callsign/grid) |
constants.py |
NBASECALL / NBASEGRID / NUSERGRID / NMAXGRID |
262177560 / 32400 / 32410 / 32767 |
constants.py |
| Frame types | HEARTBEAT 0, COMPOUND 1, COMPOUND_DIRECTED 2, DIRECTED 3, DATA 4, DATA_COMPRESSED 6 |
constants.py |
| Transmission (i3) | TX_ANY 0, TX_FIRST 1, TX_LAST 2, TX_DATA 4 |
constants.py |
JSC_B / S / C / SIZE |
4 / 7 / 9 / 262144 |
constants.py |
JSC_EXCEPTIONS |
{81:(7,7), 262143:(6,1)} |
constants.py |
The three alphabets in full (verify by character count):
ALPHABET72 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-+/?." (67 chars)
ALPHABET41 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?" (41 chars)
ALPHANUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ /@" (39 chars: last three are space, '/', '@')
ALPHABET72 is not used by the codec: it is a 6-bit-per-character display encoding (only indices 0…63 are addressable, so the trailing /, ?, . are never emitted) used solely by the interop harness to print a 12-character view of a msg72. ALPHABET41 packs the 16/32-bit text checksums (41³ = 68921 ≥ 65536, so 3 chars hold a 16-bit value). ALPHANUMERIC (39 chars) is the callsign/grid mixed-radix alphabet.
Huffman varicode (HUFF_TABLE, constants.py) — 44 entries, char → bit string, most-frequent-shortest; decode is greedy longest-match (text.py). Given in full (space is the two-bit 01):
' ' 01 E 100 T 1101 A 0011
O 11111 I 11100 N 10111 S 10100
H 00011 R 00000 D 111011 L 110011
C 110001 U 101101 M 101011 W 001011
F 001001 G 000101 Y 000011 P 1111011
B 1111001 . 1110100 V 1100101 K 1100100
- 1100001 + 1100000 ? 1011001 ! 1011000
" 1010101 X 1010100 0 0010101 J 0010100
1 0010001 Q 0010000 2 0001001 Z 0001000
3 0000101 5 0000100 4 11110101 9 11110100
8 11110001 6 11110000 7 11101011 / 11101010
Directed commands (DIRECTED_CMDS, constants.py) — 38 string→id entries mapping a command token to a 5-bit id; the four -1 entries (" HEARTBEAT", " HB", " CQ", and any other) are pseudo-commands handled by the heartbeat/CQ path, not real directed ids. Two tokens each alias to an existing id (" QUERY MSGS?"→12, and " "/" "→31; "?"→0, ">"→5). Given in full (source order):
' HEARTBEAT' -1 ' HB' -1 ' CQ' -1 ' SNR?' 0
'?' 0 ' DIT DIT' 1 ' HEARING?' 3 ' GRID?' 4
'>' 5 ' STATUS?' 6 ' STATUS' 7 ' HEARING' 8
' MSG' 9 ' MSG TO:' 10 ' QUERY' 11 ' QUERY MSGS' 12
' QUERY MSGS?' 12 ' QUERY CALL' 13 ' GRID' 15 ' INFO?' 16
' INFO' 17 ' FB' 18 ' HW CPY?' 19 ' SK' 20
' RR' 21 ' QSL?' 22 ' QSL' 23 ' CMD' 24
' SNR' 25 ' NO' 26 ' YES' 27 ' 73' 28
' NACK' 2 ' ACK' 14 ' HEARTBEAT SNR' 29 ' AGN?' 30
' ' 31 ' ' 31
Command classes (constants.py), by 5-bit id:
ALLOWED_CMDS = {-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31} (all)
AUTOREPLY_CMDS = {0, 2, 3, 4, 6, 9, 10, 11, 12, 13, 14, 16, 30}
BUFFERED_CMDS = {5, 9, 10, 11, 12, 13, 15, 24}
SNR_CMDS = {25, 29}
CHECKSUMMED_CMDS = {5:16, 9:16, 10:16, 11:16, 12:16, 13:16, 15:0, 24:16} # id -> checksum bits
Group/special callsigns (BASECALLS, constants.py) — 54 entries mapping a <....>/@… token into the value space NBASECALL + n for n = 1…54 (NBASECALL = 262177560, so values 262177561 … 262177614). Given in full as name → n:
+1 <....> +2 @ALLCALL +3 @JS8NET +4 @DX/NA +5 @DX/SA
+6 @DX/EU +7 @DX/AS +8 @DX/AF +9 @DX/OC +10 @DX/AN
+11 @REGION/1 +12 @REGION/2 +13 @REGION/3 +14 @GROUP/0 +15 @GROUP/1
+16 @GROUP/2 +17 @GROUP/3 +18 @GROUP/4 +19 @GROUP/5 +20 @GROUP/6
+21 @GROUP/7 +22 @GROUP/8 +23 @GROUP/9 +24 @COMMAND +25 @CONTROL
+26 @NET +27 @NTS +28 @RESERVE/0 +29 @RESERVE/1 +30 @RESERVE/2
+31 @RESERVE/3 +32 @RESERVE/4 +33 @APRSIS +34 @RAGCHEW +35 @JS8
+36 @EMCOMM +37 @ARES +38 @MARS +39 @AMRRON +40 @RACES
+41 @RAYNET +42 @RADAR +43 @SKYWARN +44 @CQ +45 @HB
+46 @QSO +47 @QSOPARTY +48 @CONTEST +49 @FIELDDAY +50 @SOTA
+51 @IOTA +52 @POTA +53 @QRP +54 @QRO
CQ / HB / dBm vocabularies (constants.py), given in full:
CQ_STRINGS = {0:'CQ CQ CQ', 1:'CQ DX', 2:'CQ QRP', 3:'CQ CONTEST',
4:'CQ FIELD', 5:'CQ FD', 6:'CQ CQ', 7:'CQ'}
HB_STRINGS = {0:'HB', 1:'HB', 2:'HB', 3:'HB', 4:'HB', 5:'HB', 6:'HB', 7:'HB'}
DBM_TO_MW = {0:1, 3:2, 7:5, 10:10, 13:20, 17:50, 20:100, 23:200, 27:500,
30:1000, 33:2000, 37:5000, 40:10000, 43:20000, 47:50000,
50:100000, 53:200000, 57:500000, 60:1000000}
6.2 The LDPC(174, 87) code — the four tables in full
The four LDPC tables are reproduced here in full; these are the authoritative values (identical to the generated constants.py). LDPC_NM and LDPC_MN are 1-based (the decoder subtracts 1); LDPC_NM's pad 0s are never read (see LDPC_NRW).
LDPC_GENERATOR_HEX — 87 rows × 22 hex chars; the top 87 of each row's 88 bits are used (LSB discarded, int(h,16) >> 1):
"23bba830e23b6b6f50982e" "1f8e55da218c5df3309052" "ca7b3217cd92bd59a5ae20"
"56f78313537d0f4382964e" "29c29dba9c545e267762fe" "6be396b5e2e819e373340c"
"293548a138858328af4210" "cb6c6afcdc28bb3f7c6e86" "3f2a86f5c5bd225c961150"
"849dd2d63673481860f62c" "56cdaec6e7ae14b43feeee" "04ef5cfa3766ba778f45a4"
"c525ae4bd4f627320a3974" "fe37802941d66dde02b99c" "41fd9520b2e4abeb2f989c"
"40907b01280f03c0323946" "7fb36c24085a34d8c1dbc4" "40fc3e44bb7d2bb2756e44"
"d38ab0a1d2e52a8ec3bc76" "3d0f929ef3949bd84d4734" "45d3814f504064f80549ae"
"f14dbf263825d0bd04b05e" "f08a91fb2e1f78290619a8" "7a8dec79a51e8ac5388022"
"ca4186dd44c3121565cf5c" "db714f8f64e8ac7af1a76e" "8d0274de71e7c1a8055eb0"
"51f81573dd4049b082de14" "d037db825175d851f3af00" "d8f937f31822e57c562370"
"1bf1490607c54032660ede" "1616d78018d0b4745ca0f2" "a9fa8e50bcb032c85e3304"
"83f640f1a48a8ebc0443ea" "eca9afa0f6b01d92305edc" "3776af54ccfbae916afde6"
"6abb212d9739dfc02580f2" "05209a0abb530b9e7e34b0" "612f63acc025b6ab476f7c"
"0af7723161ec223080be86" "a8fc906976c35669e79ce0" "45b7ab6242b77474d9f11a"
"b274db8abd3c6f396ea356" "9059dfa2bb20ef7ef73ad4" "3d188ea477f6fa41317a4e"
"8d9071b7e7a6a2eed6965e" "a377253773ea678367c3f6" "ecbd7c73b9cd34c3720c8a"
"b6537f417e61d1a7085336" "6c280d2a0523d9c4bc5946" "d36d662a69ae24b74dcbd8"
"d747bfc5fd65ef70fbd9bc" "a9fa2eefa6f8796a355772" "cc9da55fe046d0cb3a770c"
"f6ad4824b87c80ebfce466" "cc6de59755420925f90ed2" "164cc861bdd803c547f2ac"
"c0fc3ec4fb7d2bb2756644" "0dbd816fba1543f721dc72" "a0c0033a52ab6299802fd2"
"bf4f56e073271f6ab4bf80" "57da6d13cb96a7689b2790" "81cfc6f18c35b1e1f17114"
"481a2a0df8a23583f82d6c" "1ac4672b549cd6dba79bcc" "c87af9a5d5206abca532a8"
"97d4169cb33e7435718d90" "a6573f3dc8b16c9d19f746" "2c4142bf42b01e71076acc"
"081c29a10d468ccdbcecb6" "5b0f7742bca86b8012609a" "012dee2198eba82b19a1da"
"f1627701a2d692fd9449e6" "35ad3fb0faeb5f1b0c30dc" "b1ca4ea2e3d173bad4379c"
"37d8e0af9258b9e8c5f9b2" "cd921fdf59e882683763f6" "6114e08483043fd3f38a8a"
"2e547dd7a05f6597aac516" "95e45ecd0135aca9d6e6ae" "b33ec97be83ce413f9acc8"
"c8b5dffc335095dcdcaf2a" "3dd01a59d86310743ec752" "14cd0f642fc0c5fe3a65ca"
"3a0a1dfd7eee29c2e827e0" "8abdb889efbe39a510a118" "3f231f212055371cf3e2a2"
LDPC_COLORDER — the 174-entry column permutation (codeword[COLORDER[i]] = (parity87 ‖ message87)[i]). Indices 87…173 are the identity, so the message bits pass through unpermuted:
0 1 2 3 30 4 5 6 7 8 9 10 11 32 12 40 13 14 15 16 17 18 37 45 29 19 20 21 41
22 42 31 33 34 44 35 47 51 50 43 36 52 63 46 25 55 27 24 23 53 39 49 59 38 48 61 60 57
28 62 56 58 65 66 26 70 64 69 68 67 74 71 54 76 72 75 78 77 80 79 73 83 84 81 82 85 86
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
LDPC_NM — for each of the 87 checks, its 6–7 (1-based) variable nodes; 6-entry checks are padded with a trailing 0 (row index shown for reference):
0: ( 1, 30, 60, 89, 118, 147, 0) 1: ( 2, 31, 61, 90, 119, 147, 0)
2: ( 3, 32, 62, 91, 120, 148, 0) 3: ( 4, 33, 63, 92, 121, 149, 0)
4: ( 2, 34, 64, 93, 122, 150, 0) 5: ( 5, 33, 65, 94, 123, 148, 0)
6: ( 6, 34, 66, 95, 124, 151, 0) 7: ( 7, 35, 67, 96, 120, 152, 0)
8: ( 8, 36, 68, 97, 125, 153, 0) 9: ( 9, 37, 69, 98, 126, 152, 0)
10: ( 10, 38, 70, 99, 127, 154, 0) 11: ( 11, 39, 71, 100, 126, 155, 0)
12: ( 12, 40, 61, 101, 128, 145, 0) 13: ( 10, 33, 60, 95, 128, 156, 0)
14: ( 13, 41, 72, 97, 126, 157, 0) 15: ( 13, 42, 73, 90, 129, 156, 0)
16: ( 14, 39, 74, 99, 130, 158, 0) 17: ( 15, 43, 75, 102, 131, 159, 0)
18: ( 16, 43, 71, 103, 118, 160, 0) 19: ( 17, 44, 76, 98, 130, 156, 0)
20: ( 18, 45, 60, 96, 132, 161, 0) 21: ( 19, 46, 73, 83, 133, 162, 0)
22: ( 12, 38, 77, 102, 134, 163, 0) 23: ( 19, 47, 78, 104, 135, 147, 0)
24: ( 1, 32, 77, 105, 136, 164, 0) 25: ( 20, 48, 73, 106, 123, 163, 0)
26: ( 21, 41, 79, 107, 137, 165, 0) 27: ( 22, 42, 66, 108, 138, 152, 0)
28: ( 18, 42, 80, 109, 139, 154, 0) 29: ( 23, 49, 81, 110, 135, 166, 0)
30: ( 16, 50, 82, 91, 129, 158, 0) 31: ( 3, 48, 63, 107, 124, 167, 0)
32: ( 6, 51, 67, 111, 134, 155, 0) 33: ( 24, 35, 77, 100, 122, 162, 0)
34: ( 20, 45, 76, 112, 140, 157, 0) 35: ( 21, 36, 64, 92, 130, 159, 0)
36: ( 8, 52, 83, 111, 118, 166, 0) 37: ( 21, 53, 84, 113, 138, 168, 0)
38: ( 25, 51, 79, 89, 122, 158, 0) 39: ( 22, 44, 75, 107, 133, 155, 172)
40: ( 9, 54, 84, 90, 141, 169, 0) 41: ( 22, 54, 85, 110, 136, 161, 0)
42: ( 8, 37, 65, 102, 129, 170, 0) 43: ( 19, 39, 85, 114, 139, 150, 0)
44: ( 26, 55, 71, 93, 142, 167, 0) 45: ( 27, 56, 65, 96, 133, 160, 174)
46: ( 28, 31, 86, 100, 117, 171, 0) 47: ( 28, 52, 70, 104, 132, 144, 0)
48: ( 24, 57, 68, 95, 137, 142, 0) 49: ( 7, 30, 72, 110, 143, 151, 0)
50: ( 4, 51, 76, 115, 127, 168, 0) 51: ( 16, 45, 87, 114, 125, 172, 0)
52: ( 15, 30, 86, 115, 123, 150, 0) 53: ( 23, 46, 64, 91, 144, 173, 0)
54: ( 23, 35, 75, 113, 145, 153, 0) 55: ( 14, 41, 87, 108, 117, 149, 170)
56: ( 25, 40, 85, 94, 124, 159, 0) 57: ( 25, 58, 69, 116, 143, 174, 0)
58: ( 29, 43, 61, 116, 132, 162, 0) 59: ( 15, 58, 88, 112, 121, 164, 0)
60: ( 4, 59, 72, 114, 119, 163, 173) 61: ( 27, 47, 86, 98, 134, 153, 0)
62: ( 5, 44, 78, 109, 141, 0, 0) 63: ( 10, 46, 69, 103, 136, 165, 0)
64: ( 9, 50, 59, 93, 128, 164, 0) 65: ( 14, 57, 58, 109, 120, 166, 0)
66: ( 17, 55, 62, 116, 125, 154, 0) 67: ( 3, 54, 70, 101, 140, 170, 0)
68: ( 1, 36, 82, 108, 127, 174, 0) 69: ( 5, 53, 81, 105, 140, 0, 0)
70: ( 29, 53, 67, 99, 142, 173, 0) 71: ( 18, 49, 74, 97, 115, 167, 0)
72: ( 2, 57, 63, 103, 138, 157, 0) 73: ( 26, 38, 79, 112, 135, 171, 0)
74: ( 11, 52, 66, 88, 119, 148, 0) 75: ( 20, 40, 68, 117, 141, 160, 0)
76: ( 11, 48, 81, 89, 146, 169, 0) 77: ( 29, 47, 80, 92, 146, 172, 0)
78: ( 6, 32, 87, 104, 145, 169, 0) 79: ( 27, 34, 74, 106, 131, 165, 0)
80: ( 12, 56, 84, 88, 139, 0, 0) 81: ( 13, 56, 62, 111, 146, 171, 0)
82: ( 26, 37, 80, 105, 144, 151, 0) 83: ( 17, 31, 82, 113, 121, 161, 0)
84: ( 28, 49, 59, 94, 137, 0, 0) 85: ( 7, 55, 83, 101, 131, 168, 0)
86: ( 24, 50, 78, 106, 143, 149, 0)
LDPC_MN — for each of the 174 variables, its exactly-3 (1-based) checks (row index shown for reference):
0: ( 1, 25, 69) 1: ( 2, 5, 73) 2: ( 3, 32, 68) 3: ( 4, 51, 61)
4: ( 6, 63, 70) 5: ( 7, 33, 79) 6: ( 8, 50, 86) 7: ( 9, 37, 43)
8: (10, 41, 65) 9: (11, 14, 64) 10: (12, 75, 77) 11: (13, 23, 81)
12: (15, 16, 82) 13: (17, 56, 66) 14: (18, 53, 60) 15: (19, 31, 52)
16: (20, 67, 84) 17: (21, 29, 72) 18: (22, 24, 44) 19: (26, 35, 76)
20: (27, 36, 38) 21: (28, 40, 42) 22: (30, 54, 55) 23: (34, 49, 87)
24: (39, 57, 58) 25: (45, 74, 83) 26: (46, 62, 80) 27: (47, 48, 85)
28: (59, 71, 78) 29: ( 1, 50, 53) 30: ( 2, 47, 84) 31: ( 3, 25, 79)
32: ( 4, 6, 14) 33: ( 5, 7, 80) 34: ( 8, 34, 55) 35: ( 9, 36, 69)
36: (10, 43, 83) 37: (11, 23, 74) 38: (12, 17, 44) 39: (13, 57, 76)
40: (15, 27, 56) 41: (16, 28, 29) 42: (18, 19, 59) 43: (20, 40, 63)
44: (21, 35, 52) 45: (22, 54, 64) 46: (24, 62, 78) 47: (26, 32, 77)
48: (30, 72, 85) 49: (31, 65, 87) 50: (33, 39, 51) 51: (37, 48, 75)
52: (38, 70, 71) 53: (41, 42, 68) 54: (45, 67, 86) 55: (46, 81, 82)
56: (49, 66, 73) 57: (58, 60, 66) 58: (61, 65, 85) 59: ( 1, 14, 21)
60: ( 2, 13, 59) 61: ( 3, 67, 82) 62: ( 4, 32, 73) 63: ( 5, 36, 54)
64: ( 6, 43, 46) 65: ( 7, 28, 75) 66: ( 8, 33, 71) 67: ( 9, 49, 76)
68: (10, 58, 64) 69: (11, 48, 68) 70: (12, 19, 45) 71: (15, 50, 61)
72: (16, 22, 26) 73: (17, 72, 80) 74: (18, 40, 55) 75: (20, 35, 51)
76: (23, 25, 34) 77: (24, 63, 87) 78: (27, 39, 74) 79: (29, 78, 83)
80: (30, 70, 77) 81: (31, 69, 84) 82: (22, 37, 86) 83: (38, 41, 81)
84: (42, 44, 57) 85: (47, 53, 62) 86: (52, 56, 79) 87: (60, 75, 81)
88: ( 1, 39, 77) 89: ( 2, 16, 41) 90: ( 3, 31, 54) 91: ( 4, 36, 78)
92: ( 5, 45, 65) 93: ( 6, 57, 85) 94: ( 7, 14, 49) 95: ( 8, 21, 46)
96: ( 9, 15, 72) 97: (10, 20, 62) 98: (11, 17, 71) 99: (12, 34, 47)
100: (13, 68, 86) 101: (18, 23, 43) 102: (19, 64, 73) 103: (24, 48, 79)
104: (25, 70, 83) 105: (26, 80, 87) 106: (27, 32, 40) 107: (28, 56, 69)
108: (29, 63, 66) 109: (30, 42, 50) 110: (33, 37, 82) 111: (35, 60, 74)
112: (38, 55, 84) 113: (44, 52, 61) 114: (51, 53, 72) 115: (58, 59, 67)
116: (47, 56, 76) 117: ( 1, 19, 37) 118: ( 2, 61, 75) 119: ( 3, 8, 66)
120: ( 4, 60, 84) 121: ( 5, 34, 39) 122: ( 6, 26, 53) 123: ( 7, 32, 57)
124: ( 9, 52, 67) 125: (10, 12, 15) 126: (11, 51, 69) 127: (13, 14, 65)
128: (16, 31, 43) 129: (17, 20, 36) 130: (18, 80, 86) 131: (21, 48, 59)
132: (22, 40, 46) 133: (23, 33, 62) 134: (24, 30, 74) 135: (25, 42, 64)
136: (27, 49, 85) 137: (28, 38, 73) 138: (29, 44, 81) 139: (35, 68, 70)
140: (41, 63, 76) 141: (45, 49, 71) 142: (50, 58, 87) 143: (48, 54, 83)
144: (13, 55, 79) 145: (77, 78, 82) 146: ( 1, 2, 24) 147: ( 3, 6, 75)
148: ( 4, 56, 87) 149: ( 5, 44, 53) 150: ( 7, 50, 83) 151: ( 8, 10, 28)
152: ( 9, 55, 62) 153: (11, 29, 67) 154: (12, 33, 40) 155: (14, 16, 20)
156: (15, 35, 73) 157: (17, 31, 39) 158: (18, 36, 57) 159: (19, 46, 76)
160: (21, 42, 84) 161: (22, 34, 59) 162: (23, 26, 61) 163: (25, 60, 65)
164: (27, 64, 80) 165: (30, 37, 66) 166: (32, 45, 72) 167: (38, 51, 86)
168: (41, 77, 79) 169: (43, 56, 68) 170: (47, 74, 82) 171: (40, 52, 78)
172: (54, 61, 71) 173: (46, 58, 69)
LDPC_NRW — the true length (5/6/7) of each LDPC_NM row, so the pad 0s are never read:
(6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6,
6, 6, 6, 7, 6, 6, 6, 6, 7, 6, 5, 6, 6, 6, 6, 6, 6, 5, 6, 6, 6, 6, 6, 6, 6, 6,
6, 6, 5, 6, 6, 6, 5, 6, 6)
6.3 Regeneration recipe (authoritative)
git clone --depth 1 https://github.com/js8call/js8call refs/js8call_src
python scripts/gen_js8_constants.py # → app/radio/js8/constants.py + _tables/jsc_words.txt.gz
The generator draws the LDPC generator/COLORDER from lib/ft8/ldpc_174_87_params.f90, the Nm/Mn/nrw parity tables from lib/ft8/bpdecode174.f90, the Costas arrays from lib/js8/genjs8.f90, the varicode/command/callsign/CQ/HB/dBm tables from varicode.cpp, and the JSC dictionary from jsc_map.cpp + jsc_list.cpp (gen_js8_constants.py). It asserts every shape and self-consistency invariant, so the output is byte-reproducible.
6.4 The JSC dictionary and (s,c)-dense codec
Parameters (constants.py): b = 4 bits per group, s = 7, c = 9, dictionary size 2¹⁸ = 262144. The dictionary (_tables/jsc_words.txt.gz, 262 144 lines, one word per line in codeword-index order, backslash-escaped ASCII) is loaded by text._load (text.py). It is external reference data (a frequency-ordered word list, exactly reproduced from JS8Call's jsc_list.cpp by the §6.3 recipe) and is far too large to inline; its structure and the two exceptional entries are fully specified here.
Words are ordered by frequency. Indices 0…21 are the single characters E T A O I N S H R D L C U M W F G Y P B , .; then increasingly long words — index 82 = "BEACON", 99 = "BCNU", 100 = "BD", 101 = "BK", 102 = "BN", 103 = "BEAM", 500 = "HOLIDAY", 5000 = "VIRGIN", … Two entries have quirky match/consume lengths, held in JSC_EXCEPTIONS: index 81 "@ALLCALL" matches/consumes only its first 7 chars (7, 7), and index 262143 "ROSIDS" matches its full 6 chars but consumes only 1 (6, 1). Excerpt of the codeword-index-ordered dictionary:
index 0: 'E' index 1: 'T' index 2: 'A' index 3: 'O'
index 4: 'I' index 5: 'N' index 6: 'S' index 7: 'H'
index 20: ',' index 21: '.'
index 81: '@ALLCALL' (exception 7/7) index 82: 'BEACON'
index 99: 'BCNU' index 100: 'BD' index 101: 'BK' index 102: 'BN'
index 500: 'HOLIDAY' index 5000: 'VIRGIN'
index 262143: 'ROSIDS' (exception 6/1)
Compression (text.jsc_compress, text.py): the text is split on spaces; each word is repeatedly reduced by the longest dictionary prefix (_lookup, text.py), emitting a codeword per hit. The codeword for a dictionary index with a word-separator flag separate is (_codeword, text.py):
last group (5 bits): ((index % s) << 1) | separate # value < s ⇒ terminator, +separator bit
x = index // s
while x > 0:
x -= 1
prepend 4-bit group: (x % c) + s # value ≥ s ⇒ continuation
x //= c
i.e. a start-step-stop / (s,c)-dense code at 4-bit granularity: the first s = 7 group codes terminate, the next c = 9 continue, and the terminator gets a 5th bit for the inter-word space flag. jsc_decompress (§5.9) is the inverse, using the cumulative offset table base = [0, 7, 70, 637, 5740, 51667, 465010, 4185097] (base[k] = base[k−1] + s·c^(k−1)). As a worked check, "HELLO WORLD" compresses to 38 bits (011110001011100001011 + 01111011100101000) versus 55 bits of per-character Huffman — the dictionary is denser on real words (test_js8.py).
7. Interoperability & validation
Reference oracles. The port is validated against oracle binaries compiled from the actual JS8Call source (scripts/js8_interop_test.py): genjs8_oracle (encoder), varicode_oracle (message layer), and js8ref_tx / js8dec_oracle (reference waveform TX and decoder). Because JS8Call is the JS8 reference, bit/symbol/text equality with it is the correctness criterion. The suite's four stages (js8_interop_test.py), with the header's recorded results (verified 2026-07-06, macOS arm64):
- Encoder — bit/symbol exact. For 40 random
msg72× all 8i3values × both Costas sets, plus real message-layer frames, both the 87-bit information word and the full 79-tone string must equalgenjs8's output: 660/660 frames bit-exact (js8_interop_test.py). - Varicode — identical framing and rendering. Over a 22-message corpus in submodes A and C, the frame sequence from
build_message_framesmust equalbuildMessageFrames, and each RX render must equalDecodedText: 36/36 frames + 36/36 renders (js8_interop_test.py). - Audio — bidirectional over-the-air. For all four submodes × three frequencies × the corpus, the native TX waveform must decode in the reference decoder and the reference waveform must decode natively: 84/84 each direction (
js8_interop_test.py). This is what certifies the GFSK modulator (§3.6) is on-air compatible with the reference. - SNR — sensitivity comparison (informational). Under AWGN at −14…−20 dB (2.5 kHz reference bandwidth), native vs reference decode counts are compared; the port is "within ~1 dB of the reference (single-pass BP vs the reference's multi-pass BP+OSD)" (
js8_interop_test.py).
In-tree regression tests. test_js8.py (23 tests, all passing) cross-validates the physical layer without the oracle: because the generator and parity-check tables are independently transcribed from different reference files, every encode174 codeword is checked to satisfy all 87 parity checks (test_js8.py); CRC/tone-layout/slot-shape are asserted (test_js8.py); and full audio round-trips (encode → decimate → add noise → decode) recover the frame in every submode (test_js8.py), plus message-layer packers, JSC/Huffman round-trips, and multi-frame build+reassembly with checksum validation (test_js8.py).
App integration. codec.encode_pcm/decode_slot mirror the FT8 codec boundary (text in → PCM out, PCM in → {snr, dt, df, msg, …} out), and FrameAssembler handles the multi-slot reassembly the app drives one slot at a time.
8. Limitations
- Single-pass belief propagation. The decoder runs one 25-iteration BP pass per candidate; it lacks the reference's OSD (ordered-statistics decoding) and multi-pass a-priori subtraction, which is the ~1 dB sensitivity gap measured in the SNR stage (§7). This is a decode-sensitivity gap, not a compatibility gap — every frame the port decodes is a valid JS8 frame.
- Pure-Python BP is the hot spot. The doubly-nested check/variable loops run 25 iterations over up to 140 candidates per slot (
decode.py); correct and reference-matching, but a vectorised/compiled BP would be needed for very busy waterfalls. - Submode I ("ultra") is unimplemented in practice — present in the table but disabled in JS8Call and untested here (
constants.py). - Message-layer coverage follows the ported
buildMessageFrames: heartbeat/CQ, compound and compound-directed handshakes, directed commands (with buffered-command checksums), and Huffman/JSC data frames. Rarer relay/APRS/telemetry flows exercised by full JS8Call are outside the validated corpus.
9. References
- J. Sherer KN4CRD, JS8Call (GPL-3.0) — github.com/js8call/js8call; the reference implementation this port follows and the source of every constant (
lib/js8/genjs8.f90,lib/ft8/ldpc_174_87_params.f90,lib/ft8/bpdecode174.f90,varicode.cpp,jsc_map.cpp,jsc_list.cpp). - J. Taylor K1JT, S. Franke K9AN, B. Somerville G4WJS, The FT4 and FT8 Communication Protocols, QEX, 2020 — the FT8 waveform, LDPC, Costas and 8-FSK that JS8's physical layer derives from (see ft8-ft4.md).
- R. Gallager, Low-Density Parity-Check Codes, MIT Press, 1963 — the LDPC code family and iterative sum-product decoding.
- J. P. Costas, A study of a class of detection waveforms having nearly ideal range–Doppler ambiguity properties, Proc. IEEE, 1984 — Costas arrays.
- J. Zobel & A. Moffat, Adding compression to a full-text retrieval system / dense-code literature — the (s,c)-dense coding family JSC belongs to.
- This repo:
docs/native-digimodes.md(native-digimode programme and clean-room/validation policy),NOTICE.md, and the interop harnessscripts/js8_interop_test.py.
Implementation map
| File | Role |
|---|---|
app/radio/js8/constants.py |
All protocol tables (generated from JS8Call) |
app/radio/js8/crc.py |
CRC-12 (frame_crc, crc12), poly 0xC06, XOR 42 |
app/radio/js8/ldpc.py |
LDPC(174, 87) systematic encode (encode174, extract87) |
app/radio/js8/frames.py |
Frame packers/unpackers: callsign/grid/command/data |
app/radio/js8/text.py |
Huffman varicode + JSC (s,c)-dense dictionary codec |
app/radio/js8/messages.py |
build_message_frames, render_frame, FrameAssembler |
app/radio/js8/encode.py |
frame_bits, tone assembly, GFSK 8-FSK synth (synth_gfsk, synth_slot) |
app/radio/js8/decode.py |
Waterfall, Costas sync, 8-FSK LLRs, BP decode, CRC gate |
app/radio/js8/codec.py |
Public API (encode_pcm, encode_frames, decode_slot) |
app/radio/js8/_tables/jsc_words.txt.gz |
262 144-word JSC dictionary (index order) |
scripts/gen_js8_constants.py |
Regenerates constants.py + dictionary from JS8Call |
scripts/js8_interop_test.py |
4-stage interop vs JS8Call oracle binaries |
test_js8.py |
In-tree physical- and message-layer regression tests |