Patrick Lidstone
Self-hosted

RTTY: native Baudot/ITA2 radioteletype — 45.45-baud, 170 Hz-shift AFSK

A self-contained, dependency-free RTTY modem: ITA2 (Baudot) 5-bit code with asynchronous start/stop framing, carried as a 170 Hz-shift audio-FSK signal, demodulated by a pair of Goertzel tone filters with start-bit synchronisation and mid-bit sampling.

Rafe project · app/radio/rtty.py · reproduction spec (benchmark: ../rvqvoice.md)


Abstract

RTTY (radioteletype) is the oldest of the keyboard-to-keyboard digital modes and is still in daily amateur use. Rafe implements it natively — pure Python, no external modem, no fldigi sidecar — in a single ~170-line module, app/radio/rtty.py. The transmit path renders text into the ITA2 (Baudot) 5-bit alphabet with letters/figures shift management, frames each character asynchronously (one start bit, five data bits LSB-first, one-and-a-half stop bits), and modulates the bit stream as phase-continuous audio FSK at 45.45 baud with a 170 Hz shift (default AFSK tone pair 2125 Hz mark / 2295 Hz space). The receive path decimates the 48 kHz radio audio to 12 kHz, discriminates mark from space with two single-bin Goertzel power estimators, hunts for the mark→space transition that begins a start bit, samples the five data bits at their centres, validates the stop bit, and reassembles ITA2 with a running letters/figures (LTRS/FIGS) shift state. The whole thing is validated by an in-repo additive-white-Gaussian-noise loopback self-test that recovers standard amateur RTTY traffic down to ~18 dB SNR. The constants and figures set are the classic US-TTY layout used by fldigi's default RTTY modem, so the two interoperate over the air.

The document below is written so that a DSP-literate reader can reimplement the mode from scratch, byte-for-byte, without reading the source. Every constant is quoted from the implementation, and both full ITA2 code pages are tabulated below.


1. Background

1.1 Baudot / ITA2

Émile Baudot's 1870s telegraph code replaced Morse's variable-length symbols with a fixed 5-bit code, so every character occupies exactly the same time on the line — the property that makes synchronous mechanical printing possible. The version in universal teleprinter use by the mid-20th century is International Telegraph Alphabet No. 2 (ITA2), standardised by the CCITT. Five bits give only 2⁵ = 32 code points, far too few for letters + digits + punctuation, so ITA2 is a shifted code: two of the 32 code points are shift characters — LTRS (letters shift) and FIGS (figures shift) — that select which of two overlaid pages the remaining 30 code points are interpreted on. In the letters page a given code prints A; in the figures page the same code prints -. Two further code points (all-zeros NUL and, in practice, the shifts themselves) are non-printing. The receiver holds a one-bit shift state and flips it whenever a shift character arrives.

Because the shift state is stateful, a single corrupted shift character garbles every subsequent character until the next shift — the classic RTTY failure mode, visible as a burst of digits where letters were meant (or vice versa). The customary defence is to send LTRS liberally (the "diddle") and, historically, to Unshift-On-Space; see §5.5 and §8.

Rafe's figures page is the US-TTY assignment ($ ! & # " and friends), which is fldigi's default RTTY figures set — this is what makes the two interoperable.

1.2 Frequency-shift keying (FSK) and mark/space

RTTY carries the bit stream by frequency-shift keying: a binary 1 is sent as one tone (mark) and a binary 0 as another (space). The two tones differ by the shift; classic amateur RTTY uses a 170 Hz shift. On the radio this is done two ways that are electrically identical on air:

  • FSK — the transceiver's carrier is keyed between two RF frequencies 170 Hz apart.
  • AFSK — a fixed SSB carrier is modulated by two audio tones 170 Hz apart; the SSB mixer translates the audio shift into an identical RF shift.

Rafe is an AFSK implementation: it works entirely in the audio domain and the transceiver runs in SSB (upper sideband). The default audio tones are the traditional terminal-unit pair mark = 2125 Hz, space = 2295 Hz. Note that mark is the lower audio tone here; this is the standard ("normal", not reversed) amateur AFSK convention. Reverse polarity is obtained simply by swapping the two tone arguments (§6.3).

An asynchronous start/stop frame (inherited from the mechanical teleprinter) brackets each 5-bit character: the line idles at mark, a space start bit announces a character, the five data bits follow LSB-first, and one or more mark stop bits close it and return the line to idle. The classic mechanical timing is 1.5 stop bits (the "45.45-baud, 5-N-1.5" convention), which is what Rafe transmits.


