Skip to content

Feature/rx audio level strip - #61

Merged
bucknova merged 6 commits into
bucknova:mainfrom
dacrhu:feature/rx-audio-level-strip
Sep 8, 2026
Merged

Feature/rx audio level strip#61
bucknova merged 6 commits into
bucknova:mainfrom
dacrhu:feature/rx-audio-level-strip

Conversation

@dacrhu

@dacrhu dacrhu commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Title: feat(rx): always-on audio strip with TX/RX gain sliders and input level meter

SUMMARY

Adds a slim, always-visible strip down the right edge of the Receive panel
with three vertical controls, so software gain can be set - and the incoming
level watched - without opening the Settings dialog:

  1. TX gain slider - same value as Settings > Audio > Software Gain > TX output
    gain (AppConfig.audio_output_gain). Pushed live to TxWorker on every tick;
    written to the config file (debounced ~400 ms) once the slider settles.

  2. RX gain slider - same value as Settings > Audio > RX input gain
    (AppConfig.audio_input_gain), same live-push + debounced-persist behaviour.

  3. Input level meter - colour-zoned dBFS bar (green <= -6, yellow -6..-1,
    red >= -1) with a slow falling peak-hold marker, fed from
    RxWorker.waterfall_chunk (post-input-gain, i.e. what the decoder sees).
    Moves only during capture; drops to silence on stop.

BEHAVIOUR DETAILS

  • Two-way sync with the Settings dialog: a Settings save flows back into the
    strip via _apply_config; enabling overdrive expands the strip's TX ceiling
    to 200% too.
  • Persistence: debounced disk write coalesces wheel-scroll / held-arrow spam;
    a pending write is flushed in closeEvent.
  • The RX panel is wrapped in a container with the strip as a fixed-width
    sibling - not a resizable splitter pane.
  • No changes to the audio/DSP threads: the meter piggybacks on the existing
    waterfall_chunk signal already delivered to the GUI thread.

NEW FILES

  • src/open_sstv/ui/level_meter.py - LevelMeter (painted VU bar, instant
    attack / ~24 dB per second release, theme-aware).
  • src/open_sstv/ui/audio_level_strip.py - AudioLevelStrip (container widget
    • signals).

TESTS

  • New: tests/ui/test_level_meter.py, tests/ui/test_audio_level_strip.py.
  • Extended: TestAudioLevelStrip in tests/ui/test_main_window.py (seed from
    config, live worker push, debounced persist + close-flush, meter fed by RX
    chunk, reset on stop, Settings<->strip sync, overdrive ceiling); fixed the
    splitter-child assertion for the new RX wrapper.
  • test_main_window.py + both new suites: 102 passed, 2 skipped. Ruff (CI
    selection I,F,UP,W) clean; mypy clean on the new widgets.

DOCS

  • SSTV_App_User_Guide.md section 6.3 (Receive Panel).

dacrhu and others added 6 commits August 16, 2026 21:26
…ta-mode option

Two independent bugs in the Band Plan → Direct Serial tune path.

## Bug 1: FT-450D frequency changes were silently rejected

Root cause: Yaesu's `FA` (set-frequency) CAT command uses a
model-dependent digit width. FT-450/450D wants 8 digits, no leading
zero ("FA14230000;"); FT-991A and the other "modern CAT" rigs
(FT-891, FT-710, FTDX10, FTDX101, FT-950) want 9, zero-padded
("FA014230000;"). YaesuRig.set_freq() hardcoded the 9-digit format,
so every FA command sent to an FT-450D was malformed and the radio
rejected it outright with "?;".

That rejection was completely invisible, for two compounding
reasons:

- Yaesu/Kenwood *set* commands are fire-and-forget by design (the
  radio sends no response at all to a legitimate set, so
  _write_command() never reads one back) — there was no way to
  observe a "?;" reply to the set itself.
- _RigPollWorker.tune() wrapped set_freq()/get_mode()/set_mode() in
  one `except Exception: _log.warning(...)` with nothing reaching
  the GUI, and the transient "Tuning to X (mode)..." status message
  wasn't corrected on failure — so a rejected tune looked exactly
  like a successful one.

Meanwhile MD (mode), a single-digit command, isn't sensitive to a
digit-width mismatch, so mode changes kept working — which is
exactly the asymmetry reported: freq never moved, mode did.

Fix:
- serial_rig.py: YaesuRig now detects the FA digit width from a live
  get_freq() response (already lenient about either length) and
  caches it per connection instead of hardcoding 9. set_freq() probes
  once via get_freq() if the width isn't known yet, so a tune right
  after Connect can't race the 1 Hz poll loop for this detection.
- main_window.py: _RigPollWorker.tune() now reads back get_freq()
  after every set_freq() and raises/reports a mismatch (readback of 0
  is treated as "this backend doesn't report frequency", not a
  mismatch — e.g. SerialPttRig). A new tune_failed signal carries the
  reason to the GUI thread, which now replaces the status-bar message
  instead of only logging it — so any future rejection (this radio or
  another) is visible instead of silent, whatever the root cause.

## Bug 2: Band Plan always forced plain USB/LSB, never a data mode

SSTV_BAND_PLAN hardcodes rig_mode="USB"/"LSB"/"FM", and that literal
was passed straight to Rig.set_mode() whenever tune() decided a mode
change was needed. Every backend's own mode map only knows how to
turn that literal into *plain* USB/LSB — even YaesuRig, which already
supports "DATA-U"/"DATA-L" if only asked for them. There was no
setting anywhere for which CAT mode Band Plan tuning should actually
request.

