Patrick Lidstone
Self-hosted

DominoEX: a native IFK+ 18-tone MFSK keyboard mode

A clean, pure-NumPy port of fldigi's DominoEX — 18-tone MFSK carrying data as a differential tone step (Incremental Frequency Keying, "IFK+"), a fixed +2 offset that forbids repeated/adjacent tones, and a self-synchronising nibble varicode in which every character is one to three 4-bit symbols. A phase-continuous modulator plus a streaming, single-bin-DFT + early-late-gate demodulator.

Rafe project · app/radio/domino.py, app/radio/_tables/domino_varicode.py · port of fldigi dominoex.cxx / dominovar.cxx (GPL-3)


Abstract

DominoEX is a keyboard-to-keyboard HF chat mode designed by Con Wassilieff ZL2AFP and Murray Greenman ZL1BPU. It is 18-tone MFSK — one of eighteen tones is sent per symbol — but with a twist that gives the mode its character: the data is not carried by which tone is sent, but by the step from the previous tone to the current one. This is Incremental Frequency Keying (IFK), and DominoEX uses the "plus" variant (IFK+) in which a constant +2 is added to every step so that two consecutive symbols can never land on the same tone, nor on an adjacent one. Because only tone differences matter, the whole 18-tone comb can drift bodily up or down the passband (slow LO drift, mistuning) and the data still decodes — there is no absolute-frequency reference to lose. Characters are encoded with a variable-length "nibble" varicode: each character is one, two, or three 4-bit values (symbols), the top bit (0x8) of each nibble acting as a continuation flag, so the receiver frames characters by watching for the next start nibble. The eight most common characters (space, e, t, o, a, i, n, r) are a single symbol.

This document specifies the exact implementation in app/radio/domino.py, a direct port of fldigi's dominoex.cxx / dominovar.cxx and their varicode table (dominovar.cxx, transcribed into _tables/domino_varicode.py). It covers the four submodes present in the code (DOMX5, DOMX11, DOMX16, DOMX22), the phase- continuous modulator, and the streaming demodulator — an 18-point single-bin DFT tone detector, an early-late-gate symbol-timing recovery driven by a peak-to-average "quality" metric, the differential tone-step decode, and the nibble-varicode framer. Everything is pure NumPy. There is no FEC — that is what the sibling Thor mode adds (convolutional code + interleaver on top of the same IFK+ waveform). All four submodes (DOMX22/16/11/5) round-trip losslessly through the code's own modulator→demodulator. (A doublespaced-2 decode bug in DOMX5 — a spurious divide by doublespaced — was found while writing this spec and fixed; see §7.)


1. Background

1.1 MFSK and incremental frequency keying (IFK+)

Plain M-ary FSK transmits one of M tones per symbol; the receiver picks the strongest tone and maps it back to log₂M bits. DominoEX uses NUMTONES = 18 (domino.py) tones and 4 bits (a nibble) per symbol — 16 of the 18 possible tones are actually reachable per step (see below), so each symbol carries a 4-bit value 0–15.

The distinguishing idea is differential keying. Rather than "tone t means value v", DominoEX sends "value v means step to tone (prev + 2 + v) mod 18". The transmitter keeps a running prev tone and, for each data nibble sym, emits

tone = (prev + 2 + sym) mod 18            # domino.py
prev = tone

The +2 is the defining constant of IFK+ (the "plus" over the original IFK, which used a smaller offset). Its effect, with sym ∈ 0..15:

  • The step is always in 2..17 (2 + 0 … 2 + 15). Sixteen distinct steps map to the sixteen nibble values. Steps 0 and 1 are never used.
  • Step 0 (repeat the same tone) is impossible. This is essential: a differential scheme literally cannot represent "same tone twice" — with no transition there is nothing to measure, and a held tone would be ambiguous with a long fade.
  • Step 1 (adjacent tone) is also forbidden. Adjacent FFT bins leak energy into one another; forcing every consecutive pair of tones at least two bins apart makes the argmax tone decision, and hence the differential step, far more robust to spectral leakage and to small frequency errors. This extra guard is exactly what IFK+ buys over plain IFK.
  • 2 + 15 = 17 < 18, so the step never needs a modular wrap; only the absolute tone index wraps around the 18-tone comb (mod NUMTONES, domino.py).

The code comment states the design goal directly (domino.py): "each data nibble is sent as a tone step of (2 + nibble) mod 18 from the previous tone, so no two adjacent symbols share a tone."

1.2 Why differential — robustness to drift

Because information lives in tone differences, DominoEX has no absolute frequency reference to lose. If the entire 18-tone comb slides up or down the audio passband — HF LO drift, thermal drift, deliberate mistuning, Doppler — every tone moves together, so every step is preserved and the data survives. The demodulator never needs to know where tone 0 "should" be; it only measures argmax(this symbol) − argmax(previous symbol). This is the same robustness argument that makes differential PSK attractive, applied in the frequency domain. It is why DominoEX tolerates casual tuning where a coherent absolute-frequency MFSK mode would require an AFC loop.

The one thing the receiver does need to track is symbol timing — where each symbol begins — and that is handled by an early-late gate (§4.2), not by any absolute-frequency machinery.

1.3 The secondary-tone (nibble) varicode

