Skip to content

feat(audio): mute-all-slices button + fix stale mute display after reconnect#2833

Open
chibondking wants to merge 5 commits into
aethersdr:mainfrom
chibondking:feat/mute-all-slices
Open

feat(audio): mute-all-slices button + fix stale mute display after reconnect#2833
chibondking wants to merge 5 commits into
aethersdr:mainfrom
chibondking:feat/mute-all-slices

Conversation

@chibondking
Copy link
Copy Markdown
Collaborator

Add a mute-all toggle button (🔇) to the RxApplet slice tab row; clicking mutes all slices when any are unmuted, unmutes all when all are muted. Also registers a mute_all_slices_toggle keyboard shortcut in MainWindow.

Fix: SliceModel::applyStatus() now resets m_audioMute to false when a full status (in_use=1) arrives without an audio_mute key. SliceModels survive reconnect, but the radio resets audio_mute to 0 on reconnect without echoing the key (it is the default). The stale true caused the button to show 🔇 while audio was playing.

Reported by Robbie KI4TTZ (@rfoust): intermittent "slice shows as muted but audio is playing" after a reconnect.

Root cause

RadioModel::onDisconnected() clears panadapters, meters, and other models on disconnect, but does not delete SliceModel objects. They survive reconnect so the UI does not flicker — this is intentional for SmartConnect fast-reconnect, but it creates a stale-state hazard.

The radio does not persist audio_mute across sessions (see the migration comment in MainWindow.cpp around line 1265). After any disconnect — even a brief network drop — the radio resets all slice audio_mute values to 0. When it sends the full slice status on reconnect, it only includes non-default values. Since audio_mute=0 is the default, the key is omitted entirely from the reconnect status message.

The existing SliceModel::applyStatus() code only updates m_audioMute when kvs.contains("audio_mute") is true. So a slice that was muted before the drop would keep m_audioMute = true indefinitely after reconnect — the button shows 🔇 and the model reports muted, but the radio is playing audio.

Fix

applyStatus() now has an else if branch that fires when audio_mute is absent from the KVS and in_use=1 is present (the marker for a full status, not a partial incremental update). In that case m_audioMute is reset to false and audioMuteChanged(false) is emitted. This is safe because:

The radio is documented as not persisting audio_mute.
Full-status messages (in_use=1) arrive only on initial slice creation or reconnect — not on the frequent partial-update messages that carry only changed keys.
If the radio genuinely wants the slice muted after reconnect, it will include audio_mute=1 in the full status, which the existing branch handles normally.

…connect

Add a mute-all toggle button (🔇) to the RxApplet slice tab row; clicking
mutes all slices when any are unmuted, unmutes all when all are muted.
Also registers a `mute_all_slices_toggle` keyboard shortcut in MainWindow.

Fix: SliceModel::applyStatus() now resets m_audioMute to false when a full
status (in_use=1) arrives without an audio_mute key. SliceModels survive
reconnect, but the radio resets audio_mute to 0 on reconnect without echoing
the key (it is the default). The stale true caused the button to show 🔇
while audio was playing. See commit_msg.txt for full root-cause analysis.

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

@AetherClaude thoughts on this?

@aethersdr-agent
Copy link
Copy Markdown
Contributor

@chibondking — I traced the claim through the code and the analysis holds up cleanly:

  • RadioModel::onDisconnected() (RadioModel.cpp:2238) clears panadapters, meters, transmit/tuner/xvtr state, etc., but never touches m_slices — so SliceModel lifetimes do indeed span reconnects.
  • in_use=1 is already canonical for "full status" in this codebase. RadioModel::handleSliceStatus() (RadioModel.cpp:4337-4341) refuses to even create a SliceModel from a status that lacks in_use=1, so using the same marker to detect a full status inside applyStatus() is consistent with how the rest of the protocol layer treats it.
  • The migration block at MainWindow.cpp:1264-1275 explicitly documents that the radio does not persist audio_mute, and that restoring it client-side caused the same "Slice A starts muted" class of bug. The reconnect case is the unfixed sibling of that one.
  • SliceModel::applyStatus() (SliceModel.cpp:613-619) does only react when the key is present, confirming the stale-state path described.

The proposed else if is the right shape. One small refinement I'd suggest — guard the emit so we don't fire audioMuteChanged(false) on every full-status message for slices that were already unmuted (initial-create path always goes through this branch since m_audioMute is constructed false, and partial updates that happen to carry in_use=1 would also no-op):

