FT8/FT4 encoder and decoder in pure TypeScript. A port of the Fortran implementation from WSJT-X v3.0.1.
FT8/FT4 are digital amateur radio modes designed for weak-signal communication, developed by Joe Taylor (K1JT) and Steve Franke (K9AN).
This library provides pure TypeScript implementations of both encoding and decoding for FT8/FT4, suitable for use in Node.js or the browser.
https://e04.github.io/ft8ts/example/browser/index.html
# Decode WAV file (FT8 or FT4)
npx @e04/ft8ts decode foo.wav [--mode ft8|ft4] [--low 200] [--high 3000] [--depth 2] [--contest NA_VHF] [--threads auto]
# Encode message to WAV file
npx @e04/ft8ts encode "CQ JK1IFA PM95" [--out output.wav] [--df 1000]The benchmark below was compiled with reference to Comparing PyFT8 with WSJT-x and FT8_lib (sample: 210703_133430.wav).
| Call a | Call b | Message | WSJT-x(default) | WSJT-x (fast) | PyFT8 | ft8_lib | ft8ts (depth=1) | ft8ts (depth=2, 3) |
|---|---|---|---|---|---|---|---|---|
| W1FC | F5BZB | -8 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
| WM3PEN | EA6VQ | -9 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
| CQ | F5RXL | IN94 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
| N1JFU | EA6EE | R-07 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
| A92EE | F5PSR | -14 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
| K1BZM | EA3GP | -9 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | |
| W0RSJ | EA3BMU | RR73 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | |
| K1JT | HA0DU | KN07 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
| W1DIG | SV9CVY | -14 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | |
| K1JT | EA3AGB | -15 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | |
| XE2X | HA2NP | RR73 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
| N1PJT | HB9CQK | -10 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
| K1BZM | EA3CJ | JN01 | ☑️ | ☑️ | ☑️ | ☑️ | ||
| KD2UGC | F6GCP | R-23 | ☑️ | ☑️ | ☑️ | ☑️ | ||
| WA2FZW | DL5AXX | RR73 | ☑️ | ☑️ | ☑️ | |||
| N1API | HA6FQ | -23 | ☑️ | ☑️ | ||||
| N1API | F2VX | 73 | ☑️ | ☑️ | ||||
| K1JT | HA5WA | 73 | ☑️ | ☑️ | ||||
| CQ | EA2BFM | IN83 | ☑️ | ☑️ | ||||
| K1BZM | DK8NE | -10 | ☑️ |
At depth 2 (default) and depth 3, ft8ts decodes all 20 rows in the table above, plus CQ DX DL8YHR JO41 (-16 dB), which is not listed in the original comparison: 21 messages in total, the same as WSJT-X 2.7.0 at its deepest setting. Even depth 1 (15 rows) outperforms PyFT8 (12), FT8_lib (8), and WSJT-x FAST mode (14).
npm i @e04/ft8ts
import { encodeFT8, decodeFT8, encodeFT4, decodeFT4, HashCallBook } from "@e04/ft8ts";
// Encode a message to audio samples (Float32Array)
const samples = encodeFT8("CQ JK1IFA PM95", {
sampleRate: 12000,
baseFrequency: 1000,
});
// Create a HashCallBook to resolve hashed callsigns.
// Reuse the same instance across multiple decode calls so that
// callsigns learned from earlier frames can resolve hashes in later ones.
const book = new HashCallBook();
// Decode audio samples to messages
const decoded = decodeFT8(samples, {
sampleRate: 12000,
freqLow: 200,
freqHigh: 3000,
depth: 2,
hashCallBook: book,
});
for (const d of decoded) {
console.log(`${d.freq} Hz SNR ${d.snr} dB ${d.msg}`);
}import { encodeFT4, decodeFT4, HashCallBook } from "@e04/ft8ts";
// Encode FT4 message
const samples = encodeFT4("CQ JK1IFA PM95", {
sampleRate: 12000,
baseFrequency: 1000,
});
// Decode FT4
const book = new HashCallBook();
const decoded = decodeFT4(samples, {
sampleRate: 12000,
freqLow: 200,
freqHigh: 3000,
depth: 2,
hashCallBook: book,
});At depth 3, the FT8 decoder can also use the decodes of the slot 30 s earlier, like WSJT-X's "a7" decoding: a station decoded then and not decoded now is looked for at the same frequency with the messages it is likely to send next (RRR, RR73, 73, reports, its grid, ...), which finds signals a few dB weaker than regular decoding. Pass the same FT8History for consecutive slots together with the slot's start time:
import { decodeFT8, FT8History, HashCallBook } from "@e04/ft8ts";
const book = new HashCallBook();
const history = new FT8History();
// Call once per 15 s slot
function onSlot(samples: Float32Array, slotStart: Date) {
const decoded = decodeFT8(samples, { depth: 3, hashCallBook: book, history, slotStart });
for (const d of decoded) {
// d.ap is 7 for a7 decodes (1 for the "CQ ??? ???" AP pass)
console.log(`${d.freq} Hz SNR ${d.snr} dB ${d.msg}${d.ap ? ` a${d.ap}` : ""}`);
}
}Like other a priori decodes, a7 decodes are more likely to be false than regular ones; they are marked with ap.
Like the FT8 decoding threads of WSJT-X 3, FT8DecoderPool splits the band into one sub-band per thread and decodes them in parallel in Web Workers (browsers) or worker threads (Node.js), which meet after every decoding pass to subtract each other's signals. It takes the same options as decodeFT8 and returns a promise:
import { FT8DecoderPool, FT8History, HashCallBook } from "@e04/ft8ts";
const pool = new FT8DecoderPool(); // threads: as WSJT-X "auto", or { threads: 4 }
const book = new HashCallBook();
const history = new FT8History();
async function onSlot(samples: Float32Array, slotStart: Date) {
return pool.decode(samples, { sampleRate: 48000, depth: 3, hashCallBook: book, history, slotStart });
}- In browsers, the workers run
dist/ft8ts-worker.mjs, which the pool loads from next todist/ft8ts.mjswithnew Worker(new URL("./ft8ts-worker.mjs", import.meta.url), { type: "module" }). Bundlers that understand this pattern (Vite, webpack 5, ...) bundle the worker too; otherwise serve the file next to the module, or passworkerFactoryto create the workers yourself. - In Node.js (ESM or CommonJS), the workers are
worker_threadsrunningdist/ft8ts-worker-node.mjs. Idle threads do not keep the process from exiting. If you bundle ft8ts into your own Node.js app, passworkerFactory: () => nodeDecoderWorker(urlOfFt8tsWorkerNode). - Reuse one pool for all slots: the workers are started on first use and kept until
pool.terminate(). - Results are those of
decodeFT8up to small differences (SNRs by a fraction of a dB, rarely a decode near a sub-band edge), as each thread fits the spectrum baseline and finds candidates in its own sub-band. - With
threads: 1, or where neither kind of worker is available, the pool decodes on the calling thread. - The CLI decodes FT8 in worker threads with
--threads <n|auto>.
| Option | Default | Description |
|---|---|---|
sampleRate |
12000 | Input audio sample rate (Hz) |
freqLow |
200 | Lower frequency bound (Hz) |
freqHigh |
3000 | Upper frequency bound (Hz) |
syncMin |
FT8: 2.1 (depth ≤ 2) / 1.3 (depth 3), FT4: 1.18 | Minimum sync threshold |
depth |
2 | Decoding depth: 1=fast BP only, 2=BP+OSD, 3=deep (values above 3 behave like 3) |
maxCandidates |
1000 (FT8) / 200 (FT4) | Maximum candidates to process |
contest |
— | FT8 only. WSJT-X "Special operating activity": NA_VHF, EU_VHF, FIELD_DAY, RTTY, WW_DIGI or ARRL_DIGI. Without it, standard messages containing /R or starting with TU; are rejected as likely false decodes, as in WSJT-X 3 |
hashCallBook |
— | HashCallBook instance for resolving hashed callsigns |
history |
— | FT8 only. FT8History instance for a7 decoding (see above); decodes are saved at any depth, a7 runs at depth 3 |
slotStart |
— | FT8 only. Start of (or any time within) the slot being decoded, as a Date or ms since epoch. Required with history |
npm run buildGPL-3.0
-
PyFT8 — A pure Python implementation of an FT8 encoder and decoder.
-
ft8_lib — A lightweight C implementation of an FT8 and FT4 encoder and decoder.
-
wsjtx_lib — A C++ library that wraps the original WSJT-X Fortran source code.
-
wsjtx-lib-nodejs — A Node.js native C++ extension and TypeScript wrapper for
wsjtx_lib. -
ft8js - My previous experimental project using WebAssembly (WASM) with ft8_lib.