DominoEX does not spend a fixed 4 bits per character. It uses a variable-length varicode — like PSK31's — but built from 4-bit nibbles (symbols) instead of bits, and biased so that common characters are short. Each character maps to one to three nibbles (MAX_VARICODE_LEN = 3, domino.py):

  • The first ("primary") nibble always has its top bit clear — its value is in 0..7 (3 payload bits). A primary nibble marks the start of a character.
  • Each continuation ("secondary") nibble always has its top bit (0x8) set — value 8..15. The 0x8 bit is a pure framing flag; the low 3 bits are payload.

So each nibble carries 3 payload bits plus 1 framing bit, and the receiver frames characters simply by watching for the next primary nibble (top bit clear). The code is self-synchronising: catching the stream mid-character costs at most one garbled character. In DominoEX parlance the primary nibble is the character's first MFSK tone and the continuation nibbles are its secondary tones — hence "secondary-tone varicode". The eight most frequent characters — space, e, t, o, a, i, n, r — are a single primary nibble (values 0..7 respectively; §5.3), so ordinary English text averages well under two symbols per character.

DominoEX carries no FEC. The sibling mode Thor reuses this exact IFK+ waveform and nibble alphabet but wraps a convolutional code + interleaver around it; that is a separate module and is out of scope here (domino.py).


2. Signal structure

2.1 The 18-tone alphabet and centre frequency

A submode is fully described by three integers held in PARAMS (domino.py): symlen (nominal samples per symbol), doublespaced (tone-spacing multiplier), and samplerate (the nominal modem rate that fixes baud and tone spacing in Hz). From these, _cfg (domino.py) derives:

tonespacing = samplerate * doublespaced / symlen          # Hz   (domino.py)
bw          = NUMTONES * tonespacing                       # Hz   (domino.py)

The eighteen tone frequencies are laid symmetrically about a fixed audio centre CENTER = 1500.0 Hz (domino.py). With base = center − bw/2 (domino.py), tone t (for t = 0..17) sits at

f(t) = (t + 0.5) * tonespacing + base                      # Hz   (domino.py)

The +0.5 centres the comb: the 18 tones occupy [center − bw/2, center + bw/2] with half a tone-spacing of margin at each end. For DOMX22 the comb runs 1316.97 … 1683.03 Hz; for DOMX16, 1367.19 … 1632.81 Hz (see §5.2).

2.2 Per-variant baud and tone spacing

The symbol rate ("baud") is samplerate / symlen, independent of doublespaced. The tone spacing in Hz is baud × doublespaced. The four submodes in PARAMS:

submode symlen doublespaced samplerate baud (sym/s) tone spacing occupied BW
DOMX5 2048 2 11025 5.3833 10.7666 Hz 193.80 Hz
DOMX11 1024 1 11025 10.7666 10.7666 Hz 193.80 Hz
DOMX16 512 1 8000 15.6250 15.6250 Hz 281.25 Hz
DOMX22 512 1 11025 21.5332 21.5332 Hz 387.60 Hz

The submode number is the approximate baud (DOMX22 ≈ 21.5 baud, DOMX16 ≈ 15.6, DOMX11 ≈ 10.8, DOMX5 ≈ 5.4). The doublespaced flag is the DominoEX convention for widening the tone comb without changing the baud: DOMX5 keeps the same symbol rate as a 5-baud mode but doubles the tone spacing to 2×baud (giving it the same 10.77 Hz spacing and 193.8 Hz footprint as DOMX11) for extra multipath immunity.

The app front-end exposes only DOMX22 and DOMX16 (domino.py); DOMX11 and DOMX5 are also present in the table and all four now round-trip end-to-end (the DOMX5 doublespaced-2 decode was fixed — see §7). The full published DominoEX family also includes DOMX4 and DOMX8; those are not present in this implementation's PARAMS.

2.3 The IFK+ mapping

Given the running prev tone (initialised to 0) and a data nibble sym ∈ 0..15:

step = 2 + sym                     ∈ 2..17
tone = (prev + step) mod 18        # the transmitted tone index (domino.py)
prev = tone

Decoding inverts it. Given consecutive detected tones prev and tone:

step = (tone − prev) mod 18                       # in 2..17
sym  = (step − 2) mod 18                           # recovered nibble, 0..15

The implementation writes the inverse as (domino.py, _process_symbol):

c = (tone - self._prev - 2) % NUMTONES             # remove +2, wrap into 0..17

The tone bins in _tone_freqs are already spaced by tonespacing (which folds in doublespaced), so argmax gives the tone index directly and the step is read straight off — there is no division by doublespaced (an earlier port divided here, which corrupted DOMX5; see §7). The recovered c is the full 4-bit nibble, including its 0x8 continuation bit — the framer (§4.4) reads that bit directly.

2.4 Symbol length and sample-rate independence

DominoEX baud and tone spacing are fixed in Hz by the nominal samplerate in PARAMS, but the actual audio the code produces or consumes may be at any sample rate. Both modulator and demodulator rescale the samples-per-symbol to the working rate sr:

sps = symlen / samplerate * sr        # samples per symbol at rate sr  (domino.py)

The tone frequencies (§2.1) are absolute Hz and do not depend on sr. Consequently the modulator defaults to sr = 48000 (domino.py) and the streaming decoder defaults to sr = 12000 (domino.py), and either can be driven at any rate — the mode's identity (baud, spacing, centre) is preserved. Worked sps values:

