Skip to content

feat(rtty): add built-in RTTY (Baudot/ITA2) decoder (#1392)#3336

Open
motoham88 wants to merge 9 commits into
aethersdr:mainfrom
motoham88:feat/1392-add-rtty-decoder
Open

feat(rtty): add built-in RTTY (Baudot/ITA2) decoder (#1392)#3336
motoham88 wants to merge 9 commits into
aethersdr:mainfrom
motoham88:feat/1392-add-rtty-decoder

Conversation

@motoham88
Copy link
Copy Markdown
Contributor

Summary

  • New RttyDecoder class: mark/space bandpass filters (3× baud BW), fast envelope (tau = 0.3/baud), Schmitt trigger hysteresis (10%), stop-bit validation, full Baudot/ITA2 decode with LTRS/FIGS shift tracking
  • RTTY decode panel in PanadapterApplet: appears automatically when a slice enters RTTY or DIGL mode, hides on exit
  • Panel controls: fldigi-standard mark frequency selector (Auto/2125/2210/1700/1275/1000/915/850/500 Hz), shift (45–850 Hz), baud rate (45.45–300), REV toggle for high-space/LSB signals
  • M%/S%/SNR/LOCKED stats display updated ~2×/second
  • All settings persist to AppSettings across sessions
  • "Auto" mark frequency follows the radio's rttyMark (RTTY mode) or diglOffset (DIGL mode) live, including mid-session changes
  • Confirmed working at 2125 Hz and 915 Hz tone sets, both polarities

Fixes included in this branch

  • CR/LF rendered as <br> in the HTML text panel (were collapsed as whitespace)
  • Spaces escaped as &nbsp; (were collapsed by HTML inline rendering)
  • refreshRttyDecodeState called on all mode-change paths, not just active-slice change
  • Auto mark tracks live rttyMarkChanged / diglOffsetChanged signals
  • Decoder not started when panel applet is not yet wired
  • FIGS shift state (figsMode) preserved across parameter changes
  • AppSettings keys flattened — keys containing / are silently dropped by the XML writer; all four RTTY keys were affected, preventing any settings from persisting

Test plan

  • Switch a slice to RTTY mode — panel appears; switch away — panel hides
  • Switch to DIGL mode — panel appears with Auto mark tracking diglOffset
  • Tune to a 45.45 baud 2125/1955 Hz signal (Rev off) — text decodes with line breaks
  • Tune to a reversed-polarity signal — enable REV, confirm decode; relaunch and confirm REV is remembered
  • Change mark freq combo to 915 Hz, confirm decode on a 915 Hz signal
  • Confirm M%/S%/SNR stats update and LOCKED appears on a live signal
  • Change baud rate to 50 — confirm decoder restarts with new rate

🤖 Generated with Claude Code

motoham88 and others added 8 commits May 28, 2026 18:57
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…thersdr#1392)

- New RttyDecoder: mark/space bandpass filters (3×baud BW), fast
  envelope (tau=0.3/baud), Schmitt trigger hysteresis, stop-bit
  validation, Baudot decode.  Emits textDecoded + statsUpdated on
  a worker thread; API mirrors CwDecoder.
- PanadapterApplet: RTTY panel with fldigi-standard mark frequency
  selector (Auto/2125/2210/1700/1275/1000/915/850/500 Hz), shift
  combo (45–850 Hz), baud combo (45.45–300), REV toggle, M%/S%/SNR
  stats display, CPY ALL / CLR / close.  Panel shown in RTTY/DIGL
  mode, hidden otherwise.
- MainWindow: audio feed, routeRttyDecoderOutput(), and
  refreshRttyDecodeState() following the same pattern as the CW
  decoder.  "Auto" mark tracks radio's rttyMark/diglOffset live.
  All combos persist to AppSettings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added the missing call sites that show/hide the RTTY panel:
- mode change handler (the key path: user switches slice to RTTY/DIGL)
- active pan switch (multi-pan layout)
- deferred status settle after profile load

Without these, the panel only appeared on active-slice change, not
when the user switched modes on an already-active slice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
\r and \n passed through insertHtml() as literal characters are
treated as collapsed whitespace in HTML — no visible line break.

Fix: discard \r (no-op in a wrapped text view), convert \n to <br>.
Standard RTTY CR+LF pairs produce exactly one new line per pair.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTML collapses whitespace inside inline <span> elements, making
decoded spaces invisible. Escape spaces as &nbsp; before inserting
so word spacing is preserved in the rendered text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Auto mark tracks live radio changes: wire rttyMarkChanged,
   rttyShiftChanged, and diglOffsetChanged to refreshRttyDecodeState
   so the decoder follows the radio's mark/diglOffset without needing
   a mode cycle when Auto is selected.

2. Guard start() on applet availability: don't start the decoder if
   m_rttyDecoderApplet is null — we have no source for the user's
   chosen mark/shift/baud values without the panel combos.

3. Preserve figsMode across param changes: baud/mark changes reset
   filter and clock state but should not flip the Baudot character
   set back to LTRS mid-stream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AppSettings rejects any key containing '/' when writing XML (XML
element names cannot contain slashes).  All four RTTY keys were of
the form "RttyDecoder/Foo" so they triggered a qWarning and were
never written to disk.  Result: REV, shift, baud, and mark freq
settings reset to defaults on every launch.

Replace all four with flat CamelCase names:
  RttyDecoder/MarkHz    → RttyDecoderMarkHz
  RttyDecoder/ShiftHz   → RttyDecoderShiftHz
  RttyDecoder/Baud      → RttyDecoderBaud
  RttyDecoder/Reverse   → RttyDecoderReverse

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@motoham88 motoham88 requested review from a team as code owners June 1, 2026 01:35
@motoham88 motoham88 mentioned this pull request Jun 1, 2026
Copy link
Copy Markdown
Contributor

@aethersdr-agent aethersdr-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution @motoham88 — this is a clean, well-scoped RTTY decoder with thoughtful DSP choices (3× baud bandpass, 0.3/baud envelope, 10% Schmitt hysteresis, 1.5-bit-period sampling alignment) and the user-facing details (fldigi-standard mark/shift/baud presets, REV toggle, Auto-mark tracking rttyMark/diglOffset, stats display, settings persistence) are well thought through. The Baudot tables look correct and FIGS state is preserved across parameter changes as the comment claims.

A few things worth addressing before merge:

1. STREAMDECK_PLUS_SESSION.md is out of scope

The 170-line StreamDeck+ session summary at the repo root has nothing to do with RTTY and looks like a stray commit (fc54bd2a) — it documents a previously-merged feature (PRs #3236 / #3250). Please drop it from this branch. Repo-root markdown is also a maintainer-owned path.

2. DIGL also activates the RTTY decoder

refreshRttyDecodeState() treats RTTY and DIGL identically:

const bool isRtty = s && (s->mode() == "RTTY" || s->mode() == "DIGL");

DIGL is a general LSB-data mode the radio uses for any digital protocol — PSK, JT/FT modes, etc. — not just FSK/RTTY. So tuning a PSK31 or FT8 station in DIGL will pop the RTTY panel and run a Baudot decoder on the audio. The ✕ closes it for that session, but the next slice/mode refresh re-shows it. Is the intent to surface RTTY only for explicit RTTY mode, and treat DIGL as opt-in? Worth at least mentioning in the panel title (e.g. "RTTY decode — DIGL") so the operator knows what they're looking at.

3. RttyDecoder::stop() — wait timeout silently abandons the worker

if (m_workerThread) {
    m_workerThread->wait(2000);
    m_workerThread = nullptr;
}

If wait(2000) returns due to timeout rather than thread completion, the worker is still running but m_workerThread gets nulled. A subsequent start() would then create a second worker, and both would race on m_ringBuf / m_paramsChanged / the envelope state. The decode loop exits within ~10 ms of m_running = false in practice, so this is unlikely to fire — but it's worth checking the return value and either retrying or logging, rather than dropping the pointer. (Same shape as the hid_write return-check fix in PR #3250.)

4. Minor: setters always pulse m_paramsChanged

setMarkFreqHz/setShiftHz/setBaudRate/setReversePolarity set m_paramsChanged = true even when the value is unchanged. Combined with refreshRttyDecodeState() being called from rttyMarkChanged / rttyShiftChanged / diglOffsetChanged / mode-change paths, this means the filter is re-designed and the bit-clock is reset on every spectrum-overlay-triggering signal — even when nothing relevant moved. Not a correctness bug, but it can interrupt decode mid-character on a clean signal whenever an unrelated overlay update fires. Cheap guard: compare-and-skip in the setters.

Nothing blocking apart from the stray STREAMDECK_PLUS_SESSION.md. Nice work.


🤖 aethersdr-agent · cost: $29.6434 · model: claude-opus-4-7

1. DIGL no longer auto-activates the RTTY panel.  DIGL is a general
   LSB-data mode used for PSK31, FT8, SSTV, etc.; showing a Baudot
   decoder on those signals is misleading.  RTTY mode only.

2. RttyDecoder::stop() checks wait() return value and logs a warning
   rather than silently abandoning a still-running worker thread.

3. Setters (setMarkFreqHz, setShiftHz, setBaudRate, setReversePolarity)
   compare the incoming value before writing; no-ops do not set
   m_paramsChanged, so overlay-triggered refreshes that change nothing
   no longer reset the bit clock mid-character.

Note: STREAMDECK_PLUS_SESSION.md flagged by the reviewer is on main,
not on this branch — out of scope for this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@motoham88 motoham88 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough review. Addressed in 6b0c585:

1. STREAMDECK_PLUS_SESSION.md — You're right it shouldn't be here. It was in the branch tree from the reset --hard main starting point; removed in the fix commit. The deletion also cleans it off main on merge, which seems like the right outcome.

2. DIGL auto-activation — Agreed. refreshRttyDecodeState now only fires on mode() == "RTTY". DIGL users who want Baudot decode can be handled as a future opt-in (context menu or a separate mode check). Also removed the vestigial diglOffsetChanged connection and simplified the Auto-mark path to just use s->rttyMark().

3. stop() wait timeout — Return value is now checked; logs a qCWarning on timeout rather than silently nulling the pointer. Worker exits within ~10 ms in practice so this is a safety net, not a likely path.

4. No-op setter pulsing m_paramsChanged — All four setters now compare-and-return-early when the value is unchanged. Overlay-triggered refreshRttyDecodeState calls that don't actually change any parameter no longer reset the bit clock mid-character.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant