Skip to content

feat: port Hearthwave STT, TX-safety, net-stats, calibration, and two-tier STT features - #100

Merged
Xpiatio merged 12 commits into
mainfrom
worktree-feat+hearthwave-port
Aug 7, 2026
Merged

feat: port Hearthwave STT, TX-safety, net-stats, calibration, and two-tier STT features#100
Xpiatio merged 12 commits into
mainfrom
worktree-feat+hearthwave-port

Conversation

@Xpiatio

@Xpiatio Xpiatio commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Ports the features worth keeping from Hearthwave (Radio-TTY), the FastAPI+React fork of this project, back into the PySide6 desktop app. Seven phases, then a six-axis audit pass over the result.

Deliberately not ported: auth/users, wall display, plugin system, neighborhood/family/NCS panels, and the Leaflet map — all multi-user web-native features that don't fit a single-user desktop app. CW decoder and the AAC board were deferred.

What landed

CI + version sync — flake8 + offscreen pytest on every push; gmrs_tty/__init__.py is now the canonical version, with a workflow that fails if README or a release tag disagrees.

Shared STT foundationaudio/dsp.py and stt/segmenter.py upgraded to the Hearthwave supersets (stationary denoise, lowpass, dynamic AGC, noise-profile buffer, squelch-close force-finalize). Utterances now finalize on carrier drop, not only on VAD end.

STT quality pack — Whisper initial_prompt vocabulary biasing from contacts and saved phrases, selectable gain modes, noise profiling, per-utterance debug capture, and a python -m gmrs_tty.tools.eval_stt WER harness. Also fixes a live bug: worker.py discarded normalize_rms()'s return value, so gain normalization never actually applied. Transcription output changes measurably as a result.

TX quality and safety — TX band conditioning, a VOX primer tone and primer word so the first syllable isn't swallowed, a synthesis timeout that never keys PTT, and a max-TX watchdog that stops playback and releases PTT.

Net stats and CSV export — session records beside the journals, attendance statistics (totals, recent-window, streaks), a stats dialog, and CSV export.

STT calibration wizard — records a read passage, sweeps staged models against gain modes and noise-profile settings, ranks by WER, and applies the winner to config.

Optional two-tier STT — streaming partials plus a whole-utterance final pass that rewrites the line in place. GPU is opt-in: torch and transformers live in requirements-gpu.txt, are lazily imported, never vendored into the .deb, and fall back to CPU on any failure. The CPU-only package is unchanged.

Audit fixes

The last three commits address a six-axis review of the above — 0 Critical, 7 Major, 7 Minor. Highlights:

  • A Max callsigns cap of 0 kept every callsign (callsigns[-0:] is the whole list) and the spinbox range is (0, 50), so it was reachable.
  • The two-tier pending-final buffers were unbounded: a TX pause mid-utterance dropped the utterance without emitting a final segment, so its audio was held for the rest of the session.
  • An over-length message was keyed and then cut off mid-word; it's now refused before PTT is touched.
  • A failed calibration sweep dropped the last reference to a running QThread.
  • A late final pass on an already-closed line appended a duplicate chat entry instead of rewriting it.
  • CSV export didn't neutralize formula-injection prefixes (=, +, -, @).

Testing

1124 tests pass (up from 1070 pre-branch — 54 added for the audit fixes alone), flake8 clean across gmrs_tty tests scripts bootstrap_models.py, version-sync agrees at 1.8.0. Launched and smoke-tested on X11.

Accessibility: every new widget across the two new dialogs and ~15 new config rows carries accessibleName/accessibleDescription, tooltips, and mnemonics, per WCAG 2.1 AA.

README and docs/USER_MANUAL.pdf regenerated for each phase.

Xpiatio added 12 commits August 7, 2026 17:25
Establish gmrs_tty.__version__ as the canonical version source. build-deb.sh
and the user-manual generator now derive their version from it, README carries
a checked release callout, and scripts/check_version_sync.py (enforced by the
version-sync workflow) fails the PR when any stamp drifts. New ci workflow runs
flake8 and the pytest suite offscreen on every PR and push to main.
Remove unused imports, fix E402 logger-between-imports ordering, E221
alignment, and add the missing SpectrogramWidget import in main_window
(latent NameError in _build_waterfall_dock).
Adopt the Hearthwave (Radio-TTY) supersets of dsp.py (noise-clip stationary
denoise, causal lowpass, dynamic AGC), squelch.py (opt-in adaptive noise-floor
threshold, off by default), and segmenter.py (squelch-close force-finalize
with crash trim, noise-profile buffer, speech-sample min-duration gate). Add
pure modules stt/vocab.py, stt/_prompt.py, stt/preprocess.py, stt/wer.py and
GAIN_MODES / VALID_WHISPER_MODELS constants. Requirements gain explicit scipy,
jiwer, and noisereduce>=3.0 / faster-whisper>=1.0 floors. Tests ported
alongside; utterances now finalize on carrier drop, not only VAD end.
…debug capture, eval CLI