submode sps @ 48 kHz sps @ 12 kHz
DOMX22 2229.116 557.279
DOMX16 3072.000 768.000
DOMX11 4458.231 1114.558
DOMX5 8916.463 2229.116

sps is generally fractional; the modulator lays each symbol at round(idx·sps) … round((idx+1)·sps) so cumulative rounding never drifts (domino.py), and the demodulator carries a fractional sampling position _pos (§4).


3. Transmitter (encode) — step by step

modulate(text, mode="domx22", sr=48000, center=CENTER) (domino.py) produces little-endian 16-bit mono PCM.

Step 1 — configure. _cfg(mode) yields tonespacing, bw; base = center − bw/2; sps = symlen/samplerate·sr. A one-element state dict holds prev = 0 (domino.py).

Step 2 — nibble → tone (sendsym). For each nibble sym, tone = (prev + 2 + sym) mod 18; append tone to the tone list; update prev (domino.py). This is the IFK+ mapping of §2.3.

Step 3 — character → nibbles (sendchar). Look up the varicode triple code = ENC[ord(ch) & 0x1FF] (masked to 9 bits → the 512-entry table; domino.py). Always send the primary nibble code[0]. Then send code[1] and code[2] only while the 0x8 continuation bit is set, stopping at the first nibble without it (domino.py):

sendsym(code[0])
for k in (1, 2):
    if code[k] & 0x8:
        sendsym(code[k])
    else:
        break

So a character emits 1, 2, or 3 tones depending on its varicode length (§5.3).

Step 4 — frame the message. Emit 16 lead-in idle symbols (sendsym(0)) so the receiver can lock symbol timing, then every character, then 8 trailer idle symbols to flush the final character through the framer (domino.py). An idle symbol is nibble 0 → tone step +2. (Why the flush matters: the framer emits a character only when the next primary nibble arrives — see §4.4 — so the last real character needs at least one following primary nibble, which the trailer idles provide.)

Step 5 — synthesise phase-continuous audio. Allocate round(len(tones)·sps) + 1 samples. For each symbol idx with tone index tone, compute the absolute frequency f = (tone + 0.5)·tonespacing + base, place it in the sample window [round(idx·sps), round((idx+1)·sps)), and generate cos(2π f t/sr + phase). Carry phase across symbol boundaries (phase += 2π f n / sr, taken mod 2π) so there are no phase discontinuities between tones (domino.py). Finally scale by 0.5, clip to [−1, 1], quantise to int16 little-endian (domino.py):

return np.round(np.clip(out * 0.5, -1, 1) * 32767).astype("<i2").tobytes()

There is no windowing/ramping of individual symbols beyond phase continuity, and no FEC, interleaving, or scrambling.

Worked micro-example (message "e!", 2 lead-in idles, no trailer, for brevity):

step source nibble(s) tone step absolute tone (mod 18)
0 idle 0 +2 2
1 idle 0 +2 4
2 e = [1,·,·] 1 +3 7
3 ! = [7,11,·] 7 +9 16
4 ! cont. 11 +13 (16+13) mod 18 = 11

Tone-index stream: [2, 4, 7, 16, 11]. The differential decode (§4.3) of these five tones recovers nibbles [0, 1, 7, 11] — idle, e, then the two nibbles of !.


4. Receiver (decode) — step by step

DominoDecoder(on_text, mode="domx22", sr=12000, center=CENTER) (domino.py) is a streaming decoder: call feed(pcm) with successive audio chunks (bytes of s16le, or a float array); it invokes on_text(char) for each decoded character.

State (domino.py): _buf (accumulated float samples), _carry (leftover odd byte between chunks), _pos (fractional sampling position, symbols measured in samples), _prev (previous detected tone, None until the first symbol), plus the varicode framer's _symbuf/_symcounter.

4.1 Single-bin DFT tone detection (_mags_at)

At a candidate start offset, take int(sps) samples and compute, for each of the 18 tone frequencies, the magnitude of a single-bin DFT (a Goertzel-style dot product against a complex exponential) (domino.py):

seg = self._buf[a : a + int(self.sps)]
n = np.arange(len(seg))
mags = np.abs([ np.sum(seg * np.exp(-2j*np.pi*f*n/self.sr)) for f in self.freqs ])

self.freqs is the 18-element array from _tone_freqs(mode, center) (domino.py, 38-41). If fewer than int(sps) samples are available it returns None. There is no window function; the integration length is exactly one symbol.

4.2 Symbol-timing recovery — early-late gate (feed)

DominoEX needs symbol timing but not absolute frequency. The decoder runs a peak-to-average quality metric

quality(mags) = mags.max() / (mags.mean() + 1e-10)      # domino.py

which is largest when the integration window is aligned with a symbol (one bin dominates). With a probe offset d = sps/8 (domino.py), for each symbol it evaluates quality at _pos − d (early), _pos (centre), _pos + d (late) (domino.py):

  • if late > centre and late ≥ early → nudge _pos += 0.4·d and re-measure at centre;
  • elif early > centre and early > late → nudge _pos −= 0.4·d and re-measure;
  • else keep the centre.

This is a classic early-late gate: it walks the sampling instant toward the point of maximum per-symbol energy concentration. It only runs when _pos − d ≥ 0. After choosing the sampling instant it decodes the symbol (§4.3–4.4) and advances _pos += sps (domino.py). The main loop runs while a full symbol plus the late probe fits in the buffer (while _pos + sps + d < len(_buf), domino.py), then trims consumed samples, keeping one symbol of history, and rebases _pos (domino.py).

4.3 Differential tone-step recovery (_process_symbol)

Pick the loudest tone, tone = argmax(mags) (domino.py). If a previous tone exists, recover the nibble by the inverse IFK+ map of §2.3 — take the tone-index difference (tone − prev), subtract the +2 offset, wrap mod NUMTONES — and hand the nibble to the framer. Then store tone as the new _prev. The very first detected symbol only seeds _prev and yields no nibble (hence the lead-in idles).

4.4 Nibble accumulation and varicode framing (_decode_domino)

The framer (domino.py) uses the 0x8 continuation bit to delimit characters. _symbuf is a 3-slot shift register with the newest nibble at index 0; _symcounter counts nibbles since the last character boundary.

When a nibble c with 0x8 clear arrives (a primary nibble → a new character is starting), the framer first emits the character just completed from the nibbles buffered so far, then resets the counter. When 0x8 is set (a continuation nibble), it merely accumulates. Either way it then shifts c into _symbuf[0] and increments _symcounter (saturating at MAX_VARICODE_LEN + 1):

if not (c & 0x8):                              # start of a new character
    if self._symcounter <= MAX_VARICODE_LEN:
        sym = 0
        for i in range(self._symcounter):
            sym |= self._symbuf[i] << (4 * i)  # newest nibble is least-significant
        ch = _VDEC[sym & 0xFFF]                # 12-bit index into the 4096-entry DEC
        if 0 <= ch < 256 and (32 <= ch < 127 or ch in (10, 13)):
            self.on_text(chr(ch))              # printable ASCII, or CR/LF, only
    self._symcounter = 0
# shift c into slot 0
for i in range(MAX_VARICODE_LEN - 1, 0, -1):
    self._symbuf[i] = self._symbuf[i - 1]
self._symbuf[0] = c
self._symcounter = min(self._symcounter + 1, MAX_VARICODE_LEN + 1)

The reversed-nibble index. Because _symbuf[0] is the newest nibble and the sum weights it least-significantly, a character received as primary→continuations [n0, n1, n2] produces the DEC index

sym = n0 << 4·(k−1)  |  n1 << 4·(k−2)  |  …  |  n_{k−1}          (k = nibble count)

i.e. the first nibble is the most-significant, and each continuation nibble's 0x8 bit is part of the index. This is why _VDEC is a 4096-entry (12-bit) table even though only 512 characters exist (§5.3). Examples:

character ENC triple nibbles sent DEC index DEC[index]
space (32) [0,0,0] 0 0x000 32
e (101) [1,0,0] 1 0x001 101
! (33) [7,11,0] 7, 11 0x07B 33
CR (13) [2,13,0] 2, 13 0x02D 13
NUL (0) [1,15,9] 1, 15, 9 0x1F9 0

Output filter. A character is delivered only if it is printable ASCII (32 ≤ ch < 127) or CR/LF (10, 13) (domino.py). Control/high codepoints in the table are decoded but silently dropped. DEC uses the value 1 as its "invalid slot" sentinel (domino.py), which the 0 ≤ ch < 256 and printable tests discard anyway.

Framing lag. A character is emitted only when the next primary nibble arrives, so decoded output trails one character behind the air; the transmitter's 8 trailer idles (§3) flush the final real character. Lead-in idles (nibble 0) each frame as a single-nibble character = DEC[0] = space, which is why a decoded stream begins with a run of spaces.


5. Constants & tables

5.1 Scalar constants (domino.py)

name value meaning
NUMTONES 18 tones in the MFSK comb
MAX_VARICODE_LEN 3 max nibbles per character
CENTER 1500.0 Hz audio centre of the tone comb
IFK+ offset +2 added to every tone step (TX); removed (RX)
lead-in idles 16 idle symbols before text (RX sync)
trailer idles 8 idle symbols after text (flush last char)
TX amplitude 0.5 pre-quantisation scale
default TX sr 48000 modulator output rate
default RX sr 12000 decoder working rate
timing probe d sps/8 early/late offset
timing nudge 0.4·d timing correction step

Derived per submode (domino.py): tonespacing = samplerate·doublespaced/symlen, bw = 18·tonespacing, base = center − bw/2, f(t) = (t+0.5)·tonespacing + base, sps = symlen/samplerate·sr.

5.2 Per-variant parameters (PARAMS, domino.py)

PARAMS = {
    "domx22": dict(symlen=512,  doublespaced=1, samplerate=11025),
    "domx16": dict(symlen=512,  doublespaced=1, samplerate=8000),
    "domx5":  dict(symlen=2048, doublespaced=2, samplerate=11025),
    "domx11": dict(symlen=1024, doublespaced=1, samplerate=11025),
}
submode baud tone spacing occupied BW tone 0 tone 17
DOMX22 21.5332 21.5332 Hz 387.60 Hz 1316.97 Hz 1683.03 Hz
DOMX16 15.6250 15.6250 Hz 281.25 Hz 1367.19 Hz 1632.81 Hz
DOMX11 10.7666 10.7666 Hz 193.80 Hz 1408.48 Hz 1591.52 Hz
DOMX5 5.3833 10.7666 Hz 193.80 Hz 1408.48 Hz 1591.52 Hz