Fix: a WSJT-X-style "SSTV mode" policy (Settings -> Radio -> Direct
Serial): None / Voice (default, today's behavior) / Data-Pkt. "Data"
resolves through a small per-protocol table in band_plan.py
(resolve_tune_mode / DATA_MODE_BY_PROTOCOL) — currently populated for
Yaesu CAT only (DATA-U/DATA-L), since Icom's data mode is a separate
CI-V sub-command (0x1A 0x06) and Kenwood/Elecraft's is model-specific
(e.g. K3's DT command) — neither verified against real hardware, so
both intentionally fall back to Voice with a logged warning rather
than guess a wrong CAT string.

Docs: README's Direct Serial / Band Plan sections and
docs/hamlib-integration-notes.md (the Hamlib-direct research doc's
"known gotchas" list) updated to describe both the digit-width quirk
and the new SSTV-mode setting. CHANGELOG and version intentionally
left untouched.

Tests: TestRigPollWorkerTune (main_window), TestYaesuFreqDigitWidth
(serial_rig), TestResolveTuneMode (band_plan) — 348 tests green
across tests/radio, tests/config, tests/ui/test_main_window.py, and
tests/ui/test_settings_dialog.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e poisoning, missing log

Follow-up to the FT-450D / Band Plan fix (0c4e7b9), addressing bucknova's
review on bucknova#47:
bucknova#47 (comment)

## 1. Readback false-fails on async-cached backends (TCI, FlexRadio) — must fix

_RigPollWorker.tune() is the Band Plan path for every backend (the Band
Plan button is gated only on "connected", not connection mode). The
set_freq() -> get_freq() -> raise-on-mismatch sequence added in the
original fix assumed get_freq() is a synchronous radio query. It isn't,
on two of our four backends:

- TciRig.get_freq() returns a locally-cached _last_freq that only
  updates when the server's async push arrives on the receive thread;
  set_freq() is a fire-and-forget send(). The readback ran essentially
  immediately after, so it read the pre-tune value almost every time.
- FlexRig.get_freq() reads self._freq_hz, updated from an async slice-
  status push; set_freq()'s _command() blocks for the reply, but not for
  that separate push.

Worse, the mismatch raise sat before the `if mode:` block, so a false
frequency failure also skipped the mode change — a functional regression
on a previously-working path.

Fix: set_freq(), the mode-change block, and frequency verification each
catch their own exception independently now and collect into an error
list, so a frequency false-positive (or a real failure) can never block
the mode change. Frequency verification is a small settle-and-retry loop
(_verify_freq_settled): on mismatch, sleep ~150 ms and re-read, up to 2
extra attempts (~300 ms total budget, nothing added on the success path),
with a 10 Hz tolerance instead of exact equality to tolerate step-
quantizing rigs. Chosen over a synchronous-backend capability flag (the
reviewer's other suggested option) because it needs no per-backend
classification a future backend could forget to set correctly.

## 2. _freq_digits cache could be poisoned by a garbled response — small fix

YaesuRig.get_freq() cached the detected FA digit width before int()
confirmed the response actually parsed. A noise-garbled but "FA"-
prefixed, correctly-terminated response could poison the cached width
for the rest of the connection. Fixed: the width is cached only after a
successful parse.

## 3. resolve_tune_mode() silently fell back without the promised log — small fix

The original PR description said an unsupported protocol under the
"data" policy falls back to Voice "with a logged warning" — the code
didn't actually log anything, so a user picking Data/Pkt on e.g. Icom
got plain USB with zero indication why. Added a _log.warning in the
fallback branch (only for USB/LSB families — FM has no data-mode concept
at all, so passing it through isn't a missing mapping and doesn't warn).

Tests: TestRigPollWorkerTune gets a new async-catch-up case (get_freq()
side_effect returning stale values before the real one) and the existing
mismatch test now asserts the mode change still ran; TestYaesuFreqDigitWidth
gets two garbled-response cases; TestResolveTuneMode gets caplog-based
assertions that the fallback warns (and that FM, known mappings, and
voice/none policies stay silent). 348 tests green across tests/radio,
tests/config, tests/ui/test_main_window.py, and tests/ui/test_settings_dialog.py.

Two points from the review are explicitly out of scope for this PR per
the reviewer's own note: extending the mode policy to rigctld users, and
using topic branches instead of the fork's main for future PRs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a slim fixed-width column to the right of the RX panel holding three
vertical controls:

- TX gain slider  — mirrors AppConfig.audio_output_gain (same value as
  Settings -> Audio -> Software Gain), pushed live to TxWorker on every
  tick and persisted to disk (debounced) when the slider settles.
- RX gain slider  — mirrors AppConfig.audio_input_gain, same behaviour.
- Colour-zoned dBFS input-level meter fed from the post-input-gain RX
  audio (RxWorker.waterfall_chunk), with green/yellow/red zones and a
  falling peak-hold marker, so the operator can set RX gain by eye.

The sliders stay two-way in sync with the Settings dialog: a Settings
save flows back into the strip via _apply_config, and overdrive expands
the TX ceiling to 200% in the strip too. A pending debounced gain write
is flushed in closeEvent.

New widgets: ui/level_meter.py (LevelMeter), ui/audio_level_strip.py
(AudioLevelStrip). Tests: test_level_meter.py, test_audio_level_strip.py,
plus TestAudioLevelStrip in test_main_window.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@bucknova
bucknova merged commit c462e48 into bucknova:main Sep 8, 2026
9 checks passed
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.

2 participants