if (kvs.contains("audio_mute")) {
    bool mute = kvs["audio_mute"] == "1";
    if (mute != m_audioMute) {
        m_audioMute = mute;
        emit audioMuteChanged(mute);
    }
} else if (kvs.value("in_use") == "1" && m_audioMute) {
    // Full status w/o audio_mute key → radio reset to default (0)
    // on (re)connect. Resync so UI doesn't show a stale 🔇 while
    // audio is actually playing. Radio does not persist audio_mute
    // (see MainWindow.cpp:1264 migration note).
    m_audioMute = false;
    emit audioMuteChanged(false);
}

Two things worth thinking through for the mute-all button while you're in there:

  1. RADE / FreeDV interaction. MainWindow::m_radePrevMute (MainWindow.cpp:13803) stashes mute state across RADE mode transitions and around line 14030 restores it when leaving the FDV family. A mute-all toggle that calls setAudioMute() directly should be fine since it goes through the normal path, but it's worth verifying that mute-all while a slice is in a RADE-managed state doesn't leave m_radePrevMute pointing at a now-incorrect baseline.

  2. Foreign / unowned slices. m_ownedSliceIds gates per-slice handling elsewhere — mute-all should probably iterate only owned slices so a multi-flex session doesn't try to mute another client's slice (which the radio will reject anyway, but it's noise on the wire and confusing in logs).

The keyboard shortcut should slot into the existing registerShortcutActions() pattern (MainWindow.cpp:1389) rather than being a free QShortcut like the Ctrl+M minimal-mode toggle — that path participates in the s_keyboardShortcutsEnabled / shortcutInputCaptured() guards and is configurable via ShortcutDialog.

The issue has no labels currently. A maintainer can authorize the orchestrator to land this fix by adding the aetherclaude-eligible label.

73, Jeremy KK7GWY & Claude (AI dev partner)

- SliceModel::applyStatus(): use kvs.value("in_use") == "1" && m_audioMute
  in the else-if condition (avoids redundant emit on already-unmuted slices,
  cleaner than nested if — per Jeremy KK7GWY review)
- RxApplet mute-all handler: add comment documenting that RadioModel::slices()
  is owned-only (foreign slices removed on client_handle), and that
  setAudioMute() early-return guards the RADE m_radePrevMute invariant
- commit_msg.txt: updated with review-refined rationale

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

@AetherClaude changes applied. Thoughts?

@aethersdr-agent
Copy link
Copy Markdown
Contributor

@chibondking — reviewed the applied changes against the points I raised:

SliceModel.cppapplyStatus() else-if branch lands exactly where it needs to (lines 620-630), guards on m_audioMute so it doesn't churn audioMuteChanged(false) on every initial-create or partial-with-in_use=1 message, and the comment captures the why (radio doesn't persist audio_mute, SliceModels survive disconnect). The fix is correct.

Shortcut registrationmute_all_slices_toggle slots into registerShortcutActions() at line 12469, so it participates in s_keyboardShortcutsEnabled / shortcutInputCaptured() guards and is editable via ShortcutDialog. Good — that was the right call vs. a free QShortcut.

Foreign / unowned slices — already safe. I went back and checked RadioModel::handleSliceStatus() (RadioModel.cpp:4295-4304): when a slice's client_handle doesn't match ours, the existing SliceModel is removed from m_slices and deleteLater()'d, not just gated. So m_radioModel.slices() only ever contains owned slices — my earlier concern was misplaced and the iteration in both call sites is fine as written.

One remaining concern: RADE interaction.

MainWindow::activateRade() (MainWindow.cpp:13803-13804) captures m_radePrevMute = s->audioMute() then forces the slice muted. If the user hits mute-all while RADE is active, two issues can arise:

  1. Unmute-all unmutes the RADE slice. When RADE is active the RADE slice is always muted. If every other slice is also muted, the toggle's "all muted → unmute all" branch will unmute the RADE slice too, breaking RADE's audio gating until the user deactivates RADE.
  2. m_radePrevMute becomes stale. Mute-all changes the other slices' mute states but doesn't touch m_radePrevMute. When RADE deactivates and restores the RADE slice to m_radePrevMute, it's restoring to the pre-RADE snapshot, which is what you want — unless the user's intent with mute-all was to mute everything. Minor, but worth a thought.

Cheapest fix is to skip the RADE slice in both mute-all iterations when m_radeSliceId >= 0:

// In MainWindow shortcut handler:
m_shortcutManager.registerAction("mute_all_slices_toggle", "Mute All Slices", "Audio",
    QKeySequence(), [this]() {
        const auto slices = m_radioModel.slices();
        bool anyUnmuted = false;
        for (const SliceModel* s : slices) {
            if (!s || s->sliceId() == m_radeSliceId) continue;  // RADE owns its mute
            if (!s->audioMute()) { anyUnmuted = true; break; }
        }
        for (SliceModel* s : slices) {
            if (!s || s->sliceId() == m_radeSliceId) continue;
            s->setAudioMute(anyUnmuted);
        }
    });