(Tone edges are the centre-frequencies of tones 0 and 17 about CENTER = 1500; the occupied band, tone-0-lower-edge to tone-17-upper-edge, is exactly bw.)

5.3 The nibble varicode (_tables/domino_varicode.py)

Two lookup tables, transcribed verbatim from fldigi's dominovar.cxx (domino_varicode.py):

  • ENC — 512 entries (indexed by 9-bit character code), each a [primary, cont1, cont2] nibble triple. Absent continuation nibbles are 0.
  • DEC — 4096 entries (12-bit reversed-nibble index → character code), with the value 1 marking unused slots.

Verified structural properties (over the committed tables):

  • Well-formedness. Every primary nibble ENC[i][0] is in 0..7 (0x8 clear); every present continuation nibble is in 8..15 (0x8 set); continuation nibbles are contiguous. So the TX continuation loop and the RX 0x8 framing are exact inverses.

  • Length distribution. 8 characters are 1 nibble, 64 are 2 nibbles, 440 are 3 nibbles.

  • The 8 single-nibble characters (primary nibble → character), the fastest codes:

    primary nibble 0 1 2 3 4 5 6 7
    character space e t o a i n r

    (Equivalently DEC[0..7] = 32,101,116,111,97,105,110,114.) These are the eight most frequent characters in English text — the varicode is Huffman-like, mirroring PSK31's frequency-optimised code.

  • Round-trip. Feeding every ENC triple through the decoder's nibble-index formula and back through DEC recovers the original character for 511 of 512 codes. The single exception is a benign collision: ENC[379] == ENC[381] == [5,10,12], so those two codepoints share a DEC slot (it resolves to 381). Both are codepoints > 255 and therefore never emitted by the printable-only output filter (§4.4) — harmless in practice.

Landmark ENC entries (for orientation — the complete 512-entry array follows):

index  0: [1,15, 9]     # NUL     -> nibbles 1,15,9
index 32: [0, 0, 0]     # space   -> nibble 0            (single)
index 33: [7,11, 0]     # '!'     -> nibbles 7,11
index 44: [2,11, 0]     # ','     -> nibbles 2,11
index 65: [3, 9, 0]     # 'A'     -> nibbles 3,9
index 97: [4, 0, 0]     # 'a'     -> nibble 4            (single)
index101: [1, 0, 0]     # 'e'     -> nibble 1            (single)

The complete ENC table (512 entries, indexed by the 9-bit character code; each entry is a [primary, cont1, cont2] nibble triple, unused continuation slots 0). This is the hand-assigned source table, reproduced verbatim — it cannot be derived from any rule:

