Patrick Lidstone
Self-hosted

FreeDV — native-implementation blueprint (today: a libcodec2 wrapper)

Open-source HF/VHF digital voice: what Rafe binds today via libcodec2, and the full protocol specification needed to reimplement it natively.

Rafe project · app/radio/freedv_native.py, freedv_voice.py, codec2_native.py · wrapper shipping · native port not yet implemented


1. Abstract

Status, stated plainly. Rafe does not have a from-scratch FreeDV implementation. Unlike FT8, the WSJT-X family, the fldigi family, JS8, LoRa, ADS-B and our own RVQ-Voice — all of which are native in-repo NumPy codecs — FreeDV is currently reached through a thin ctypes wrapper around David Rowe's libcodec2 (freedv_native.py). That wrapper binds the reference freedv_* C API directly, so it is bit-identical to the freedv_rx/ freedv_tx command-line tools (same shared library, same modem, same vocoder) while dropping the two subprocess binaries. It interoperates with every other FreeDV station because it is the reference code. What it is not is ours: the OFDM modem, the LDPC decoder and the Codec 2 vocoder all live inside a compiled LGPL library we link at runtime.

This document does two jobs. Part A (§3) documents exactly what the current wrapper does — the ctypes binding, the mode it selects, the sample-rate/buffer conventions, and how it slots into the digital-voice path — citing the real source and constants. Part B (§4–§9) is a reproduction-grade blueprint for a native port: the FreeDV wire protocol — the OFDM modem, the LDPC FEC, the pilot/unique-word framing, and the Codec 2 frame that rides inside — specified from David Rowe's drowe67/codec2 reference in enough detail that someone could build it the way we built the text digimodes. The flagship target is FreeDV 700D (Codec 2 700C + a 17-carrier pilot-assisted OFDM/QPSK modem + a rate-½ (224,112) LDPC code), which §4 covers to the symbol.

Every number in Part A is quoted from Rafe's own source with a file:line citation. Every number in Part B is grounded in the codec2/FreeDV reference (README_freedv.md, README_ofdm.md, src/ofdm.c, src/freedv_api.h, and David Rowe's design write-ups); where a value cannot be confirmed from the reference it is flagged as such rather than invented.


2. Background

2.1 What FreeDV is

FreeDV is the amateur-radio community's open digital-voice system: an HF/VHF mode that sends compressed speech over an ordinary SSB (HF) or FM (VHF/UHF) transceiver as an audio-band waveform, with no proprietary vocoder chip. It is the antithesis of DMR/D-STAR/P25/NXDN, whose AMBE/IMBE vocoders are patent- encumbered black boxes. FreeDV's two halves — the Codec 2 speech vocoder and the modem + FEC that carries it — are both open source (Codec 2 is LGPL-2.1), designed largely by David Rowe (VK5DGR) with the LDPC codes by Bill Cowley (VK5DSP). A FreeDV "mode" is a specific pairing of a Codec 2 (or LPCNet) bit rate with a modem waveform and an FEC code.

2.2 The mode family and its lineage

FreeDV modes fall into an HF branch (audio-band waveforms for an SSB radio) and a VHF branch (baseband/FSK for an FM radio). The HF branch is the one that matters for a native port and the one Rafe defaults to. Its lineage, oldest to newest:

Mode Year Vocoder Modem FEC Character
1600 2012 Codec 2 1300 FDMDV — 14 DQPSK + 1 DBPSK pilot Golay (23,12) The original. Differential PSK, no coherent reference, light FEC. Simple, but poor on multipath and needs ~4 dB SNR.
700C 2017 Codec 2 700C COHPSK — 14-carrier coherent QPSK, ×2 diversity none First coherent modem: pilot-assisted phase recovery + frequency diversity (each carrier sent twice). Works to ~2 dB, good on multipath, but no FEC.
700D 2018 Codec 2 700C OFDM — 17-carrier pilot-assisted QPSK LDPC (224,112) r=½ The flagship. OFDM packs the carriers tighter; a powerful LDPC code buys ~4 dB, pushing the floor to −2 dB SNR. Modest 2 ms cyclic prefix ⇒ only fair on fast multipath.
700E 2020 Codec 2 700C OFDM — 21-carrier QPSK LDPC (112,56) r=½ 700D re-tuned for fast-fading channels: higher pilot rate + longer cyclic prefix, at the cost of a higher SNR floor (~1 dB).
2020 2019 LPCNet 1733 OFDM — 31-carrier QPSK LDPC (504,396) r≈0.79 Neural (LPCNet) wideband vocoder for near-communications-quality speech; needs the neural vocoder and more SNR/bandwidth.
2020B 2022 LPCNet 1733 OFDM — 29-carrier QPSK LDPC (112,56) unequal 2020 re-tuned like 700E: higher pilot rate, shorter (90 ms) frame, lower latency, fast-fading-tolerant.

(Sources: README_freedv.md mode tables; README_ofdm.md; freedv.org specification.) The VHF branch — 2400A (Codec 2 1300 + 4FSK, 5 kHz), 2400B (Codec 2 1300 + FM-baseband) and 800XA (Codec 2 700C + 4FSK) — plus the newer OFDM data modes (DATAC0/1/3/4/13/14) share the same code base but are out of scope for a voice port; they are noted only where they clarify the shared OFDM machinery.

The design arc is worth internalising because it is exactly the arc a native port would retrace: differential → coherent → coherent + OFDM + LDPC. Each step trades implementation complexity for link budget. 1600 needs no channel estimation (differential) but wastes ~3 dB and dies on multipath. 700C adds a coherent pilot reference and diversity. 700D adds OFDM (tighter spectral packing, graceful multipath handling via the cyclic prefix) and a strong LDPC code, and is the mode you actually want on a weak HF channel.

