Codec SDK & native codec library¶
This document has two parts:
- The SDK — how to add your own digital-mode codec so it self-attaches to Rafe's receive path, transmit path and UI, with no app-code changes.
- The native codec library — the full set of in-repo encoders/decoders and their direct APIs, usable standalone (import them into any Python project; they only depend on numpy/scipy).
All of the native codecs are clean-room ports validated against the reference
implementations (WSJT-X's own *sim/*code tools and fldigi's compiled codec
source). See docs/native-digimodes.md for the validation detail.
Part 1 — The SDK¶
The contract¶
A codec is a TextCodec (see app/radio/codec_sdk.py) describing how to turn
text into audio and audio back into text:
@dataclass
class TextCodec:
id: str # unique mode key, e.g. "mymode"
label: str # UI display name
category: str = "Custom"
make_decoder: Callable[[on_text, sr], Decoder] | None = None # RX
modulate: Callable[[text, sr], bytes] | None = None # TX
rx_rate: int = 12000 # sample rate the decoder is fed
tx_rate: int = 48000 # sample rate the modulator emits
description: str = ""
make_decoder(on_text, sr)returns an object withfeed(pcm: bytes).pcmis signed 16-bit little-endian mono atsr. Callon_text(str)for each decoded character (verbatim; include\r/\nas you like). Streaming: the app feeds you audio in arbitrary-sized chunks; keep your own state.modulate(text, sr)returns signed 16-bit LE mono PCM bytes atsr, ready to key. Either callable may beNone(receive-only or transmit-only).
Registering¶
Call register() at import time. Two ways to get your module imported:
- Drop a
.pyfile inapp/radio/codecs/(bundled built-ins live here). - Put it in a folder and point
$RAFE_CODECS_DIRat it — every*.py(not starting with_) is loaded at startup.
from app.radio.codec_sdk import TextCodec, register
class MyDecoder:
def __init__(self, on_text, sr):
self.on_text, self.sr, self.buf = on_text, sr, b""
def feed(self, pcm):
... # demodulate; self.on_text("A") per character
def my_modulate(text, sr):
... # -> s16le mono PCM bytes
return pcm
register(TextCodec(
id="mymode", label="My Mode", category="Custom",
make_decoder=lambda on_text, sr: MyDecoder(on_text, sr),
modulate=my_modulate))
Two worked examples ship in app/radio/codecs/:
- example_tonefsk.py — the minimal skeleton (one MFSK tone per ASCII char);
copy it, rename the id, swap the modulation.
- lora_css.py — registers a real, non-trivial mode: an audio-band
Chirp Spread Spectrum (LoRa-family) modem (app/radio/loracss.py). It
shows a full LoRa-style PHY as a plugin: preamble/SFD matched-filter frame
sync, dechirp + folded-FFT demod, and the data path of whitening (LFSR PN) →
Hamming(8,4) SECDED FEC → block interleaving → Gray → CRC-8. The FEC buys
~4 dB: SF8 decodes to about −9.6 dB and SF10 to about −12.7 dB
wideband SNR. It registers three selectable SF/BW sub-modes (they appear as
separate modes in the picker under the "LoRa CSS" category):
| id | SF / BW | trade-off |
|---|---|---|
loracss-fast |
SF7 / 2 kHz | fastest |
loracss |
SF8 / 1.5 kHz | balanced (default) |
loracss-robust |
SF10 / 1 kHz | slowest, most sensitive |
Not Semtech-interoperable (real LoRa is ≥125 kHz in UHF ISM); it's a standalone
narrowband mode you can run through the SSB path. See the LoRa feasibility
discussion for the SDR route to true LoRa.
- mercury_ofdm.py — registers the 17 Mercury robustness configs
(Mercury 0..Mercury 16), a clean-room OFDM + LDPC HF data modem
(BPSK→32QAM, LDPC 1/16..14/16, CRC-16/MODBUS, a Dx/Dy pilot grid + preamble
matched-filter sync). The PHY lives under app/radio/mercury/. It shows a full
OFDM+FEC modem as a plugin; frame geometry and constellations are validated
against a locally-built Mercury binary (used only as a reference oracle — no
AGPL source is copied). Not bit-compatible with the reference. See
docs/mercury.md.
Registry helpers: register, get(id), codecs(), ids(), manifest(),
load_plugins().
What you get automatically¶
Once registered, the codec self-attaches:
- RX — selecting your mode instantiates your decoder and feeds it the
radio's audio (decimated to
rx_rate); decoded characters stream to the client just like the built-in modes. - TX — typed text is routed to
modulate()and keyed on the radio. - UI — the server advertises the codec manifest to every client on connect
(
{"t":"codecs", ...}); the web UI appends your mode to the keyboard-mode picker (Custom modes get a ★) and wires up the terminal + TX line for it.
No edits to digimodes.py, manager.py, main.py or the HTML are needed — the
dispatch is registry-driven.
Notes / limits¶
- Feed rate is
rx_rate(default 12000). The app currently decimates the 48 kHz radio audio to 12 kHz, so keeprx_rate = 12000unless you add a resampler. TX is rendered at 48 kHz. - Your decoder runs in the audio thread; keep
feed()cheap (per-symbol FFTs are fine — that's what the built-ins do). - Signal centring: the built-in modems assume a fixed audio centre (≈1000–1500 Hz). There's no AFC yet, so document where your mode expects the signal, or track it yourself.
Part 2 — The native codec library¶
Every codec below is a self-contained module under app/radio/. They share two
shapes:
- Streaming (keyboard/chat modes): a
modulate(text, ...) -> bytesfunction and a decoder classfeed(pcm) -> on_text(char). - Slot (weak-signal modes):
encode_pcm(...) -> bytesfor one T/R slot anddecode_slot(pcm) -> [{snr, dt, df|freq, msg}].
WSJT-X family (slot modes)¶
| Mode | Module | Encode | Decode | FEC |
|---|---|---|---|---|
| FT8 / FT4 | app.radio.ftx |
encode_pcm(msg, "ft8"\|"ft4", df, sr) |
decode_slot(pcm) |
LDPC(174,91) + CRC-14 |
| WSPR | app.radio.wsprx |
encode_pcm(call, grid, dbm) |
decode_slot(pcm12k) |
K=32 conv, Fano |
| JT9 | app.radio.jtx.jt9 |
encode_pcm(msg) |
decode_slot(pcm) |
K=32 conv |
| JT4 | app.radio.jtx.jt4 |
encode_pcm(msg) |
decode_slot(pcm) |
K=32 conv |
| JT65 | app.radio.jtx.jt65 |
encode_pcm(msg) |
decode_slot(pcm) |
Reed-Solomon(63,12)/GF(64) |
| FST4 | app.radio.jtx.fst4 |
encode_pcm(msg, period=…) |
decode_slot(pcm, period=…) |
LDPC(240,101) + CRC-24 |
| FST4W | app.radio.jtx.fst4 |
encode_pcm(msg, period=…, wspr=True) |
decode_slot(pcm, period=…, wspr=True) |
LDPC(240,74) |
| Q65 | app.radio.jtx.q65 |
encode_pcm(msg, period=…) |
decode_slot(pcm, period=…) |
QRA65 (15,65)/GF(64) + CRC-12 |
decode_slot takes 12 kHz s16le mono bytes (one T/R period) and returns a list
of {"snr","dt","df"|"freq","msg"} dicts.
from app.radio.jtx.jt65 import encode_pcm, decode_slot
pcm = encode_pcm("CQ K1ABC FN42", f0=1000.0) # 60 s slot @ 12 kHz
print(decode_slot(pcm)) # [{'snr': ..., 'dt': ..., 'df': ..., 'msg': 'CQ K1ABC FN42'}]
fldigi keyboard family (streaming modes)¶
| Mode | Module | Modulate | Decoder class | Scheme |
|---|---|---|---|---|
| MFSK16 / MFSK32 | app.radio.mfsk |
modulate(text, mode="mfsk16", sr=…) |
MfskDecoder(on_text, mode, sr) |
MFSK + K=7 Viterbi + interleave |
| DominoEX | app.radio.domino |
modulate(text, mode="domx22", sr=…) |
DominoDecoder(on_text, mode, sr) |
IFK+ 18-tone varicode |
| Thor | app.radio.thor |
modulate(text, mode="thor22", sr=…, secondary=…) |
ThorDecoder(on_text, mode, sr, on_sec=…) |
IFK+ + K=7 FEC, soft-decision |
| Olivia / Contestia | app.radio.olivia |
modulate(text, mode="olivia", sr=…) |
OliviaDecoder(on_text, mode, sr) |
Walsh/Hadamard block code |
| THROB | app.radio.throb |
modulate(text, mode="throb2", sr=…) |
ThrobDecoder(on_text, mode, sr) |
dual-tone pulse keying |
| Feld Hell | app.radio.feld |
modulate(text, sr=…) |
FeldDecoder(on_text, sr) |
on/off-keyed 7×14 raster |
from app.radio.mfsk import modulate, MfskDecoder
out = []
d = MfskDecoder(lambda ch: out.append(ch), mode="mfsk16", sr=12000)
d.feed(modulate("CQ DE M0SUP K", mode="mfsk16", sr=12000))
print("".join(out)) # CQ DE M0SUP K
Submodes available: mfsk16/mfsk32; domx22/domx16/domx11/domx5;
thor22/thor16/thor11; olivia/olivia-8-500/olivia-16-500/contestia/contestia-4-250;
throb2/throb4. Feld Hell also exposes the block codec OliviaCodec and a
font-correlation text decoder.
Already-native (pre-existing, same repo)¶
RTTY (app.radio.rtty), PSK31/63/125 (app.radio.psk), CW
(app.radio.cwdecoder), NAVTEX (app.radio.navtex), WeFax (app.radio.wefax).
Shared building blocks (reusable)¶
app.radio.jtx.fec— generalized K=32 r=1/2 stack sequential decoder.app.radio.jtx.pack/unpack— the 72-bit JT message packing.app.radio.ftx— 77-bit pack/unpack, LDPC belief-propagation, Costas/GFSK.app.radio.mfsk— K=7 convolutional encoder, soft-decision Viterbi, fldigi interleaver (reused by Thor).app.radio.olivia— forward/inverse Fast Hadamard Transform +OliviaCodec.
Licensing¶
The native modems are clean-room ports of the protocols. The WSJT-X-family FEC constant tables and the fldigi varicode/font tables are transcribed as protocol facts from GPL-3 sources (WSJT-X, fldigi, Pawel Jalocha's MFSK library) and are credited in-source. If you reuse these modules, honour those upstream licences.