ENC = [[1, 15, 9], [1, 15, 10], [1, 15, 11], [1, 15, 12], [1, 15, 13], [1, 15, 14], [1, 15, 15], [2, 8, 8], [2, 12, 0], [2, 8, 9], [2, 8, 10], [2, 8, 11], [2, 8, 12], [2, 13, 0], [2, 8, 13], [2, 8, 14], [2, 8, 15], [2, 9, 8], [2, 9, 9], [2, 9, 10], [2, 9, 11], [2, 9, 12], [2, 9, 13], [2, 9, 14], [2, 9, 15], [2, 10, 8], [2, 10, 9], [2, 10, 10], [2, 10, 11], [2, 10, 12], [2, 10, 13], [2, 10, 14], [0, 0, 0], [7, 11, 0], [0, 8, 14], [0, 10, 11], [0, 9, 10], [0, 9, 9], [0, 8, 15], [7, 10, 0], [0, 8, 12], [0, 8, 11], [0, 9, 13], [0, 8, 8], [2, 11, 0], [7, 14, 0], [7, 13, 0], [0, 8, 9], [3, 15, 0], [4, 10, 0], [4, 15, 0], [5, 9, 0], [6, 8, 0], [5, 12, 0], [5, 14, 0], [6, 12, 0], [6, 11, 0], [6, 14, 0], [0, 8, 10], [0, 8, 13], [0, 10, 8], [7, 15, 0], [0, 9, 15], [7, 12, 0], [0, 9, 8], [3, 9, 0], [4, 14, 0], [3, 12, 0], [3, 14, 0], [3, 8, 0], [4, 12, 0], [5, 8, 0], [5, 10, 0], [3, 10, 0], [7, 8, 0], [6, 10, 0], [4, 11, 0], [4, 8, 0], [4, 13, 0], [3, 11, 0], [4, 9, 0], [6, 15, 0], [3, 13, 0], [2, 15, 0], [2, 14, 0], [5, 11, 0], [6, 13, 0], [5, 13, 0], [5, 15, 0], [6, 9, 0], [7, 9, 0], [0, 10, 14], [0, 10, 9], [0, 10, 15], [0, 10, 10], [0, 9, 12], [0, 9, 11], [4, 0, 0], [1, 11, 0], [0, 12, 0], [0, 11, 0], [1, 0, 0], [0, 15, 0], [1, 9, 0], [0, 10, 0], [5, 0, 0], [2, 10, 0], [1, 14, 0], [0, 9, 0], [0, 14, 0], [6, 0, 0], [3, 0, 0], [1, 8, 0], [2, 8, 0], [7, 0, 0], [0, 8, 0], [2, 0, 0], [0, 13, 0], [1, 13, 0], [1, 12, 0], [1, 15, 0], [1, 10, 0], [2, 9, 0], [0, 10, 12], [0, 9, 14], [0, 10, 13], [0, 11, 8], [2, 10, 15], [2, 11, 8], [2, 11, 9], [2, 11, 10], [2, 11, 11], [2, 11, 12], [2, 11, 13], [2, 11, 14], [2, 11, 15], [2, 12, 8], [2, 12, 9], [2, 12, 10], [2, 12, 11], [2, 12, 12], [2, 12, 13], [2, 12, 14], [2, 12, 15], [2, 13, 8], [2, 13, 9], [2, 13, 10], [2, 13, 11], [2, 13, 12], [2, 13, 13], [2, 13, 14], [2, 13, 15], [2, 14, 8], [2, 14, 9], [2, 14, 10], [2, 14, 11], [2, 14, 12], [2, 14, 13], [2, 14, 14], [2, 14, 15], [0, 11, 9], [0, 11, 10], [0, 11, 11], [0, 11, 12], [0, 11, 13], [0, 11, 14], [0, 11, 15], [0, 12, 8], [0, 12, 9], [0, 12, 10], [0, 12, 11], [0, 12, 12], [0, 12, 13], [0, 12, 14], [0, 12, 15], [0, 13, 8], [0, 13, 9], [0, 13, 10], [0, 13, 11], [0, 13, 12], [0, 13, 13], [0, 13, 14], [0, 13, 15], [0, 14, 8], [0, 14, 9], [0, 14, 10], [0, 14, 11], [0, 14, 12], [0, 14, 13], [0, 14, 14], [0, 14, 15], [0, 15, 8], [0, 15, 9], [0, 15, 10], [0, 15, 11], [0, 15, 12], [0, 15, 13], [0, 15, 14], [0, 15, 15], [1, 8, 8], [1, 8, 9], [1, 8, 10], [1, 8, 11], [1, 8, 12], [1, 8, 13], [1, 8, 14], [1, 8, 15], [1, 9, 8], [1, 9, 9], [1, 9, 10], [1, 9, 11], [1, 9, 12], [1, 9, 13], [1, 9, 14], [1, 9, 15], [1, 10, 8], [1, 10, 9], [1, 10, 10], [1, 10, 11], [1, 10, 12], [1, 10, 13], [1, 10, 14], [1, 10, 15], [1, 11, 8], [1, 11, 9], [1, 11, 10], [1, 11, 11], [1, 11, 12], [1, 11, 13], [1, 11, 14], [1, 11, 15], [1, 12, 8], [1, 12, 9], [1, 12, 10], [1, 12, 11], [1, 12, 12], [1, 12, 13], [1, 12, 14], [1, 12, 15], [1, 13, 8], [1, 13, 9], [1, 13, 10], [1, 13, 11], [1, 13, 12], [1, 13, 13], [1, 13, 14], [1, 13, 15], [1, 14, 8], [1, 14, 9], [1, 14, 10], [1, 14, 11], [1, 14, 12], [1, 14, 13], [1, 14, 14], [1, 14, 15], [1, 15, 8], [6, 15, 9], [6, 15, 10], [6, 15, 11], [6, 15, 12], [6, 15, 13], [6, 15, 14], [6, 15, 15], [7, 8, 8], [4, 10, 12], [7, 8, 9], [7, 8, 10], [7, 8, 11], [7, 8, 12], [4, 10, 13], [7, 8, 13], [7, 8, 14], [7, 8, 15], [7, 9, 8], [7, 9, 9], [7, 9, 10], [7, 9, 11], [7, 9, 12], [7, 9, 13], [7, 9, 14], [7, 9, 15], [7, 10, 8], [7, 10, 9], [7, 10, 10], [7, 10, 11], [7, 10, 12], [7, 10, 13], [7, 10, 14], [3, 8, 8], [4, 15, 11], [5, 8, 14], [5, 10, 11], [5, 9, 10], [5, 9, 9], [5, 8, 15], [4, 15, 10], [5, 8, 12], [5, 8, 11], [5, 9, 13], [5, 8, 8], [4, 10, 11], [4, 15, 14], [4, 15, 13], [5, 8, 9], [4, 11, 15], [4, 12, 10], [4, 12, 15], [4, 13, 9], [4, 14, 8], [4, 13, 12], [4, 13, 14], [4, 14, 12], [4, 14, 11], [4, 14, 14], [5, 8, 10], [5, 8, 13], [5, 10, 8], [4, 15, 15], [5, 9, 15], [4, 15, 12], [5, 9, 8], [4, 11, 9], [4, 12, 14], [4, 11, 12], [4, 11, 14], [4, 11, 8], [4, 12, 12], [4, 13, 8], [4, 13, 10], [4, 11, 10], [4, 15, 8], [4, 14, 10], [4, 12, 11], [4, 12, 8], [4, 12, 13], [4, 11, 11], [4, 12, 9], [4, 14, 15], [4, 11, 13], [4, 10, 15], [4, 10, 14], [4, 13, 11], [4, 14, 13], [4, 13, 13], [4, 13, 15], [4, 14, 9], [4, 15, 9], [5, 10, 14], [5, 10, 9], [5, 10, 15], [5, 10, 10], [5, 9, 12], [5, 9, 11], [3, 8, 12], [4, 9, 11], [4, 8, 12], [4, 8, 11], [3, 8, 9], [4, 8, 15], [4, 9, 9], [4, 8, 10], [3, 8, 13], [4, 10, 10], [4, 9, 14], [4, 8, 9], [4, 8, 14], [3, 8, 14], [3, 8, 11], [4, 9, 8], [4, 10, 8], [3, 8, 15], [4, 8, 8], [3, 8, 10], [4, 8, 13], [4, 9, 13], [4, 9, 12], [4, 9, 15], [4, 9, 10], [4, 10, 9], [5, 10, 12], [5, 9, 14], [5, 10, 12], [5, 11, 8], [7, 10, 15], [7, 11, 8], [7, 11, 9], [7, 11, 10], [7, 11, 11], [7, 11, 12], [7, 11, 13], [7, 11, 14], [7, 11, 15], [7, 12, 8], [7, 12, 9], [7, 12, 10], [7, 12, 11], [7, 12, 12], [7, 12, 13], [7, 12, 14], [7, 12, 15], [7, 13, 8], [7, 13, 9], [7, 13, 10], [7, 13, 11], [7, 13, 12], [7, 13, 13], [7, 13, 14], [7, 13, 15], [7, 14, 8], [7, 14, 9], [7, 14, 10], [7, 14, 11], [7, 14, 12], [7, 14, 13], [7, 14, 14], [7, 14, 15], [5, 11, 9], [5, 11, 10], [5, 11, 11], [5, 11, 12], [5, 11, 13], [5, 11, 14], [5, 11, 15], [5, 12, 8], [5, 12, 9], [5, 12, 10], [5, 12, 11], [5, 12, 12], [5, 12, 13], [5, 12, 14], [5, 12, 15], [5, 13, 8], [5, 13, 9], [5, 13, 10], [5, 13, 11], [5, 13, 12], [5, 13, 13], [5, 13, 14], [5, 13, 15], [5, 14, 8], [5, 14, 9], [5, 14, 10], [5, 14, 11], [5, 14, 12], [5, 14, 13], [5, 14, 14], [5, 14, 15], [5, 15, 8], [5, 15, 9], [5, 15, 10], [5, 15, 11], [5, 15, 12], [5, 15, 13], [5, 15, 14], [5, 15, 15], [6, 8, 8], [6, 8, 9], [6, 8, 10], [6, 8, 11], [6, 8, 12], [6, 8, 13], [6, 8, 14], [6, 8, 15], [6, 9, 8], [6, 9, 9], [6, 9, 10], [6, 9, 11], [6, 9, 12], [6, 9, 13], [6, 9, 14], [6, 9, 15], [6, 10, 8], [6, 10, 9], [6, 10, 10], [6, 10, 11], [6, 10, 12], [6, 10, 13], [6, 10, 14], [6, 10, 15], [6, 11, 8], [6, 11, 9], [6, 11, 10], [6, 11, 11], [6, 11, 12], [6, 11, 13], [6, 11, 14], [6, 11, 15], [6, 12, 8], [6, 12, 9], [6, 12, 10], [6, 12, 11], [6, 12, 12], [6, 12, 13], [6, 12, 14], [6, 12, 15], [6, 13, 8], [6, 13, 9], [6, 13, 10], [6, 13, 11], [6, 13, 12], [6, 13, 13], [6, 13, 14], [6, 13, 15], [6, 14, 8], [6, 14, 9], [6, 14, 10], [6, 14, 11], [6, 14, 12], [6, 14, 13], [6, 14, 14], [6, 14, 15], [6, 15, 8]]

