feat: port Hearthwave STT, TX-safety, net-stats, calibration, and two-tier STT features - #100
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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__.pyis now the canonical version, with a workflow that fails if README or a release tag disagrees.Shared STT foundation —
audio/dsp.pyandstt/segmenter.pyupgraded 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_promptvocabulary biasing from contacts and saved phrases, selectable gain modes, noise profiling, per-utterance debug capture, and apython -m gmrs_tty.tools.eval_sttWER harness. Also fixes a live bug:worker.pydiscardednormalize_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:
0kept every callsign (callsigns[-0:]is the whole list) and the spinbox range is(0, 50), so it was reachable.QThread.=,+,-,@).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.pdfregenerated for each phase.