A browser-native JS8Call decoder compiled to WebAssembly from Rust. Runs entirely client-side — no server, no Python, no WSJT-X installation required.
Decodes JS8Call (all four speed modes: Slow / Normal / Fast / Turbo) using belief-propagation LDPC and Gray-coded 8-FSK demodulation. Accepts raw PCM audio at 12 kHz mono via a Web Worker, so the main thread is never blocked.
JS8Call is a weak-signal digital mode for amateur radio derived from FT8. It uses 8-tone FSK with 79 symbols per transmission and a (174, 91) LDPC error-correcting code, allowing contacts at signal levels well below the noise floor. Website: https://js8call.com
The dist/ directory contains pre-compiled files ready to drop into any
web project served over HTTP.
| File | Purpose |
|---|---|
dist/js8call_wasm.js |
JavaScript glue generated by wasm-bindgen |
dist/js8call_wasm_bg.wasm |
Compiled WebAssembly binary (~350 KB) |
dist/js8call-worker.js |
Module Web Worker (audio accumulation + decode) |
dist/js8call.js |
Controller class for the main thread |
All four files must be served from the same HTTP directory. They cannot
be loaded from file:// URLs due to WASM CORS restrictions.
Copy the four dist/ files to your web server. Add to your HTML:
<script src="js8call.js"></script>// 1. Create decoder and wire up callbacks
const decoder = new Js8CallDecoder();
await decoder.init(
(messages, slotTime) => {
messages.forEach(m => {
console.log(`${m.time} ${m.freq.toFixed(0)} Hz SNR ${m.snr > 0 ? '+' : ''}${m.snr} ${m.msg}`);
});
},
(statusText) => console.log('[JS8]', statusText)
);
// 2. Start buffering in Normal mode (15.6 s slots)
// Mode IDs: 0=Slow(30s) 1=Normal(15.6s) 2=Fast(10s) 3=Turbo(6s)
decoder.start(1);
// 3. Feed raw audio. Each ArrayBuffer must be:
// [4-byte uint32 LE timestamp][Int16 PCM samples at 12 kHz mono]
// (The 4-byte header matches the /ws/audio WebSocket frame format
// used by the T-Embed SI4732 firmware — strip or keep as-is.)
websocket.onmessage = (e) => {
decoder.pushAudioFrame(e.data);
};
// 4. Change speed mode at runtime (no restart needed)
decoder.setMode(2); // switch to Fast
// 5. Stop and release
decoder.stop();Each entry in the messages array has:
{
msg: string, // decoded text, e.g. "W1AW K1TTT FN31"
snr: number, // signal-to-noise ratio in dB
freq: number, // carrier frequency in Hz
time: string, // HH:MM:SS UTC at slot boundary
dt: number // time offset (0 — not yet implemented)
}| Parameter | Value |
|---|---|
| Sample rate | 12 000 Hz (12 kHz) |
| Channels | Mono |
| Format | Int16 (signed 16-bit) |
| Frame header | 4 bytes uint32 LE timestamp prefix (can be zeros) |
The 4-byte timestamp prefix matches the WebSocket audio frame format of the T-Embed SI4732 firmware. If your audio source does not have this header, prepend four zero bytes:
// Prepend a dummy 4-byte header to a raw Int16 buffer
function wrapFrame(int16ArrayBuffer) {
const out = new Uint8Array(4 + int16ArrayBuffer.byteLength);
out.set(new Uint8Array(int16ArrayBuffer), 4);
return out.buffer;
}
decoder.pushAudioFrame(wrapFrame(rawPcmBuffer));| ID | Name | Slot duration | Symbol period | Tone spacing |
|---|---|---|---|---|
| 0 | Slow | 30 s | 320 ms | 3.125 Hz |
| 1 | Normal | 15.6 s | 160 ms | 6.25 Hz |
| 2 | Fast | 10 s | 80 ms | 12.5 Hz |
| 3 | Turbo | 6 s | 40 ms | 25.0 Hz |
All modes use 79 symbols per frame, 8-FSK modulation, and the same (174, 91) LDPC code. Tune your radio to USB on a JS8Call frequency:
| Band | Frequency (USB dial) |
|---|---|
| 80 m | 3.578 MHz |
| 40 m | 7.078 MHz |
| 30 m | 10.130 MHz |
| 20 m | 14.078 MHz |
| 17 m | 18.104 MHz |
| 15 m | 21.078 MHz |
The Decimator class converts audio from higher sample rates down to 12 kHz
using a Kaiser-windowed FIR lowpass filter. Useful if your audio source is
at 48 kHz, 96 kHz, etc.
// Load js8call_wasm.js first (in a module context)
import init, { Decimator } from './js8call_wasm.js';
await init();
const dec = new Decimator(4, 127); // 48 kHz → 12 kHz, 127 taps
const output = dec.process(float32InputAt48kHz); // returns Float32Array at 12 kHzFollow these steps to recompile the WASM from the Rust source in wasm/.
Download and run the installer from https://rustup.rs
Accept the default (press Enter for option 1). Close and reopen your terminal after install.
rustc --version
rustup target add wasm32-unknown-unknown
cargo install wasm-pack compiles a native Windows binary and requires
the MSVC C++ runtime libraries.
If you see LNK1104: cannot open file 'msvcrt.lib' during Step 4:
- Open Visual Studio Installer (search Start menu)
- Click Modify next to Visual Studio 2022
- Check Desktop development with C++
- Click Modify and wait (~2–4 GB download)
- Close all terminals and reopen
Quick test first: open x64 Native Tools Command Prompt for VS 2022 instead of a normal terminal. If Step 4 succeeds from there, the workload is already installed — just always run the build from that prompt.
cargo install wasm-pack
Takes 2–5 minutes (compiles from source).
wasm-pack --version
Download Python from https://python.org (check Add Python to PATH).
pip install requests
cd path/to/js8call-wasm-decoder
The parity-check matrix is not stored in the repo — it is fetched from
the ft8_lib source on GitHub and written to wasm/src/ldpc_matrix.rs.
This file is auto-generated and gitignored.
python tools/gen_ldpc_matrix.py
Expected output:
Fetching ft8lib source (trying candidate URLs) ...
Using: https://raw.githubusercontent.com/kgoba/ft8_lib/master/ft8/constants.c
kFTX_LDPC_Mn: 174 rows -> NM (variable->check)
kFTX_LDPC_Nm: 83 rows -> MN (check->variable)
NM max degree: 3
MN max degree: 7
Wrote wasm/src/ldpc_matrix.rs (6,850 bytes)
Now rebuild WASM: wasm-pack build --target web
cd wasm
wasm-pack build --target web --out-dir ../wasm/pkg
cd ..
This takes 15–60 seconds on a modern machine. Output ends with:
[INFO]: :-) Done in Xs
[INFO]: :-) Your wasm pkg is ready to publish at ./wasm/pkg.
Windows Command Prompt:
copy wasm\pkg\js8call_wasm.js dist\js8call_wasm.js
copy wasm\pkg\js8call_wasm_bg.wasm dist\js8call_wasm_bg.wasm
Linux / macOS / Git Bash:
cp wasm/pkg/js8call_wasm.js dist/
cp wasm/pkg/js8call_wasm_bg.wasm dist/
The dist/js8call-worker.js and dist/js8call.js files are hand-written
and do not need to be recompiled.
js8call-wasm-decoder/
├── README.md
├── LICENSE GPL-3.0
├── .gitignore
├── tools/
│ └── gen_ldpc_matrix.py Fetches LDPC matrix from ft8lib GitHub
├── wasm/
│ ├── Cargo.toml
│ ├── pkg/ Build output (gitignored)
│ └── src/
│ ├── lib.rs Public WASM API (Js8Decoder, Decimator)
│ ├── types.rs SpeedMode, frame geometry constants
│ ├── spectrogram.rs Hann-windowed sliding FFT
│ ├── sync.rs Costas array correlation search
│ ├── llr.rs Gray-coded 8-FSK LLR extraction
│ ├── ldpc.rs Belief-propagation LDPC decoder
│ ├── message.rs Callsign / grid / free-text unpacking
│ ├── decimate.rs Kaiser FIR decimation filter
│ └── ldpc_matrix.rs GENERATED — do not edit
└── dist/
├── js8call_wasm.js Pre-compiled JS glue (wasm-bindgen)
├── js8call_wasm_bg.wasm Pre-compiled WASM binary
├── js8call-worker.js Module Web Worker
└── js8call.js Main-thread controller class
Audio PCM (12 kHz, Int16)
│
▼
js8call-worker.js
┌──────────────────────────────────────┐
│ Accumulate one slot of samples │
│ (6–30 s depending on speed mode) │
│ │
│ Js8Decoder.push_samples() │
│ Js8Decoder.run_decode() │
│ │
│ spectrogram.rs sliding FFT │
│ ↓ │
│ sync.rs Costas search │
│ ↓ │
│ llr.rs LLR extraction │
│ ↓ │
│ ldpc.rs belief propagation │
│ ↓ │
│ message.rs callsign decode │
└──────────────────────────────────────┘
│
▼
{ msg, snr, freq, time }
| Problem | Fix |
|---|---|
couldn't read src/ldpc_matrix.rs |
Run python tools/gen_ldpc_matrix.py first |
stream did not contain valid UTF-8 |
Re-run gen_ldpc_matrix.py (encoding bug was fixed) |
59 errors E0600: cannot apply unary - to u8 |
Re-run gen_ldpc_matrix.py (padding fix was applied) |
LNK1104: cannot open file 'msvcrt.lib' |
Install Desktop development with C++ in Visual Studio Installer |
| Linker error only in regular terminal | Use x64 Native Tools Command Prompt for VS 2022 |
| WASM loads but no decodes | Tune to a JS8Call frequency in USB mode; ensure audio is at 12 kHz mono |
Status shows <bits:hex> |
Directed/relay message type — standard callsign pairs decode correctly |
| Worker fails with CORS error | Files must be served over HTTP, not file:// |
GPL-3.0 — see LICENSE.
The LDPC parity-check matrix is fetched from kgoba/ft8_lib (MIT license) at build time.
JS8Call is developed by KN4CRD. This project is not affiliated with or endorsed by the JS8Call project.