2. Signal structure (exact)

All values below are the defaults in app/radio/rtty.py; each is a constructor argument and can be overridden.

Parameter Value Source
Symbol rate 45.45 baud BAUD = 45.45 — rtty.py
Bit period 1/45.45 s ≈ 22.002 ms derived
Shift 170 Hz SPACE − MARK = 2295 − 2125 — rtty.py
Mark tone (binary 1) 2125.0 Hz MARK = 2125.0 — rtty.py
Space tone (binary 0) 2295.0 Hz SPACE = 2295.0 — rtty.py
Alphabet ITA2 (Baudot), 5 data bits _LTRS / _FIGS — rtty.py
Bit order LSB first encode/modulate — rtty.py; decode — rtty.py
Framing 1 start (space) + 5 data + 1.5 stop (mark) rtty.py (TX)
Idle continuous mark rtty.py
Sideband USB (AFSK in SSB) §1.2
RX sample rate 12000 Hz (decimated from 48 kHz) RttyDecoder(..., sr=12000) — rtty.py; digimodes.py
TX sample rate 48000 Hz modulate(..., sr=48000) — manager.py

Character duration. One character = start + 5 data + 1.5 stop = 7.5 bit periods ≈ 165.0 ms at 45.45 baud. Throughput is therefore ≈ 45.45 / 7.5 ≈ 6.06 characters per second (the traditional "60 words per minute" figure).

2.1 The frame on the wire

     idle          char N                              char N+1
  ┌────────┐┌───┬───┬───┬───┬───┬───┬────────┐┌───┬─── ...
  │  MARK  ││ S │b0 │b1 │b2 │b3 │b4 │  MARK   ││ S │
  │ (1…∞)  ││sp │   data bits, LSB first    │ 1.5 stop ││sp │
  └────────┘└───┴───┴───┴───┴───┴───┴────────┘└───┴─── ...
   1 = mark   0    ← each cell = one 22.002 ms bit period →
   0 = space

S (start) is always space (0); the five data cells carry the ITA2 code with b0 = LSB; the stop is mark (1) held for 1.5 bit periods. Idle is mark held indefinitely.


3. The ITA2 code (both pages, exact)

The tables are stored as two 32-character Python strings indexed by the 5-bit code value (code = b4b3b2b1b0, i.e. the integer whose LSB is data bit 0):

_LTRS = "\x00E\nA SIU\rDRJNFCKTZLWHYPQOBG\x0eMXV\x0f"      # rtty.py
_FIGS = "\x003\n- \x0787\r$4',!:(5\")2#6019?&\x0e./;\x0f"   # rtty.py

Enumerating those strings character by character gives the complete code pages. \x0e and \x0f in the strings are placeholders only; the real shift handling keys off the numeric code values 0x1B (FIGS) and 0x1F (LTRS), as the in-source comment states (rtty.py).

3.1 Full ITA2 table

Code (dec) Code (hex) b4 b3 b2 b1 b0 LTRS (letters) FIGS (figures, US-TTY)
0 0x00 0 0 0 0 0 NUL NUL
1 0x01 0 0 0 0 1 E 3
2 0x02 0 0 0 1 0 LF (line feed) LF
3 0x03 0 0 0 1 1 A -
4 0x04 0 0 1 0 0 SPACE SPACE
5 0x05 0 0 1 0 1 S BEL (\x07)
6 0x06 0 0 1 1 0 I 8
7 0x07 0 0 1 1 1 U 7
8 0x08 0 1 0 0 0 CR (carriage return) CR
9 0x09 0 1 0 0 1 D $
10 0x0A 0 1 0 1 0 R 4
11 0x0B 0 1 0 1 1 J '
12 0x0C 0 1 1 0 0 N ,
13 0x0D 0 1 1 0 1 F !
14 0x0E 0 1 1 1 0 C :
15 0x0F 0 1 1 1 1 K (
16 0x10 1 0 0 0 0 T 5
17 0x11 1 0 0 0 1 Z "
18 0x12 1 0 0 1 0 L )
19 0x13 1 0 0 1 1 W 2
20 0x14 1 0 1 0 0 H #
21 0x15 1 0 1 0 1 Y 6
22 0x16 1 0 1 1 0 P 0
23 0x17 1 0 1 1 1 Q 1
24 0x18 1 1 0 0 0 O 9
25 0x19 1 1 0 0 1 B ?
26 0x1A 1 1 0 1 0 G &
27 0x1B 1 1 0 1 1 FIGS shift FIGS shift
28 0x1C 1 1 1 0 0 M .
29 0x1D 1 1 1 0 1 X /
30 0x1E 1 1 1 1 0 V ;
31 0x1F 1 1 1 1 1 LTRS shift LTRS shift

Notes on the table as implemented:

  • SPACE (code 4) and the non-printing controls NUL/CR/LF occupy the same code on both pages, so they need no shift change.
  • Shift codes 0x1B (FIGS) and 0x1F (LTRS) are the same on both pages; they change state and print nothing.
  • The FIGS page is the US-TTY variant: D=$, F=!, H=#, G=&, plus SBEL. (Other national ITA2 variants place WRU/'/etc. differently; this one matches fldigi's default.)
  • The letters page is AZ only — RTTY has no lower case; the transmitter upper-cases all text (§4).

4. Transmit (encode + modulate), step by step

TX is two functions: encode(text) turns a string into a list of ITA2 codes with shift management (rtty.py), and modulate(...) turns that code list into phase-continuous s16le audio (rtty.py).

4.1 Text → ITA2 codes (encode)

Two inverse lookup dicts are built once from the tables, excluding NUL and the two placeholder shift characters (rtty.py):

_LTR_CODE = {c: i for i, c in enumerate(_LTRS) if c not in "\x00\x0e\x0f"}
_FIG_CODE = {c: i for i, c in enumerate(_FIGS) if c not in "\x00\x0e\x0f"}

encode (rtty.py) then walks the upper-cased input, tracking a shift state figs ∈ {None, False, True} initialised to None:

  1. Space → emit code 4 directly (space is code 4 on both pages, so no shift is spent — rtty.py).
  2. A letter (character in _LTR_CODE and not a digit 09): if not already in letters shift (figs is not False), emit LTRS = 0x1F and set figs = False; then emit the letter's code (rtty.py).
  3. A figure/punctuation (character in _FIG_CODE): if not already in figures shift (figs is not True), emit FIGS = 0x1B and set figs = True; then emit the figure's code (rtty.py).
  4. Characters in neither page are silently dropped.

Because figs starts at None, the first printable character always forces a leading shift code, so the stream is self-synchronising on shift from its first character. Example:

encode("CQ DE M0ABC 599")
 → [31, 14, 23, 4, 9, 1, 4, 28, 27, 22, 31, 3, 25, 14, 4, 27, 16, 24, 24]
    LTRS C  Q  ␣  D  E  ␣  M FIGS 0 LTRS A  B  C  ␣ FIGS 5  9  9

Note there is no Unshift-On-Space in the encoder: after a FIGS→space→letter sequence the encoder still re-inserts an explicit LTRS (it never assumes the far end unshifts on space), which is the safe, interoperable choice.

4.2 ITA2 codes → audio (modulate)

modulate(text, sr=48000, baud=45.45, mark=2125, space=2295) (rtty.py):

  1. Compute samples-per-symbol sps = sr / baud (= 1056.106 at 48 kHz).

  2. Prepend a diddle preamble of three LTRS codes so the far end locks its shift page before real text: codes = [0x1F, 0x1F, 0x1F] + encode(text) (rtty.py).

  3. Build a bit plan — a list of (bit, n_samples) pairs (rtty.py):

    • mark idle head: (1, int(sps*30)) — 30 bit periods of mark (rtty.py).
    • for each code: a start bit (0, int(sps)); then five data bits ((code >> b) & 1, int(sps)) for b = 0..4 (LSB first); then a 1.5-length stop (1, int(sps*1.5)) (rtty.py).
    • mark idle tail: (1, int(sps*20)) — 20 bit periods of mark (rtty.py).
  4. Synthesize with a single running phase accumulator so the waveform is phase-continuous across every bit boundary (no clicks — rtty.py):

    phase = 0.0
    for bit, n in bitplan:
        f = mark if bit else space          # 1 → mark, 0 → space
        for _ in range(n):
            phase += 2 * pi * f / sr
            out += pack("<h", int(0.5 * sin(phase) * 32767))

    Output is s16le mono at amplitude 0.5 (≈ −6 dBFS). Return value is bytes.

The mark/space assignment is f = mark if bit else space, i.e. bit 1 → mark tone, bit 0 → space tone, exactly matching the demodulator's convention (1 = mark, rtty.py).

4.3 App TX path

RadioManager._native_tx (manager.py) drives it: input text is filtered to printable ASCII (32 ≤ ord < 127) and truncated to 500 characters (manager.py), pcm = rtty_mod(text, sr=48000) is rendered (manager.py), the modulator audio is routed (_route_mod(0x19, True)), PTT is asserted, the PCM is streamed to the radio (audio.stream_tx), then PTT/route are released.


5. Receive (demodulate + decode), step by step

RX is the RttyDecoder class (rtty.py). Construction defaults are sr=12000, baud=45.45, mark=2125, space=2295 (rtty.py). Derived state, set in reset() (rtty.py):

  • sps = sr / baud = 264.026 samples/bit at 12 kHz.
  • _figs = False — start in letters shift.
  • _win = max(8, int(sr / baud / 3)) = 88 samples — the tone-measurement window, one-third of a bit period (rtty.py).

5.1 Audio in and decimation

The app feeds 48 kHz RX audio; DigiModes.feed decimates it to 12 kHz with a 4-tap boxcar average (_decimate_12k, digimodes.py — sum four consecutive samples, divide by 4) and hands the result to RttyDecoder.feed(pcm12k) (digimodes.py). feed (rtty.py) buffers any odd trailing byte in _carry, converts each little-endian s16 sample to float via / 32768.0, appends to self._samples, and calls _decode().

5.2 Tone discrimination — Goertzel

Mark and space powers over a sample window are each measured by a single-bin Goertzel filter (rtty.py):

def _tone(self, seg, freq):
    w = 2*pi*freq / sr
    c = 2*cos(w)
    s1 = s2 = 0.0
    for x in seg:
        s0 = x + c*s1 - s2
        s2, s1 = s1, s0
    return s1*s1 + s2*s2 - c*s1*s2      # |X(freq)|² (magnitude squared)

This is the standard second-order Goertzel recurrence evaluated over _win = 88 samples, returning the squared magnitude of the DFT bin at freq. The signed discriminator (rtty.py) is the difference of the two tone powers at sample index i (window clamped to the buffer):

def _disc(self, i):                       # + = mark, − = space
    seg = self._samples[i : i + self._win]
    return self._tone(seg, self.mark) - self._tone(seg, self.space)

_disc(i) > 0 means the window centred near i is more mark-like; < 0 means more space-like. (A helper _bit_at also exists at rtty.py but is dead code — the decoder uses _disc exclusively.)

5.3 Bit-clock recovery and start-bit sync

_decode (rtty.py) processes the buffer whenever at least need = int(sps * 9) = 2376 samples (9 bit periods) are resident. The coarse search step is step = max(1, int(sps / 16)) = 16 samples (rtty.py).

For each character:

  1. Coarse hunt (rtty.py) — advance p in step increments looking for the idle-mark→start-space transition: the condition is _disc(p) > 0 (mark now) and _disc(p + sps/2) < 0 (space half a bit later). This finds a falling (mark→space) edge that could be the start bit.
  2. Edge refinement (rtty.py) — from that p, walk q forward in step increments while still mark (_disc(q) > 0), up to p + sps, to land on the actual mark→space crossing. e = q is taken as the start of the start (space) bit.
  3. If no edge is found within the searchable range, drop the stale samples and return (rtty.py).

5.4 Mid-bit sampling and stop validation

With the frame anchored at e, bits are sampled at their centres — half a bit period into each cell (rtty.py):

  • Start-bit check (rtty.py): _disc(e + 0.5*sps) must be space (< 0). If it reads mark, this was a false edge — discard up to e + step and retry.
  • Data bits (rtty.py): for j = 0..4, sample at e + (j + 1.5)*sps (so data bit 0 centre = e + 1.5·sps, …, data bit 4 centre = e + 5.5·sps); _disc > 0 (mark) sets that bit: code |= 1 << j. This reads LSB first, matching the transmitter.
  • Stop-bit check (rtty.py): _disc(e + 6.5*sps) must be mark (> 0). If it reads space, the frame is malformed — discard up to e + sps and retry (re-hunting for a cleaner edge).
  • On success, _emit(code) is called and the consumed samples up to e + 6.5*sps are dropped from the buffer (rtty.py).

