Thor — native IFK+ MFSK with convolutional FEC and a secondary channel
A weak-signal keyboard mode that wraps DominoEX-style incremental frequency keying (IFK+) around MFSK's forward error correction, adds soft-decision magnitude metrics with erasure puncturing, early-late symbol timing recovery, carrier-wave-interference rejection, and a second logical text channel for station identification.
Rafe project · clean port of fldigi thor.cxx / thorvaricode.cxx (GPL-3)
Source files (this repo): app/radio/thor.py (the modem), app/radio/mfsk.py (shared convolutional encoder, diagonal interleaver, soft Viterbi), app/radio/_tables/thor_varicode.py (secondary varicode, §6.5), app/radio/_tables/mfsk_varicode.py (primary varicode, §6.6). Every constant and table those files hold is reproduced in this document — nothing here needs the source to be on hand.
Abstract
Thor is the most intricate of the fldigi keyboard modes and the most intricate modem in this repository. It transmits text as a stream of 18 tones using IFK+ (Incremental Frequency Keying) — the same differential tone-step keying as DominoEX — but where DominoEX sends a raw variable-length nibble code, Thor first protects the data with the classic MFSK channel coding: the IZ8BLY MFSK varicode, a rate-½ constraint-length-7 convolutional code, and a 4×10 diagonal interleaver. The receiver is a soft-decision chain end to end: a bank of single-bin DFT correlators measures per-tone magnitudes; an early-late gate tracks symbol timing by maximising tone concentration; the differential IFK+ decode turns each tone step into a 4-bit nibble whose reliability is graded by how far its tone stood above the noise; out-of-range steps are declared erasures and punctured (with the blame shared onto the adjacent symbol); the soft bits are de-interleaved and run through a soft Viterbi decoder with a 45-symbol traceback; and the recovered bitstream is framed by the varicode's self-delimiting structure. A per-tone persistence counter rejects CWI (continuous-wave interference / birdies): because a genuine IFK+ data tone must change every symbol, any bin that stays strong for more than eight consecutive symbols is masked out as an interferer. Beyond the primary text channel Thor carries a secondary channel — an extended varicode set numerically disjoint from the primary one (all codes ≥ 0xB80) — normally used to loop a station's callsign/ID underneath the conversation. This document specifies every constant, table, and step needed to reimplement Thor from scratch and remain byte-identical to fldigi on the air.
1. Background
1.1 Where Thor sits
Thor is one branch of a small family that all share the same 18-tone IFK+ front end:
- DominoEX (
app/radio/domino.py) — IFK+ 18-tone MFSK with a variable-length nibble varicode and no FEC. This is Thor's tone layer stripped of channel coding. - MFSK16/32 (
app/radio/mfsk.py) — the same MFSK varicode, rate-½ K=7 convolutional code, and 4×10 diagonal interleaver Thor uses, but with Gray-coded absolute tone keying instead of IFK+. - Thor (
app/radio/thor.py) — DominoEX's IFK+ tone keying carrying MFSK's FEC-protected, interleaved varicode. The module docstring states it plainly: "THOR is DominoEX's 18-tone IFK+ keying wrapped around MFSK's forward error correction: the primary text channel reuses the MFSK varicode, a K=7 r=1/2 convolutional code (0x6d/0x4f) and the 4x10 diagonal interleaver -- the only differences from MFSK are the IFK+ tone mapping (no Gray code) and a Viterbi traceback of 45" (app/radio/thor.py).
Thor therefore reuses three components verbatim from the MFSK modem — imported at app/radio/thor.py:
from .mfsk import _ConvEnc, _Interleaver, _Viterbi
and reuses the MFSK varicode for its primary channel (app/radio/thor.py), adding only the IFK+ tone mapping, the differential soft-decision decode, the CWI rejection, and the extended secondary varicode.
1.2 Why IFK+ over MFSK
Absolute MFSK keying maps each nibble to a fixed tone; a slow carrier drift or a tuning error walks every symbol off its bin. IFK+ instead encodes the nibble as the step from the previous tone to the current one, so only the difference between adjacent tones carries data. This is inherently robust to slow frequency drift (a common HF condition) and needs no absolute frequency lock. The "+" in IFK+ is the guaranteed minimum step of 2 tones: no two adjacent symbols ever land on the same or an adjacent bin, which both aids differential detection and keeps the spectral occupancy busy so a static demodulator cannot mistake a steady birdie for data (the basis of the CWI rejection in §5.6).
1.3 Why the extra FEC layer
DominoEX has no error correction; a single mis-detected tone corrupts a character. Thor spends roughly 2× the bits (rate-½ code) to make weak-signal copy reliable: the convolutional code plus interleaver turn scattered fades and single-tone errors into recoverable events. The receiver keeps the decoder soft throughout — every tone contributes a graded reliability rather than a hard 0/1 — which is worth several dB at threshold and is the reason erasure puncturing (§5.5) matters: a tone that violates the IFK+ step rule is not a coin-flip, it is a known-bad symbol the decoder should ignore rather than trust.
2. Signal structure
2.1 Tones and geometry
Thor always uses 18 tones (NUMTONES = 18, app/radio/thor.py), the same as DominoEX. The tone spacing and baud rate depend on the sub-mode's symbol length and reference sample rate:
tonespacing = samplerate * doublespaced / symlen (app/radio/thor.py)
bandwidth = NUMTONES * tonespacing (app/radio/thor.py)
baud = samplerate / symlen (symbols per second)
The tone frequencies are placed half a spacing above the low edge of the band, which is centred on CENTER = 1500.0 Hz (app/radio/thor.py):
base = center - bandwidth / 2
freq[t] = (t + 0.5) * tonespacing + base for t = 0 … 17 (app/radio/thor.py)
2.2 Implemented sub-modes
The PARAMS table (app/radio/thor.py) defines exactly three variants in this port. Every one uses doublespaced = 1:
| mode | symlen |
samplerate |
doublespaced |
tone spacing | bandwidth | baud (symbols/s) |
|---|---|---|---|---|---|---|
| thor22 | 512 | 11025 Hz | 1 | 21.5332 Hz | 387.60 Hz | 21.5332 |
| thor16 | 512 | 8000 Hz | 1 | 15.6250 Hz | 281.25 Hz | 15.6250 |
| thor11 | 1024 | 11025 Hz | 1 | 10.7666 Hz | 193.80 Hz | 10.7666 |
thor22 is the default (ThorDecoder(mode="thor22"), app/radio/thor.py; modulate(..., mode="thor22"), app/radio/thor.py) and the mode the app registers Thor as (app/radio/fldigi_native.py maps "thor" → (thor, "thor22"); builtin_fldigi.py labels it "THOR22").
The wider family. fldigi's full Thor family spans THOR4 / THOR5 / THOR8 / THOR11 / THOR16 / THOR22 / THOR25x25 / … built from the same structure — 18 tones, the same FEC and interleaver — differing only in
symlen,samplerateanddoublespaced. The slow variants (e.g. THOR4/5) usedoublespaced = 2(double tone spacing for robustness, as the sibling DominoEX code does fordomx5;app/radio/domino.py). This port wires only the three variants above; any other member of the family is obtained purely by adding aPARAMSrow — no algorithm change. The formulas in §2.1 are exact and sufficient to derive the tone plan of any variant, so the numbers here are computed, never guessed.
2.3 The reference rate vs. the audio rate
symlen and samplerate are a reference pair that pin down the symbol length in cycles and hence the tone spacing in Hz; they are not the actual audio sample rate. The real audio may be at any rate — modulate defaults to sr = 48000 (app/radio/thor.py) and ThorDecoder to sr = 12000 (app/radio/thor.py). Samples per symbol scale by the ratio:
sps = symlen / samplerate * sr (app/radio/thor.py)
and all tone frequencies are computed in absolute Hz (§2.1) so the modem is sample-rate agnostic. For thor22 at 48 kHz, sps = 512/11025 × 48000 ≈ 2229.9 samples/symbol.
2.4 IFK+ tone mapping and the valid raw-diff ranges
Each transmitted symbol is a 4-bit interleaved nibble sym ∈ [0,15]. IFK+ maps it to a tone step from the previous tone (app/radio/thor.py):
def sendsym(sym):
st["prev"] = (st["prev"] + 2 + sym) % NUMTONES # step = 2 + sym, mod 18
tones.append(st["prev"])
So the forward tone step is 2 + sym, which for sym ∈ [0,15] gives a step in [2, 17] — this is the only range of steps Thor ever transmits. The "+2" is the IFK+ minimum-step guarantee.
On receive the step is recovered differentially (app/radio/thor.py):
raw = int(np.floor((tone - self._prev) / self.doublespaced + 0.5))
Because tone and prev are integer bin indices in [0,17], the raw difference tone − prev lies in [-17, +17]. A step of 2…17 appears either directly as a positive raw ∈ [2,17] (no wrap) or as its mod-18 complement raw ∈ [-16,-1] (when the tone wrapped past 17 back through 0). The two together are the valid IFK+ raw-diff ranges (app/radio/thor.py):
valid : raw ∈ [2, 17] ∪ [-16, -1]
invalid : raw < -16 or raw > 17 or raw == 0 or raw == 1
0 and 1 are steps below the IFK+ minimum of 2 and can never be legitimately transmitted; anything outside [-16,17] is impossible under mod-18 arithmetic. Any of these signals a reception error and triggers erasure puncturing (§5.5). The nibble is recovered by inverting the map (app/radio/thor.py):
c = (raw - 2) % NUMTONES # undoes step = 2 + sym; c ∈ [0,15]
which maps both raw ∈ [2,17] and the wrapped raw ∈ [-16,-1] back to sym ∈ [0,15] (e.g. raw = -16 → (−18) mod 18 = 0; raw = -1 → (−3) mod 18 = 15).
2.5 Symbol content and rates
One IFK+ symbol carries one 4-bit interleaved nibble = 4 coded bits = 2 message (varicode) bits at the rate-½ code. Thus:
| mode | symbol rate | coded bit rate | message bit rate |
|---|---|---|---|
| thor22 | 21.53 sym/s | 86.1 bit/s | 43.1 bit/s |
| thor16 | 15.63 sym/s | 62.5 bit/s | 31.3 bit/s |
| thor11 | 10.77 sym/s | 43.1 bit/s | 21.5 bit/s |
The decode is differential, so as the module docstring notes, "the soft symbols are released one tone late" (app/radio/thor.py): the nibble derived from the step into the current symbol cannot be emitted until the next symbol confirms the receiver still has a valid prior tone (see §5.4).
3. Transmit chain (encode), step by step
modulate(text, mode, sr, center, secondary) — app/radio/thor.py.
3.1 Per-bit pipeline
The three-stage coder is created once per transmission (app/radio/thor.py):
enc = _ConvEnc() # K=7 rate-1/2 conv encoder
inlv = _Interleaver(4, 10, True) # 4x10 diagonal interleaver, forward
st = {"prev": 0, "bs": 0, "sh": 0}
Each message bit flows through sendbit (app/radio/thor.py):
def sendbit(bit):
data = enc.encode(bit) # 2 coded bits
for i in range(2):
st["sh"] = (st["sh"] << 1) | ((data >> i) & 1)
st["bs"] += 1
if st["bs"] == 4: # 4 coded bits accumulated -> one nibble
sendsym(inlv.bits(st["sh"] & 15))
st["bs"] = 0
st["sh"] = 0
- Convolutional encode.
enc.encode(bit)returns 2 coded bits (§6.1). - Accumulate a nibble. Coded bits are shifted LSB-first into
sh; every 4th coded bit completes a nibble. - Interleave. The 4-bit nibble
sh & 15is passed through the forward diagonal interleaverinlv.bits(...), which returns another 4-bit value (§6.2). - IFK+ key.
sendsymsteps the tone by2 + nibblemod 18 (§2.4) and appends the absolute tone index to thetoneslist.
3.2 Characters → varicode
Primary text is emitted MSB-first through the MFSK varicode (app/radio/thor.py):
def sendchar(c):
for ch in _VENC[c & 0xFF]: # _VENC = MFSK varicode ENC table
sendbit(ord(ch) - ord("0"))
_VENC[c] is the ASCII-0/1 bit string for byte c (e.g. space 0x20 → "100"; see _tables/mfsk_varicode.py). Each character's bits are self-delimiting (§5.4).
3.3 The secondary (ID) channel
Optional secondary text uses the extended Thor varicode and rides the same FEC/interleave/tone chain (app/radio/thor.py):
def sendsecondary(c):
if 0x20 <= c <= 0x7A: # space .. 'z'
for ch in _THOR_ENC[c - 0x20]:
sendbit(ord(ch) - ord("0"))
Only bytes 0x20…0x7A (space through lowercase z, 91 code points) are representable on the secondary channel — the exact span of the Thor varicode table (§6.4). Because those codes are numerically disjoint from every primary MFSK varicode value (all ≥ 0xB80, §6.3), the receiver can tell primary text from secondary text purely by the decoded code value — the two logical channels share one physical bitstream.
3.4 Frame layout
modulate emits, in order (app/radio/thor.py):
| segment | content | purpose |
|---|---|---|
| preamble | 16 × sendbit(0) |
idle bits for receiver sync |
| start | sendchar(13); sendchar(2); sendchar(13) |
CR, STX (0x02), CR |
| body | sendchar(ord(ch)) for each char of text |
the message |
| end | sendchar(13); sendchar(4); sendchar(13) |
CR, EOT (0x04), CR |
| secondary | sendsecondary(ord(ch)) for each char of secondary |
optional callsign/ID |
| flush | 200 × sendbit(0) |
drain interleaver + Viterbi traceback pipelines |
The 200 trailing zero bits (app/radio/thor.py) are essential: they push the last real data bits all the way through the interleaver's size²·depth = 160 symbol delay and the Viterbi's 45-symbol traceback so the tail of the message is actually decodable.
3.5 Synthesis (tones → PCM)
Each tone index becomes a phase-continuous cosine burst of sps samples (app/radio/thor.py):
for idx, tone in enumerate(tones):
f = (tone + 0.5) * tonespacing + base
a = int(round(idx * sps))
n = int(round((idx + 1) * sps)) - a
t = np.arange(n)
out[a:a + n] = np.cos(2 * np.pi * f * t / sr + phase)
phase = (phase + 2 * np.pi * f * n / sr) % (2 * np.pi) # carry phase across symbols
return np.round(np.clip(out * 0.5, -1, 1) * 32767).astype("<i2").tobytes()
Phase is carried across symbol boundaries so the waveform is continuous (no keying clicks). Output is scaled by 0.5, clipped to [-1,1], and quantised to little-endian 16-bit PCM.
3.6 TX block diagram
text ─▶ MFSK varicode ─▶ conv K=7 r=1/2 ─▶ pack 4 coded bits ─▶ 4x10 diagonal
(primary) (0x6d/0x4f) into a nibble interleaver
secondary text ─┘ │
▼
PCM ◀── phase-continuous ◀── freq[t]=(t+0.5)·Δf+base ◀── IFK+ key
cosine bursts tone += 2+nibble (mod 18)
4. The soft-decision front end (RX magnitudes and timing)
ThorDecoder.feed(pcm) — app/radio/thor.py.
4.1 Per-tone magnitude measurement
For each candidate symbol position the decoder measures the magnitude at all 18 tone frequencies with a bank of single-bin DFT correlators — one complex inner product per tone over one symbol window (app/radio/thor.py):
def _mags_at(self, start):
a = int(round(start))
seg = self._buf[a:a + int(self.sps)]
if len(seg) < int(self.sps):
return None
n = np.arange(len(seg))
return np.abs(np.array([np.sum(seg * np.exp(-2j * np.pi * f * n / self.sr))
for f in self.freqs]))
This is not a full FFT — it is 18 targeted Goertzel-style correlations at the exact tone frequencies (which need not fall on FFT bin centres because the audio rate is arbitrary). The result is an 18-element magnitude vector.
4.2 Symbol timing recovery (early-late gate)
Symbol timing is tracked by an early-late gate keyed on tone concentration — the ratio of the strongest bin to the mean bin, _quality (app/radio/thor.py):
@staticmethod
def _quality(mags):
return mags.max() / (mags.mean() + 1e-10)
The gate spacing is one eighth of a symbol, d = sps / 8.0 (app/radio/thor.py). For each symbol the decoder samples quality at the current position and at ±d, and nudges the fractional sample clock _pos toward the better gate by 0.4·d (app/radio/thor.py):
mc = self._mags_at(self._pos)
if self._pos - d >= 0:
qe = self._quality(self._mags_at(self._pos - d)) # early gate
ql = self._quality(self._mags_at(self._pos + d)) # late gate
qc = self._quality(mc) # on-time
if ql > qc and ql >= qe:
self._pos += d * 0.4 # slip later
mc = self._mags_at(self._pos)
elif qe > qc and qe > ql:
self._pos -= d * 0.4 # slip earlier
mc = self._mags_at(self._pos)
self._process_symbol(mc)
self._pos += sps
After processing, _pos advances by a full sps. Consumed samples are dropped from the ring buffer while keeping the sub-symbol remainder (app/radio/thor.py), so the decoder is truly streaming and phase-locked across feed calls.
5. The decode chain, step by step
_process_symbol (app/radio/thor.py) and _decode_pairs (app/radio/thor.py).
5.1 Tone detection with graded confidence
_detect_tone(mags) (app/radio/thor.py) returns the dominant tone index and a confidence currmag ∈ [0,127]:
avg = mags.mean() or 1e-10
strong = mags > 2.0 * avg
self._cwi = np.where(strong, self._cwi + 1, np.maximum(self._cwi - 1, 0))
cwi_mask = self._cwi > _CWI_MAXCOUNT # _CWI_MAXCOUNT = 8
search = np.where(cwi_mask, 0.0, mags) # blank out interfering carriers
symbol = int(np.argmax(search))
mx = search[symbol]
staticburst = (mx / avg) < 1.2 # no tone clearly above noise
currmag = 0 if staticburst else int(min(127, max(0, 127.0 * (mx - avg) / mx)))
return symbol, currmag
- CWI masking is applied before the argmax (see §5.6).
- Static-burst gate: if the peak is under 1.2× the average, no tone is clearly present and the confidence is forced to 0 (erasure).
- Graded confidence: otherwise
currmag = 127·(mx − avg)/mx, i.e. how far the peak stands above the noise floor, clamped to[0,127]. This drives the soft-bit reliability.
5.2 Differential IFK+ decode
The raw step and its validity are computed as in §2.4 (app/radio/thor.py). The recovered nibble is c = (raw − 2) mod 18.
5.3 Soft-bit generation (magnitude-weighted)
The nibble decoded from the step into the current symbol is not emitted immediately; the previous nibble (_lastc, held with its confidence _lastmag) is expanded into four soft bits, MSB first (app/radio/thor.py):
lastc, lastmag = self._lastc, self._lastmag
if lastmag <= 0:
one = zero = 128 # erasure -> neutral soft value
else:
one, zero = lastmag + 128, 127 - lastmag
softbits = [one if (lastc >> (3 - i)) & 1 else zero for i in range(4)]
Each nibble bit becomes a soft value in [0,255]: a 1 bit → lastmag + 128 (range 128…255), a 0 bit → 127 − lastmag (range 0…127). lastmag = 0 yields 128 for both — a neutral value carrying zero information, i.e. an erasure. The larger lastmag, the further the soft value swings from the neutral 128 and the more the Viterbi trusts it. 128 is exactly the metric-neutral point of the Viterbi's soft tables (§6.5).
5.4 Framing, pairing, and Viterbi
The four soft bits are pushed through the receive interleaver and then, one at a time, into _decode_pairs (app/radio/thor.py):
self.inlv.symbols(softbits) # de-interleave (in place)
for sb in softbits:
self._decode_pairs(sb)
self._lastc, self._lastmag = c, currmag # hold this nibble for next time
_decode_pairs (app/radio/thor.py) collects soft bits in pairs (the two outputs of the rate-½ encoder), runs each pair through the soft Viterbi to recover one message bit, and frames the varicode:
self._sympair[0] = self._sympair[1]
self._sympair[1] = symbol
self._symcounter = 0 if self._symcounter else 1
if self._symcounter: # wait for the second of the pair
return
c = self.vit.decode(self._sympair[0], self._sympair[1]) # -> 1 message bit
self._datashreg = (self._datashreg << 1) | (1 if c else 0)
if (self._datashreg & 7) == 1: # varicode delimiter: ...001
ch = _thorvaridec(self._datashreg >> 1)
... emit ...
self._datashreg = 1
Varicode framing. Message bits shift MSB-first into _datashreg. Every valid codeword begins with 1 and ends with the 00 inter-character gap, so the trigger (_datashreg & 7) == 1 (the low three bits equal 001) fires exactly when the 00 gap is followed by the next codeword's leading 1. The completed codeword is _datashreg >> 1; after decoding, _datashreg resets to 1 (keeping that leading one). This is identical to the MFSK/PSK varicode framing.
5.5 Erasure puncturing on out-of-range steps
When the raw IFK+ step is out of range (§2.4), the symbol is punctured and its neighbour is discounted (app/radio/thor.py):
if outofrange:
self._lastmag //= 2 # the previous symbol shares the blame
currmag = 0 # this symbol becomes an erasure
currmag = 0makes the current nibble a pure erasure — its four soft bits will all be the neutral128(§5.3), contributing zero to the Viterbi metrics.self._lastmag //= 2halves the confidence already banked for the previous nibble, because a differential decode implicates both endpoints of a bad step.
This is puncturing in the erasure sense: rather than change the code rate, the decoder inserts metric-neutral (zero-information) symbols wherever the IFK+ step rule is violated, so the soft Viterbi rides over the damaged region on the surviving good symbols instead of being misled by a hard guess. It is the single most important weak-signal feature of the mode after the FEC itself.
5.6 CWI (carrier-wave interference) rejection
A steady carrier, birdie, or tuning tone sits on one bin indefinitely. But IFK+ guarantees the data tone moves every symbol (minimum step 2), so any bin that stays strong across many consecutive symbols cannot be data — it is interference. _detect_tone tracks per-bin persistence in self._cwi (an 18-element counter, app/radio/thor.py):
strong = mags > 2.0 * avg
self._cwi = np.where(strong, self._cwi + 1, np.maximum(self._cwi - 1, 0))
cwi_mask = self._cwi > _CWI_MAXCOUNT # 8 consecutive-ish strong symbols
search = np.where(cwi_mask, 0.0, mags) # zero those bins before argmax
Each bin's counter increments while it is more than 2× the average and decays (floored at 0) otherwise. Once a bin's counter exceeds _CWI_MAXCOUNT = 8 (app/radio/thor.py) it is blanked to zero in the search vector, so the argmax ignores it. A real data tone visits any given bin only briefly and never trips the threshold; a persistent interferer is masked out within a few symbols and stays masked until it goes away.
5.7 Primary vs. secondary demux
The decoded codeword is dispatched by _thorvaridec (app/radio/thor.py):
def _thorvaridec(code):
if code < 0xB80:
try:
return _VDEC.index(code) # primary text: MFSK varicode -> byte
except ValueError:
return -1
for i, pat in enumerate(_THOR_DEC):
if code == pat:
return 0x100 | (0x20 + i) # secondary: extended varicode -> byte | 0x100
return -1
- Primary (
code < 0xB80): looked up in the MFSK varicode_VDEC; returns the character byte0…255. - Secondary (
code ≥ 0xB80): matched against the Thor varicode_THOR_DEC; returns0x100 | (0x20 + i)whereiis the table index — i.e. the ASCII byte with bit 8 set as a channel tag.
The caller routes on that tag bit (app/radio/thor.py):
if ch > 0 and (ch & 0x100):
if self.on_sec:
self.on_sec(chr(ch & 0xFF)) # secondary (callsign/ID) sink
elif 0 <= ch < 256 and (32 <= ch < 127 or ch in (10, 13)):
self.on_text(chr(ch)) # printable primary text or CR/LF
Only printable ASCII (0x20…0x7E) plus LF (10) / CR (13) are surfaced on the primary channel; control bytes such as the STX/EOT frame markers are decoded but silently dropped.
5.8 RX block diagram
PCM ─▶ 18-bin DFT ─▶ early-late ─▶ CWI mask ─▶ argmax ─▶ IFK+ diff ─▶ nibble c,
magnitudes timing (persist>8) + grade raw step conf mag
│ out-of-range?
▼ yes: erasure + halve prev
release PREVIOUS nibble ─▶ 4 soft bits ─▶ de-interleave ─▶ pair ─▶ soft Viterbi
(differential, 1 symbol late) (mag-weighted) (4x10) (2) (K=7, tb=45)
│
on_text / on_sec ◀── varicode framing (&7==1) ◀── primary/secondary demux ◀─ bits
6. Constants and tables
6.1 Convolutional code (shared with MFSK)
From app/radio/mfsk.py:
POLY1, POLY2 = 0x6D, 0x4F
_K = 7
class _ConvEnc:
def encode(self, bit):
self.reg = ((self.reg << 1) | (1 if bit else 0)) & 0x7F
return _parity(POLY1 & self.reg) | (_parity(POLY2 & self.reg) << 1)
- Constraint length K = 7, rate ½, 64 states.
- Generator polynomials
0x6Dand0x4F(fldigi's bit layout of the classic NASA/CCSDS K=7 code).POLY1yields output bit 0,POLY2output bit 1. - The 7-bit shift register is masked with
0x7F;_parity(x)is the XOR of the bits ofx(app/radio/mfsk.py).
6.2 Interleaver (shared with MFSK)
_Interleaver(4, 10, fwd) — app/radio/mfsk.py. A diagonal interleaver over a size × size × depth delay matrix with size = 4, depth = 10 (the "4×10" in the docstring). TX uses fwd = True (app/radio/thor.py), RX uses fwd = False (app/radio/thor.py); the two are matched inverse read-out diagonals:
psyms[i] = self.t[idx(k, i, s - i - 1)] if fwd else self.t[idx(k, i, i)]
bits(val) (TX) unpacks a 4-bit value into 4 one-bit symbols, interleaves, and repacks; symbols(list) (RX) interleaves a list of 4 soft bytes in place. The matrix imposes a size²·depth = 4·4·10 = 160-symbol end-to-end delay — the reason for the 200-bit TX flush (§3.4).
6.3 Soft Viterbi (shared, but traceback = 45)
_Viterbi(traceback=45) — app/radio/mfsk.py, instantiated at app/radio/thor.py. Thor's only structural change from MFSK is this traceback depth — MFSK's default is _K * 12 = 84 (app/radio/mfsk.py); Thor uses _TRACEBACK = 45 (app/radio/thor.py). Key internals:
- 64 states (
nstates = 1 << (_K − 1)),PATHMEM = 256-deep path memory. - Per-state output table
out[i] = parity(POLY1 & i) | (parity(POLY2 & i) << 1)fori ∈ [0, 128)(app/radio/mfsk.py). - Soft branch metrics (
app/radio/mfsk.py):self.met = [[128 - i for i in range(256)], [i - 128 for i in range(256)]]met[0][s] = 128 − sscores a soft symbolsagainst an expected0;met[1][s] = s − 128against an expected1. A soft value of 128 scores 0 for both — this is exactly why an erased soft bit (§5.3, §5.5) carries no information.decode(sym0, sym1)combines the two soft symbols into the four possible 2-bit branch metrics and runs one add-compare-select step, returning the bit attracebacksymbols back. - Metric renormalisation subtracts
1 << 29from every state metric whenevermetrics[0] > (1 << 29)(app/radio/mfsk.py) to prevent overflow in the streaming decoder.
6.4 Key Thor constants
| constant | value | location | meaning |
|---|---|---|---|
NUMTONES |
18 | thor.py |
tone count (all sub-modes) |
_TRACEBACK |
45 | thor.py |
Viterbi traceback (Thor's signature) |
_CWI_MAXCOUNT |
8 | thor.py |
persistence before a bin is masked as CWI |
| secondary threshold | 0xB80 (2944) |
thor.py |
code ≥ this ⇒ secondary channel |
| valid raw diff | [2,17] ∪ [-16,-1] |
thor.py |
legal IFK+ steps; else erasure |
| IFK+ step | 2 + nibble, mod 18 |
thor.py |
minimum-2 tone increment |
CENTER |
1500.0 Hz | thor.py |
audio band centre |
soft-bit strong >2·avg |
2.0 | thor.py |
CWI persistence increment gate |
static-burst gate <1.2 |
1.2 | thor.py |
no-clear-tone erasure gate |
| early-late gate | d = sps/8, nudge 0.4·d |
thor.py |
timing recovery |
6.5 The Thor secondary varicode (complete, all 91 entries)
The extended varicode is used only by the secondary channel — 91 codewords for ASCII bytes 0x20…0x7A (space through z), one entry per index i, mapping to byte 0x20 + i. The DEC value is simply the integer value of the ENC bit string (DEC[i] == int(ENC[i], 2)), so the two arrays below are redundant — both are printed verbatim so nothing needs to be looked up. Every value is ≥ 0xB80 (2944), which is what makes the channel numerically disjoint from the primary MFSK varicode: the primary table's maximum codeword is 0xB7C = 2940 (verified against the complete primary table in §6.6 — no primary codeword reaches 0xB80). The secondary table runs from 0xB80 (space, i = 0) to 0xFB0 (z, i = 90).
# Thor secondary varicode — index i => ASCII byte (0x20 + i); DEC[i] == int(ENC[i], 2)
ENC = ['101110000000', '101110100000', '101110101000', '101110101100', '101110110000', '101110110100', '101110111000', '101110111100', '101111000000', '101111010000', '101111010100', '101111011000', '101111011100', '101111100000', '101111101000', '101111101100', '101111110000', '101111110100', '101111111000', '101111111100', '110000000000', '110100000000', '110101000000', '110101010100', '110101011000', '110101011100', '110101100000', '110101101000', '110101101100', '110101110000', '110101110100', '110101111000', '110101111100', '110110000000', '110110100000', '110110101000', '110110101100', '110110110000', '110110110100', '110110111000', '110110111100', '110111000000', '110111010000', '110111010100', '110111011000', '110111011100', '110111100000', '110111101000', '110111101100', '110111110000', '110111110100', '110111111000', '110111111100', '111000000000', '111010000000', '111010100000', '111010101100', '111010110000', '111010110100', '111010111000', '111010111100', '111011000000', '111011010000', '111011010100', '111011011000', '111011011100', '111011100000', '111011101000', '111011101100', '111011110000', '111011110100', '111011111000', '111011111100', '111100000000', '111101000000', '111101010000', '111101010100', '111101011000', '111101011100', '111101100000', '111101101000', '111101101100', '111101110000', '111101110100', '111101111000', '111101111100', '111110000000', '111110100000', '111110101000', '111110101100', '111110110000']
DEC = [2944, 2976, 2984, 2988, 2992, 2996, 3000, 3004, 3008, 3024, 3028, 3032, 3036, 3040, 3048, 3052, 3056, 3060, 3064, 3068, 3072, 3328, 3392, 3412, 3416, 3420, 3424, 3432, 3436, 3440, 3444, 3448, 3452, 3456, 3488, 3496, 3500, 3504, 3508, 3512, 3516, 3520, 3536, 3540, 3544, 3548, 3552, 3560, 3564, 3568, 3572, 3576, 3580, 3584, 3712, 3744, 3756, 3760, 3764, 3768, 3772, 3776, 3792, 3796, 3800, 3804, 3808, 3816, 3820, 3824, 3828, 3832, 3836, 3840, 3904, 3920, 3924, 3928, 3932, 3936, 3944, 3948, 3952, 3956, 3960, 3964, 3968, 4000, 4008, 4012, 4016]
Orientation (first three and the last entry; the arrays above are the complete table):
index i |
byte | char | ENC (bits) |
DEC (hex) |
|---|---|---|---|---|
| 0 | 0x20 | (space) | 101110000000 |
2944 = 0xB80 |
| 1 | 0x21 | ! |
101110100000 |
2976 = 0xBA0 |
| 2 | 0x22 | " |
101110101000 |
2984 = 0xBA8 |
| … | … | … | … | … |
| 90 | 0x7A | z |
111110110000 |
4016 = 0xFB0 |
6.6 The primary MFSK varicode (complete, all 256 entries)
The primary text channel reuses the IZ8BLY MFSK varicode, indexed by the full byte value 0x00…0xFF (256 entries; DEC[i] == int(ENC[i], 2)). Both arrays are printed in full below so the primary channel needs no external table either. Common letters get the shortest codes — e.g. space 0x20 → "100" → 4; e (0x65, index 101) → "1000" → 8. Every value is ≤ 0xB7C (2940), i.e. strictly below the secondary channel's 0xB80 floor. Both tables obey the varicode delimiting rule of §5.4 (each codeword starts with 1, ends with the 00 inter-character gap).
# MFSK primary varicode — index i == ASCII byte value (0x00..0xFF); DEC[i] == int(ENC[i], 2)
ENC = ['11101011100', '11101100000', '11101101000', '11101101100', '11101110000', '11101110100', '11101111000', '11101111100', '10101000', '11110000000', '11110100000', '11110101000', '11110101100', '10101100', '11110110000', '11110110100', '11110111000', '11110111100', '11111000000', '11111010000', '11111010100', '11111011000', '11111011100', '11111100000', '11111101000', '11111101100', '11111110000', '11111110100', '11111111000', '11111111100', '100000000000', '101000000000', '100', '111000000', '111111100', '1011011000', '1010101000', '1010100000', '1000000000', '110111100', '111110100', '111110000', '1010110100', '111100000', '10100000', '111011000', '111010100', '111101000', '11100000', '11110000', '101000000', '101010100', '101110100', '101100000', '101101100', '110100000', '110000000', '110101100', '111101100', '111111000', '1011000000', '111011100', '1010111100', '111010000', '1010000000', '10111100', '100000000', '11010100', '11011100', '10111000', '11111000', '101010000', '101011000', '11000000', '110110100', '101111100', '11110100', '11101000', '11111100', '11010000', '11101100', '110110000', '11011000', '10110100', '10110000', '101011100', '110101000', '101101000', '101110000', '101111000', '110111000', '1011101000', '1011010000', '1011101100', '1011010100', '1010110000', '1010101100', '10100', '1100000', '111000', '110100', '1000', '1010000', '1011000', '110000', '11000', '10000000', '1110000', '101100', '1000000', '11100', '10000', '1010100', '1111000', '100000', '101000', '1100', '111100', '1101100', '1101000', '1110100', '1011100', '1111100', '1011011100', '1010111000', '1011100000', '1011110000', '101010000000', '101010100000', '101010101000', '101010101100', '101010110000', '101010110100', '101010111000', '101010111100', '101011000000', '101011010000', '101011010100', '101011011000', '101011011100', '101011100000', '101011101000', '101011101100', '101011110000', '101011110100', '101011111000', '101011111100', '101100000000', '101101000000', '101101010000', '101101010100', '101101011000', '101101011100', '101101100000', '101101101000', '101101101100', '101101110000', '101101110100', '101101111000', '101101111100', '1011110100', '1011111000', '1011111100', '1100000000', '1101000000', '1101010000', '1101010100', '1101011000', '1101011100', '1101100000', '1101101000', '1101101100', '1101110000', '1101110100', '1101111000', '1101111100', '1110000000', '1110100000', '1110101000', '1110101100', '1110110000', '1110110100', '1110111000', '1110111100', '1111000000', '1111010000', '1111010100', '1111011000', '1111011100', '1111100000', '1111101000', '1111101100', '1111110000', '1111110100', '1111111000', '1111111100', '10000000000', '10100000000', '10101000000', '10101010000', '10101010100', '10101011000', '10101011100', '10101100000', '10101101000', '10101101100', '10101110000', '10101110100', '10101111000', '10101111100', '10110000000', '10110100000', '10110101000', '10110101100', '10110110000', '10110110100', '10110111000', '10110111100', '10111000000', '10111010000', '10111010100', '10111011000', '10111011100', '10111100000', '10111101000', '10111101100', '10111110000', '10111110100', '10111111000', '10111111100', '11000000000', '11010000000', '11010100000', '11010101000', '11010101100', '11010110000', '11010110100', '11010111000', '11010111100', '11011000000', '11011010000', '11011010100', '11011011000', '11011011100', '11011100000', '11011101000', '11011101100', '11011110000', '11011110100', '11011111000', '11011111100', '11100000000', '11101000000', '11101010000', '11101010100', '11101011000']
DEC = [1884, 1888, 1896, 1900, 1904, 1908, 1912, 1916, 168, 1920, 1952, 1960, 1964, 172, 1968, 1972, 1976, 1980, 1984, 2000, 2004, 2008, 2012, 2016, 2024, 2028, 2032, 2036, 2040, 2044, 2048, 2560, 4, 448, 508, 728, 680, 672, 512, 444, 500, 496, 692, 480, 160, 472, 468, 488, 224, 240, 320, 340, 372, 352, 364, 416, 384, 428, 492, 504, 704, 476, 700, 464, 640, 188, 256, 212, 220, 184, 248, 336, 344, 192, 436, 380, 244, 232, 252, 208, 236, 432, 216, 180, 176, 348, 424, 360, 368, 376, 440, 744, 720, 748, 724, 688, 684, 20, 96, 56, 52, 8, 80, 88, 48, 24, 128, 112, 44, 64, 28, 16, 84, 120, 32, 40, 12, 60, 108, 104, 116, 92, 124, 732, 696, 736, 752, 2688, 2720, 2728, 2732, 2736, 2740, 2744, 2748, 2752, 2768, 2772, 2776, 2780, 2784, 2792, 2796, 2800, 2804, 2808, 2812, 2816, 2880, 2896, 2900, 2904, 2908, 2912, 2920, 2924, 2928, 2932, 2936, 2940, 756, 760, 764, 768, 832, 848, 852, 856, 860, 864, 872, 876, 880, 884, 888, 892, 896, 928, 936, 940, 944, 948, 952, 956, 960, 976, 980, 984, 988, 992, 1000, 1004, 1008, 1012, 1016, 1020, 1024, 1280, 1344, 1360, 1364, 1368, 1372, 1376, 1384, 1388, 1392, 1396, 1400, 1404, 1408, 1440, 1448, 1452, 1456, 1460, 1464, 1468, 1472, 1488, 1492, 1496, 1500, 1504, 1512, 1516, 1520, 1524, 1528, 1532, 1536, 1664, 1696, 1704, 1708, 1712, 1716, 1720, 1724, 1728, 1744, 1748, 1752, 1756, 1760, 1768, 1772, 1776, 1780, 1784, 1788, 1792, 1856, 1872, 1876, 1880]
Both varicode arrays above are transcribed verbatim from the modem's tables and are the complete reproduction — there is no larger table elsewhere to consult.
7. Interoperability and validation
Thor's oracle is fldigi itself. The port is a byte-level reimplementation of fldigi's thor.cxx / thorvaricode.cxx (GPL-3), so the correctness bar is that Rafe's transmitted tones are indistinguishable from fldigi's and that Rafe decodes fldigi's THOR transmissions.
- Encoder — byte-identical tones. Because Thor reuses fldigi's exact varicode tables, convolutional polynomials (
0x6d/0x4f), 4×10 diagonal interleaver, and IFK+ mapping (step = 2 + nibble, mod 18), the sequence of tone indices produced bymodulatematches fldigi's for the same text. Any divergence would show up as fldigi failing to decode Rafe's audio. - Decoder — cross-decode + loopback. The app registers the native modem in place of the fldigi bridge (
app/radio/fldigi_native.py;codecs/ builtin_fldigi.py), and the historical bridge path (fldigiover XML-RPC + PulseAudio,docs/native-digimodes.md) remains available as the reference. Validation is the same behavioural-oracle discipline used across the fldigi and WSJT-X families (docs/native-digimodes.md): drivemodulate → ThorDecoderin loopback for a self-consistency check, and decode fldigi-generated THOR22 audio to confirm on-air interop. - Subtle points that must be exact to interoperate:
- the IFK+ "+2" minimum step and the mod-18 wrap — get the valid raw-diff ranges wrong and every character mis-frames;
- the secondary threshold
0xB80— the split between primary and secondary varicode is a single comparison and both tables were transcribed to keep it clean; - fldigi's "backwards" Gray naming is irrelevant here — Thor uses no Gray code (unlike MFSK), so the tone is the raw IFK+ step;
- the 200-bit flush and 45-symbol traceback must both be honoured or the message tail is lost.
8. Limitations
- Three sub-modes only.
PARAMSwires thor22 / thor16 / thor11 (app/radio/thor.py). The rest of fldigi's family (THOR4/5/8/25x25/…) is a one-linePARAMSaddition away but is not present;doublespaced = 2variants are supported by the formulas but untested here. - Per-tone DFT cost.
_mags_atevaluates 18 explicit complex correlations per candidate window, and the early-late gate can call it up to four times per symbol (app/radio/thor.py). This is clear but not the fastest possible front end; a Goertzel or strided FFT would cut the arithmetic. - Pure-Python Viterbi and interleaver. The soft Viterbi ACS loop and the triple-nested interleaver shift (
app/radio/mfsk.py) run in interpreted Python per symbol. Fine at 10–22 baud, but not a template for a high-rate mode. - Timing recovery is a coarse gate. The early-late gate nudges by a fixed
0.4·dtoward the better of three probe points; it has no loop filter or acquisition mode, so very poor initial timing may take several symbols to pull in and it can dither around the optimum. - CWI heuristic is fixed. The
>2·avgstrength gate and the>8-symbol persistence threshold are constants; a very strong wideband burst or an unusually slow variant could interact with them, and there is no per-signal adaptation. - No confidence surfaced to the app. The soft magnitudes drive the decoder but the emitted characters are hard text; there is no per-character quality readout.
- Secondary channel is receive-and-forward text only (bytes
0x20…0x7A), with no structured ID framing beyond whatever the operator loops.
9. References
- fldigi source —
thor.cxx,thorvaricode.cxx,dominoex.cxx,mfsk.cxx,viterbi.cxx,interleave.cxx(GPL-3). The behavioural oracle and the origin of every table and constant transcribed here. - Murray Greenman ZL1BPU, DominoEX — the IFK+ (Incremental Frequency Keying) tone-step keying that Thor's physical layer is built on.
- Nino Porcino IZ8BLY / Tomi Manninen OH2BNS, gmfsk / MFSK16 — the MFSK varicode, K=7 r=½ convolutional code, and diagonal interleaver that Thor's primary channel reuses.
- In-repo companions:
app/radio/domino.py(IFK+ without FEC),app/radio/mfsk.py(the shared FEC/interleaver/Viterbi), and the sibling specsdocs/protocols/dominoex.mdanddocs/protocols/mfsk.md. docs/rvqvoice.md— the depth/structure/tone benchmark for this document;docs/native-digimodes.md— the family-wide native-modem status and validation methodology.