2.3 The Codec 2 vocoder

Codec 2 is a sub-3-kbit/s harmonic sinusoidal / source-filter vocoder. It models each ~10–40 ms speech frame as a set of harmonic amplitudes on a pitch fundamental F0, plus voicing and energy, and quantises that description hard. The modes relevant here:

Codec 2 mode bit rate frame bits/frame used by FreeDV
3200 3200 20 ms 64
2400 2400 20 ms 48
1600 1600 40 ms 64
1400 1400 40 ms 56
1300 1300 40 ms 52 FreeDV 1600, 2400A, 2400B
1200 1200 40 ms 48
700C 700 40 ms 28 FreeDV 700C / 700D / 700E, 800XA

(3200 = 64 bits/20 ms and 1300 = 52 bits/40 ms confirmed from the Codec 2 Wikipedia summary and Rowe's write-ups; 700C = 28 bits/40 ms confirmed from Rowe's "Codec 2 700C" post. The intermediate rows are the standard Codec 2 bit allocations.)

The critical number for the 700-series modes is Codec 2 700C = 28 bits every 40 ms. Rowe's bit allocation is: 18 bits two-stage vector-quantised spectral envelope (a K=20 mel-resampled log-amplitude vector), 4 bits frame energy, 6 bits pitch — total 28. Voicing is implicit: an unvoiced frame is signalled by the reserved zero value of the pitch quantiser, so there is no separate voicing bit. Encoding avoids differential/predictive coding between frames deliberately, to stay robust to bit errors. This 28-bit frame is the atom that every 700-series FreeDV modem carries; four of them (112 bits, 160 ms of speech) are the payload of one 700D LDPC codeword (§4.3).

For a native port, Codec 2 is the hard dependency: interop requires bit-exact Codec 2 frames. §8 discusses the options (bridge the LGPL library for the vocoder while going native on the modem+FEC, versus a full native Codec 2).


3. Current implementation — the libcodec2 wrapper (Part A)

Rafe's FreeDV support is three small files plus a hook in the digital-voice dispatcher. It is honestly scoped: a ctypes binding to the reference C API, nothing more. There is no in-repo modem, no in-repo LDPC, no in-repo vocoder for FreeDV.

3.1 The Codec 2 binding — codec2_native.py

codec2_native.py loads libcodec2 once and exposes the low-level vocoder API (shared with the native M17 modem). It searches a list of SONAMEs (app/radio/codec2_native.py):

for n in ("codec2", "libcodec2.so.1.2", "libcodec2.so.1.0", "libcodec2.so"):

and prototypes codec2_create/destroy/encode/decode plus codec2_bits_per_frame and codec2_samples_per_frame (codec2_native.py). The CODEC2_MODE_* integer map is quoted verbatim from codec2.h (codec2_native.py):

_MODES = {"3200": 0, "2400": 1, "1600": 2, "1400": 3, "1300": 4,
          "1200": 5, "700c": 8, "700d": 9, "700e": 10}

The module comment states the licensing rationale plainly (codec2_native.py): "FreeDV and M17 are locked to David Rowe's Codec2 vocoder; interop requires its exact voice frames. Codec2 is LGPL-2.1, so linking the shared library is fine (unlike copying it)." This Codec2 class is used by the native M17 path today; the FreeDV wrapper does not call it directly — FreeDV goes through the higher-level freedv_* API (§3.2), which internally owns its own Codec 2 instance.

3.2 The FreeDV binding — freedv_native.py

This is the whole of Rafe's FreeDV "codec". It reuses the _LIB handle from codec2_native.py (freedv_native.py) — the FreeDV API and Codec 2 live in the same libcodec2 shared object — and prototypes the reference freedv_* functions (freedv_native.py):

_LIB.freedv_open.restype  = ctypes.c_void_p
_LIB.freedv_open.argtypes = [ctypes.c_int]
...  freedv_get_n_speech_samples, freedv_get_n_max_modem_samples,
     freedv_get_n_nom_modem_samples, freedv_nin,
     freedv_get_modem_sample_rate, freedv_get_sync  (all -> c_int)
_LIB.freedv_rx.argtypes = [c_void_p, c_void_p, c_void_p]   # (state, speech_out, modem_in)
_LIB.freedv_tx.argtypes = [c_void_p, c_void_p, c_void_p]   # (state, modem_out, speech_in)

The module docstring names the architecture exactly (freedv_native.py): "FreeDV's modem (COHPSK for 1600, OFDM+LDPC for 700D/700E, etc.) lives inside libcodec2 together with the Codec2 vocoder; the freedv_rx/freedv_tx programs are thin CLI wrappers over the freedv_open/freedv_rx/freedv_tx API. Binding that API directly drops those two binaries while staying bit-identical to the reference (same library)." That sentence is the entire premise of the current implementation, and it is accurate.

The mode map (freedv_native.py) is quoted straight from freedv_api.h's FREEDV_MODE_* enum:

_MODES = {"1600": 0, "700C": 6, "700D": 7, "700E": 13, "800XA": 5,
          "2400A": 3, "2400B": 4, "2020": 8}

These integers match the reference header exactly: FREEDV_MODE_1600=0, FREEDV_MODE_2400A=3, FREEDV_MODE_2400B=4, FREEDV_MODE_800XA=5, FREEDV_MODE_700C=6, FREEDV_MODE_700D=7, FREEDV_MODE_2020=8, FREEDV_MODE_700E=13 (confirmed from src/freedv_api.h; the header also defines FREEDV_MODE_2020B=16 and the data modes FSK_LDPC=9, DATAC1=10, DATAC3=12, DATAC0=14, which Rafe does not expose).

Object construction (freedv_native.py): freedv_open(mode_id) returns an opaque state pointer; the wrapper then caches four geometry constants from the library rather than hard-coding any waveform numbers:

self.n_speech    = _LIB.freedv_get_n_speech_samples(self._f)
self.n_max_modem = _LIB.freedv_get_n_max_modem_samples(self._f)
self.n_nom_modem = _LIB.freedv_get_n_nom_modem_samples(self._f)
self.modem_rate  = _LIB.freedv_get_modem_sample_rate(self._f)

This is the key design point of the wrapper: it queries all buffer sizes from the library at runtime. For 700D these evaluate to modem_rate = 8000 Hz, n_speech = 1280 samples (= 160 ms of 8 kHz speech = the 4 Codec-2-700C frames of one LDPC codeword), and n_nom_modem = 1280 samples (one 160 ms OFDM modem frame) — but the wrapper never assumes those; it uses whatever the library reports, so it is correct for any FreeDV mode.

3.3 Sample-rate and buffer conventions

FreeDV's HF modes run their modem and speech at 8 kHz, int16, mono. The wrapper's docstring states it (freedv_native.py): "All audio here is 8 kHz int16 (FreeDV's modem/speech rate on HF)."

The receive path is variable-rate, which is the single most important convention to get right in a native port. FreeDV's RX consumes a time-varying number of modem samples per call so it can track sample-clock offset and drift:

  • nin() (freedv_native.py) returns how many modem samples the library wants right now — nominally n_nom_modem, but ±a few samples as the timing loop pulls or pushes to stay locked.
  • rx(modem_i16) (freedv_native.py) is handed exactly nin() int16 modem samples and returns 0…n_speech speech samples — zero while searching for sync, a full frame once locked. The <i2 (little-endian int16) dtype and n = freedv_rx(...) return-count convention are handled explicitly.
  • synced() (freedv_native.py) wraps freedv_get_sync.

The transmit path (freedv_native.py) is fixed-rate: tx(speech_i16) requires exactly n_speech int16 samples and returns n_nom_modem int16 modem samples — one modem frame per speech frame. freedv_close is called in __del__ (freedv_native.py).

3.4 Where it slots into the digital-voice path

Rafe treats FreeDV as one of several digital-voice modes (alongside M17, P25 and the native RVQ-Voice), distinct from the digital-text modes. The wiring:

Mode registrydigivoice.py defines the freedv entry in VOICE_MODES: rate_in/rate_out = 8000, rig = "USB" (FreeDV is an upper-sideband HF mode), tx = True, and the external fallback argv builds [FREEDV_RX, FREEDV_MODE, "-", "-"]. The waveform is chosen by the FREEDV_MODE environment variable, defaulting to "700D" (digivoice.py).

Backend selectiondigivoice.py:

# FreeDV backend: "external" (freedv_rx/tx) or "native" (libcodec2 freedv API via
# ctypes -- same reference modem, no binary). Default native when libcodec2 loads.
FREEDV_BACKEND = os.environ.get("FREEDV_BACKEND", "native")

So Rafe prefers the ctypes wrapper and only falls back to spawning the freedv_rx/freedv_tx binaries (paths from FREEDV_RX/FREEDV_TX, default /opt/codec2/build/src/...) if libcodec2 is absent.

Dispatchdigimodes.py is the fork. When the requested mode is in VOICE_MODES and equals freedv with FREEDV_BACKEND == "native", it checks FreeDV.available() (digimodes.py); if the library loaded it constructs the native handler (digimodes.py):

elif _fdv_native:                               # native FreeDV (libcodec2)
    from .freedv_voice import FreeDVVoice
    self._voice = FreeDVVoice(self.mode, on_speech, on_meta)

otherwise it falls through to the subprocess-based DigiVoice (digimodes.py). (Note the naming: "native" here means "in-process via ctypes", not "in-repo modem" — the RVQ-Voice branch immediately above, gated on VOICE_MODES[mode].get("native"), is the only truly native voice codec.)

The streaming adapterfreedv_voice.py is a DigiVoice-shaped async handler that owns the 48 kHz↔︎8 kHz rate conversion and the nin()/rx() loop:

  • RX feed(pcm48k) (freedv_voice.py): resamples the incoming 48 kHz SSB audio down to the modem rate, appends to a buffer, and drains it through the variable-nin loop — while self._buf.size >= nin: sp = self._fd.rx(buf[:nin]) — re-querying nin() each iteration (freedv_voice.py). Decoded speech is resampled back to 48 kHz for listeners; sync transitions are surfaced as metadata (freedv_voice.py).
  • TX feed_tx(pcm48k) (freedv_voice.py): resamples mic audio to 8 kHz, chunks it into exact n_speech-sample blocks, calls tx() per block, and resamples the returned modem audio back to 48 kHz for the radio.
  • Both the RX and TX FreeDV instances are opened with the FREEDV_MODE environment variable, default "700D" (freedv_voice.py).

The linear-interpolation resampler _rs (freedv_voice.py) is deliberately simple; the module notes it "Uses the reference modem, so it interoperates with other FreeDV stations."

3.5 Honest scope of the current implementation

What the wrapper is: an exact, in-process, binary-free binding to the reference FreeDV modem + Codec 2, correct for every mode the library supports, with the RX variable-rate contract handled properly.