The RxApplet button can't see m_radeSliceId directly — easiest is to hoist the logic into a RadioModel::muteAllSlicesToggle() method (also dedupes the identical block currently sitting in two places), or expose radeSliceId() on MainWindow and have RxApplet query it via a signal/setter. Either is a small follow-up.

Minor nit (not blocking): the iteration block is byte-identical between MainWindow.cpp:12469-12478 and RxApplet.cpp:286-295. A one-liner RadioModel::muteAllSlicesToggle() would fold both call sites and naturally house the RADE-skip logic.

The issue has no aetherclaude-eligible label yet. A maintainer can authorize the orchestrator to land the RADE-skip refinement (and optional dedup) by adding the aetherclaude-eligible label to this issue.

73, Jeremy KK7GWY & Claude (AI dev partner)

…slice

The previous mute-all implementation kept toggle logic in RxApplet and relied
on setAudioMute() being a no-op for the RADE slice. That holds for mute-all
(RADE slice already true, early-return guard fires) but NOT for unmute-all:
when every slice including the RADE slice is muted, anyUnmuted=false causes
setAudioMute(false) on the RADE slice — changes m_audioMute true→false, sends
audio_mute=0 to the radio, breaks RADE audio gating, and leaves m_radePrevMute
pointing at a stale baseline for deactivateRADE() to restore.

Fix: move all toggle logic to MainWindow::onMuteAllSlicesToggle() which has
full access to m_radeSliceId. Both the scan and the apply loop skip the RADE
slice under #ifdef HAVE_RADE. RxApplet button now emits muteAllToggled();
keyboard shortcut calls the same slot. Logic is no longer duplicated.

Squelch paths confirmed clean: neither auto nor manual squelch ever calls
setAudioMute(); squelch state preservation (m_savedSquelchOn) is independent.
Foreign-slice safety confirmed: RadioModel::slices() is owned-only.

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

@AetherClaude took a crack at it locally, thoughts on these changes?

@chibondking chibondking added the aetherclaude-eligible Issue approved for AetherClaude automated agent label May 18, 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 @chibondking — the bug fix here is excellent. The SliceModel::applyStatus reset is precisely targeted: in_use=1 is indeed the canonical full-status marker (verified at RadioModel.cpp:4340 where handleSliceStatus refuses to create a SliceModel without it), the && m_audioMute guard avoids spurious signals on every full status, and the migration block at MainWindow.cpp:1265 confirms the radio doesn't persist audio_mute. Clean, narrow, well-reasoned fix.

The onMuteAllSlicesToggle logic is also correct — moving it to MainWindow for RADE context was the right call, the skip in both the scan and apply loops is necessary (your commit message correctly identifies why the unmute direction is not a no-op), and RadioModel::slices() already filters foreign clients in handleSliceStatus (lines 4295-4304), so no extra filtering is needed. Wiring the action through ShortcutManager is the right pattern.

Two things to address before merge:

  1. The mute-all button only appears on FLEX-6700 (>4 slices), not on all multi-slice radios as the description claims. In RxApplet::setMaxSlices (lines 1252-1265), the useInline branch (maxSlices ≤ 4 — i.e. 6300/6400/6500/6600, the majority of users) places the slice buttons into m_headerRow and leaves m_sliceTabRow hidden — setVisible(true) is only called in the !useInline branch at line 1309. So on 2/4-slice radios the button is never shown. The keyboard shortcut still works, but the UI affordance is missing for most users.

    Options: also surface the button on the inline header row, or scope the description to "visible on 8-slice radios."

  2. commit_msg.txt (103 lines) is checked into the repo root. This looks like an artifact from your commit-message workflow rather than something intended for the tree. Please drop it from the PR (the same content is already in the PR description).

Once the visibility gap is addressed (or scoped explicitly) and the stray file is removed, this is good to go. The applyStatus reset alone is worth the merge.

chibondking and others added 2 commits May 18, 2026 00:06
…connect

## Bug fix: slice shows as muted after reconnect when radio is not muted

Reported by Robbie KI4TTZ: intermittent "slice shows as muted but audio is
playing" after a reconnect.

Root cause: RadioModel::onDisconnected() clears panadapters, meters, etc., but
does NOT delete SliceModel objects — they survive reconnect intentionally for
SmartConnect fast-reconnect. The radio resets audio_mute to 0 on reconnect but
omits the key from the full-status message (0 is the default). applyStatus()
only updates m_audioMute when kvs.contains("audio_mute"), so a previously
muted SliceModel keeps m_audioMute=true — button shows 🔇, model reports muted,
radio is playing audio.