The stop-bit test at +6.5·sps (rather than the +6.5..+7.0 the 1.5-stop nominal would suggest) samples the first stop bit period's centre, which is robust and does not require the full 1.5-length stop to be present — the decoder therefore tolerates both 1.0 and 2.0 stop-bit senders as well.

5.5 ITA2 reassembly and shift state (_emit)

_emit(code) (rtty.py) maintains the letters/figures state and prints:

if code == 0x1B:  self._figs = True;  return     # FIGS shift
if code == 0x1F:  self._figs = False; return     # LTRS shift
table = _FIGS if self._figs else _LTRS
ch = table[code]
if ch and ch not in ("\x0e", "\x0f", "\x07"):    # drop SO/SI placeholders + BEL
    self.on_text(ch)
  • Shift codes flip _figs and print nothing.
  • Otherwise the current page is indexed by code and the character is delivered to on_text, except the two \x0e/\x0f placeholders and BEL (\x07), which are filtered out. (NUL/\x00 is not filtered by the if ch guard — a non-empty one-char string is truthy — so a decoded code 0 would pass through; in practice code 0 does not arise from a valid mark-idle line.)
  • Delivered characters include printable letters/figures and the CR (\r) / LF (\n) controls, which reach the client verbatim.

UOS (Unshift-On-Space) is NOT implemented. Receiving a space (code 4) prints ' ' but leaves _figs unchanged, so the decoder stays on whatever page the last shift selected. This is a deliberate, documented gap (§8): the encoder compensates by always re-sending an explicit LTRS/FIGS when the page must change, so Rafe's own round-trip is unaffected; interop with a UOS-sending station is where the difference could show (see §8).

5.6 App RX path and character delivery

DigiModes._on_native_char (digimodes.py) forwards every decoded character to the client as {"mode": "rtty", "text": ch} — streamed verbatim, so the client appends characters as they arrive exactly like the fldigi-backed modes.


6. Constants and defaults (quick reference)

6.1 Module constants (rtty.py)

BAUD  = 45.45     # symbol rate (baud)
MARK  = 2125.0    # mark tone, Hz  (binary 1)
SPACE = 2295.0    # space tone, Hz (binary 0) → shift = 170 Hz

6.2 Shift code points

FIGS shift = 0x1B (27)   # rtty.py
LTRS shift = 0x1F (31)   # rtty.py
SPACE      = 0x04 (4)    # both pages; rtty.py

6.3 Overridable at construction / call

Symbol Default Notes
sr (RX) 12000 decoder sample rate (digimodes decimates 48 k→12 k)
sr (TX) 48000 modulate sample rate (streamed to radio)
baud 45.45 also 50, 75 etc. by passing another value — no separate presets are defined; the module is baud-agnostic (§8)
mark / space 2125 / 2295 swap the two to invert polarity (reverse RTTY)

The 45.45-baud / 170 Hz / 2125/2295 / US-TTY-figures combination is the amateur default and the only one exercised by the tests; the code itself imposes no other restriction.


7. Interoperability and validation

7.1 Why it interoperates with fldigi

Rafe decodes RTTY (and PSK) natively and reserves the fldigi sidecar for the MFSK family only — the in-source note is explicit:

"RTTY and PSK are decoded natively (see rtty.py / psk.py); the sidecar only carries the MFSK-family modes that aren't worth reimplementing." — fldigi_bridge.py

Interoperability is by construction: the mode uses the standard amateur RTTY parameters (45.45 baud, 170 Hz shift, ITA2 async 5-N-1.5) and the US-TTY figures set, which is fldigi's default RTTY configuration. A signal produced by modulate is a conforming 45.45/170 AFSK RTTY signal that any standard TU or software modem (fldigi, MMTTY, cocoaModem) decodes, and conversely.

7.2 Loopback self-test