What it is not:

  • Not a native modem. The OFDM demod, coherent PSK, COHPSK diversity, timing and frequency recovery all execute inside libcodec2.
  • Not a native FEC. The LDPC belief-propagation decoder is the library's, even though Rafe already has a working LDPC BP decoder from FT8/JS8 (§8).
  • Not a native vocoder. Codec 2 analysis/synthesis is the library's.
  • A build dependency. libcodec2 must be installed. This is the one voice mode (besides external M17/P25) that is not self-contained, which is the motivation for this blueprint and, ultimately, for RVQ-Voice.

The rest of this document specifies what a native FreeDV would have to implement.


4. Signal & frame structure of the FreeDV modes

This section gives the wire format per mode, with the exact OFDM+LDPC geometry of 700D first because it is the flagship and the one worth porting. All HF modes share Fs = 8000 Hz, a 1500 Hz centre frequency, and low-baud parallel carriers (~50 symbols/s); they differ in modem class, carrier count, FEC and vocoder.

4.1 FreeDV 700D — the flagship OFDM + LDPC waveform

700D is Codec 2 700C carried by a pilot-assisted coherent QPSK OFDM modem with a rate-½ (224,112) LDPC code. The following parameters are taken from the reference OFDM defaults in src/ofdm.c (whose default ofdm_create() config is the 700D config) and the README_ofdm.md waveform sheet; they are mutually self-consistent (the derived RF bandwidth and frame period match the reference's own quoted 944 Hz and 160 ms).

Modem geometry (source: src/ofdm.c defaults + README_ofdm.md)

Parameter Symbol Value Notes
Modem sample rate Fs 8000 Hz int16 audio into the SSB radio
Centre frequency Fcentre 1500 Hz carriers straddle mid-SSB-passband
Number of carriers Nc 17 parallel OFDM subcarriers
Bits per symbol bps 2 QPSK (Gray-mapped)
Useful symbol period (FFT window) Tu = Ts 18 ms 1/Tu = 55.6 Hz subcarrier spacing
Subcarrier spacing Δf ≈ 55.6 Hz = 1/Tu (orthogonality)
Cyclic prefix Tcp 2 ms guard against multipath delay spread
On-air symbol period Tu+Tcp 20 ms ⇒ symbol rate Rs = 50 symbols/s
RF bandwidth ≈ 944 Hz = Nc · Δf = 17 · 55.6 (matches ref.)
Symbols per modem frame Ns 8 1 pilot + 7 data symbols
Modem frame period 160 ms = Ns · (Tu+Tcp) = 8 · 20 ms
Bits per modem frame 238 = (Ns−1)·Nc·bps = 7·17·2
Unique-word bits Nuwbits 10 sync/verification (5·bps)
Text bits Ntxtbits 4 low-rate aux text (25 bit/s)
Pilot rate 1 in 8 symbols + edge pilots (edge_pilots = 1)
FT window width ftwindowwidth 32 fine-timing search window

The bit budget of one 238-bit modem frame closes exactly:

   238 bits/frame  =  224  LDPC codeword bits          (the FEC payload)
                   +   10  unique-word bits            (sync + coherent ref)
                   +    4  text bits                   (25 bit/s side channel)

FEC (source: README_freedv.md, README_ofdm.md, Rowe/VK5DSP): a rate-½ LDPC code, n = 224, k = 112 — reference identifier HRA_112_112 (CodeLength 224). It encodes 112 payload bits → 224 coded bits, decoded by belief propagation (sum-product). One LDPC decode happens every 160 ms.

The payload = 4 Codec 2 700C frames. The 112 LDPC data bits are exactly four Codec 2 700C frames (4 × 28 = 112), i.e. 4 × 40 ms = 160 ms of speech — which is why the wrapper reports n_speech = 1280 samples (160 ms @ 8 kHz) for 700D and why the modem frame period is also 160 ms: one LDPC codeword, one OFDM modem frame, four voice frames, all 160 ms, all in lock-step. This is the single most important structural fact of 700D:

 160 ms  ┌───────────────────────────────────────────────────────────┐
 speech  │  C2#0 (28b)   C2#1 (28b)   C2#2 (28b)   C2#3 (28b)  = 112b  │
         └───────────────────────────────┬───────────────────────────┘
                                          │  LDPC (224,112) r=½ encode
                                          ▼
 224 coded bits  +  10 UW  +  4 text  =  238 bits
                                          │  QPSK map (2 bits/symbol)
                                          ▼
 119 QPSK symbols spread over 7 data symbols × 17 carriers  (+ 1 pilot symbol)
                                          │  IDFT per OFDM symbol, add 2 ms CP
                                          ▼
 8 OFDM symbols × 20 ms  =  160 ms modem frame  =  1280 samples @ 8 kHz

OFDM frame layout. Each 160 ms modem frame is Ns = 8 OFDM symbols. Symbol 0 is a pilot symbol carrying a known BPSK sequence on all Nc carriers; symbols 1…7 are data symbols carrying Nc · bps = 34 coded/UW/text bits each (7 · 34 = 238). edge_pilots = 1 means pilots are also placed at the top and bottom carrier edges to bound the channel-estimation interpolation. The pilot rate of 1-in-8 symbols (and 2 ms CP) is the deliberate trade that makes 700D excellent at low SNR but only fair on fast (>1 Hz) fading — the sparse pilots cannot track a rapidly rotating channel. (700E and 2020B exist precisely to fix this with a denser pilot grid; §4.5.)

Sync / acquisition envelope (source: README_ofdm.md): initial frequency acquisition within ±60 Hz; tracks frequency drift of ±0.2 Hz/s (for 0.5 dB loss); tolerates 1000 ppm sample-clock error. Operating points: AWGN Eb/N0 ≈ −0.5 dBSNR(3000 Hz) ≈ −2.5 dB; HF multipath ("CCIR poor") Eb/N0 ≈ 4.0 dB ⇒ SNR ≈ 2.0 dB. These are the numbers that make 700D usable below the noise floor where SSB voice is unintelligible.

4.2 The pilot / unique-word sequence

The reference modem derives both the pilot BPSK values and the unique word from a single fixed 64-element ±1 sequence, pilotvalues[] in src/ofdm.c (quoted verbatim):

static const int8_t pilotvalues[] = {
   -1,-1, 1, 1,-1,-1,-1, 1,-1, 1,-1, 1, 1, 1, 1, 1,
    1, 1, 1,-1,-1, 1,-1, 1,-1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1,-1, 1, 1, 1, 1, 1,-1,-1,-1,-1,-1,-1, 1,
   -1, 1,-1, 1,-1,-1, 1,-1, 1, 1, 1, 1,-1, 1,-1, 1};

The first Nc (+edge) entries modulate the pilot symbol's carriers as BPSK; the Nuwbits = 10 unique-word bits are placed at fixed (symbol, carrier) positions within the data region and checked on RX to confirm frame lock and resolve the QPSK phase ambiguity. A native port must reproduce this sequence and placement bit-for-bit to interoperate. (The exact UW bit positions are computed by the reference ofdm_get_uw() logic; a native port should mirror that code rather than re-derive the positions.)

4.3 The Codec 2 700C frame that rides inside

Each of the four 28-bit frames per 700D modem frame is (§2.3): 18 bits two-stage VQ spectral envelope + 4 bits energy + 6 bits pitch (with implicit voicing via a reserved pitch code), covering 40 ms / 320 samples of 8 kHz speech. A native 700D must produce/consume these exact 28-bit frames — this is the interop-critical vocoder atom.

4.4 FreeDV 1600 — FDMDV differential PSK (the older waveform)

700D's ancestor. Vocoder: Codec 2 1300 (52 bits/40 ms). Modem: FDMDV14 DQPSK data carriers + 1 DBPSK pilot carrier (15 total), 50 baud, 1125 Hz RF bandwidth, Fs = 8000 Hz. Being differential (each symbol decoded from its phase step relative to the previous symbol on the same carrier), FDMDV needs no coherent channel estimate — simple, but it forfeits ~3 dB versus coherent detection and degrades badly on multipath. FEC: a Golay (23,12) code protects the perceptually most-significant Codec 2 bits (the rest are sent uncoded); the frame also carries a sync bit. SNR floor ≈ 4 dB; multipath resilience poor. Emission designator 1K20J2E. (Sources: README_freedv.md table; freedv.org spec.)

4.5 FreeDV 700C — COHPSK coherent QPSK with diversity

The first coherent modem, and the bridge from 1600 to 700D. Vocoder: Codec 2 700C (28 bits/40 ms). Modem: COHPSK14-carrier coherent QPSK with pilot-symbol-assisted phase recovery and frequency diversity: the 7 data carriers' worth of information is transmitted twice across the 14 carriers, so the raw channel rate is 1400 bit/s carrying 700 bit/s of unique speech, and the receiver combines the two copies for a diversity gain. 1500 Hz RF bandwidth, Fs = 8000 Hz, ~50 baud. No FEC (diversity is the only protection). SNR floor ≈ 2 dB; multipath good (the coherent reference + diversity ride fading far better than 1600's differential detection). (Source: README_freedv.md.)

4.6 FreeDV 700E — OFDM re-tuned for fast fading

700D's sibling for rough channels. Same Codec 2 700C vocoder; 21-carrier OFDM/QPSK; 1500 Hz RF bandwidth; ~3000 bit/s raw. FEC: rate-½ LDPC (112,56). The design change versus 700D is a higher pilot symbol rate and a longer cyclic prefix, so the channel estimate tracks fast (>1 Hz) fading — at the cost of a higher SNR floor (~1 dB) because more of the frame is pilot overhead. Multipath resilience good. (Source: README_freedv.md; README_ofdm.md notes 700E's dense-pilot design shared with 2020B/C. Exact Ns/pilot-grid/frame-period for 700E are set in the reference mode-config code and are not independently reconfirmed here — a native port should read them from ofdm.c's 700E branch rather than assume they equal 700D's.)

4.7 FreeDV 2020 / 2020B — LPCNet + OFDM (blueprint-only, needs the neural vocoder)

2020 replaces Codec 2 with LPCNet, a neural (WaveRNN-derived) vocoder running at 1733 bit/s on 16 kHz wideband speech — a large quality step, but it needs the LPCNet neural network at run time. Modem: 31-carrier OFDM/QPSK, 1600 Hz RF bandwidth, ~3000 bit/s raw, FEC rate≈0.79 LDPC (504,396), ~20.5 ms symbol period; poor on multipath. 2020B (2022) re-tunes it like 700E — 29-carrier OFDM, 2100 Hz BW, **~4100 bit/s** raw, LDPC (112,56) unequal-protection, a shorter 90 ms frame, lower latency, good on fast fading. Both are out of scope for a near-term native port: even with a native OFDM+LDPC modem, the LPCNet vocoder is a neural network (trained weights + a fast inference kernel), a substantially larger undertaking than Codec 2. This document specifies them for completeness only. (Source: README_freedv.md; README_ofdm.md.)


