THROB: pulsed dual-tone MFSK with a 45-symbol tone-pair alphabet
A very-slow keyboard mode that sends one character per symbol as a pulse-shaped pair of simultaneous tones drawn from a fixed 9-tone comb — the "throb."
Rafe project · app/radio/throb.py, app/radio/_tables/throb_tables.py · native port of fldigi throb.cxx (GPL-3)
Abstract
THROB is a narrowband, no-FEC amateur keyboard mode. It keys a comb of nine fixed audio tones and encodes each transmitted character as a raised-cosine pulse whose spectrum contains one or two of those tones simultaneously. There are exactly 45 distinct one-or-two-tone selections of nine tones (C(9,2) + 9 = 36 + 9 = 45), and THROB uses all of them: 43 map to printable characters, one is the idle/carrier symbol, and one is a reserved "shift" slot. Because a symbol is just "which one or two of the nine tones are lit for this pulse," the demodulator is memoryless and non-coherent: for each pulse it runs a nine-point DFT filter bank, picks the one or two dominant tones, and looks the resulting tone-pair up in a table. The Rafe implementation is a from-scratch NumPy port of fldigi's throb.cxx and ships two speeds — THROB2 (~1.95 baud, 8 Hz tone spacing, trapezoidal pulse) and THROB4 (~3.9 baud, 16 Hz spacing, Hann pulse) — with a streaming early/late-gate decoder. This document specifies the tone comb, the pulse shapes, the full symbol→character table, and the encode/decode algorithms exactly as implemented, so the mode can be rebuilt from this page alone.
1. Background
THROB (originally a Windows program by Lionel Sear, G3PPT; later absorbed into fldigi as throb.cxx) belongs to the family of MFSK keyboard modes, but with two unusual choices that give it its name and its character:
Dual-tone symbols. Most MFSK modes light exactly one tone per symbol. THROB lights one or two tones at once. With nine tones that yields
C(9,2) = 36two-tone symbols plus9single-tone symbols — 45 in all — which is enough for a compact uppercase alphabet without any shift state. The audible result of a slow train of overlapping tone pairs is a rhythmic "throbbing," hence the name.Pulse keying. Each symbol is a single amplitude-shaped pulse (a raised-cosine / trapezoidal envelope), not a continuous carrier switched between frequencies. The tones are summed and multiplied by the pulse envelope, so every symbol rises from and returns to (near) zero amplitude. The pulse both sets the occupied bandwidth and delimits symbols in time.
There is no forward error correction, no interleaving, and no CRC — a mis-decoded pulse is simply a wrong (or dropped) character. THROB's appeal is weak-signal readability at extremely low speed and narrow bandwidth, not throughput. The docstring of the port summarises the mode precisely (app/radio/throb.py):
THROB sends one character per symbol as a pair of simultaneous pulse-shaped tones chosen from a 9-tone comb (hence the "throb"). 45 characters map to 45 distinct one-or-two-tone combinations. No FEC; very slow (THROB2 ~1.95 baud, THROB4 ~3.9 baud).
The fldigi family also defines THROB1 (~1 baud). This port implements only THROB2 and THROB4 — the PARAMS table (app/radio/throb.py) has no throb1 entry.
2. Signal structure
2.1 The nine-tone comb
THROB places nine equally spaced tones symmetrically around a center frequency CENTER = 1000.0 Hz (app/radio/throb.py). The per-tone frequency offsets from center are held in two tables (app/radio/_tables/throb_tables.py):
FREQS_NAR = [-32,-24,-16,-8,0,8,16,24,32] # THROB2: 8 Hz spacing
FREQS_WID = [-64,-48,-32,-16,0,16,32,48,64] # THROB4: 16 Hz spacing
Tones are numbered 1…9 (one-based) throughout the tables; tone t has absolute frequency CENTER + FREQS_*[t-1]. Concretely, with CENTER = 1000:
| tone # | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | spacing | span (1↔︎9) |
|---|---|---|---|---|---|---|---|---|---|---|---|
| THROB2 (narrow) Hz | 968 | 976 | 984 | 992 | 1000 | 1008 | 1016 | 1024 | 1032 | 8 Hz | 64 Hz |
| THROB4 (wide) Hz | 936 | 952 | 968 | 984 | 1000 | 1016 | 1032 | 1048 | 1064 | 16 Hz | 128 Hz |
Tone 5 is the center tone (offset 0 → exactly CENTER). The idle symbol is a single tone at tone 5, i.e. a bare carrier at 1000 Hz.
CENTER is a parameter of both the modulator and the decoder (default 1000 Hz); there is no AFC, so transmitter and receiver must agree on the center to within well under one tone spacing.
Occupied bandwidth is the tone span plus the pulse's spectral skirts: ~72 Hz for THROB2 and ~144 Hz for THROB4 (the 64/128 Hz outer-tone span widened by roughly one tone spacing of raised-cosine skirt).
2.2 Symbol timing and baud
A symbol is one pulse of symlen samples. symlen is defined at a reference sample rate SR = 8000 (app/radio/throb.py) and scaled to whatever output / decode rate is in use (app/radio/throb.py):
PARAMS = {
"throb2": dict(symlen=4096, freqs=FREQS_NAR, pulse="semi"),
"throb4": dict(symlen=2048, freqs=FREQS_WID, pulse="full"),
}
Symbol duration is symlen / SR seconds, independent of the actual audio rate:
| mode | symlen @ 8 kHz |
duration | baud (symbols/s) | comb | pulse | ≈ WPM* |
|---|---|---|---|---|---|---|
| THROB2 | 4096 | 0.512000 s | 1.953125 | 8 Hz (FREQS_NAR) |
semi (trapezoid) |
~20 |
| THROB4 | 2048 | 0.256000 s | 3.906250 | 16 Hz (FREQS_WID) |
full (Hann) |
~40 |
* One character per symbol; WPM ≈ baud·60/6 with a 6-character word, the usual rough reckoning. Character rate equals the baud (idle pulses excepted).
To run at an audio rate sr, the symbol length in samples is int(symlen / SR * sr) (app/radio/throb.py); e.g. THROB2 at 48 kHz is int(4096/8000·48000) = 24576 samples/symbol, and the decoder's samples-per-symbol is the same ratio kept as a float, sps = symlen/SR·sr (app/radio/throb.py).
2.3 The 45-symbol alphabet
A symbol is an index 0…44. Two parallel 45-element tables (app/radio/_tables/throb_tables.py) define it:
TONEPAIRS[i]— the pair of (one-based) tone numbers lit for symboli. When both entries are equal the symbol is a single tone; when they differ it is a dual tone.CHARSET[i]— the ASCII code of the character symbolirepresents (0for the two non-printing slots).
The 45 tone pairs are precisely the 45 distinct unordered one-or-two-tone selections of the nine tones — verified: 9 single-tone symbols (all of (1,1)…(9,9)) and 36 dual-tone symbols (all of C(9,2)), no duplicates, covering the complete set. Two slots carry no character:
- Symbol 0 — idle.
CHARSET[0] = 0,TONEPAIRS[0] = [5,5]→ single center tone (1000 Hz). Transmitted as preamble/postamble/fill; produces no output. - Symbol 5 — "shift" (reserved, unused).
CHARSET[5] = 0,TONEPAIRS[5] = [4,6]. Never emitted and never decoded — both the encoder and decoder explicitly skip indices0and5(app/radio/throb.py). All 43 printable characters are directly reachable, so no shift state is needed.
The remaining 43 symbols map one-to-one to printable characters: the 26 letters A–Z, the 10 digits, and 7 punctuation/space characters (, . ' / ) ( and space). The mode is uppercase-only — the encoder folds input to uppercase (app/radio/throb.py) and silently drops any character not in the table.
2.4 The pulse shapes
Each symbol is windowed by a pulse envelope of length symlen (app/radio/throb.py). Two shapes exist, selected per mode:
full — raised cosine over the whole symbol (Hann window), used by THROB4 (app/radio/throb.py):
p[i] = 0.5·(1 − cos(2π·i / n)), i = 0 … n−1 # n = symlen
Zero at both ends, unity at the middle; the classic Hann taper. Spectrally compact, but the tone is at full amplitude for only an instant.
semi — trapezoid with raised-cosine ramps, used by THROB2 (app/radio/throb.py). Ramp length is n/5 at each end, flat unity in the middle three-fifths:
r = n // 5
rise (0 … r−1): p[i] = 0.5·(1 − cos(π·i / (n/5))) # 0 → 1
flat (r … 4r−1): p[i] = 1
fall (4·(n//5) … n−1): p[i] = 0.5·(1 + cos(π·(i−4r') / (n/5))) # 1 → 0
The flat top keeps each tone at full amplitude for ~60 % of the symbol, which improves the narrow-comb (8 Hz) tone separation at the cost of slightly wider skirts than a pure Hann. (The rise uses the first n//5 samples and the fall the last n − 4·(n//5) samples; for n = 4096, n//5 = 819, giving a 819-sample rise, a 2457-sample flat top, and an 820-sample fall.)
3. Encode / transmit
modulate(text, mode="throb2", sr=48000, center=CENTER, preamble=4) (app/radio/throb.py) returns a little-endian 16-bit PCM byte string. The default output rate is 48 kHz; the in-repo round-trip test drives it at 8 kHz.
Step 1 — set up the mode. Look up symlen, freqs, and pulse from PARAMS; scale symlen = int(symlen_ref / SR · sr); precompute the pulse envelope pulse = _pulse(kind, symlen) and the sample index vector t = arange(symlen) (app/radio/throb.py).
Step 2 — text → symbol indices. For each input character, uppercase it and find its symbol (_char_symbol, app/radio/throb.py): scan CHARSET for a matching ASCII code, ignoring slots 0 and 5. Characters not found are dropped. Prepend preamble idle symbols (default 4) and append 2 idle symbols as flush (app/radio/throb.py):
symbols = [0]·preamble + [ _char_symbol(c) for c in text if found ] + [0]·2
Step 3 — symbol → waveform. For symbol s, take its two one-based tones t1, t2 = TONEPAIRS[s], convert to zero-based comb indices (−1), and form the two absolute frequencies f = center + freqs[·]. The symbol waveform is the pulse-shaped sum of the two unit sinusoids, divided by two (app/radio/throb.py):
sym_wave(s) = pulse · ( sin(2π·f1·t/sr) + sin(2π·f2·t/sr) ) / 2
A consequence of the /2: a single-tone symbol (t1 == t2) has peak amplitude 1.0 (the two identical sinusoids add), while each tone of a dual-tone symbol sits at 0.5. This 2:1 amplitude relationship is exactly what the decoder's single-vs-dual test keys on (§4). Each symbol's sinusoids start at phase 0 — the waveform is not phase-continuous across symbol boundaries, but the pulse tapers to ~0 at the edges so the discontinuity is masked; the demod is non-coherent anyway.
Step 4 — concatenate and quantize. Concatenate all symbol waveforms, scale by 0.9, clip to [−1, 1], and quantize to signed 16-bit (app/radio/throb.py):
out = np.concatenate([sym_wave(s) for s in syms])
return np.round(np.clip(out * 0.9, -1, 1) * 32767).astype("<i2").tobytes()
The full on-air frame is therefore:
[ 4 × idle ] [ char₁ ][ char₂ ] … [ charₖ ] [ 2 × idle ]
preamble one pulse per character flush
with each bracket one symlen-sample pulse. There is no unique word, no header, and no explicit sync field — the idle preamble simply gives the receiver's timing loop something to lock onto.
4. Decode / receive
ThrobDecoder(on_text, mode="throb2", sr=12000, center=CENTER) (app/radio/throb.py) is a streaming demodulator: feed it PCM in arbitrary chunks and it fires on_text(char) per decoded character. Internally it maintains a growing float sample buffer, an odd-byte carry, and a fractional symbol-start cursor _pos.
Setup (app/radio/throb.py). Precompute the nine absolute tone frequencies self.freqs = center + FREQS_*; set sps = symlen/SR·sr (float samples per symbol). _pos starts at 0.0 — decoding begins at the buffer origin; there is no separate acquisition search.
Step 1 — buffer input (app/radio/throb.py). If given bytes, prepend the carried odd byte, take an even byte count, decode as little-endian int16, and scale by 1/32768. Append the samples to self._buf.
Step 2 — tone filter bank (_mags_at, app/radio/throb.py). For a candidate symbol start a = round(start), take the segment seg = buf[a : a+sps] (require a full int(sps) samples). For each of the nine tone frequencies f compute a single-bin DFT and its magnitude — a nine-point, non-coherent Goertzel-style filter bank over a rectangular window (the transmit pulse shape is not reapplied):
m_f = | Σₙ seg[n] · e^(−j2π·f·n / sr) | , n = 0 … len(seg)−1
returning a 9-vector of tone magnitudes. (Frequency resolution: the rectangular window's null-to-null main lobe is ≈ 2·baud ≈ 3.9 Hz for THROB2 and 7.8 Hz for THROB4, so the 8/16 Hz-spaced tones are resolved with a few bins to spare — but the narrow THROB2 comb leaves little margin for center-frequency error.)
Step 3 — symbol-timing (early/late gate) (app/radio/throb.py). Define a search step d = sps/8. Around the current cursor, compute a quality metric at three offsets — _pos−d (early), _pos (center), _pos+d (late) — where (app/radio/throb.py):
quality(mags) = (largest + 2nd-largest) / (mean(mags) + 1e-10)
i.e. how strongly the top two tones dominate the average. If late beats center and is ≥ early, nudge the cursor forward by 0.4·d (= 0.05 symbol); else if early beats both center and late, nudge back by 0.4·d; then recompute the magnitudes at the adjusted cursor. This tracks the sampling instant toward the point that best concentrates energy in the intended tone pair — at most ±0.05 symbol of correction per symbol.
Step 4 — pick 1 or 2 tones → character (_process_symbol, app/radio/throb.py). Sort the nine magnitudes descending; let a and b be the indices of the strongest and second-strongest tones. Decide single vs dual by the 0.4 amplitude ratio (mirroring the encoder's 2:1 amplitude design):
if mags[b] < 0.4 · mags[a]: b = a # second tone not really present → single
want = tuple(sorted((a+1, b+1))) # back to one-based tone numbers
For a genuine dual symbol both tones were transmitted at 0.5, so mags[b]/mags[a] ≈ 1 (well above 0.4); for a single symbol the runner-up is only leakage/noise (well below 0.4). Then scan TONEPAIRS for the entry equal to want, skipping slots 0 and 5, and emit chr(CHARSET[i]) if it is printable (32 ≤ code < 127). Idle ((5,5)) and shift ((4,6)) therefore decode to nothing.
Step 5 — advance and compact (app/radio/throb.py). Decode the symbol, advance _pos by exactly one sps, and loop while a whole symbol (plus the d timing margin) remains buffered. Finally drop consumed samples from the front of the buffer (keeping one symbol of margin) and rebase _pos.
5. Constants & tables
All constants are transcribed verbatim from the source; do not paraphrase these when reimplementing.
5.1 Scalar constants (app/radio/throb.py)
| name | value | meaning |
|---|---|---|
SR |
8000 |
reference rate at which symlen is defined |
CENTER |
1000.0 |
comb center frequency (Hz) |
IDLE |
0 |
idle symbol index (preamble/flush) |
throb2 |
symlen=4096, freqs=FREQS_NAR, pulse="semi" |
~1.95 baud, 8 Hz comb |
throb4 |
symlen=2048, freqs=FREQS_WID, pulse="full" |
~3.9 baud, 16 Hz comb |
| preamble | 4 idle symbols |
default TX lead-in |
| flush | 2 idle symbols |
TX tail |
| TX scale | × 0.9, clip ±1, × 32767 |
int16 quantization |
| single/dual threshold | mags[b] < 0.4·mags[a] |
second-tone presence test |
| timing step | d = sps/8, nudge 0.4·d |
early/late gate |
| printable gate | 32 ≤ code < 127 |
emit filter |
5.2 Tone-offset tables (app/radio/_tables/throb_tables.py)
FREQS_NAR = [-32,-24,-16,-8,0,8,16,24,32] # THROB2, 8 Hz spacing
FREQS_WID = [-64,-48,-32,-16,0,16,32,48,64] # THROB4, 16 Hz spacing
5.3 The symbol tables (app/radio/_tables/throb_tables.py)
Quoted exactly:
CHARSET = [0, 65, 66, 67, 68, 0, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88, 89, 90, 49, 50, 51, 52, 53, 54, 55, 56, 57,
48, 44, 46, 39, 47, 41, 40, 69, 32]
TONEPAIRS = [[5,5],[4,5],[1,2],[1,3],[1,4],[4,6],[1,5],[1,6],[1,7],[3,7],[1,8],
[2,3],[2,4],[2,8],[2,5],[5,6],[2,6],[2,9],[3,4],[3,5],[1,9],[3,6],
[8,9],[3,8],[3,3],[2,2],[1,1],[3,9],[4,7],[4,8],[4,9],[5,7],[5,8],
[5,9],[6,7],[6,8],[6,9],[7,8],[7,9],[8,8],[7,7],[6,6],[4,4],[9,9],[2,7]]
5.4 Full symbol → character → tone-pair map
Derived from the two tables above (— = non-printing; SP = space). "Kind" marks single/dual tone; idle and shift are the two reserved slots.
| sym | char | ASCII | tones | kind | sym | char | ASCII | tones | kind | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | — | 0 | (5,5) | idle | 23 | W | 87 | (3,8) | dual | |
| 1 | A | 65 | (4,5) | dual | 24 | X | 88 | (3,3) | single | |
| 2 | B | 66 | (1,2) | dual | 25 | Y | 89 | (2,2) | single | |
| 3 | C | 67 | (1,3) | dual | 26 | Z | 90 | (1,1) | single | |
| 4 | D | 68 | (1,4) | dual | 27 | 1 | 49 | (3,9) | dual | |
| 5 | — | 0 | (4,6) | shift | 28 | 2 | 50 | (4,7) | dual | |
| 6 | F | 70 | (1,5) | dual | 29 | 3 | 51 | (4,8) | dual | |
| 7 | G | 71 | (1,6) | dual | 30 | 4 | 52 | (4,9) | dual | |
| 8 | H | 72 | (1,7) | dual | 31 | 5 | 53 | (5,7) | dual | |
| 9 | I | 73 | (3,7) | dual | 32 | 6 | 54 | (5,8) | dual | |
| 10 | J | 74 | (1,8) | dual | 33 | 7 | 55 | (5,9) | dual | |
| 11 | K | 75 | (2,3) | dual | 34 | 8 | 56 | (6,7) | dual | |
| 12 | L | 76 | (2,4) | dual | 35 | 9 | 57 | (6,8) | dual | |
| 13 | M | 77 | (2,8) | dual | 36 | 0 | 48 | (6,9) | dual | |
| 14 | N | 78 | (2,5) | dual | 37 | , | 44 | (7,8) | dual | |
| 15 | O | 79 | (5,6) | dual | 38 | . | 46 | (7,9) | dual | |
| 16 | P | 80 | (2,6) | dual | 39 | ' | 39 | (8,8) | single | |
| 17 | Q | 81 | (2,9) | dual | 40 | / | 47 | (7,7) | single | |
| 18 | R | 82 | (3,4) | dual | 41 | ) | 41 | (6,6) | single | |
| 19 | S | 83 | (3,5) | dual | 42 | ( | 40 | (4,4) | single | |
| 20 | T | 84 | (1,9) | dual | 43 | E | 69 | (9,9) | single | |
| 21 | U | 85 | (3,6) | dual | 44 | SP | 32 | (2,7) | dual | |
| 22 | V | 86 | (8,9) | dual |
Notes: the nine single-tone symbols are Z(1,1) Y(2,2) X(3,3) ((4,4) idle(5,5) )(6,6) /(7,7) '(8,8) E(9,9) — one per comb tone. Note that the very common letter E is the single tone 9 and the idle is the single center tone 5. The 43rd printable is space (2,7).
6. Interoperability & validation
fldigi is the oracle. The tone comb, symbol map, pulse shapes, and baud are transcribed directly from fldigi's throb.cxx (GPL-3; see the port header, app/radio/throb.py, and the tables header, app/radio/_tables/throb_tables.py). Because the mode is non-coherent — a symbol is just "which one or two of nine known tones are lit" — cross-decoding with fldigi depends only on three things being identical: the tone frequencies (CENTER + FREQS_*), the symbol timing (symlen/SR seconds per pulse), and the TONEPAIRS/CHARSET map. All three match fldigi here. The exact pulse shape (full vs semi) affects only spectral occupancy and inter-tone leakage, not the tone-pair identity a magnitude demod recovers, so minor pulse differences do not break interop.
Practical caveats when pairing with fldigi:
- Center alignment. This port uses a fixed
CENTER(default 1000 Hz) and has no AFC. fldigi centers on the waterfall-selected frequency and tracks drift; the two must be aligned to well under a tone spacing (8 Hz for THROB2). - Squelch. There is no squelch here — off-signal noise decodes to garbage characters. fldigi's THROB squelch suppresses that.
- THROB1 and any figures/letters shift behaviour of the original program are not implemented (the shift slot is inert).
In-repo validation. test_jt.py (test_throb_roundtrip) round-trips "CQ DE M0SUP" through modulate → ThrobDecoder for both throb2 and throb4 at 8 kHz and asserts exact recovery. Re-running the round-trip for "CQ DE M0SUP 73" at 8 / 12 / 48 kHz recovers the text intact in every case (single-tone and dual-tone symbols and space all exercised). A captured reference recording is kept at samples/THROB.audio.mp3. Within the app, THROB is registered as a native fldigi-family mode (app/radio/fldigi_native.py, defaulting to throb4) and exposed through the built-in codec backend (app/radio/codecs/builtin_fldigi.py, app/radio/fldigi_bridge.py).
7. Limitations
- No error protection. No FEC, interleaving, or CRC. A single mis-picked tone is a silently wrong character; a dropped tone can turn a dual symbol into the wrong single symbol (and vice versa).
- Tiny alphabet, uppercase only. 43 printable characters — A–Z, 0–9, and
, . ' / ) (and space. Lowercase is folded to uppercase; everything else (including?,-,+,=, CR/LF) is silently dropped on transmit. - Very low throughput. ~1.95 char/s (THROB2) / ~3.9 char/s (THROB4).
- Narrow-comb fragility. THROB2's 8 Hz spacing versus the demod's ~3.9 Hz bin resolution leaves little margin; with no AFC, modest center-frequency error or drift blurs adjacent tones. THROB4's 16 Hz comb is more forgiving.
- Weak synchronisation. The only timing recovery is a ±0.05-symbol early/late gate seeded from
_pos = 0; there is no correlation-based acquisition or unique word. The 4-symbol idle preamble is the entire lead-in, and the loop can mis-track under deep fading. - Fixed single/dual threshold. The 0.4 magnitude ratio is a hard threshold; selective fading that suppresses one tone of a dual pair (or a strong co-channel tone) can flip the single/dual decision and mis-map the character.
- Amplitude imbalance. Single-tone symbols are transmitted at twice the tone amplitude of dual-tone symbols (the
/2after summing), so mean power varies with the text and the peak-to-average behaviour is symbol-dependent. - THROB1 not implemented; only
throb2andthrob4exist inPARAMS.
8. References
- fldigi
throb.cxx(GPL-3) — the reference implementation this port transcribes; the source ofCHARSET,TONEPAIRS,FREQS_NAR/WID, the pulse shapes, and the baud constants. Cited inapp/radio/throb.pyandapp/radio/_tables/throb_tables.py. - THROB / THROBX, Lionel Sear, G3PPT — the original amateur THROB program the fldigi module derives from (background/attribution).
- In-repo implementation —
app/radio/throb.py(modulator +ThrobDecoder),app/radio/_tables/throb_tables.py(tables),test_jt.py(test_throb_roundtrip),samples/THROB.audio.mp3(reference capture). - DSP background — the Goertzel algorithm / single-bin DFT tone detector; the Hann (raised-cosine) window; early/late-gate symbol-timing recovery. Standard references: Oppenheim & Schafer, Discrete-Time Signal Processing; Proakis & Manolakis, Digital Signal Processing.
- Companion specs — the sibling native-mode documents indexed in
README.md;../rvqvoice.mdis the depth/style template.