test_rtty.py is an end-to-end additive-white-Gaussian-noise loopback that exercises encode → AFSK synth (with noise) → decode:

  • synth(text, snr_db, seed) (test_rtty.py) encodes the text, then renders the exact wire framing — a mark idle lead-in, then per character a space start bit, five LSB-first data bits, and two mark bits (a full stop plus an extra mark to emulate the 1.5 stop) — as AFSK at 2125/2295 Hz, 12 kHz, adding Gaussian noise scaled to the requested SNR.

  • run(text, ...) (test_rtty.py) feeds the PCM to a fresh RttyDecoder in 2400-byte chunks and asserts the sent text appears in the decoded output.

  • Three cases run and all pass:

    Text SNR Result
    CQ CQ DE M0ABC 25 dB PASS
    RYRYRY THE QUICK BROWN FOX 20 dB PASS
    TEST 599 001 18 dB PASS

    (RYRY… is the traditional RTTY tuning/test pattern — R=01010, Y=10101 — which alternates mark/space every bit and stresses the bit clock.) Reproduce with python3 test_rtty.py3/3 passed.

The test covers letters, the letters↔︎figures shift (M0ABC, 599, 001), and space handling, which is the full functional surface of §§4–5.


8. Limitations

Honest gaps in the current implementation:

  • No Unshift-On-Space (UOS). The decoder never returns to letters shift on a space (§5.5). Against a UOS-transmitting station, text sent after a figures run and a space (a common pattern in contest exchanges) could stay stuck on the figures page until an explicit LTRS arrives. The fix is a one-line addition in _emit (unshift when code == 4), optionally guarded by a UOS toggle.
  • No standard baud presets. baud is a free parameter, so 50-baud and 75-baud RTTY work if the value is passed, but there are no named 45.45 / 50 / 75 presets in the module or wired into the app — only 45.45 is registered and tested. Adding 50/75 is a UI/config change, not a modem change.
  • Fixed AFSK tones in the app. MARK/SPACE are constructor arguments but the app instantiates RttyDecoder(self._on_native_char) with defaults (digimodes.py), so tone/polarity are not exposed to the operator; reverse RTTY requires code change or a config hook.
  • No automatic frequency/tone tracking (AFC). The Goertzel bins are fixed at 2125/2295 Hz; the signal must be tuned so the audio tones land on those frequencies (the usual RTTY tuning-indicator discipline). There is no narrow-band pre-filter or matched filter beyond the two Goertzels, and no soft-decision or FEC (RTTY has none by definition).
  • Single-character shift errors propagate. As with all Baudot RTTY, one missed LTRS/FIGS garbles the run until the next shift; the diddle preamble mitigates start-up but not mid-stream hits.
  • BEL and control filtering. BEL (\x07) is dropped; NUL (\x00) is not filtered by the if ch guard (§5.5) — a benign quirk, since valid traffic does not decode to code 0.
  • Minor dead code. _bit_at (rtty.py) is unused, and modulate has two unused locals (frames, seq, rtty.py). Cosmetic only.

None of these are architectural; each is a bounded addition on a working, interoperable base.


9. Implementation and reproduction

File Role
app/radio/rtty.py the entire modem — RttyDecoder (RX), encode/modulate (TX), ITA2 tables
test_rtty.py AWGN loopback self-test (3 cases, 18–25 dB SNR)
app/radio/digimodes.py app wiring — RX feed/decimation (_decimate_12k), char delivery (_on_native_char)
app/radio/manager.py app TX — _native_tx: modulate → route → PTT → stream

Runtime API:

from app.radio.rtty import RttyDecoder, encode, modulate

# --- TX ---
pcm = modulate("CQ CQ DE M0ABC", sr=48000)   # -> s16le mono bytes, ready to stream

# --- RX ---
dec = RttyDecoder(on_text=print, sr=12000)    # default 45.45/170, 2125/2295
dec.feed(pcm12k_bytes)                         # call repeatedly with s16le @ 12 kHz

Everything is pure-Python stdlib (math, struct); there is no NumPy, no external modem process, and no data table beyond the two ITA2 strings quoted in §3.


10. References

  1. CCITT/ITU-T, International Telegraph Alphabet No. 2 (ITA2) — the 5-bit Baudot code and letters/figures shift structure tabulated in §3.
  2. J. E. Goertzel, "An Algorithm for the Evaluation of Finite Trigonometric Series," Amer. Math. Monthly, 1958 — the single-bin DFT recurrence used for mark/space tone power (§5.2).
  3. W. G. Schwartz / amateur RTTY practice — 45.45 baud, 170 Hz shift, 2125/2295 Hz AFSK, US-TTY figures: the de facto HF RTTY standard this module targets.
  4. fldigi documentation — the reference software modem whose default RTTY configuration (45.45/170, US-TTY figures) defines the interop target (§7).
  5. ../rvqvoice.md — the depth/structure benchmark for this reproduction-spec series.