Skip to content

fix(gui): dim the Radio Setup headphone row. Principle VIII. - #6353

Merged
NF0T merged 2 commits into
aethersdr:mainfrom
on8st:fix/radio-audio-outputs-gate
Oct 11, 2026
Merged

NF0T merged 2 commits into
aethersdr:mainfrom
on8st:fix/radio-audio-outputs-gate

Conversation

@on8st

@on8st on8st commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Refs #5482

Limits, first

The defect

At b3e0302e, Radio Setup → Audio → "Radio Audio Outputs" offers the radio's headphone level and mute on every radio (src/gui/RadioSetupDialog.cpp:4791-4818, no gate). RadioModel::setHeadphoneGain / setHeadphoneMute only send mixer headphone ... wire text. RadioModel::sendCmd drops that when there is no command plane, so on a Hermes-Lite 2 (or ANAN, Icom) the slider moves and nothing happens. #6044 fixed the same pair in the title bar. This is the second site the triage on #5482 found.

Relation to #6260

#6260 dimmed the Line Out row of the same group, on lineoutControlsAvailable(connected, hasCommandPlane, declaresLineout). This PR adds the Headphone row in the same shape, directly below it, on the predicate the title bar already uses:

row predicate (src/gui/MixerControlAvailability.h) HL2 ANAN Flex
Line Out (#6260) lineoutControlsAvailable, line 43 dimmed live live
Headphone (this PR) headphoneControlsAvailable, line 37 dimmed dimmed live

The title bar's headphone pair is driven from the same accessor (src/gui/MainWindow_Session.cpp:3244), so the two sites cannot disagree. No predicate and no capability field is added or changed.

The change

src/gui/RadioSetupDialog.cpp, buildAudioTab(): the Headphone row (caption, slider, value, mute) takes headphoneControlsAvailable(isConnected(), hasCommandPlane()):

  • unavailable: dimmed, never hidden. The reason is on the tooltip and the accessibleDescription ("Unavailable: this radio has no headphone output. Its audio plays on this computer; set its level with the master volume slider."), the sentence the title-bar slider carries;
  • available (a command plane, or no radio connected): enabled, with no tooltip or description;
  • re-evaluated on connectionStateChanged.

Neither direction writes the level or the mute.

Test

tests/radio_setup_headphone_output_gate_test.cpp (new, offscreen, socket-free; registered like its radio_setup_* siblings):

  1. HL2 (real capabilities, connected, no command plane): all four widgets of the Headphone row are disabled and still visible. The slider and mute carry the reason on tooltip and accessibleDescription. The level and mute are unchanged and no commandDropped fires. On disconnect the row is live again with no reason left on it, and on reconnect it dims again.
  2. ANAN (real capabilities, which declare lineoutControl): the Headphone row is dimmed, the Line Out row stays live with no reason. This pins that the two rows are gated separately.
  3. Demo (command plane): both rows live, no reason.

Fail-before. The test alone on b3e0302e, full build and ctest: 2 of its 3 cases fail, the control passes.

FAIL!  : RadioSetupHeadphoneOutputGateTest::hl2DimsTheHeadphoneRowWithItsReason() 'rowDimmed(hp)' returned FALSE. (HL2: the whole Headphone row is dimmed)
FAIL!  : RadioSetupHeadphoneOutputGateTest::ananDimsHeadphoneButKeepsLineOut() 'rowDimmed(hp)' returned FALSE. (ANAN: the Headphone row is dimmed)
PASS   : RadioSetupHeadphoneOutputGateTest::connectedCommandPlaneUnchanged()
Totals: 3 passed, 2 failed, 0 skipped, 0 blacklisted

With the change the test passes.

Checks

  • Full ctest at 6df430e3 (macOS, Qt 6.12.0, -j8, failures rerun serially): 759 tests, 753 pass, 4 skipped (nr_stereo_independence_dfnr_24000/48000, _bnr_24000/48000), 2 fail. Both fail identically in the test-only run on b3e0302e, so neither comes from this change:
  • tools/check_a11y.py on RadioSetupDialog.cpp: 9 warnings, the same 9 as on b3e0302e, none on the new block. check_capability_records (70, unchanged), check_command_plane, check_engine_boundary, check_test_registration, check_wdsp_test_links, check_qsettings, check_ci_test_gate, check_build_options, check_network_timeouts, check_qt_pin, check_shader_dialects, check_theme_seed with --strict: OK. gen_touchpoint_manifest.py --check: up to date.

73, ON8ST

🤖 Generated with Claude Code

Refs aethersdr#5482

Radio Setup -> Audio -> "Radio Audio Outputs" offers the radio's headphone
level and mute on every radio. RadioModel::setHeadphoneGain/Mute only send
`mixer headphone ...` wire text, which RadioModel::sendCmd drops when there is
no command plane, so on a Hermes-Lite 2 (or ANAN, Icom) the slider moves and
nothing happens.

The row (label, slider, value, mute) now uses the title bar's predicate,
headphoneControlsAvailable(isConnected(), hasCommandPlane()): dimmed, not
hidden, with the title-bar slider's reason on tooltip and
accessibleDescription; restored on disconnect; re-evaluated on
connectionStateChanged.

The Line Out row above it keeps its own gate, lineoutControlsAvailable (aethersdr#6260):
the two rows are gated separately, so on an ANAN, which declares
RadioCapabilities::lineoutControl, Line Out stays live while Headphone dims.

Test: radio_setup_headphone_output_gate_test, socket-free, with the
capabilities the real hl2 and anan backends declare and the Demo as the
command-plane control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st
on8st requested a review from a team as a code owner October 10, 2026 22:06

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Issue fit

Yes. Refs #5482 is the honest relation: #5482 itself is the title-bar site, answered by #6044, and this PR takes the one remaining site the triage comment on that issue named and the author's 2026-10-04 follow-up comment left open — "the 'Radio Audio Outputs' group in Radio Setup → Audio … still offers the line-out and headphone controls on a radio with no command plane". Line Out was gated by #6260 (merged); this closes the headphone half, so every caller of the two setters is now gated. I grepped setHeadphoneGain|setHeadphoneMute across src/: the only callers are the title-bar connects (MainWindow.cpp:2055, :2088, gated at MainWindow_Session.cpp:3244), the wheel action (MainWindow_Controllers.cpp:1555, gated at :1545), the MIDI knob (:2342), and this row — no TCI, CAT, automation or rigctld path reaches them, so the set really is closed. The load-bearing premise checks out: RadioModel::setHeadphoneGain/setHeadphoneMute are wire-only (sendCmd("mixer headphone gain %1") / mute %1, no local-audio arm), so dimming removes nothing an HL2/ANAN/Icom operator could hear.

The triage's own suggestion was one outGroup->setEnabled(...) for all four controls. The PR deliberately does not do that, and is right not to: an ANAN declares RadioCapabilities::lineoutControl, so a group-level gate would dim a Line Out row that works. Gating the two rows on their two predicates is the better answer and the body says so.

Scope

File What it changes Claimed? Verdict
src/gui/RadioSetupDialog.cpp +23: one applyHeadphoneAvailability lambda, its initial call, one connect to connectionStateChanged Yes In scope
tests/radio_setup_headphone_output_gate_test.cpp New socket-free test, 3 cases incl. a positive control Yes In scope
tests/tests.cmake Registers the target; adds it to AETHER_SETTINGS_CONSUMERS Yes In scope

Everything in the diff is explained by the issue. No CHANGELOG.md entry (correct). No new public/protocol surface, no settings key, no capability field, no default changed. Nothing removed — the diff is additive only (git diff --stat: 291 insertions, 0 deletions), so there is no deleted guard or comment to account for. The tests.cmake block is a verbatim clone of the radio_setup_recording_mode_dim_test block above it (same source list, same aetherdesktop_support link, same QT_QPA_PLATFORM=offscreen + TIMEOUT 60), which is also why the settings-consumer registration is right.

Preference check: this is a fix, not a preference. #5482's acceptance criteria name the behaviour ("visibly dimmed with a reason; accessibleDescription carries it"), the three-state doctrine from #5262 forbids hiding, and the PR dims rather than hides. The reason string is lifted from TitleBar.cpp:1300-1302 rather than invented.

Socket tests: none added or removed. The new test is socket-free in fact, not just in claim — the injected NoCommandPlaneBackend never touches a transport; realCapabilities() builds a real Hl2Backend/AnanBackend, and I read both constructors: MetisClient and P2Client are constructed but neither binds nor connects until connectRadio(), which is never called. The command-plane control goes through SimBackend, whose connectRadio() just sets m_connected = true and emits — no socket despite the LocalHost:4992 in the RadioInfo (the comment "never dialled" is accurate).

Blockers

None.

Nits (non-blocking)

  • src/gui/RadioSetupDialog.cpp:4827-4829 — the mute button is given the volume slider's reason. The body discloses this ("One reason text for the whole row … not the title bar's separate mute sentence"), so this is a maintainer call rather than an oversight — raising it because the divergence from the surface this PR says it mirrors is the kind of thing that gets re-filed later:
            const QString reason = available
                ? QString()
                : QStringLiteral("Unavailable: this radio has no headphone output. "
                                 "Its audio plays on this computer; set its level "
                                 "with the master volume slider.");
    TitleBar::setHeadphoneAvailable splits this deliberately — TitleBar.cpp:1296-1303 gives the mute button "…mute it with the speaker button." and only the slider "…set its level with the master volume slider." Here all four widgets share the slider's text, so a screen-reader user on Mute is told how to change the level. The row's own Line Out twin has the same single-string shape, so it is consistency-with-the-sibling rather than a regression, but if you want the title bar's wording verbatim:
        auto applyHeadphoneAvailability = [this, hpLabel, hpSlider, hpValue, hpMute] {
            const bool available = AetherSDR::headphoneControlsAvailable(
                m_model->isConnected(), m_model->hasCommandPlane());
            const QString prefix = QStringLiteral(
                "Unavailable: this radio has no headphone output. "
                "Its audio plays on this computer; ");
            const QString levelReason = available
                ? QString()
                : prefix + QStringLiteral("set its level with the master volume slider.");
            const QString muteReason = available
                ? QString()
                : prefix + QStringLiteral("mute it with the speaker button.");
            for (QWidget* w : {static_cast<QWidget*>(hpLabel),
                               static_cast<QWidget*>(hpSlider),
                               static_cast<QWidget*>(hpValue)}) {
                w->setEnabled(available);
                w->setToolTip(levelReason);
                w->setAccessibleDescription(levelReason);
            }
            hpMute->setEnabled(available);
            hpMute->setToolTip(muteReason);
            hpMute->setAccessibleDescription(muteReason);
        };
    (The test's announcesReason() checks startsWith("Unavailable:"), contains("no headphone output"), contains("master volume") and toolTip() == accessibleDescription(), so the mute assertion would need its substring changed with it.)
  • tests/radio_setup_headphone_output_gate_test.cpp:189 — the ANAN case asserts !model.hasCommandPlane() but never model.isConnected(), unlike the HL2 case. It cannot pass vacuously (a disconnected radio would make the row live and rowDimmed would fail), so this is cosmetic symmetry only.
  • tests/radio_setup_headphone_output_gate_test.cpp — announcesReason() is asserted on the slider and the mute only; hpLabel and hpValue are checked for isEnabled() but their reason text is pinned only in the negative (rowHasNoReason). Cheap to extend, not worth a round trip.
  • The two known failures the body names match what I have on record for this tree — peripheral_auth_dialog_interaction_test is the #6008 clipped-page check on a host serial-port name, and unified_title_bar_test's two checks are #6224. The hand comparison offered for the first one (page minimum still 784, no widget added) is the right check to have made, given this PR does touch RadioSetupDialog.cpp. I could not re-run either.

What I tried to break

  • The missing capabilitiesChanged connect. The Line Out twin 35 lines above (RadioSetupDialog.cpp:4785-4789) wires both connectionStateChanged and capabilitiesChanged; this hunk wires only the former, which looks like a copy-paste omission. I went after it from both ends. headphoneControlsAvailable reads only hasCommandPlane(), which is m_wanConn != nullptr || m_connection != nullptr (RadioModel.h:608), set in setupBackend() (RadioModel.cpp:885; :914 for the Sim) and cleared in teardownBackend() (:1800) — so a capability edge cannot move it. Then the inverse, which is the one that would actually bite: are there capabilitiesChanged emissions on edges connectionStateChanged misses? Yes — publishCapabilities() has four call sites (RadioModel.cpp:1150, :1161, :1173, :2195): the connectionStateChanged relay, a meterDefined/meterRemoved edge that flips hasMicPeakMeter/hasSupplyVoltage, and the backend's own capabilitiesChanged. None of those three extra edges touches m_connection or m_wanConn. The remaining window is the live family switch in connectToRadio() (:3683), which tears the backend down mid-session — but neither signal fires there (rebuildBackendForFamily emits only backendRebuilt), so the Line Out row is equally stale, and the connect attempt that follows raises the edge both rows need. Conclusion: the second connect would be a no-op for this predicate. Still worth a maintainer's one-line ruling on whether two adjacent rows should nonetheless look alike; note the test leg hand-emits connectionStateChanged, so it would not catch a regression here either way.
  • Blanking the widgets' own tooltips. setToolTip(QString()) on the available branch is exactly the failure mode #5859/#6253 is about, so I grepped every hpLabel/hpSlider/hpValue/hpMute reference in the file (lines 4793-4876): none of the four is ever given a tooltip or an accessibleDescription of its own, and the audioOutputChanged reconciler at :4866 writes values only. Nothing is clobbered. Same for the enabled state — no other site touches these four.
  • Would the test pass against unfixed code? No, and the body backs that empirically rather than by assertion: it pastes the fail-before run on b3e0302e with the two rowDimmed(hp) failures and the control passing. My own read agrees — findRow() is keyed on the operator-visible caption "Headphone:", which exists on main, so the lookup succeeds and the assertion fails rather than skipping. The connectedCommandPlaneUnchanged positive control means the three cases can't pass on a row dimmed for every connected radio, and dropped.count() == 0 plus the gain/muted round-trip means they can't pass on a hunk that also writes the level.
  • Compile risk from the fake backend. NoCommandPlaneBackend overrides 11 methods; IRadioBackend has exactly 11 pure virtuals (capabilities, connectRadio, disconnectRadio, isConnected, setSliceFrequency, setSliceMode, setSliceFilter, setSliceAgc, setPanCenter, setKeying, invokeExtension), and setBackendForTest's third parameter defaults, so the two-argument call compiles. All five checks are green on a0ea328 — build, check-macos and check-windows each compiled with the new target registered, so the test source builds on every lane that reaches it. Note the new test is not in any ctest -R filter in ci.yml — it is build-only until full-suite.yml picks it up after merge.
  • CodeGuard's 20 findings. All 20 are false positives, verified line by line in the head checkout — credit security-audit (CodeGuard): :181 is if (!manager || id.isEmpty()), a C++ || and not a shell pipe; the four CS-VLN-CMDI-AMP hits (:179, :5118, :5809, :5965) are const QString& / const QByteArray& reference declarators; all fourteen CS-VLN-CMDI-BACKTICK hits are Markdown-quoted Flex/HL2 wire verbs inside // comments (apd configurable=1, freqcal, interlock set, file update failed=, …) with no QProcess/system()/popen anywhere near them; and CS-INJ-homoglyph is em-dashes in comments and label text, no mixed-script identifier. None is in the added hunk, and the hunk builds no string from input — the reason is a compile-time QStringLiteral.
  • Principle VI / the drop notice. Dimming means the control cannot be moved, so nothing reaches sendCmd and the once-per-session "nothing was sent to the radio" notice is no longer burned by a headphone move — the opposite of the #5919 concern, and the test pins commandDropped at zero. No TX path is touched. Hl2Backend's constructor does set m_txAllowed/enableTransmit(true) when the test builds one for its capabilities, but connectRadio() is never called and no keying request is made.
  • a11y lint. tools/check_a11y.py src/gui/RadioSetupDialog.cpp reports 9 warnings, every one on a pre-existing line (:2056, :2152, :2156, :2230, :2379, :2392, :4591, :4599, :6304). The new block is clean — it writes accessibleDescription beside the tooltip, which is what the lint asks for.

All of the above is reasoned from the head checkout at /tmp/aetherclaude/pr-6353 and from CI status; I have no build and no way to drive the dialog, so nothing here was reproduced at runtime. Not checked, and named as such: whether an Icom actually has a headphone mixer (#5482 leaves that open and the predicate gates it either way); how the row looks on screen; and the body's own local numbers (759 tests, 753 pass, 4 skipped, 2 known failures) — that claim stands or falls on the author's run, and it is consistent with the two failures I have on record here.

Recommendation

Approve with nits. Small, additive, correctly scoped, reuses the predicate and the wording the title bar already shipped, and the test is honest — a positive control, a findRow lookup that fails rather than skips against unfixed code, and a check that the dimming moves no level and drops no command. The one thing I would change before merge is the mute button's reason string (:4827); the capabilitiesChanged asymmetry with the Line Out row deserves a maintainer's one-line ruling but is a no-op either way. Thanks ON8ST — the "Limits, first" section did real work here: the two-rows-gated-separately table stopped me writing a wrong-footed finding about the ANAN case, and disclosing the single reason string up front is the difference between a nit and an argument.


🤖 aethersdr-agent · cost: $15.1509 · model: claude-opus-5

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (20 finding(s))

  • [CRITICAL] CS-VLN-CMDI-PIPE — Vulnerability: CS-VLN-CMDI-PIPE in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:181
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:179
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:5118
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:5809
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:5965
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:844
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:1003
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:1333
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:1544
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:1829
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:2575
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:2913
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:3414
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:3834
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:4309
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:6721
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:6878
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:6968
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:7167
  • [HIGH] CS-INJ-homoglyph — Mixed Unicode scripts detected (possible homoglyph attack) in src/gui/RadioSetupDialog.cpp /tmp/aetherclaude/pr-6353/src/gui/RadioSetupDialog.cpp:0

Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them.


🤖 aethersdr-agent · cost: $15.3397 · model: claude-opus-5

Review of aethersdr#6353: the dimmed mute button carried the level slider's
reason, so a screen reader on Mute said how to change the level. It now
carries the title bar's mute sentence (TitleBar::setHeadphoneAvailable);
label, slider and value keep the level sentence.

The test compares all four widgets with the title bar's two sentences
in full, on the HL2 and the ANAN case, and the ANAN case asserts the
injected radio is connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Fixed in 6df430e3, a new commit on top (no rebase).

  • The mute button now carries the title bar's mute sentence, "…mute it with the speaker button." Label, slider and value keep the level sentence. Both are the strings in TitleBar::setHeadphoneAvailable, word for word.
  • The test compares all four widgets with those two sentences in full, tooltip and accessible description, on the HL2 and on the ANAN case. That also covers the label and the value, which were only checked in the negative.
  • The ANAN case now asserts that the injected radio is connected.

Not changed: the capabilitiesChanged connect. As you worked out, it cannot move this predicate; if the maintainer wants the two rows to look alike, it is one line.

Full ctest at 6df430e3: 759 tests, 753 pass, 4 skipped, 2 fail. Those are the same two as before, #6008 and #6224, and the dialog page minimum is still 784. The PR body is updated.

73, ON8ST

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approve

Small, additive, correctly scoped. The Headphone row takes the same predicate the title bar already uses (headphoneControlsAvailable) and the title bar's two sentences, word for word. I checked that against TitleBar.cpp:1296-1302. The Line Out row is left alone, which is right: an ANAN declares lineoutControl, so a group-level setEnabled would have dimmed a row that works. The "Limits, first" section and the two-rows-gated-separately table made this quick to review. Thank you for that.

Scope

File Change Claimed? Verdict
src/gui/RadioSetupDialog.cpp +30: one applyHeadphoneAvailability lambda, its initial call, one connect Yes In scope
tests/radio_setup_headphone_output_gate_test.cpp New, 3 cases incl. a positive control Yes In scope
tests/tests.cmake Registers the target, adds it to AETHER_SETTINGS_CONSUMERS Yes In scope

Additive only (+311/−0). No CHANGELOG.md entry, no new public surface, no capability field, no changed default, no deleted guard. It is a fix, not a preference: #5482 names the behaviour (dimmed, reason announced) and the three-state doctrine forbids hiding. CODEOWNERS: * and tests/ only, so Tier 3 (@aethersdr/reviewers). Docs: audio-settings.md already says "On radios without one of these outputs, the matching controls are dimmed with the reason in the tooltip", so this change brings the code in line with the docs and none are owed.

CI limits

build, check-macos, check-windows, Static checks and Sanitizer option configures are green on 6df430e3. The new test is not in the frozen per-PR allow-list, so CI only compiled it. It first runs on main via full-suite.yml. The run below is the only execution of it on Windows. The PR is 10 commits behind main and merges cleanly.

What I tried to break

Built the merge result of main (d6402657) + this PR on Windows (MinGW 13.1, Qt 6.12.0, ENABLE_ASR=OFF), not the branch tip alone.

  • Test passes with the change: radio_setup_headphone_output_gate_test, radio_setup_recording_mode_dim_test, mixer_control_availability_test all pass.
  • Fail-before reproduced. With the RadioSetupDialog.cpp hunk reverted: hl2DimsTheHeadphoneRowWithItsReason and ananDimsHeadphoneButKeepsLineOut fail on rowDimmed(hp), and connectedCommandPlaneUnchanged passes. This matches your numbers.
  • Two finer mutations, both caught. (1) Deleting the connectionStateChanged connect fails rowLive(hp) ("disconnected: the row is live again"). (2) Giving the mute button the level sentence fails rowAnnouncesItsReasons(hp) on both the HL2 and ANAN cases. So the mute-wording fix in 6df430e3 is pinned.
  • Connect and disconnect ordering, from source. On connectionStateChanged(true) for HL2, Hl2Backend sets m_connected = true (:427) before emit connected() (:450), which reaches RadioModel::onConnected (:6959, emit at :7012), so isConnected() is already true. For Flex, m_connection is set in setupBackend() (:885) and m_wanConn at :3813, both before the edge. On disconnect isConnected() is false, so the row goes live whichever pointer is still set. The row cannot be wrongly dimmed on a Flex connect.
  • The missing capabilitiesChanged connect. The predicate reads only hasCommandPlane() and isConnected(). I traced where m_connection/m_wanConn are assigned and cleared, and none of those edges is a capabilities-only edge. I agree it is a no-op for this predicate. If you want the two adjacent rows to look alike, it is one line, and I would not hold the PR for it.
  • Step 0 (socket boundary). The new test opens no socket. NoCommandPlaneBackend is a stub with no transport, and SimBackend has zero QTcp*/QUdp*/QLocal*/QWebSocket* references. The LocalHost:4992 in RadioInfo is never dialled.
  • Bridge, Demo only (DEMO-0001, AETHER_AUTOMATION_NO_TX=1, isolated settings dir, own socket). Radio Setup → Audio on the PR build: Line Out and Headphone rows live, both Mute buttons enabled: true, no tooltip or accessible description on them. The title-bar headphone pair is live too. I closed my instance afterwards.
  • Not tested: HL2 or ANAN in the running app (the Demo is the only no-hardware backend, and it has a command plane), so the HL2 and ANAN rows are covered by the injected-backend test and by reading the code, not by driving the app. macOS and Linux were not run. The 20 CodeGuard findings in the timeline are on lines this PR does not touch. I checked only that the added hunk builds no string from input (compile-time QStringLiteral, no process or shell call).

Forward guidance (non-blocking)

  1. "Dimmed" is visible on the slider only. I rendered the group box on the HL2 case and on the Demo case and compared pixels region by region:

    Region Differing pixels, dimmed vs live
    Headphone label 0 / 1504
    Headphone slider groove 2350 / 9528
    Headphone value label 0 / 432
    Headphone Mute button 0 / 864
    Line Out Mute button (#6260, merged) 0 / 918

    The label, value and Mute button carry explicit stylesheets with no :disabled rule, so they look identical when disabled. They are still disabled (they do not respond) and still announce the reason, so this does not block the PR. It is the same shape as the merged Line Out row, and changing only one row would make the two disagree. theme-style-guide.md §4a names color.control.unavailable for the unavailable state, so a small follow-up covering both rows (a :disabled rule on the Mute buttons and a disabled colour on the labels) seems right to me. That is your call or the maintainer's. The pixels came from a throwaway grab() in my local copy of the test; nothing from it is in the PR.

  2. The reason sentences now live in three places (TitleBar.cpp, this lambda, and the test's kLevelReason/kMuteReason). "The two sites cannot disagree" holds for the predicate, but not for the wording: if the title bar's text changes, this test still passes against its own copy. Hoisting the two sentences next to the predicate in MixerControlAvailability.h would make that structural. It is not worth a round trip here.

Squash subject

fix(gui): dim the Radio Setup headphone row. Principle VIII. (#6353)

Principle VIII (Evidence Over Assertion) fits: the claim is backed by a functional check that fails before and passes after.

73, Ryan NF0T


👨🏼‍💻 Co-authored by Claude Sonnet 5.5

@NF0T
NF0T merged commit 9fabcbf into aethersdr:main Oct 11, 2026
5 checks passed
@NF0T NF0T removed their assignment Oct 11, 2026
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 11, 2026
Review of aethersdr#6388, nit 1: :checked and :disabled tie on specificity, so a
Mute engaged on an unavailable Radio Audio Outputs row kept the engaged
background under unavailable text. QPushButton:checked:disabled now sets
the row's own unchecked background, color.background.1, and the
unavailable token. A control renders in one of three states
(theme-style-guide.md, Three-state controls), and this one is
unavailable: engaged or not, it renders the same.

The test engages both rows' Mute in both themes with the toggled signal
blocked, so no model setter runs and commandDropped stays at 0. The
dimmed button has no #8b0000 pixel, the live one does, and the dimmed
button renders as it does not engaged.

Nit 2: a size mismatch reports "regions differ in size", not "dimmed
renders as live". Nit 3: an RAII guard restores the starting theme on
every exit from the slot. The header comment says the Line Out reason is
not one of the hoisted sentences.

Refs aethersdr#5482, aethersdr#6353

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 11, 2026
Conflicted file: tests/tests.cmake, in two places.

1. The registration block after radio_setup_recording_mode_dim_test.
   Main added radio_setup_headphone_output_gate_test there (aethersdr#6353,
   9fabcbf); this branch has radio_setup_show_tx_waterfall_test in the
   same spot. Both blocks are kept whole, main's first, ours after it.
   The two source lists are identical, so ours links what main's sibling
   links.
2. AETHER_SETTINGS_CONSUMERS: both names kept, main's first.

The merged file is origin/main plus exactly this branch's 40 lines;
nothing from either side is dropped.

No other file conflicted. RtlSdrBackend.cpp, SimBackend.cpp,
RadioSetupDialog.cpp and RadioModel.cpp merged as text; each still
states txWaterfallClientFlag next to main's new receiveAudioExport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rfoust pushed a commit that referenced this pull request Oct 12, 2026
#6388)

Refs #5482, #6353

This is the follow-up to the two forward notes in the approving review
of #6353.

## Limits, first

- **macOS, offscreen platform only** (Qt 6.12.0). The pixels are from
`QWidget::grab()` in a test. Nobody looked at the dialog on a screen,
and there is no screenshot.
- **Two themes rendered: Default Dark and Default Light.** No user
theme.
- **The Line Out row's styling is touched; its logic is not.** That row
is @jcmerg's (#6260). His predicate, his reason text and his
`applyLineoutAvailability` lambda are unchanged. Only the three
stylesheets of the row gain a `:disabled` rule, the same one the
Headphone row gets, so the two rows keep agreeing.
- **The Front Speaker row is not touched.** It is never disabled.
- **A dimmed Mute looks the same engaged or not** (from this PR's
review, `c0bfd883`). An engaged Mute on an unavailable row took the
unavailable text on the engaged red background. It now takes the row's
own unchecked background (`color.background.1`), so the engaged state is
not shown while the row is unavailable. §4a names no look for a control
that is unavailable and also has state, so whether the state should stay
visible is the maintainer's call. The checked state is still on the
widget for accessibility clients.
- **Contrast is what the token gives.** `color.control.unavailable` is
2.09:1 (dark) and 1.77:1 (light) by the style guide's own table (§4a).
This PR uses the token and does not change it.
- **The Line Out reason sentence is not hoisted.** It exists in one
place only (`src/gui/RadioSetupDialog.cpp:4774-4775` at `9fabcbf8`); no
other source file and no test repeats it.
- `Refs`, not `Fixes`: closing #5482 stays your call.

## What was wrong

At `9fabcbf8`, in Radio Setup → Audio → "Radio Audio Outputs", a
disabled row shows it on the slider only. The other three widgets of
each row carry their own stylesheet, and none has a `:disabled` rule:

| widget | stylesheet | applied at (Line Out / Headphone) |
|---|---|---|
| caption label | `kLabelStyleTemplate`, `QLabel { color:
{{color.text.primary}}; … }` (`src/gui/RadioSetupDialogCommon.h:31-32`)
| `src/gui/RadioSetupDialog.cpp:4740` / `:4794` |
| value label | `kValueStyleTemplate`, `QLabel { color:
{{color.accent.bright}}; … }` (`RadioSetupDialog.cpp:129-130`) | `:4745`
/ `:4799` |
| Mute button | inline `QPushButton { … color: {{color.text.primary}}; …
}` plus `:checked` | `:4751-4753` / `:4805-4807` |
| slider | none of its own | `:4742` / `:4796` |

An explicit `color` with no `:disabled` rule also applies when the
widget is disabled, so the label, the value and the Mute button render
the same in both states. The review of #6353 measured 0 differing pixels
on them; the test here reproduces that (below).

The two headphone reason sentences were written out three times:
`src/gui/TitleBar.cpp:1297-1302`,
`src/gui/RadioSetupDialog.cpp:4826-4834`, and
`tests/radio_setup_headphone_output_gate_test.cpp:134-139`.

## The change

**The look.** A disabled label, value or Mute button of the two rows
takes `color.control.unavailable`, the token §4a of
`docs/style/theme-style-guide.md` names for the unavailable state:

```
QLabel:disabled      { color: {{color.control.unavailable}}; }
QPushButton:disabled { color: {{color.control.unavailable}}; }
```

This is the pattern already in the tree for the TX timing fields (#6166,
`RadioSetupDialog.cpp:2565-2567`, `QLineEdit:disabled`) and for
`WfmApplet.cpp:58`. The rules go through
`ThemeManager::applyStyleSheet`, so they follow a theme switch like
their neighbours. No colour literal is added. The enabled look is
unchanged: the rule is appended to the existing template, and only for
these six widgets. `makeValueLabel` takes an optional extra rule so
`kValueStyleTemplate` keeps its single reference. The shared
`applyLabelStyle` is not changed, so no other page of the dialog moves.

**The wording.** `src/gui/MixerControlAvailability.h` now holds the two
sentences next to the predicate, as `kHeadphoneLevelUnavailableReason`
and `kHeadphoneMuteUnavailableReason`. They are `constexpr char[]`, so
the header stays free of Qt. `TitleBar::setHeadphoneAvailable`, the
Radio Setup lambda and both tests read them from there. The text itself
is unchanged, character for character.

No predicate, no capability field, no default and no user-visible string
changes. `docs/user/docs/audio-settings.md` already says the controls
"are dimmed with the reason in the tooltip", which stays true.

## Tests

`tests/radio_setup_headphone_output_gate_test.cpp` gains
`dimmedRowsLookUnavailableInBothThemes` (offscreen, no socket). For each
of Default Dark and Default Light it builds the dialog on the HL2
capabilities (both rows dimmed) and on the Demo (both rows live), and
for the label, value and Mute button of each row:

- renders the widget's own region with `grab()` and counts differing
pixels, exactly, no tolerance. It requires more than 0 between dimmed
and live;
- first asserts the two renders are comparable: same size, same value
text, same mute state. So the enabled state is the only difference;
- control 1: the same widget rendered twice differs in 0 pixels;
- control 2: after a disconnect the same HL2 widgets are live again and
render pixel-identical to the Demo's, and different from their own
dimmed render;
- requires the resolved stylesheet to carry the colour
`color.control.unavailable` has in the active theme.

With the change, differing pixels (measured at `6c83ac5a`, the same diff
before it was rebased onto current `main`; the test that asserts them
passes at the head), dimmed vs live, identical in both themes:

| region | Headphone | Line Out |
|---|---|---|
| label | 1329 / 6840 | 852 / 6840 |
| value | 410 / 2280 | 410 / 2280 |
| Mute | 512 / 3800 | 512 / 3800 |

**Fail-before.** Named mutation: this commit with both `:disabled` rule
strings emptied, which is main's styling. Full build and ctest. All
twelve regions read 0 differing pixels, as in the review:

```
PIXELS Default Dark | Headphone: label: dimmed vs live 0 / 6840 (same widget twice: 0)
PIXELS Default Dark | Headphone: value: dimmed vs live 0 / 2280 (same widget twice: 0)
PIXELS Default Dark | Headphone: mute: dimmed vs live 0 / 3800 (same widget twice: 0)
PIXELS Default Dark | Line Out: label: dimmed vs live 0 / 6840 (same widget twice: 0)
PIXELS Default Dark | Line Out: value: dimmed vs live 0 / 2280 (same widget twice: 0)
PIXELS Default Dark | Line Out: mute: dimmed vs live 0 / 3800 (same widget twice: 0)
(the same six lines for Default Light)
FAIL!  : RadioSetupHeadphoneOutputGateTest::dimmedRowsLookUnavailableInBothThemes() 'failures.isEmpty()' returned FALSE. (Default Dark | Headphone: label: dimmed renders as live
Default Dark | Headphone: label: not the unavailable token #3a4a5a
…
Totals: 5 passed, 1 failed, 0 skipped, 0 blacklisted
```

The test could not be run on unmodified main as it stands, because it
reads the sentences from the header. That is why the fail-before is a
mutation and not "the test alone".

**The wording.** `radio_setup_headphone_output_gate_test` compares the
row against the header's two constants.
`tests/mixer_control_availability_test.cpp` gains two rows: the title
bar's mute and slider descriptions equal the header's sentences. Its
existing rows still check the content independently ("Unavailable:", "no
headphone output", "speaker button", "master volume").

## ctest

macOS (Apple Silicon), Qt 6.12.0, `ENABLE_ASR=OFF`, full suite at `-j8`,
failures rerun serially, on `c0bfd883` (this PR's head):

- **765 tests, 763 passed, 2 failed**, both also on the serial rerun:
the dialog test below, and `vkamp_connection_test` with only its two
known checks (#5254).
- `peripheral_auth_dialog_interaction_test` fails, at `-j8` and
serially: "Clear code or Status is clipped at the default dialog size",
`dialog 960x680, viewport 658, page minimum 784`. This is the
host-dependent failure of #6008 (diagnostic in #6134): the Peripherals
page is as wide as the longest serial-port name on this Mac. The page
minimum is 784, the same figure as on clean main here. This PR does not
touch the Peripherals page.
- `radio_setup_headphone_output_gate_test` (6 passed),
`mixer_control_availability_test`, `titlebar_headphone_mute_test`,
`unified_title_bar_test` and `radio_setup_label_theme_token_test` pass.

Static checks, run locally: `tools/check_a11y.py` on
`RadioSetupDialog.cpp` 9 findings before and 9 after, `TitleBar.cpp` 0;
`tools/audit_colours.py` summary identical before and after;
`gen_touchpoint_manifest.py --check` and `docs/gen_reference.py --check`
up to date; the `tools/check_*.py --strict` set passes
(`check_codeql_coverage.py` needs a build directory and was not run).

73, ON8ST

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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