5. Encode / TX blueprint (step by step)

Target: native FreeDV 700D transmit. Input is 8 kHz int16 speech in 160 ms (1280-sample) blocks; output is 8 kHz int16 modem audio, one 160 ms OFDM frame per block. The stages mirror the RVQ-Voice TX chain (§7 of rvqvoice.md) but with FreeDV's specific codec/FEC/modem.

Step 1 — Codec 2 700C analysis (×4). Split the 1280-sample block into four 320-sample (40 ms) sub-frames. Codec 2 encode each to 28 bits (18 VQ + 4 energy + 6 pitch). Concatenate to the 112-bit payload in the reference bit order. (Vocoder: bridge libcodec2 or port Codec 2 — see §8.)

Step 2 — LDPC encode. Encode the 112 payload bits with the rate-½ HRA_112_112 generator to 224 coded bits. Systematic form (the 112 payload bits appear in the codeword) is convenient but the reference parity structure must be matched exactly. This is a sparse LDPC encode — reuse the parity-check matrix that the BP decoder will use (§8).

Step 3 — Assemble the modem frame bits. Interleave the 224 LDPC bits + 10 UW bits + 4 text bits = 238 bits into the reference frame bit layout (UW at its fixed positions, text in its slots, coded bits filling the remainder).

Step 4 — QPSK map. Gray-map the 238 bits two at a time to 238/2 = 119 QPSK symbols {±1±j}/√2. These fill the 7 data OFDM symbols × 17 carriers = 119 data cells of the frame.

Step 5 — Build the pilot symbol. OFDM symbol 0 carries the known BPSK pilotvalues[] on all 17 carriers (+ edge pilots). This is the coherent phase/ amplitude reference the receiver will use for channel estimation.

Step 6 — OFDM modulate. For each of the 8 OFDM symbols, place the 17 complex carrier values at their subcarrier bins (spacing 55.6 Hz, centred on 1500 Hz) and take the inverse DFT to produce the 18 ms (144-sample) useful symbol. Prepend the 2 ms (16-sample) cyclic prefix (copy of the symbol's tail) → 20 ms (160-sample) on-air symbol.

Step 7 — Concatenate & scale. Concatenate the 8 symbols → 1280-sample (160 ms) frame; apply the transmit gain/clipping the reference uses; emit as int16 at 8 kHz. Upstream, resample to 48 kHz for the radio (as freedv_voice.py does).

speech 8k ─▶ [×4 Codec2-700C] ─▶ 112b ─▶ [LDPC 224,112] ─▶ 224b
   ─▶ +UW(10)+text(4) ─▶ 238b ─▶ [QPSK map] ─▶ 119 syms
   ─▶ [place on 17 carriers, 7 data + 1 pilot OFDM syms]
   ─▶ [IDFT + 2ms CP each] ─▶ 1280 samples (160 ms) modem frame ─▶ radio

6. Decode / RX blueprint (step by step)

Target: native FreeDV 700D receive, matching the reference's variable-nin contract (§3.3). Input is 8 kHz int16 modem audio; output is 0…1280 speech samples per frame plus a sync flag.

Step 1 — Coarse acquisition (frequency + timing). Search for the pilot structure across a ±60 Hz frequency range and all timing offsets. The reference correlates against the known pilot symbol / cyclic-prefix redundancy to find the coarse symbol timing and carrier frequency offset. Until a candidate is found, rx() returns 0 speech samples and sync == False.

Step 2 — Frame sync via the unique word. Once a candidate timing/frequency is locked, demodulate provisionally and test the 10-bit unique word at its fixed positions. A UW match (within a small Hamming threshold) declares frame sync and resolves the QPSK π/2 phase ambiguity. This is directly analogous to the UW/sync search in Rafe's other burst modems (e.g. the RVQ-Voice preamble correlator).

Step 3 — Fine frequency & timing tracking. With sync held, run the timing loop that makes nin() vary ±a few samples per frame to null residual sample-clock error (up to 1000 ppm) and track frequency drift (±0.2 Hz/s). This is the machinery behind the variable-nin API the wrapper already exposes.

Step 4 — Strip CP & DFT. For each of the 8 OFDM symbols, discard the 2 ms cyclic prefix and take the DFT of the 18 ms useful window to recover the 17 per-carrier complex values.

Step 5 — Pilot-based channel estimation. From the pilot symbol (and edge pilots), estimate each carrier's complex channel gain (amplitude + phase); interpolate across the 1-in-8 pilot spacing to equalise the 7 data symbols. Coherently de-rotate the data cells. (This sparse-pilot interpolation is exactly why 700D is only fair on fast fading — the estimate goes stale between pilots.)

Step 6 — QPSK demod to soft bits. Convert the 119 equalised data cells to 238 soft bits (log-likelihood ratios) — soft information is essential for the LDPC decoder to hit the −2 dB operating point. Extract the 4 text bits; de-map the 224 coded soft bits.

Step 7 — LDPC decode. Run belief-propagation (sum-product) decoding of the HRA_112_112 (224,112) code on the 224 soft bits → 112 hard payload bits, with a parity-check pass to flag an undecodable frame. Rafe already has an LDPC BP decoder (FT8/JS8 use the (174,91) code) — the same machinery, a different H matrix (§8).

Step 8 — Codec 2 700C synthesis (×4). Split the 112 bits into four 28-bit frames and Codec 2-decode each to 320 samples → 1280 samples (160 ms) of 8 kHz speech. Upstream, resample to 48 kHz for the listener (as freedv_voice.py does).

modem 8k ─▶ [coarse freq/timing acq ±60Hz] ─▶ [UW frame sync]
   ─▶ [fine track: variable nin, drift ±0.2Hz/s]
   ─▶ [strip CP + DFT ×8] ─▶ [pilot channel est + equalise]
   ─▶ [QPSK → 238 soft bits] ─▶ [LDPC BP 224→112] ─▶ [×4 Codec2-700C decode]
   ─▶ 1280 speech samples (160 ms)  +  sync/SNR metadata

7. Constants & tables

All Part-B numbers here are from the codec2/FreeDV reference; Part-A numbers carry file:line citations to Rafe source.

7.1 FreeDV mode ids (from src/freedv_api.h; mirrored in freedv_native.py)

Mode FREEDV_MODE_* Vocoder Modem Nc FEC
1600 0 Codec 2 1300 FDMDV DQPSK 14+1 Golay (23,12)
2400A 3 Codec 2 1300 4FSK Golay (23,12)
2400B 4 Codec 2 1300 FM baseband Golay (23,12)
800XA 5 Codec 2 700C 4FSK none
700C 6 Codec 2 700C COHPSK ×2 diversity 14 none
700D 7 Codec 2 700C OFDM QPSK 17 LDPC (224,112)
2020 8 LPCNet 1733 OFDM QPSK 31 LDPC (504,396)
700E 13 Codec 2 700C OFDM QPSK 21 LDPC (112,56)
2020B 16 LPCNet 1733 OFDM QPSK 29 LDPC (112,56) uneq.

7.2 FreeDV 700D OFDM parameters (from src/ofdm.c defaults + README_ofdm.md)

Fs Fcentre Nc bps Tu (Ts) Δf Tcp Rs RF BW Ns frame bits/frame
8000 Hz 1500 Hz 17 2 18 ms 55.6 Hz 2 ms 50 sym/s 944 Hz 8 160 ms 238

238 bits/frame = 224 LDPC + 10 UW + 4 text. Nuwbits = 10, Ntxtbits = 4, edge_pilots = 1, ftwindowwidth = 32, pilot rate 1-in-8. Acquisition ±60 Hz, drift ±0.2 Hz/s, clock 1000 ppm. Operating point −2 to −2.5 dB SNR (AWGN), +2 dB (CCIR poor).

7.3 LDPC codes

FreeDV mode code (n, k) rate reference id decode cadence
700D (224, 112) ½ HRA_112_112 every 160 ms
700E (112, 56) ½ (700E branch in reference) per frame
2020 (504, 396) ≈0.79 (2020 branch) per frame
2020B (112, 56) unequal ½* (2020B branch) per frame

(700D's HRA_112_112 / (224,112) confirmed from README_freedv.md, README_ofdm.md, and the reference LDPC test context. The 700E/2020/2020B code names in the source are not independently reconfirmed here — read them from the reference ldpc_codes.c tables when porting.)

7.4 Unique-word / pilot sequence (from src/ofdm.c)

The 64-element pilotvalues[] ±1 BPSK sequence in §4.2 seeds both the pilot symbol modulation and the 10-bit unique word placement.

7.5 Codec 2 700C bit allocation (from Rowe, "Codec 2 700C")

28 bits / 40 ms = 18 two-stage VQ (spectral envelope, K=20 mel) + 4 energy + 6 pitch; voicing implicit (reserved pitch code). 320 samples/frame at 8 kHz.


8. What a native port needs

A native FreeDV — matching the ambition of the native text digimodes and RVQ-Voice — is three subsystems, in rough order of effort:

(1) The OFDM modem — the largest new piece. A native 700D modem must implement, at 8 kHz: carrier placement / IDFT-DFT over 17 subcarriers at 55.6 Hz spacing on a 1500 Hz centre; 2 ms cyclic-prefix add/strip; QPSK Gray map/de-map to soft LLRs; the pilot symbol and pilotvalues[] sequence; coarse frequency acquisition (±60 Hz) and cyclic-prefix / pilot-correlation timing; the unique-word frame-sync and phase-ambiguity resolution; pilot-based channel estimation with 1-in-8 interpolation and coherent equalisation; and the fine timing/frequency tracking that drives the variable-nin sample budget. Rafe has strong precedent for every one of these primitives — the OFDM machinery in the Mercury/mercury_ofdm.py codec and the LoRa/RVQ modems already do carrier mapping, CP handling, preamble/UW correlation and fine timing — but assembling them to bit-exact FreeDV 700D geometry is real work and must be validated against the reference (§9). Recommended first target: 700D only (one geometry, one code), then 700C/1600.

(2) The LDPC decoder — mostly already done. 700D needs a sum-product belief-propagation decoder for the (224,112) HRA_112_112 code. Rafe already ships an LDPC BP decoder for FT8/JS8 (the (174,91) code). The decoder is code-agnostic — feed it a different sparse parity-check matrix and it decodes 700D. The concrete task is: obtain/transcribe the HRA_112_112 H matrix from the reference (as the LoRa/Meshtastic tables were transcribed and committed once), and a matching rate-½ encoder. This is the cheapest native piece and the highest confidence, because the hard part (BP on soft LLRs) is proven in-repo.

(3) The Codec 2 vocoder — bridge or port. Interop requires bit-exact Codec 2 700C frames (28 bits/40 ms). Two options:

  • Bridge (pragmatic): keep linking libcodec2 only for Codec 2 analysis/synthesis via the existing codec2_native.py binding, while going native on the modem + LDPC. This yields a native waveform (the interesting, differentiating part) with a small, well-isolated LGPL vocoder dependency, and is the recommended first cut. It also mirrors how M17's native path already uses Codec2.
  • Full native Codec 2 (ambitious): port the Codec 2 700C analysis/synthesis (harmonic sinusoidal model, newamp1 K=20 mel VQ, LSP/energy/pitch quantisers) to NumPy for a zero-dependency mode — a substantial project in its own right, and arguably better spent on RVQ-Voice, which is already a native vocoder we own.

Recommended path: native OFDM modem + native LDPC (reusing the FT8/JS8 BP decoder) + bridged Codec 2 700C for FreeDV 700D, validated bit-exactly against freedv_rx. That delivers the whole modem/FEC waveform natively — the part with no in-repo equivalent — while deferring the vocoder port. 2020/2020B stay blueprint-only until (and unless) an LPCNet inference kernel exists in-repo.


9. Interoperability & validation plan

FreeDV's advantage for validation is that the reference is executable: every stage can be checked bit-exactly against libcodec2 / freedv_rx / freedv_tx, which Rafe already links. A staged plan mirroring how the native text modes were validated:

  1. Codec 2 vector match. Encode a speech corpus through codec2_native.py (700C) and through the native/bridged path; assert bit-identical 28-bit frames. (Trivial for the bridge; a conformance gate for a full port.)
  2. LDPC round-trip + reference vectors. Encode 112-bit payloads, corrupt at a controlled BER, decode with the reused BP decoder; verify the correction curve matches the reference and that clean codewords round-trip. Cross-check against the codec2 ldpc_enc/ldpc_dec test vectors for HRA_112_112.
  3. TX waveform match. Feed identical 160 ms speech frames to the native TX and to freedv_tx 700D; compare the emitted 8 kHz int16 modem frames sample-for-sample (or to a tight tolerance after gain normalisation). A match here proves the encoder, LDPC, QPSK map, pilots and OFDM modulation together.
  4. Cross-decode both ways. (a) Native TX → freedv_rx 700D → recovered speech; (b) freedv_tx 700D → native RX → recovered speech. Both must sync and decode. This is the real interop test: our modem talking to the reference and vice-versa.
  5. Impaired-channel parity. Run both decoders over AWGN and CCIR-poor multipath at a sweep of SNRs; the native RX's frame-error curve should track the reference's, confirming the −2 dB operating point and the timing/frequency tracking envelope (±60 Hz, ±0.2 Hz/s, 1000 ppm).
  6. Live loopback in the app. Drive the native handler through the same FreeDVVoice-style 48 k↔︎8 k streaming path (§3.4) end-to-end on the prod radio, confirming sync/SNR metadata and audio against a second FreeDV station or a reference-freedv_rx capture.

Because the wrapper (§3) stays in-repo, the reference decoder is always on hand as the oracle — the native port can be developed and gated stage-by-stage without ever leaving the codebase.


10. References

  1. D. Rowe (VK5DGR), drowe67/codec2 — the reference implementation of Codec 2 and every FreeDV modem. Key files: src/freedv_api.h (FREEDV_MODE_* ids), src/ofdm.c (700D OFDM defaults, pilotvalues[]), src/ldpc_codes.c (LDPC matrices). https://github.com/drowe67/codec2
  2. README_freedv.md (drowe67/codec2) — the per-mode tables (vocoder, modem, Nc, RF BW, raw bit rate, FEC, SNR, multipath) for 1600/700C/700D/700E/2020/ 2020B and the VHF modes. https://github.com/drowe67/codec2/blob/main/README_freedv.md
  3. README_ofdm.md (drowe67/codec2) — the 700D OFDM waveform sheet: 17 carriers, 944 Hz RF BW, 18 ms symbol, 2 ms CP, 1-in-8 pilots, 160 ms frame, 10-bit UW, rate-½ (224,112) LDPC, and the acquisition/drift/clock envelope. https://github.com/drowe67/codec2/blob/main/README_ofdm.md
  4. D. Rowe, "FreeDV 700D" — the release write-up (design rationale, LDPC by VK5DSP, −2 dB AWGN / +2 dB CCIR-poor operating points). https://www.rowetel.com/?p=6103
  5. D. Rowe, "Codec 2 700C" — the 28-bit (18 VQ + 4 energy + 6 pitch) frame allocation and the newamp1 K=20 mel VQ. https://www.rowetel.com/?p=5373
  6. freedv.org — FreeDV specification — the FreeDV 1600 FDMDV details (14 DQPSK
  7. Codec 2 (Wikipedia) — the Codec 2 mode bit-rate/frame summary (3200 = 64 bits/20 ms, 1300 = 52 bits/40 ms). https://en.wikipedia.org/wiki/Codec_2

Appendix — Rafe source map (Part A)

file role
app/radio/codec2_native.py ctypes binding to libcodec2 Codec 2 API (CODEC2_MODE_*; shared _LIB)
app/radio/freedv_native.py ctypes binding to the freedv_* API (FREEDV_MODE_*; nin/rx/tx)
app/radio/freedv_voice.py FreeDVVoice — 48 k↔︎8 k streaming adapter, variable-nin RX loop, TX
app/radio/digivoice.py VOICE_MODES["freedv"], FREEDV_BACKEND, external freedv_rx/tx fallback
app/radio/digimodes.py dispatch: native FreeDVVoice vs external DigiVoice

Bottom line: today FreeDV in Rafe is a faithful, binary-free wrapper over David Rowe's reference modem + vocoder — correct and interoperable, but not ours. This document is the blueprint for making the modem + LDPC ours (bridging only Codec 2), the way we made the text digimodes ours.