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):
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):
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):
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 |