The complete DEC table is not a separate hand-made table — it is the exact inverse of ENC, so instead of printing 4096 integers here it is given as a self-contained regeneration recipe. Building it from the ENC array above reproduces the committed 4096-entry DEC array byte-for-byte (verified, including the sentinel fill and the one collision below):

# Regenerate the full 4096-entry DEC table from ENC (verified exact).
DEC = [1] * 4096                       # 1 == "invalid slot" sentinel
for i in range(512):                   # i is the 9-bit character code
    nb = [ENC[i][0]]                   # primary nibble (0x8 clear)
    for k in (1, 2):                   # append continuation nibbles while 0x8 set
        if ENC[i][k] & 0x8:
            nb.append(ENC[i][k])
        else:
            break
    k = len(nb)                        # 1, 2, or 3 nibbles
    idx = 0                            # reversed-nibble index: primary is MOST significant
    for j in range(k):
        idx |= nb[j] << (4 * (k - 1 - j))
    DEC[idx & 0xFFF] = i               # later i wins on collision

Two facts fall straight out of this recipe and are worth stating explicitly:

  • 3586 of the 4096 slots stay 1 (the sentinel) — every index whose low nibble would be a "continuation" value where a primary is expected is unreachable.
  • The single collision ENC[379] == ENC[381] == [5,10,12] means both codepoints hash to the same slot; because 381 > 379, the loop leaves DEC[...] = 381. Both are codepoints > 255, so the printable-only output filter (§4.4) never emits either — harmless.