Port the Hearthwave STT quality features into the Qt worker:

- WhisperTranscriber gains initial_prompt vocabulary biasing (curated radio
  vocab + operator phrases + contact callsigns), beam size 5, per-segment
  no-speech/logprob confidence filtering, decode_options and word-confidence
  rebuild hooks, and update_prompt() for live phrase refresh.
- STTWorker: selectable gain stage (AGC/RMS/off) via preprocess_segment —
  also fixes the long-standing bug where normalize_rms()'s return value was
  discarded so no gain was ever applied — plus squelch-derived noise-profile
  denoising, a causal 2.7 kHz lowpass ahead of squelch/VAD, per-utterance
  debug capture, and update_phrases() wired from contact changes.
- New ConfigDialog STT tab (model, gain mode, noise profile, custom phrases,
  max callsigns, debug capture/dir) with full accessibility affordances.
- New gmrs_tty/tools/eval_stt.py offline WER eval CLI replaying captures
  through the exact production pipeline; ordered_callsigns() contacts helper.
- Docs: README STT tuning bullets, USER_MANUAL six-tab section, NOTICES
  (scipy, jiwer), config.example.json keys. Manual regenerated.
- New gmrs_tty/audio/tx_conditioning.py: band-limit -> compress ->
  voiced-RMS normalize -> peak ceiling applied to synthesized speech before
  it drives the radio mic (Behavior tab toggle, default off).
- VOX priming (PTT tab): optional 1 kHz primer tone spliced between the PTT
  lead-in and speech, and/or a spoken priming word prefixed to the message
  (gmrs_tty/text/primer.py), so VOX attack can't clip the first word.
- TX watchdog: hard cap on keyed transmission length stops the audio device
  and releases PTT; synthesis timeout abandons a stuck Piper run without
  ever keying the radio (generation counter discards the late result).
- Operator kill switch: Abort TX button (Esc) appears in the Transmit row
  while a transmission is in progress.
- Config keys, Behavior/PTT tab rows with accessibility affordances, README
  and USER_MANUAL sections, config.example.json. Manual regenerated.
- New gmrs_tty/persistence/net_sessions.py: one JSON record per Listen
  session under net_sessions/ (attendance-grid roster shape), with
  newest-first summaries, path-traversal-guarded load/delete.
- New gmrs_tty/persistence/net_stats.py (ported near-verbatim): per-station
  aggregation keyed (callsign, name) so family-shared GMRS callsigns count
  per operator — total nets, attended-of-last-10, streak, last seen.
- New gmrs_tty/persistence/csv_export.py: stdlib-csv rendering for one
  session, all sessions, and the stats table.
- New Tools → Net Attendance History dialog (History + Statistics tabs,
  CSV exports, per-session delete with confirmation).
- Callsigns Detected panel gains Save session and Export CSV buttons;
  optional auto-save on Listen stop (attendance.autosave_sessions, Behavior
  tab checkbox; empty sessions skipped). AppConfig attendance setter now
  merges instead of clobbering sibling keys.
- README + USER_MANUAL sections, config.example.json, .gitignore entries.
  Manual regenerated.
Four-step guided tuner (Tools -> Calibrate STT, enabled while Listen is
active): shows a reference passage, records it being read over the air by
tapping the live STTWorker audio_chunk fan-out (CalibrationCapture, bounded
at 3 minutes), sweeps every staged Whisper model x gain mode x noise
profile against the reading on a background CalibrationSweepWorker
(disk-staged models only — never downloads), and ranks combinations by WER.
Apply selected writes whisper_model / stt_gain_mode / stt_noise_profile to
config; model changes take effect at the next Listen start. Escape cancels
cleanly at any step — capture disconnects, an in-flight sweep is orphaned
and reaped when its thread finishes. Per-step focus management and
accessible progress text throughout. README + USER_MANUAL section, manual
regenerated.
Second-pass transcription (off by default): the streaming model keeps
delivering live partials; when an utterance finalizes, the whole raw audio
is re-transcribed by a larger model (large-v3-turbo preferred by 'auto') on
a reduced-priority thread, and the chat line is rewritten in place via the
new replace semantics — transcribed_segment gains a 4th 'replace' argument,
RXSession handles replacing/empty finals (including late replacements that
land after the next transmission starts), and ChatDisplay/_DualChatProxy
gain replace_block.

