Olivia & Contestia: native Walsh–Hadamard MFSK, reproduction-grade specification
A self-contained, dependency-free port of Pawel Jalocha's (SP9VRC) MFSK block codec — the engine fldigi ships as Olivia and Contestia — built on a bi-orthogonal Walsh/Hadamard character code, a 64-bit scrambler, a diagonal bit-plane interleaver, and Gray-coded MFSK tones. Pure NumPy at runtime.
Rafe project · app/radio/olivia.py · fldigi-family native modem · GPL-3 lineage
Abstract
Olivia and Contestia are among the most robust keyboard-to-keyboard HF modes in service: they trade throughput (a couple of characters per second) for the ability to copy signals well below the noise floor, several dB into negative SNR. Both are MFSK modes — one of T tones is transmitted per symbol — layered over a strong forward-error-correction block code. The FEC is not a convolutional or LDPC code but a bi-orthogonal Walsh/Hadamard block code: every character is expanded into a length-N sequence of ±1 chips (one row of an N×N Hadamard matrix, optionally negated), spread by a fixed pseudo-random scrambler, and diagonally interleaved across the bit-planes of N MFSK symbols. Decoding is a Fast Hadamard Transform (FHT): after DFT tone detection, soft symbol estimates are de-interleaved, de-scrambled, and transformed; the single largest Walsh coefficient — and its sign — recovers the character. Contestia is the half-block-length, 6-bit, uppercase-folded variant of the 7-bit ASCII Olivia.
This document specifies the in-repo implementation in app/radio/olivia.py completely enough to reimplement it from scratch: every constant, the exact FHT butterfly convention, the scrambler walk, the interleave geometry, the tone/ bandwidth grid, the Contestia charset, and the encode/decode/sync procedures. All constants are quoted from the source with file:line citations; none are invented. The implementation ports Jalocha's pj_mfsk.h / pj_fht.h (as embedded in fldigi), and is validated by internal round-trip tests and against fldigi as the interoperability oracle.
1. Motivation
Rafe carries the fldigi keyboard-mode family (RTTY, PSK, MFSK16/32, DominoEX, Thor, Olivia, Contestia, THROB, Feld Hell) natively — in-repo, pure NumPy, no external fldigi binary and no XML-RPC bridge (app/radio/fldigi_native.py). Olivia and Contestia are the two entries in that family whose robustness comes from a Walsh/Hadamard block code rather than a bitwise convolutional code, so they need a codec that is structurally different from the MFSK16/Thor path. olivia.py is that codec: a single OliviaCodec block engine shared by both modes, plus an MFSK audio layer (modulate, OliviaDecoder) that renders and recovers the tone stream.
The design intent, quoted from the module header (app/radio/olivia.py):
Ports Pawel Jalocha's MFSK library as used by fldigi (pj_mfsk.h / pj_fht.h, GPL-3). Each FEC block carries BitsPerSymbol characters: every character becomes a 64-point (Contestia: 32) Walsh function via an inverse Fast Hadamard Transform, is scrambled by a fixed 64-bit code, and the BitsPerSymbol Walsh vectors are diagonally rotated together into SymbolsPerBlock MFSK tone symbols. Decoding reverses it: de-rotate, de-scramble, forward FHT, take the Walsh peak.
2. Background
2.1 MFSK
In M-ary FSK, each symbol selects one of T orthogonal tones spaced Δf apart. If the tones are separated by the symbol rate (Δf = baud), they are orthogonal over one symbol and can be detected non-coherently by taking the DFT/Goertzel magnitude at each tone frequency. Olivia's core insight is that MFSK's T tones already give log2(T) bits per symbol of raw diversity; wrapping those bits in a heavy block code buys enormous coding gain at the cost of throughput.
2.2 The bi-orthogonal (Walsh/Hadamard) character code
The Hadamard matrix H_N (N a power of two) has N mutually orthogonal rows of ±1 (the Walsh functions). Encoding a symbol as one Walsh row gives an orthogonal code of N codewords carrying log2(N) bits, with minimum Hamming distance N/2. Adding the negations of every row doubles the codebook to 2N words carrying log2(N)+1 bits — a bi-orthogonal code — for the same length and distance. This is exactly the first-order Reed–Muller code RM(1, m) = [2^m, m+1, 2^(m-1)].
Olivia sets m = BitsPerCharacter − 1:
| mode | m | code | length N | info bits | min dist | rate |
|---|---|---|---|---|---|---|
| Olivia | 6 | RM(1,6) = [64, 7, 32] | 64 | 7 | 32 | 7/64 ≈ 0.109 |
| Contestia | 5 | RM(1,5) = [32, 6, 16] | 32 | 6 | 16 | 6/32 = 0.1875 |
Each character is one codeword of length N. Maximum-likelihood decoding of a bi-orthogonal code is exactly a Hadamard transform followed by a peak search: the transform correlates the received chips against all N Walsh rows in N log N operations, and the largest-magnitude coefficient names the row; its sign resolves the extra (negation) bit. This is why the demodulator is an FHT — it is the optimal decoder, not an approximation.
2.3 Scrambling and interleaving
Two hazards remain. First, a raw Walsh codeword has strong spectral lines (e.g. the all-ones row 0 is a DC/constant sequence), so a fixed scrambler (a pseudo-random ±1 sequence) is multiplied in to whiten the transmitted symbol stream. Second, the N chips of a character, if mapped to a single MFSK bit-plane, would all ride the same "sub-carrier bit" and share its fate under a selective fade; a diagonal interleaver rotates each character's chips across all BitsPerSymbol bit-planes over the block, so no character is confined to one bit-plane. Together they turn burst/selective impairments into the diffuse, independent chip errors the Walsh code handles best.
2.4 Olivia vs Contestia
Contestia is Nick Fedoseev's (UT2UZ) faster, lighter cousin of Pawel Kaczmarczyk's Olivia (the mode is usually attributed to SP9VRC's library; see §9). Structurally, in this implementation the differences are exactly three (app/radio/olivia.py):
- Block length. Contestia uses
BitsPerCharacter = 6→ N = 32-chip Walsh codewords / 32-symbol blocks; Olivia uses7→ N = 64. Contestia blocks are half the length, so at equal tone count and bandwidth Contestia is twice as fast (and ~3 dB less robust). - Character set. Olivia carries 7-bit ASCII verbatim; Contestia carries a 6-bit charset (uppercase-folded, punctuation-shifted) — §6.4.
- Scrambler & shift. Different 64-bit/32-bit scrambler constant and per- character scrambler shift (§6.2).
Everything else — the FHT, the impulse/sign encoding, the diagonal interleave, the Gray-coded MFSK front end — is shared code.
3. Signal structure
3.1 The tones × bandwidth grid
An Olivia/Contestia waveform is named T/BW — T tones in BW Hz. The tones are equally spaced and the symbol rate equals the spacing, so the tones are orthogonal:
tone spacing Δf = BW / T (Hz)
symbol rate = BW / T (baud) ← numerically equal to Δf
symbol duration Tsym = T / BW (s)
tones per symbol = T → bits/symbol = log2(T) = BitsPerSymbol
The classical Olivia standard spans T ∈ {2, 4, 8, 16, 32, 64} and BW ∈ {125, 250, 500, 1000, 2000} Hz. The resulting symbol-rate (baud = Δf) grid is:
| T \ BW | 125 Hz | 250 Hz | 500 Hz | 1000 Hz | 2000 Hz |
|---|---|---|---|---|---|
| 2 | 62.50 | 125.0 | 250.0 | 500.0 | 1000 |
| 4 | 31.25 | 62.50 | 125.0 | 250.0 | 500.0 |
| 8 | 15.63 | 31.25 | 62.50 | 125.0 | 250.0 |
| 16 | 7.81 | 15.63 | 31.25 | 62.50 | 125.0 |
| 32 | 3.91 | 7.81 | 15.63 | 31.25 | 62.50 |
| 64 | 1.95 | 3.91 | 7.81 | 15.63 | 31.25 |
BitsPerSymbol = log2(T) and SymbolsPerBlock = 2^(BitsPerCharacter−1) are independent: tone count sets the bits carried per MFSK symbol and the number of characters per block, while the character-code length (64 for Olivia, 32 for Contestia) sets the block length in symbols.
3.2 The modes this implementation registers
olivia.py is fully parameterized (OliviaCodec accepts any bits_per_symbol/bits_per_char), but only five named modes are wired into the audio layer, in the PARAMS table (app/radio/olivia.py):
PARAMS = {
"olivia": dict(bps=5, bpc=7, tones=32, bw=1000.0, contestia=False),
"olivia-8-500": dict(bps=3, bpc=7, tones=8, bw=500.0, contestia=False),
"olivia-16-500": dict(bps=4, bpc=7, tones=16, bw=500.0, contestia=False),
"contestia": dict(bps=3, bpc=6, tones=8, bw=250.0, contestia=True),
"contestia-4-250": dict(bps=2, bpc=6, tones=4, bw=250.0, contestia=True),
}
CENTER = 1000.0
Note the invariant tones == 2**bps holds for every registered mode (the Gray/MFSK mapping requires it). Derived timing and code geometry:
| mode | T | BW Hz | bps | bpc | N (symbols/block) | Δf = baud | Tsym | block dur | chars/block | char rate | code bits/block |
|---|---|---|---|---|---|---|---|---|---|---|---|
olivia (32/1000) |
32 | 1000 | 5 | 7 | 64 | 31.25 Hz | 32.0 ms | 2.048 s | 5 | 2.44 c/s | 320 |
olivia-8-500 (8/500) |
8 | 500 | 3 | 7 | 64 | 62.50 Hz | 16.0 ms | 1.024 s | 3 | 2.93 c/s | 192 |
olivia-16-500 (16/500) |
16 | 500 | 4 | 7 | 64 | 31.25 Hz | 32.0 ms | 2.048 s | 4 | 1.95 c/s | 256 |
contestia (8/250) |
8 | 250 | 3 | 6 | 32 | 31.25 Hz | 32.0 ms | 1.024 s | 3 | 2.93 c/s | 96 |
contestia-4-250 (4/250) |
4 | 250 | 2 | 6 | 32 | 62.50 Hz | 16.0 ms | 0.512 s | 2 | 3.91 c/s | 64 |
The two modes the app's fldigi map selects by default are olivia-8-500 for the olivia key and contestia (8/250) for the contestia key (app/radio/fldigi_native.py).
3.3 Block anatomy
A FEC block is SymbolsPerBlock (N) consecutive MFSK symbols. It carries BitsPerSymbol (= bps) characters — one Walsh codeword each. Each MFSK symbol is a bps-bit value (0…T−1); the N symbols therefore form an N × bps array of coded bits. The bps codewords are packed into that array diagonally (§4.3): symbol k contributes exactly one chip of every character, each landing in a distinct bit-plane. So the block budget is
coded bits = N · bps (e.g. 64·5 = 320 for Olivia 32/1000)
info bits = bps · bpc (e.g. 5·7 = 35)
code rate = bpc / N = (m+1)/2^m (7/64 ≈ 0.109 Olivia, 6/32 = 0.1875 Contestia)
The waveform is one block after another. modulate frames the payload with idle blocks (all-zero characters) fore and aft — lead_blocks at each end (app/radio/olivia.py) — to give the receiver's symbol-timing and block-phase trackers something to lock onto before real characters arrive.
3.4 Tone audio parameters
spacing = bw / tones # olivia.py
base = center − bw/2 # olivia.py (low edge of the band)
symrate = bw / tones # olivia.py
sps = sr / symrate # samples per symbol
tone f = base + gray(symbol) · spacing # olivia.py
With the default center = 1000 Hz, Olivia 32/1000 occupies 500–1468.75 Hz; Contestia 8/250 occupies 875–1093.75 Hz. Tones are phase-continuous across symbols (the modulator carries phase, olivia.py), and the symbol value is Gray-coded onto the tone index so a one-tone slip corrupts only one bit-plane.
4. Encode — step by step
Reference: OliviaCodec.encode_block (app/radio/olivia.py) for the block FEC, modulate for the audio.
Let N = SymbolsPerBlock, bps = BitsPerSymbol, bpc = BitsPerCharacter.
4.1 Character → code point
For each of the bps character slots in the block, map the input to a code point (_enc_char):
- Olivia:
code = ch & ((N<<1) − 1)=ch & 0x7F— plain 7-bit ASCII, no folding. - Contestia: apply the charset remap of §6.4, then mask to 6 bits (
ch & 0x3F).
The code point is a bpc-bit value: the low bpc−1 bits select a Walsh row and the top bit is the bi-orthogonal sign bit.
4.2 Character → Walsh codeword (inverse FHT)
For character slot freqbit in 0 … bps−1:
fht = [0]*N
ch = enc_char(chars[freqbit] or 0) # 0-padded if fewer chars supplied
if ch < N: fht[ch] = +1 # sign bit clear → Walsh row ch, '+'
else: fht[ch − N] = −1 # sign bit set → Walsh row ch−N, '−'
codeword = ifht(fht) # length-N sequence of ±1
ifht of a unit impulse at position r is the r-th Walsh function (a ±1 sequence); a negative impulse yields its negation. That single ±1 sequence is the RM(1, m) codeword for this character (§2.2).
4.3 Scramble, then diagonal interleave
Still inside the freqbit loop:
Scramble. Multiply the codeword by the mode's scrambler bits, walked LSB-first from a per-character starting offset:
codebit = (freqbit · nshift) & (N−1) # start offset; nshift = 13 Olivia / 5 Contestia
for tb in 0 … N−1:
if scramble & (1 << codebit): codeword[tb] = −codeword[tb]
codebit = (codebit + 1) & (N−1) # advance, wrapping mod N
scramble is the 64-bit SCRAMBLE_OLIVIA or the 32-bit SCRAMBLE_CONTESTIA (§6.1); nshift staggers each character onto a different phase of the scrambler so the bps characters are whitened independently.
Diagonal interleave. Fold this character's N scrambled chips into the block's N output symbols, one chip per symbol, rotating the target bit-plane by one for each successive symbol:
rotate = 0
for tb in 0 … N−1:
if codeword[tb] < 0: # a set bit ⇔ negative chip
bit = (freqbit + rotate) % bps
out[tb] |= (1 << bit)
rotate = (rotate + 1) % bps # rotate == tb mod bps
So character freqbit's chip tb lands in bit-plane (freqbit + tb) mod bps of MFSK symbol tb. For a fixed symbol tb, as freqbit runs over all bps characters the target bit-planes (freqbit + tb) mod bps are a permutation of 0 … bps−1 — a Latin-square (diagonal) interleave. Each symbol therefore receives exactly one chip from each character, and each character is spread across every bit-plane over the block.
out[tb] is now a bps-bit MFSK symbol value in 0 … T−1.
4.4 Block sequence
modulate converts text to code points, zero-pads the character count up to a multiple of bps, prepends lead_blocks idle blocks (all-zero chars), encodes each group of bps characters, and appends lead_blocks trailing idle blocks:
[ idle × lead_blocks ] [ block₀ ] [ block₁ ] … [ idle × lead_blocks ]
4.5 MFSK modulation
For each symbol value s in the block sequence:
tone = base + gray(s) · spacing # gray(s) = s ^ (s>>1)
sample the tone across sps samples, phase-continuous with the previous symbol
Output is scaled by 0.5, clipped to ±1, quantized to signed 16-bit and emitted as little-endian s16 PCM (np.round(clip(out·0.5) · 32767).astype("<i2")). Default modulator rate sr = 48000; sr, center, and lead_blocks are parameters.
5. Decode — step by step
Reference: OliviaDecoder (app/radio/olivia.py) and OliviaCodec.decode_block. The streaming decoder runs three nested loops: symbol timing recovery, per-symbol soft demod, and block-phase FHT decoding.
5.1 Tone detection (DFT per tone)
Precompute the tone frequencies freqs[t] = base + t·spacing for t = 0 … T−1. For a candidate symbol window of sps = sr / (bw/tones) samples starting at fractional offset start (_mags_at), compute the complex DFT at each tone frequency and take its magnitude:
mags[t] = | Σ_n seg[n] · exp(−2πj · freqs[t] · n / sr) | # non-coherent
This is a T-point single-bin DFT (Goertzel-equivalent) — one magnitude per tone.
5.2 Symbol timing recovery (early–late gate)
feed advances a fractional sample pointer self._pos by sps each symbol. Before committing a symbol it early–late gates with offset d = sps/8: it measures a timing quality Q = max(mags)/(mean(mags)+ε) (_quality) at pos−d, pos, pos+d, and nudges pos by ±0.4·d toward whichever neighbour peaks sharper, then re-samples the on-time window. Old buffer samples are dropped once consumed.
5.3 Tone → soft symbol bits (soft combining)
For the on-time magnitudes, _soft_bits collapses the T tone magnitudes into bps soft bit values using the fldigi sign convention (negative ⇒ bit set). Each tone t stands for symbol value s = inv_gray(t) (_inv_gray) and votes its magnitude into each bit-plane with the sign of that bit:
b[bit] = Σ_t ( +mags[t] if bit `bit` of inv_gray(t) is 0
−mags[t] if bit `bit` of inv_gray(t) is 1 ) # bit = 0 … bps−1
b is a bps-vector of signed soft values for this one MFSK symbol — a maximal-ratio soft combine over all tones consistent with each bit hypothesis. These per-symbol soft vectors accumulate in a rolling list self._syms.
5.4 Block-phase acquisition
The decoder must find where blocks begin. Once 2·N symbols are buffered and no phase is locked, it tries all N candidate start phases, runs a trial decode_block(..., return_conf=True) at each, and locks the phase with the highest confidence:
for p in 0 … N−1:
_, conf = decode_block(syms[p : p+N], return_conf=True)
keep the p with the largest conf
lock self._phase = self._next = best_p
Confidence is the mean, over the bps characters, of the Walsh peak-to-noise ratio peak² / noise where noise = (Σ fht² − peak²)/(N−1) — i.e. how far the winning Walsh coefficient stands above the transform's residual energy. A correctly aligned block produces sharp Walsh peaks and a high score; a misaligned one smears energy and scores low. After lock, every completed block is decoded in stride and the symbol buffer is trimmed to stay bounded. (This assumes the stream begins on a block boundary, which the app's continuous stream and the round-trip path guarantee; a full block-phase search during running copy is noted as a refinement.)
5.5 Block decode (de-interleave → de-scramble → forward FHT → peak)
decode_block inverts §4.3–4.2 for each character freqbit:
rotate = freqbit # de-interleave: read the diagonal
codebit = (freqbit · nshift) & (N−1) # same scrambler walk as encode
for tb in 0 … N−1:
x = soft[tb][rotate] # pull this char's chip from symbol tb
if scramble & (1 << codebit): x = −x # de-scramble (sign flip)
fht[tb] = x
codebit = (codebit + 1) & (N−1)
rotate = (rotate + 1) % bps # rotate == (freqbit + tb) mod bps
fht = FHT(fht) # forward Fast Hadamard Transform
# maximum-likelihood: strongest Walsh coefficient + its sign
peak, peakpos = the fht[tb] of largest |value|
char = peakpos + (N if peak < 0 else 0) # sign restores the top code bit
emit dec_char(char)
The rotate = freqbit … +tb read exactly reverses the encoder's (freqbit + tb) mod bps write, so it lifts character freqbit's chips off the diagonal. The forward FHT correlates the de-scrambled soft chips against all N Walsh rows at once; the largest-magnitude output bin is the ML row index and its sign is the bi-orthogonal bit — the optimal decoder for RM(1, m).
5.6 Character recovery and output gating
_dec_char maps the code point back to a character: identity for Olivia (7-bit ASCII); the inverse charset table for Contestia (§6.4). The streaming decoder only surfaces printable/whitespace results — it passes chr(ch) to the callback only when 32 ≤ ch < 127 or ch ∈ {10, 13}, dropping idle (NUL) and other control codes.
6. Constants & tables
All values quoted verbatim from app/radio/olivia.py.
6.1 Scramblers
SCRAMBLE_OLIVIA = 0xE257E6D0291574EC # 64-bit (olivia.py)
SCRAMBLE_CONTESTIA = 0xEDB88320 # 32-bit (olivia.py)
The scrambler is applied as a ±1 sign mask: chip tb is negated iff bit codebit of the constant is 1, testing LSB-first via scramble & (1 << codebit), with codebit wrapping mod N (N−1 mask). Contestia's constant is the bit-reversed CRC-32 polynomial; it is used as an opaque 32-entry whitening word here, cycled mod 32.
6.2 Per-character scrambler shift and derived sizes
Set in OliviaCodec.__init__:
symbols_per_block = 1 << (bits_per_char − 1) # N = 64 Olivia / 32 Contestia
scramble = SCRAMBLE_CONTESTIA if contestia else SCRAMBLE_OLIVIA
nshift = 5 if contestia else 13 # scrambler start-offset step
wrap = symbols_per_block − 1 # = N−1 (mod-N mask)
Character freqbit's scrambler phase starts at (freqbit · nshift) & wrap. The code-point mask in _enc_char is (N<<1) − 1 = 0x7F (Olivia) / 0x3F (Contestia).
6.3 Fast Hadamard Transform (Jalocha convention)
Two unnormalized, in-place butterflies. They are not mutually normalized: FHT(IFHT(x)) = N·x (verified for N = 2…64). The peak-location and peak-sign are all the decoder uses, so the N scale factor is immaterial.
Forward _fht — fine→coarse, butterfly (a, b) → (a+b, b−a):
step = 1
while step < N:
for p in range(0, N, 2*step):
for q in range(p, p+step):
b1, b2 = a[q], a[q+step]
a[q] = b2 + b1
a[q+step] = b2 − b1
step *= 2
Inverse _ifht — coarse→fine, butterfly (a, b) → (a−b, a+b):
step = N // 2
while step:
for p in range(0, N, 2*step):
for q in range(p, p+step):
b1, b2 = a[q], a[q+step]
a[q] = b1 − b2
a[q+step] = b1 + b2
step //= 2
Sanity anchors: ifht([1,0,0,0,0,0,0,0]) = [1,1,1,1,1,1,1,1] (Walsh row 0, the constant), ifht([0,1,0,0,0,0,0,0]) = [−1,1,−1,1,−1,1,−1,1] (Walsh row 1). An impulse in always produces a ±1 Walsh row out.
6.4 Gray-code MFSK tone mapping
gray(n) = n ^ (n >> 1)
inv_gray(n) = n ^ (n>>1) ^ (n>>2) ^ … (fold-down XOR)
TX: symbol value s → tone index gray(s) → f = base + gray(s)·spacing. RX: tone index t → symbol value inv_gray(t). Gray coding guarantees adjacent tones differ in one bit-plane, so the most likely tone error (a one-step slip) corrupts a single bit.
6.5 Contestia character set (6-bit)
Encode (_enc_char) and decode (_dec_char). Lowercase folds to uppercase; printable ASCII 33–90 (!…Z) shifts down by 32; space/CR/BS and idle get dedicated code points; everything else becomes ?.
| input (ASCII) | Contestia code | notes |
|---|---|---|
A–Z (65–90) |
33–58 | code = ascii − 32 |
a–z (97–122) |
33–58 | folded to uppercase first, then −32 |
!–@ (33–64) |
1–32 | code = ascii − 32 (digits 0–9 → 16–25) |
| space (32) | 59 | |
| CR (13) | 60 | |
| BS (8) | 61 | |
| LF (10), NUL (0) | 0 | idle; LF is not round-tripped (decodes to NUL, gated out) |
| any other byte | 31 | rendered as ? (= ord('?') − 32) |
Decode is the inverse: 59 → space, 60 → CR, 61 → BS, code > 0 → code + 32, 0 → NUL (idle). Code points 62–63 are unused by the encoder. Olivia has no such table — it stores 7-bit ASCII directly (_enc_char returns c & 0x7F, _dec_char is the identity).
6.6 Audio-layer constants
CENTER = 1000.0 Hz default band centre
modulate sr = 48000 (default)
OliviaDecoder sr = 12000 (default) app feeds 12 kHz post-decimation (fldigi_native.py)
lead_blocks = 1 (default) idle blocks fore & aft; tests use 2
TX amplitude = 0.5 of full-scale
PCM format = little-endian s16 mono ("<i2")
early-late offset = sps / 8
timing nudge = ±0.4 · d
7. Interoperability & validation
7.1 The oracle: Jalocha's pj_mfsk / pj_fht via fldigi
The codec is a transcription of Pawel Jalocha's pj_mfsk.h / pj_fht.h as embedded in fldigi (GPL-3, app/radio/olivia.py). Bit-exact on-air interoperability with fldigi therefore hinges on four constants/conventions matching the reference implementation, all pinned in §6:
- the scrambler word and its LSB-first, mod-N walk with per-character
nshift; - the FHT butterfly convention (sum/difference ordering and pass direction);
- the diagonal interleave geometry
(freqbit + tb) mod bps; - the Gray tone map and, for Contestia, the 6-bit charset.
Acceptance methodology (fldigi as oracle). Because fldigi is the reference decoder in the field, cross-validation is bidirectional:
- Encoder → fldigi: render a known string with
modulate(text, mode=…)at a standard rate, play it into fldigi configured for the matchingT/BWsubmode, and confirm fldigi prints the text. Any residual scrambler/interleave/charset mismatch shows up as garbled copy. - fldigi → decoder: have fldigi transmit a known string for the same submode, capture the audio, and feed it to
OliviaDecoder. The repo carries reference capturessamples/Olivia.audio.mp3andsamples/Contestia.audio.mp3for exactly this direction.
A standalone build of Jalocha's pj_mfsk/pj_fht can serve the same oracle role at the block-codec level (compare encode_block/decode_block outputs symbol-for- symbol against the C++ library for identical inputs).
7.2 Internal round-trip tests
test_jt.py exercises both layers (these are self-consistency, not interop, checks — they prove the encoder and decoder agree, and that the DSP front end recovers the block stream):
- Block codec (
test_jt.py):encode_block→ ideal soft bits (−1if bit set else+1) →decode_blockreproduces the input string, for an Olivia config (bps=5, bpc=7) and a Contestia config (bps=4, bpc=6). - Full modem (
test_jt.py):modulate→OliviaDecoder.feedrecovers"CQ DE M0SUP"forolivia,contestia, andolivia-8-500, atsr = 8000, including a non-block-aligned start (300 samples of leading silence) to exercise symbol-timing and block-phase acquisition. The modulator is called withlead_blocks=2to seed the trackers.
These pass with the FHT normalization verified (FHT(IFHT(e_k)) = N·e_k for N = 2…64) and with high-code-point (MSB-set) characters round-tripping through the bi-orthogonal sign path.
8. Limitations
Honest gaps in the current implementation:
- Block-phase lock is one-shot. After acquiring on the first
2·Nsymbols the phase is frozen (olivia.py); there is no continuous re-search or re-sync if the lock is wrong or timing drifts across a long over. This is fine for the app's continuous stream (started on a block boundary) and for the round-trip path, but a from-scratch mid-transmission catch — fldigi's usual case — would want a rolling multi-phase search. - DFT tone detector, not a full FFT filterbank. Each tone is a single-bin DFT over one symbol window; there is no overlap-add, windowing, or Doppler tracking, so performance on drifting/multipath HF is below a tuned fldigi decoder.
- Symbol timing is a coarse early–late gate (
d = sps/8, ±0.4·d nudges); it has no fractional-sample interpolation of the waveform beyond re-evaluating DFT windows at shifted integer-ish offsets. - No AFC / centre-frequency search.
centeris a fixed parameter; a mistuned signal is not automatically pulled in. - Contestia LF is lossy. LF (ASCII 10) encodes to code point 0 (idle) and decodes to NUL, which is then gated out (§6.5); newlines do not survive a Contestia round trip. Punctuation outside ASCII 33–90 collapses to
?. - Reduced mode grid. Only five
T/BWcombinations are registered (PARAMS, §3.2); the full classical Olivia grid (§3.1) is reachable only by constructingOliviaCodecdirectly with otherbps/bpc, and the audio layer would need matchingPARAMSentries. - No soft-decision channel model in tests. Round-trip tests use clean or silence-padded audio; there is no committed BER-vs-SNR characterization for these modes (unlike the RVQ-Voice FEC).
None of these touch the wire format — they are receiver-robustness refinements on a correct, interoperable codec.
9. References
- Pawel Jalocha, SP9VRC — the MFSK library (
pj_mfsk,pj_fht) whose Olivia/ Contestia block codec, scrambler, and Fast Hadamard Transform this implementation ports (GPL-3, as embedded in fldigi). See SP9VRC's amateur DSP pages. - fldigi (W1HKJ and contributors) — the reference amateur implementation of Olivia and Contestia (
olivia.cxx,contestia.cxx, and Jalocha's headers); the interoperability oracle of §7. - Pawel Kaczmarczyk, SP9VRC, and Nick Fedoseev, UT2UZ — the originators of Olivia and of its faster Contestia variant, respectively.
- D. E. Muller / I. S. Reed — first-order Reed–Muller codes RM(1, m) =
[2^m, m+1, 2^(m-1)], the bi-orthogonal (augmented Hadamard) code that is the Olivia/Contestia per-character FEC (§2.2). - R. K. Yarlagadda, J. E. Hershey, Hadamard Matrix Analysis and Synthesis — the Walsh–Hadamard transform and its fast (butterfly) computation.
- In-repo:
app/radio/olivia.py(codec + modem),app/radio/fldigi_native.py(mode wiring),test_jt.py(round-trip tests),samples/Olivia.audio.mp3,samples/Contestia.audio.mp3(reference captures).