For orientation, the first 24 regenerated DEC entries are:

DEC[0..15]  = 32,101,116,111,97,105,110,114,115,108,104,100,99,117,109,102
              (space e t o a i n r  s l h d c u m f)
DEC[16..23] = 1,1,1,1,1,1,1,1        # unused (a continuation nibble cannot open a char)

6. Interoperability & validation

Port fidelity / fldigi as the oracle. This implementation is a direct port of fldigi's dominoex.cxx / dominovar.cxx (domino.py), and the varicode is the byte-for-byte dominovar.cxx table (domino_varicode.py). The waveform is therefore intended to be on-air compatible with fldigi's DominoEX: same 18-tone comb, same +2 IFK+ offset, same nibble alphabet and framing. A signal generated by fldigi (DominoEX 22/16/11) should decode here, and vice-versa, because both the symbol mapping and the character table are identical to fldigi's. (This document asserts port compatibility from source equivalence and the internal round-trip below; it does not report a live fldigi cross-decode run.)

Internal round-trip oracle. The code's own modulator → streaming demodulator was exercised on a mixed-content string ("CQ CQ de RAFE 73! The quick brown fox 0123456789.\n") at sr = 48000, chunk-fed to the decoder:

submode doublespaced result
DOMX22 1 exact (message recovered verbatim, framed by lead-in/trailer spaces)
DOMX16 1 exact
DOMX11 1 exact
DOMX5 2 exact (after the §7 fix)

All four submodes reproduce the input character-for-character (modulo the lead-in/trailer idle spaces and the one-character framing lag), confirming the encode/decode pair described here is self-consistent.

Reproduction checklist for an independent implementation:

  1. Build the 18-tone table f(t) = (t+0.5)·tonespacing + base about 1500 Hz.
  2. Encode: char → ENC nibbles (primary + 0x8-flagged continuations) → tone steps (prev + 2 + nibble) mod 18 → phase-continuous cosines.
  3. Decode: per-symbol 18-bin single-bin DFT → argmax → step (tone − prev) → nibble (step − 2) mod 18 → frame on the 0x8 bit → reversed-nibble index into DEC.
  4. Confirm the eight single-nibble characters map to space e t o a i n r and that "!" = tones 7, 11 → DEC index 0x07B → 33.

7. Limitations

  • DOMX5 (doublespaced = 2) — a decode bug, now fixed. The modulator places tones at integer tone-index steps regardless of doublespaced (tone = (prev+2+sym) mod 18), and the decoder's freqs are the actual 18 tone frequencies (already spaced 2·baud apart via tonespacing), so argmax yields a direct tone index. The original _process_symbol then divided the tone-index difference by doublespaced (fdiff = (tone − prev)/doublespaced) — halving a step that was never doubled — so DOMX5 nibbles were corrupted (a round-trip returned garbage), while DOMX11/16/22 (all doublespaced = 1, where the division is a no-op) were exact. That /doublespaced was a vestige of fldigi's finer-FFT-bin front-end, where tone positions are two FFT bins apart and the divide is correct; this port detects tones at their exact frequencies, so the divide was inconsistent. The fix drops the divide — the step is now c = (tone − prev − 2) mod NUMTONES — and all four submodes round-trip (covered by test_domino_roundtrip).
  • No FEC. A single mis-detected symbol corrupts a whole character (and, via the differential chain, can only affect that character — errors do not propagate beyond the next primary nibble, since framing re-syncs on every start nibble). Weak-signal robustness comes from Thor, not DominoEX.
  • No AFC / no coarse tone search. The comb is assumed centred on CENTER = 1500 Hz. Differential keying tolerates drift once locked, but there is no acquisition search to place the comb — the signal must already sit in the expected passband. A gross tuning error moves tones outside the 18 probed frequencies and decoding fails.
  • Timing recovery is a local early-late gate, seeded by the 16 lead-in idles. There is no explicit preamble correlation; a decoder joining mid-transmission relies on the gate pulling in and on varicode self-synchronisation, and may lose the first character or two.
  • Two varicode codepoints collide (ENC[379] == ENC[381]), both non-printable and filtered out — noted for completeness (§5.3).
  • Missing family members. Only DOMX5/11/16/22 are in PARAMS; DOMX4 and DOMX8 (and MicroPath variants) are not implemented.

8. References

  1. C. Wassilieff ZL2AFP & M. Greenman ZL1BPU, DominoEX — the mode's design: 18-tone MFSK, Incremental Frequency Keying (IFK / IFK+), and the nibble varicode.
  2. M. Greenman ZL1BPU, "DominoEX — a new MFSK data mode," and the ZL1BPU MFSK / IFK technical notes — the rationale for differential tone keying and the +2 offset that forbids repeated and adjacent tones.
  3. Dave Freese W1HKJ et al., fldigisrc/dominoex/dominoex.cxx and src/dominoex/dominovar.cxx (GPL-3): the reference implementation and the exact varicode table this port transcribes.
  4. P. Martinez G3PLX, PSK31 varicode — the frequency-optimised variable-length code whose design philosophy the DominoEX nibble varicode follows.
  5. This repo: app/radio/domino.py (modulator + streaming decoder) and app/radio/_tables/domino_varicode.py (ENC / DEC tables); companion mode Thor adds convolutional FEC + interleave over the same IFK+ waveform.