GPU support is strictly optional: torch stays CPU-only in the base install,
transformers lives only in the new requirements-gpu.txt, all GPU imports
are lazy, and every GPU failure falls back to CPU (gmrs_tty/stt/_device.py,
gpu_transcriber.py ported from Hearthwave). Utterances over
stt_final_max_s keep their streaming transcript; a bounded final queue
drops the oldest job under backlog and flushes its partials so no line
ever hangs open. Final model cached in ModelCache across Listen toggles;
update_phrases refreshes both engines.

bootstrap_models.py gains multi-model staging, large-v3-turbo /
distil-large-v3 CT2 repos, and --final-model/--final-backend HF staging.
Config keys + STT tab rows, README, USER_MANUAL (regenerated),
config.example.json; build-deb.sh documents that GPU extras are never
vendored.
A callsign cap of 0 kept every callsign: `callsigns[-0:]` is the whole
list, and the spinbox range is (0, 50), so the case is reachable. Compute
the drop count first and slice from the front.

The two-tier final pass held utterance audio in `_pending_final` /
`_pending_noise` keyed by uid. A TX pause mid-utterance calls
SpeechSegmenter.reset(), which drops the in-flight utterance without
emitting a final segment, so that uid was never popped and its audio was
held for the rest of the session. Bound both dicts with
MAX_PENDING_FINAL, evicting oldest-first from the transcription thread
(the capture thread must not touch them — queued segments still refer to
those uids).

Extract gmrs_tty/stt/models.py as the single staged-model probe; the
isdir check was duplicated across worker, calibration_worker, and both
config_dialog combos. Callers pass models_dir explicitly so tests can
still patch STTWorker.MODELS_STT_DIR.

Adds coverage for both STT worker loops (capture and transcription),
the new model-path helpers, and the vocab cap boundaries.
…place

An over-length message was keyed and then aborted mid-word by the max-TX
watchdog. Check the synthesized duration before AudioPlayerThread is
constructed, so PTT is never keyed at all, and tell the user how long the
message was.

A failed calibration sweep dropped the last reference to a still-running
QThread. Extract _detach_sweep() so the error path parks the object in
_ORPHANED_SWEEPS and disconnects it, exactly as cancel already did;
_release_sweep() reaps it once the thread reports finished.

A late final pass whose utterance had already been closed appended a
second chat line for the same transmission. Remember the closed uid,
block, and timestamp so the closed line stays addressable and gets
rewritten in place; the completion callback is an idempotent callsign
scan, so it still fires with the corrected text.

Net stats re-scanned every session per station. Build the appearance
index in one pass, then derive totals, the recent-window count, and the
streak from each station's own positions.

Neutralize CSV formula injection: a cell starting with = + - @ or a
control character is prefixed with an apostrophe. No exported field is
ever legitimately negative, so escaping - loses nothing. Header row is
left alone.

Also set WA_DeleteOnClose on the two modeless dialogs, and cover the
attendance config accessors.
CI ran flake8 over gmrs_tty/tests/scripts but skipped bootstrap_models.py
at the repo root. requirements-gpu.txt now floor-pins torch and
transformers rather than fighting the ROCm/CUDA index-url wheel choice.
Fixes a stale Hearthwave path in a gpu_transcriber comment.

README and the user manual now describe the two behaviors the audit
changed: Max callsigns = 0 leaves callsigns out of the prompt entirely,
and an over-length message is cancelled before the radio is keyed.
USER_MANUAL.pdf regenerated.
First CI run surfaced four failures that pass locally only because the
developer machine has internet and a sound card.

test_main_window_auto_add.py patched is_online() during MainWindow
construction but not during the scan, so the auto-add path consulted the
real network probe. Three tests failed on the runner; the other four
passed for the wrong reason, since "offline" trivially satisfies a "no
lookup was issued" assertion. Pin is_online at all seven call sites via
_patch_online() so each test asserts what it claims to.

test_main_window_listen_only.py toggled Monitor on, which opens a real
sd.OutputStream and raises PortAudioError where no audio device exists.
No test here asserts on the monitor, so the fixture installs an inert
_FakeMonitor that records calls instead.

Both files touch neither the network nor an audio device now.
@Xpiatio
Xpiatio merged commit e08b17e into main Aug 7, 2026
3 checks passed
@Xpiatio
Xpiatio deleted the worktree-feat+hearthwave-port branch August 8, 2026 00:39
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