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):
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):
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):
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:
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):
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):
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. ```