Fix (SliceModel::applyStatus): add an else-if branch that fires when audio_mute
is absent from the KVS and in_use=1 is present (the canonical full-status
marker — RadioModel::handleSliceStatus already refuses to create a SliceModel
without it). Resets m_audioMute to false and emits audioMuteChanged(false).
The && m_audioMute guard prevents spurious signals on every full-status message
for slices that were already unmuted.

## New feature: Mute All Slices

Adds a 🔇 button to RxApplet and a mute_all_slices_toggle keyboard shortcut
(category "Audio", no default binding, editable via ShortcutDialog).

Toggle logic: any owned slice unmuted → mutes all; all muted → unmutes all.

Button placement:
  - ≤4 slices (6300/6400/6500/6600): button appears at the right end of the
    inline header row alongside the slice selector buttons.
  - >4 slices (6700): button appears at the right end of the slice tab row.
  - Hidden when maxSlices ≤ 1 (single-slice configs where it has no effect).

Logic lives in MainWindow::onMuteAllSlicesToggle() — the only site with access
to RADE state. RxApplet button emits muteAllToggled(); both the signal and the
keyboard shortcut call the same slot. Logic is not duplicated.

RADE interaction: the slot skips the RADE-managed slice (m_radeSliceId) in
both the scan and apply loops under #ifdef HAVE_RADE. The mute direction would
be safe without the skip (setAudioMute(true) no-ops since the slice is already
forced muted by activateRADE()), but the unmute direction is not: when all
slices including the RADE slice are muted, anyUnmuted=false causes
setAudioMute(false) on the RADE slice — changes m_audioMute true→false, sends
audio_mute=0 to the radio, breaks RADE audio gating, and corrupts m_radePrevMute
for the deactivateRADE() restore path.

Squelch paths confirmed clean: neither auto (FFT/noise-floor driven) nor manual
squelch ever calls setAudioMute(). m_savedSquelchOn is an independent mechanism.

Foreign slices confirmed safe: RadioModel::slices() returns only owned slices
(handleSliceStatus deletes and removes foreign SliceModels from m_slices on
client_handle match at RadioModel.cpp:4295-4304).

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

@AetherClaude changes applied. Also tagged as eligible for the agent to work on.

@aethersdr-agent
Copy link
Copy Markdown
Contributor

@chibondking — went through the PR diff end-to-end. The changes are good, and the rationale captured in the commit message is sharper than what I'd written. Specifically the catch about the unmute-all direction being the wrong-side of the "setAudioMute is a no-op" argument is correct and worth calling out — that was the real reason my earlier "just skip the RADE slice" suggestion mattered. Round-up of what I checked:

SliceModel::applyStatus() else-if (SliceModel.cpp:619-626) — lands as suggested, with the m_audioMute guard preventing a churn of audioMuteChanged(false) on every full-status message for already-unmuted slices. Comment captures the why. Correct.

MainWindow::onMuteAllSlicesToggle() (MainWindow.cpp:10012-10039) — the #ifdef HAVE_RADE + s->sliceId() == m_radeSliceId continue; guard is present in BOTH the scan loop and the apply loop, which is what makes the unmute-all direction safe. Moving the logic from RxApplet into MainWindow via the muteAllToggled() signal is the right call — RxApplet has no business knowing about RADE state, and the signal indirection costs nothing. This is cleaner than my "hoist to RadioModel::muteAllSlicesToggle()" suggestion since RadioModel would have had to import RADE knowledge it doesn't otherwise need.

Shortcut registration (MainWindow.cpp:12500-12501) — slots into registerShortcutActions() so it participates in the existing s_keyboardShortcutsEnabled / shortcutInputCaptured() guards and is editable via ShortcutDialog. No default binding, which is right — let users assign it.

Button placement + reparenting (RxApplet.cpp:281-290, 1259, 1275-1289, 1352)addWidget() reparents in Qt, and the comment correctly notes this. Visibility correctly tracks m_sliceTabRow (hidden when maxSlices <= 1, hidden in clearSliceButtons(), shown in both inline and tab-row layout paths in setMaxSlices()). Good.

Foreign / unowned slices — confirmed clean by the commit message (RadioModel::handleSliceStatus() deletes foreign slices on client_handle mismatch), matching what I'd verified in the previous round.

Squelch interaction — also called out in the commit message and confirmed: auto-squelch is FFT-driven via setSquelch(), neither path touches audio_mute. Clean.

One nit, not blocking: the diff includes commit_msg.txt at the repo root, which looks like an authorship artifact rather than something meant to be committed. Easy git rm before the merge.

The aetherclaude-eligible label is already on this PR, so the orchestrator will pick this up on the next webhook cycle. Nothing else to do from the review side — the fix is correct and the RADE-skip refinement is in place.

73, Jeremy KK7GWY & Claude (AI dev partner)

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

Labels

aetherclaude-eligible Issue approved for AetherClaude automated agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant