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) and0x1F(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=&, plusS→BEL. (Other national ITA2 variants place WRU/'/etc. differently; this one matches fldigi's default.) - The letters page is
A–Zonly — 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:
- Space → emit code
4directly (space is code 4 on both pages, so no shift is spent — rtty.py). - A letter (character in
_LTR_CODEand not a digit0–9): if not already in letters shift (figs is not False), emit LTRS =0x1Fand setfigs = False; then emit the letter's code (rtty.py). - A figure/punctuation (character in
_FIG_CODE): if not already in figures shift (figs is not True), emit FIGS =0x1Band setfigs = True; then emit the figure's code (rtty.py). - 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):
- Compute samples-per-symbol
sps = sr / baud(= 1056.106 at 48 kHz). - 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). - 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))forb = 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). - 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:
- Coarse hunt (rtty.py) — advance
pinstepincrements 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. - Edge refinement (rtty.py) — from that
p, walkqforward instepincrements while still mark (_disc(q) > 0), up top + sps, to land on the actual mark→space crossing.e = qis taken as the start of the start (space) bit. - 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 toe + stepand retry. - Data bits (rtty.py): for
j = 0..4, sample ate + (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 toe + spsand retry (re-hunting for a cleaner edge). - On success,
_emit(code)is called and the consumed samples up toe + 6.5*spsare 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
_figsand print nothing. - Otherwise the current page is indexed by
codeand the character is delivered toon_text, except the two\x0e/\x0fplaceholders and BEL (\x07), which are filtered out. (NUL/\x00is not filtered by theif chguard — 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 freshRttyDecoderin 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.py → 3/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 whencode == 4), optionally guarded by a UOS toggle. - No standard baud presets.
baudis 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/SPACEare constructor arguments but the app instantiatesRttyDecoder(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 theif chguard (§5.5) — a benign quirk, since valid traffic does not decode to code 0. - Minor dead code.
_bit_at(rtty.py) is unused, andmodulatehas 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¶
- CCITT/ITU-T, International Telegraph Alphabet No. 2 (ITA2) — the 5-bit Baudot code and letters/figures shift structure tabulated in §3.
- 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).
- 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.
- fldigi documentation — the reference software modem whose default RTTY configuration (45.45/170, US-TTY figures) defines the interop target (§7).
../rvqvoice.md— the depth/structure benchmark for this reproduction-spec series. ```