Skip to content

Codec SDK & native codec library

This document has two parts:

  1. 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.
  2. 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 with feed(pcm: bytes). pcm is signed 16-bit little-endian mono at sr. Call on_text(str) for each decoded character (verbatim; include \r/\n as 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 at sr, ready to key. Either callable may be None (receive-only or transmit-only).

Registering

Call register() at import time. Two ways to get your module imported:

  1. Drop a .py file in app/radio/codecs/ (bundled built-ins live here).
  2. Put it in a folder and point $RAFE_CODECS_DIR at 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 keep rx_rate = 12000 unless 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, ...) -> bytes function and a decoder class feed(pcm) -> on_text(char).
  • Slot (weak-signal modes): encode_pcm(...) -> bytes for one T/R slot and decode_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.