Skip to content

fix(audio): keep the saved device while it is absent. Principle VIII. - #6378

Merged
NF0T merged 3 commits into
aethersdr:mainfrom
on8st:fix/audio-device-kept-when-missing
Oct 11, 2026
Merged

NF0T merged 3 commits into
aethersdr:mainfrom
on8st:fix/audio-device-kept-when-missing

Conversation

@on8st

@on8st on8st commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #6040.

Limits, first

  • No real device was unplugged or reconnected. The rule is tested as a pure function over made-up device ids. AudioEngine's setters and MainWindow::handleAudioDeviceListChanged() are reached by no test; their wiring was read, built and not exercised.
  • Built and run on macOS only (Qt 6.12.0). Linux and Windows were not built here. That cost a Linux-only regression in the first version, found in review by NF0T on a PipeWire desktop: device presence was asked of the list that leaves out ids the hot-plug monitor ignores as churn (echo-cancel, combined, …), so such a selected device was treated as missing. Fixed in 061a62e8: presence uses every enumerable id, arrivals stay filtered. The fixed build has not been run on Linux by me.
  • This includes part (b) of the triage: returning to the saved device when it comes back, silently. That is a UX behaviour change and needs a maintainer decision. If it is not wanted, part (a) stands alone: drop the RestorePreferred branch and the saved id is still kept, so the device is selected again at the next launch.
  • Accepting the hot-plug prompt still saves both directions, with one exception (from this PR's review): a direction left on "System default" while its saved device is absent is applied for this run and not saved. The prompt lists present devices only, so it cannot offer the absent one; saving its default there would erase the preference, which is this issue again. Consequence: while the saved device is absent, the prompt cannot be used to make "System default" the saved choice; picking a real device still replaces it.
  • Not done from the triage: no m_preferred*DeviceId members in AudioEngine. The settings row is the only holder of the preference and MainWindow reads it there; while the engine is on the fallback, Radio Setup's device combos show the system default device it fell back to, not the pane's earlier selection and not the saved preference.
  • New gui/ → core/ include: MainWindow.cpp includes core/AudioDevicePreferencePolicy.h (one new touchpoint row, tagged ui-support). Justification: it is the header-only policy the triage asked for, host audio only, no radio state; AudioEngine.h, which MainWindow.cpp already includes, needs the same enum for the setter.

What was wrong (at main 16cfa80)

One setting per direction (AudioOutputDeviceId / AudioInputDeviceId) was both the operator's preference and the live device, so every writer of the live device rewrote the preference.

  1. AudioEngine's constructor sets m_outputDevice / m_inputDevice only when the saved id is enumerable (src/core/AudioEngine.cpp:1991, :1996). startRxStream() and startTxStream() null the member when the device is gone (:4180, :8871). The saved id is still intact here.
  2. In handleAudioDeviceListChanged() (src/gui/MainWindow.cpp:5275) a null live device plus any removal or default change asks for a reset (:5317, :5319). audioDevicePresent() returns true for a null device (:590), so no prompt is needed (:5334) and the reset runs.
  3. resetMissingAudioDevicesToDefault() (:5414) calls setInputDevice(QAudioDevice{}) / setOutputDevice(QAudioDevice{}) (:5432, :5434), and those setters always write the id and save (src/core/AudioEngine.cpp:9944, :9963). A null device has an empty id, so the saved choice is replaced by "".

A device that was present and then dropped out went through the same step 3.

The rule now

  • The setters take an AudioDeviceSelection. Only OperatorChoice (the default argument: Radio Setup, the accepted hot-plug prompt) writes the saved id. Fallback re-points the engine for this run and writes nothing.
  • MainWindow's reset path passes Fallback.
  • The handler takes its decision per direction from decideAudioDeviceChange() in src/core/AudioDevicePreferencePolicy.h:
situation action
engine on the system default, saved id is among the newly added ids RestorePreferred: engine returns to the saved device, no prompt, no write
engine's device is no longer enumerable FollowDefault: engine falls back, saved id kept
engine on the system default, a device was removed or the default moved FollowDefault: restart on the default, as before
anything else Keep
  • A live device that is present is never swapped.
  • The prompt condition is unchanged: a device was added and a live device is missing. When it shows, a returning saved device is passed as the current one.

Tests

New: audio_device_preference_policy_test (links Qt6::Core only; no QtMultimedia, no audio device, no settings store, no socket). 37 checks plus two static_asserts (three pin that an enumerable churn-id device is present; the last five cover the accepted prompt):

  • missing at launch, then connected (the issue's reproduction): restored, saved id intact
  • missing at launch, then unrelated removals, arrivals and default changes: saved id intact, unrelated device not adopted, restored when it finally arrives
  • present, dropped out, back again: fallback, saved id intact, restored
  • another device leaving or arriving while a different, present device is selected: nothing changes
  • an explicit choice replaces the saved id; choosing the system default clears it, and nothing is restored afterwards
  • the prompt condition, five rows

Fail-before. The test cannot be compiled against main, because the seam it tests is new. Instead the policy was mutated back to the old behaviour, in a scratch copy, and the test built against it:

Results on macOS with Qt 6.12.0:

User docs: docs/user/docs/audio-settings.md describes the behaviour and no longer lists #6040 under Known issues. Its sentence about switching back when the device connects follows part (b) and changes if (b) is ruled out.

Not verified

  • The behaviour with a real Bluetooth or USB device, on any platform.
  • That the OS reports the returning device under the same id it had when saved. If the id changes, nothing is restored and the saved id simply stays.
  • The Radio Setup combo and the hot-plug prompt on screen; the app was not launched.

73, ON8ST

🤖 Generated with Claude Code

The saved PC audio device (AudioOutputDeviceId / AudioInputDeviceId) was
also the live device, so every writer of the live device rewrote the
preference. A saved device that was missing at launch left the engine on
the system default with a null selection; the next device-list change
then reset to the default through AudioEngine::setOutputDevice() /
setInputDevice(), which wrote the empty id and saved it. A device that
dropped out mid-session was forgotten the same way (aethersdr#6040).

The setters now take an AudioDeviceSelection. Only OperatorChoice (the
default: Radio Setup, the accepted hot-plug prompt) writes the saved id;
MainWindow's reset path passes Fallback, which re-points the engine for
this run and leaves the saved id alone.

handleAudioDeviceListChanged() takes its decision from a pure policy,
core/AudioDevicePreferencePolicy.h. When the saved device is among the
newly added ids and the engine sits on the system default, the engine
returns to it, without a prompt and without a write. A live device that
is present is never swapped. The prompt condition is unchanged; when it
does show, a returning saved device is offered as the current one.

audio_device_preference_policy_test pins the rule over made-up ids:
missing at launch then connected, a dropout and return, removal while a
different device is selected, an explicit choice, and the prompt
condition. With the policy mutated back to the old behaviour (every
selection persisted; no restore on arrival) it fails.

Not covered: no real device was unplugged, and AudioEngine's setters and
the MainWindow handler are reached by no test.

Refs aethersdr#6040

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

@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.

Reviewed at head 941e2100. I read the issue and the triage comment, then went after the policy, the rewritten handler, and every path that can still write the two saved ids. The fix is sound and the body is unusually honest about its own limits — including the one thing it correctly says it cannot decide for itself. Findings below, all reasoned from code: I have no build and no way to drive the GUI here, so nothing was reproduced at runtime.

1. Issue fit — yes, and it says where it stops

#6040 asks for two things, and the triage comment split them: (a) stop the fallback path persisting, and (b) return to the saved device when it comes back. The PR does both, and the body flags (b) as the maintainer's call — which matches the reporter ("If automatically returning to a device counts as a UX behaviour change under GOVERNANCE.md, the first bullet alone still fixes the lost setting") and the triage's own recommendation (silent restore).

Mapping the issue's three mechanism steps to the diff:

issue step addressed by
1 — launch keeps the preference on disk, drops it in memory not changed, by design; the settings row stays the only holder (body discloses this)
2 — a null live selection makes the next device event a reset src/core/AudioDevicePreferencePolicy.h:52-66, via liveMissing/followsDefault
3 — the reset is persisted as if it were a choice src/core/AudioEngine.cpp:9941-9945, :9960-9964, gated on persistsAudioDeviceSelection()

Triage item (c), "pull the decision out into a pure function so it can be tested", is done at exactly the seam the triage named, including the filename.

Coverage. The policy is covered by a socket-free CTest — the right layer for a decision policy per AGENTS.md. The test is registered (tests/tests.cmake:3098-3105) and links Qt6::Core only, so it runs on every runner including the Windows and macOS lanes. It discriminates: a decideAudioDeviceChange stubbed to always return Keep fails both "a removal while on the default restarts on the default" and "saved device arrives while on the default -> restore it". The fail-before story holds up for the right reason — persistsAudioDeviceSelection is constexpr, so the two static_asserts genuinely cannot compile against the old always-persist setters.

Socket tests: none added, none removed, none modified. The new target opens no socket and binds nothing.

2. Scope

Everything in the diff is explained by the issue thread.

file(s) what it changes claimed? verdict
src/core/AudioDevicePreferencePolicy.h new header-only policy over device ids yes — triage (c) in scope
src/core/AudioEngine.{h,cpp} AudioDeviceSelection param; persist only on OperatorChoice yes — triage (a) in scope
src/gui/MainWindow.{h,cpp} handler rewritten onto the policy; restorePreferredAudioDevices(); audioDevicePresent → audioDeviceById yes — triage (a) + (b) in scope
tests/audio_device_preference_policy_test.cpp, tests/tests.cmake the new target yes — triage (c) in scope
docs/architecture/aetherd-touchpoint{s.md,-tags.json} one new ui-support row, totals 252 → 253 yes, and disclosed as a new gui/→core/ include in scope

No build-config, CI, vendored or formatting churn. No new protocol verb, wire message, settings key, CLI flag or capability field — the two keys it touches (AudioInputDeviceId, AudioOutputDeviceId) are pre-existing app-global flat keys, which is the correct shape for a host audio device (not per-radio state), so Principle V and the AGENTS.md persistence rules are not in play. The new test needs no TestSettingsProfile.h because it never touches AppSettings. I ran tools/gen_touchpoint_manifest.py --check against the head checkout: "manifest up to date", and the 253 table rows match the header.

Deleted behaviour, read as carefully as the additions: the only removals are audioDevicePresent() (fully gone, no dangling references anywhere in src/ or tests/) and the if (resetInput || resetOutput) guards at the three applyFallback() sites. Those guards moved rather than vanished — resetMissingAudioDevicesToDefault already carries if (!m_audio || (!resetInput && !resetOutput)) return; at src/gui/MainWindow.cpp:5451, and restorePreferredAudioDevices has the matching both-null guard at :5481. No comment naming a fixed symptom was deleted; the #2864 comment was rewritten into the policy header at :70-71 and its rule is preserved.

3. Blockers

None.

4. Needs maintainer decision

(b) silent auto-restore is a user-visible behaviour change, and only a maintainer can ratify it. src/core/AudioDevicePreferencePolicy.h:52-56:

    if (followsDefault && !change.preferredId.isEmpty()
        && change.addedIds.contains(change.preferredId)) {
        decision.action = AudioDeviceAction::RestorePreferred;

I am naming this rather than treating it as smuggled preference, because it is not: the reporter raised it, the triage recommended exactly this (silent, not prompted, on the grounds that the operator already expressed the preference and #2864 deliberately avoids prompting on arrivals), and the PR body puts it in its own bullet with a clean fallback — drop the RestorePreferred branch and (a) stands alone, the saved id survives, and the device is selected again at the next launch. Per GOVERNANCE.md that is the ruling the maintainer owes; the implementation is ready either way.

Two smaller user-visible consequences of (b) that are worth ruling on in the same breath, since neither is in the issue:

  • src/gui/MainWindow.cpp:5396-5397 — when the hot-plug prompt does appear, it now preselects the returning saved device as "current" instead of the engine's actual device:
                                     restoreInput.isNull() ? currentInput : restoreInput,
                                     restoreOutput.isNull() ? currentOutput : restoreOutput,
    Defensible (it is what the operator wanted), but it changes what the dialog asserts is in use at the moment it opens.
  • src/gui/MainWindow.cpp:5383-5388 — with "Prompt on Audio Device Changes" switched off, the restore now happens silently where previously only a reset did. Also defensible, also new.

5. Nits (non-blocking)

  • src/gui/MainWindow.cpp:5346-5351 — the policy input is built by positional brace-init of a six-field struct.

        const AudioDeviceDecision inputDecision = decideAudioDeviceChange({
            currentInputIds, addedInputIds, !removedInputIds.isEmpty(),
            defaultInputChanged, currentInput.id(), preferredInputId});

    The ordering is correct today, but anyRemoved/defaultChanged are adjacent bools and liveId/preferredId are adjacent QByteArrays. Swapping either pair compiles silently, and swapping the last pair would invert the whole fix — the engine's device would be treated as the preference. Designated initializers make it unswappable at no cost:

        const AudioDeviceDecision inputDecision = decideAudioDeviceChange({
            .presentIds = currentInputIds,
            .addedIds = addedInputIds,
            .anyRemoved = !removedInputIds.isEmpty(),
            .defaultChanged = defaultInputChanged,
            .liveId = currentInput.id(),
            .preferredId = preferredInputId});
  • The disclosed hot-plug-accept hole is real, and it is the same defect in miniature. src/gui/MainWindow.cpp:5405-5417: accepting the prompt routes to applyAudioDeviceSelection(), which persists both directions with the default OperatorChoice. The reachable case is concrete — the live output is unplugged while a new input arrives, so output.liveMissing is true and the prompt fires; the output combo is filled from outputDevices (present devices only), so it cannot offer the now-absent saved device and shows the default; the operator accepts for the input and the saved output id is overwritten with "". That is #6040's symptom through the one door left open. Arguably the operator did look at a dialog and accept, which is why I am not calling it a blocker — but it means "Fixes #6040" is not quite complete, and it is the natural follow-up issue.

  • The body's Radio Setup sentence is imprecise. It says "Radio Setup still shows its own selection while the engine is on the fallback". src/gui/RadioSetupDialog.cpp:5034-5040 substitutes QMediaDevices::defaultAudioOutput() for a null engine device, and repopulateAudioDeviceCombo (:4698-4723) only falls back to the combo's previous id when the passed device's id is empty — which it no longer is after that substitution. So the pane names the fallback device, not its own prior selection and not the saved preference. Triage corollary (c) — no in-memory record of the preference for the dialogs to display — is genuinely still open, which the body does say elsewhere; just the one sentence reads the wrong way.

  • The handler's wiring has no test, and the test's harness re-implements it. Session::devicesChange in tests/audio_device_preference_policy_test.cpp:62-92 mirrors what handleAudioDeviceListChanged does rather than calling it, so a mis-wiring in MainWindow.cpp would leave all 28 checks green. The body states this plainly ("their wiring was read, built and not exercised"), and I am not asking for more: the handler is entangled with QMediaDevices and there is no deterministic policy-compliant seam for it, so this is the honest coverage boundary, not a gap to fill.

6. Security audit

Required by the harness (59 CodeGuard findings across seven rule families). I ran the security-audit subagent over the head checkout: no findings. Every hit is a lexical false positive or sits on untouched lines, and I confirmed the dispositions that matter:

  • CS-VLN-CMDI-AMP / CS-VLN-CMDI-BACKTICK — the C++ && operator and Markdown backticks in comment prose (e.g. src/gui/MainWindow.cpp:589, Null when \id` is empty or not in `devices`). No QProcess`, no shell, no command string anywhere in the diff.
  • CS-VLN-SSRF-HEX — the flagged AudioEngine.cpp:9807-9808 constants are pre-existing VITA-49 OUI/ICC values well outside the changed region (:9937-9977). No URL or host is built from hex.
  • CS-VLN-PATH-PROC / CG-PATH-001 — the flagged MainWindow.cpp lines (6262, 6341, 9739, 9851, 9973-9975) are all pre-existing and unrelated to this change; the diff constructs no filesystem path.
  • CS-PII-IP — MainWindow.cpp:6954, the pre-existing WSJT-X multicast group 224.0.0.1.
  • CS-INJ-homoglyph — U+2500 box-drawing comment banners, matching house style across the tree.

On the substance: the saved id is read as a QByteArray at src/gui/MainWindow.cpp:5342-5345 and used only for QList::contains() and an id-equality lookup against OS-enumerated devices. It is compared, never interpreted, executed, or used as a path; a tampered settings row can at most name a device that is actually enumerable. The change strictly reduces settings writes, and adds no logging — the dev.description() qCDebug lines at AudioEngine.cpp:9940/:9960 are pre-existing.

7. What I tried to break

  • Prompt-condition equivalence. The old gate was deviceAdded && !currentSelectionStillValid, where audioDevicePresent() returned true for a null target — i.e. prompt iff a device arrived and a non-null live device is absent. The new liveMissing = !followsDefault && !presentIds.contains(liveId) is the same predicate, and audioDeviceChoiceRequired (AudioDevicePreferencePolicy.h:72-78) composes it the same way. I could not construct an input where they diverge. Likewise resetInput/resetOutput: RestorePreferred requires followsDefault, so it can only ever preempt the old defaultNeedsRestart disjunct and never the liveMissing one — the only behaviour delta is the intended (b).
  • Can the engine ever sit on a device the saved id does not name, so a restore swaps something live out from under the operator? No. Live diverges from saved only via a Fallback, and Fallback only ever writes QAudioDevice{} or the saved device itself; every OperatorChoice path updates both. And RestorePreferred is gated on followsDefault (empty liveId), so a present live device is never swapped — the test pins this at :166-171.
  • Can audioDeviceById return null on a RestorePreferred decision and silently drop the restore? No: addedIds is derived from the same enumerated list by newlyAddedAudioDeviceIds, so addedIds ⊆ presentIds by construction, and the policy already required addedIds.contains(preferredId). If it somehow did, the result is a benign no-op (that direction stays on the default) rather than a crash, because resetInput is false on that branch.
  • Sibling call sites of the changed setters. All four remaining are intentional persist paths: RadioSetupDialog.cpp:5105/:5107 (the combo's applyChoice) and MainWindow.cpp:5440/:5441 (applyAudioDeviceSelection). AudioOutputRouter.h:43's guarded->setOutputDevice(dev) is a template over the registered followers, and the only two are m_finalMonitor (ClientPuduMonitor*) and m_qsoRecorder (QsoRecorder*) — not AudioEngine, so no fallback leaks through it as an OperatorChoice. No string-based SLOT() or invokeMethod(obj, "setOutputDevice", …) exists, so the added defaulted parameter cannot break a signature match at runtime.
  • Does a reseed re-enter the persisting setter? This was my best candidate for a regression: the Fallback arm still emits outputDeviceChanged/inputDeviceChanged, and RadioSetupDialog is connected to both (:5078-5082) and reseeds the combos. If setCurrentIndex fired currentIndexChanged, applyChoice would persist the fallback as an operator choice and reintroduce #6040 whenever Radio Setup is open. It does not: repopulateAudioDeviceCombo holds a QSignalBlocker (RadioSetupDialog.cpp:4713). Clean.
  • Do the external playback sinks follow a restore? Yes — MainWindow.cpp:1364-1366 drives AudioOutputRouter::setCurrentDevice off outputDeviceChanged, which both setter arms still emit, so the post-DSP monitor and QSO playback track a RestorePreferred the same as a reset.
  • Lifecycle and reentrancy. applyFallback captures by reference ([&]) but is only ever invoked synchronously within the same frame, including on the post-dialog.exec() reject branch — no dangling capture. Both helpers re-guard m_audio with a QPointer inside the queued lambda. FollowDefault and RestorePreferred are mutually exclusive per direction, so the two queued invocations can never contend for one direction, and startTxStream stays behind !audio->isTxStreaming() in both. The m_audioDeviceDialogOpen early return happens before m_knownAudio*Ids is updated, so a device arriving while the dialog is open is still seen as "added" on the retry — the restore is not lost. m_knownAudio*Ids is seeded from the real enumeration at :5277-5280, so the first change after launch does not report every device as new.
  • Distrusting green. All five checks on 941e2100 are success (build, check-macos, check-windows, Static checks, Sanitizer option configures) against base a4a39e75, which is current main — not a stale merge base. check-windows is the -DENABLE_ASR=OFF lane and it passed, so the new header is not config-scoped. Note that this proves compilation and the -R-filtered subset, not the new test: audio_device_preference_policy_test runs only in the full Ubuntu lane.
  • Could not check: whether an OS re-enumerates a returning Bluetooth or USB device under the id it had when saved (the whole restore hinges on it; the body flags this too, and the failure mode is benign — nothing restores and the saved id simply stays), and any on-screen behaviour of the Radio Setup combo or the hot-plug prompt.

8. Recommendation

Needs maintainer decision — on part (b) only.

Part (a) is a clean, contained fix for a confirmed root cause, lands at exactly the seam the triage specified, and is covered by a discriminating socket-free test. I found no correctness defect in it. Part (b) is well-implemented and is what the triage recommended, but it is a user-visible behaviour change that both the reporter and the author routed to a maintainer, so it needs a ruling rather than a reviewer's nod. Concrete next step: rule on silent auto-restore. If yes, this merges as-is (the nits are all optional, and the designated-initializer one is worth taking). If no, the author's stated fallback — drop the RestorePreferred branch from the policy and the two restore* locals from the handler — leaves (a) intact and still fixes the lost setting.

Nice work on the limits section — "no real device was unplugged", the named fail-before mutations with their failure counts, and the explicit "one path still overwrites the saved id, by design" made this much faster to review honestly. Thank you.

73


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

@aethersdr-agent

Copy link
Copy Markdown
Contributor

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

  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in docs/architecture/aetherd-touchpoint-tags.json /tmp/aetherclaude/pr-6378/docs/architecture/aetherd-touchpoint-tags.json:10
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in docs/architecture/aetherd-touchpoint-tags.json /tmp/aetherclaude/pr-6378/docs/architecture/aetherd-touchpoint-tags.json:1145
  • [MEDIUM] CS-VLN-SSRF-HEX — Vulnerability: CS-VLN-SSRF-HEX in src/core/AudioEngine.cpp /tmp/aetherclaude/pr-6378/src/core/AudioEngine.cpp:9807
  • [MEDIUM] CS-VLN-SSRF-HEX — Vulnerability: CS-VLN-SSRF-HEX in src/core/AudioEngine.cpp /tmp/aetherclaude/pr-6378/src/core/AudioEngine.cpp:9808
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/core/AudioEngine.cpp /tmp/aetherclaude/pr-6378/src/core/AudioEngine.cpp:1378
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/core/AudioEngine.cpp /tmp/aetherclaude/pr-6378/src/core/AudioEngine.cpp:5432
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.cpp /tmp/aetherclaude/pr-6378/src/core/AudioEngine.cpp:5632
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.cpp /tmp/aetherclaude/pr-6378/src/core/AudioEngine.cpp:9348
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.h /tmp/aetherclaude/pr-6378/src/core/AudioEngine.h:650
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.h /tmp/aetherclaude/pr-6378/src/core/AudioEngine.h:670
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.h /tmp/aetherclaude/pr-6378/src/core/AudioEngine.h:715
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.h /tmp/aetherclaude/pr-6378/src/core/AudioEngine.h:779
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.h /tmp/aetherclaude/pr-6378/src/core/AudioEngine.h:794
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.h /tmp/aetherclaude/pr-6378/src/core/AudioEngine.h:826
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.h /tmp/aetherclaude/pr-6378/src/core/AudioEngine.h:841
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/AudioEngine.h /tmp/aetherclaude/pr-6378/src/core/AudioEngine.h:1067
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:9739
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:9851
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:9973
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:9974
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:9975
  • [HIGH] CS-VLN-PATH-PROC — Vulnerability: CS-VLN-PATH-PROC in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:6262
  • [HIGH] CS-VLN-PATH-PROC — Vulnerability: CS-VLN-PATH-PROC in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:6341
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:527
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:557
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:560
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:582
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:591
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:3662
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:589
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:1282
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:1806
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:1898
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:1975
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:2017
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:2052
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:2347
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:3604
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:3695
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6378/src/gui/MainWindow.cpp:6204

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: $11.8090 · model: claude-opus-5

Answers two nits on the review of aethersdr#6378.

Nit 1 (MainWindow.cpp, the two decideAudioDeviceChange calls): the
six-field AudioDeviceListChange was built positionally, with adjacent
bools and adjacent QByteArrays that swap silently. Both calls use
designated initializers (the project builds as C++20).

Nit 2 (the hot-plug-accept hole): the prompt lists present devices
only. With the saved device of one direction absent, that list shows
"System default", and accepting the prompt for the other direction
wrote "" over the saved id. acceptedAudioDeviceSelection() in the
policy header decides per direction: "System default" accepted while a
saved id exists and is not present is a Fallback (live device set,
saved id kept). Every other accepted selection is an OperatorChoice as
before: a real device, and "System default" with the saved device
present or with nothing saved. applyAudioDeviceSelection takes the
selection kind per direction; it has one caller, the prompt.

audio_device_preference_policy_test gains five checks: the review's
scenario (output unplugged as a new input arrives, accept for the
input) keeps the output's saved id and saves the input; default with
the saved device present clears the saved id; default with nothing
saved is an operator's choice; a real device replaces the saved id.

The RestorePreferred branch and the prompt's preselection are not
touched.

Refs aethersdr#6040

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

on8st commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

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

  • Designated initializers on both decideAudioDeviceChange calls, as you wrote them.
  • The hot-plug-accept hole is closed here, not left for a follow-up, because it is this issue again. On accepting the prompt, a direction left on "System default" while its saved device is absent is applied for this run and not saved (acceptedAudioDeviceSelection() in the policy header). Every other accepted selection is saved as before. Five new checks, 34 in all; your scenario is one of them (output unplugged, new input arrives, accept for the input: the saved output id is unchanged). Four mutations each turn one check red.
    This is a small behaviour choice, so it is yours to overrule: while the saved device is absent, the prompt can no longer make "System default" the saved choice. Picking a real device still replaces it.
  • The Radio Setup sentence in the body was wrong, as you read it: on the fallback the combos show the system default device, not the pane's earlier selection and not the saved preference. Corrected.

Not touched, waiting for the ruling on (b): the RestorePreferred branch, the prompt preselecting the returning device, and the silent restore with prompts switched off.

Not tested, as before: the wiring in MainWindow.cpp, including the new accept path. It is compiled and read.

Full ctest at 60f20deb: 758 of 761. The three that fail are the known ones on this Mac: #6008, #6224 (its two known checks; this PR's change in MainWindow.cpp is the audio handler only) and vkamp_connection_test (#5254, its two known checks).

73, ON8ST

@NF0T NF0T self-assigned this Oct 11, 2026

@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.

Thanks @on8st: this is a well-built fix, and the limits section made it easy to review honestly. I agree with the root cause, I think part (a) is the right shape, and I like that the decision lives in a socket-free policy header with a test that discriminates. I'm requesting changes for one Linux-only regression in the glue and one docs gap. Neither touches the policy's design. Part (b) is waiting on a ruling (below), which I am not asking you to resolve.

I reviewed independently of the earlier bot review and compared afterwards. Where I disagree with it, I say so.

Blockers

1. On Linux, a selected echo-cancel / combined device is now treated as "missing"

handleAudioDeviceListChanged passes currentInputIds / currentOutputIds as the policy's presentIds. Those come from audioDeviceIds(), which drops "transient" PipeWire ids (MainWindow.cpp:527, the Q_OS_LINUX branch: .monitor, pulse_input_loopback, auto_null, echo-cancel, combined). That filter exists so churny devices aren't announced as new (#2864). It's right for addedIds, but it is not a statement about what is present.

On main, liveness was checked against the unfiltered list: audioDevicePresent(inputDevices, currentInput). Now it is !presentIds.contains(liveId) over the filtered list. So an operator whose selected PC mic or speaker has a transient-pattern id has a live device that is enumerable and in use, but liveMissing is true. The handler runs on every device-list change, so:

  • the engine is re-pointed at the system default (FollowDefault, Fallback: not persisted, so the saved id survives, but the live mic/speaker is swapped under the operator, and the saved preference and the device actually in use now silently disagree), and
  • if a non-transient device arrives, audioDeviceChoiceRequired now shows the hot-plug prompt where main shows nothing.

The same filtered list feeds acceptedAudioDeviceSelection, so a present transient-id preference is treated as absent there too.

This is the "input where the old and new predicates diverge" that the earlier review said it could not construct. It is invisible on macOS and Windows (the filter is the identity there), which is where the PR was built and run, and it is invisible to the test, because the test's Session models ids with no filter. The filter lives in the glue the test doesn't contain.

Observed in the running app, not just reasoned. I did not run this myself: a second session on a Linux desktop did (PipeWire 1.4.2 via its PulseAudio shim, Qt 6.12.0, demo radio only, no TX). It built the app from the PR's base (a4a39e75) and head (60f20deb), seeded AudioInputDeviceId=echo-cancel-source, and triggered device-list changes. I read its transcript and re-checked its code citations against the head. Results (live/saved input; "prompt on" = the hot-plug prompt enabled):

build live/saved input trigger prompt hot-plug dialog engine re-pointed to default saved id after
head echo-cancel-source ordinary device arrives on shown (blocked behind the modal) unchanged
base echo-cancel-source same on none no unchanged
head plain alsa_input…Mic1__source same on none no unchanged (control)
head echo-cancel-source arrival off none yes, to the system default unchanged
base echo-cancel-source arrival off none no unchanged
head echo-cancel-source a device removed, nothing added off none yes unchanged
base echo-cancel-source removal off none no unchanged

The plain-device control on the same head binary is clean, which pins it on the id filter and not on anything else in the PR. The silent re-point from the app's own log (head, prompt off), about 700 ms after the change:

ENGINE TX device -> "Echo-Cancel Source"
ENGINE TX device -> "Scarlett 2i2 4th Gen Input 1 Mic/Inst/Line"

On the base the log stops at the first line. The same session also confirmed that Radio Setup offers these ids to the operator, because its combos are filled from the unfiltered QMediaDevices::audioInputs() / audioOutputs() (RadioSetupDialog.cpp:5036, :5038), so this is reachable from the UI.

Who it hits, and the limits of what was measured:

  • It bites a default module-echo-cancel setup (ids echo-cancel-source / echo-cancel-sink) and a default module-combine-sink (combined). A hand-named module (source_name=ec_source) does not match the substring filter and is unaffected, so the filter is fragile in both directions.
  • The .monitor arm of the filter never fires on this stack, since Qt's PipeWire backend doesn't list monitor sources at all. It is not part of this regression.
  • One machine and one audio stack. A true PulseAudio server, JACK, or a native PipeWire filter-chain were not measured.

Suggested fix: build the presentIds for both calls (and the two acceptedAudioDeviceSelection calls) from the unfiltered device lists, and keep addedIds filtered as it is. A line on AudioDeviceListChange::presentIds saying "every enumerable id, including the ones the hot-plug monitor ignores as churn" would stop the next reader repeating this. On the policy header, unfiltered ids give liveMissing=0 → Keep for the same input; I have not re-run the in-app A/B on a fixed build, so it would be worth a second look from someone with a PipeWire box once it is in.

2. User docs: the PR fixes a documented Known issue without touching docs/user/

AGENTS.md ("User documentation"): a PR that changes what a user sees or does updates the page in the same PR. docs/user/docs/audio-settings.md currently lists exactly this bug under Known issues (#6040, "…is forgotten at the next device change, so you have to select it again"). After merge that bullet is false. Please:

  • remove the #6040 bullet;
  • in Choosing PC audio devices / When a device is plugged in or removed, say that a saved device that is missing keeps its saved choice and the app runs on the system default meanwhile (state current behaviour, not history, per the docs style guide).

If part (b) is ruled in, add that the saved device is picked up again when it returns; if it is ruled out, leave that sentence out and say the device is selected again at next launch.

Needs a ruling (not a request to you): part (b)

Silent auto-restore is a user-visible change, and you've routed it to a maintainer, which I agree is the right call. For whoever rules, the blast radius as I read it: it affects only operators with a saved device that is currently absent; it is local audio routing only (nothing on-air changes directly); there are three visible consequences (the silent restore, the hot-plug prompt preselecting the returning device, and the restore also happening with "Prompt on Audio Device Changes" off). One more worth knowing: restoring the input goes through setInputDevice, which stops and restarts a running TX capture, so a preferred mic returning mid-over would restart capture. The same restart already happens today when the default moves under a null device (e.g. macOS promoting AirPods), so this isn't new, but it's part of what is being approved. Your fallback (drop RestorePreferred) is clean and keeps (a) intact.

Scope, preference-vs-fix, CI

file(s) claimed by #6040? verdict
AudioDevicePreferencePolicy.h, AudioEngine.{h,cpp}, MainWindow.{h,cpp} yes (triage a/b/c) in scope
audio_device_preference_policy_test.cpp, tests.cmake yes (triage c) in scope
aetherd-touchpoint{s.md,-tags.json} yes, disclosed new gui/→core/ include in scope
docs/user/ should be: see blocker 2 missing

Preference-vs-fix: (a) is a fix. (b) is a behaviour addition the reporter explicitly raised, so I don't read it as a smuggled preference; it just needs the ruling above. No new settings key, protocol verb or CLI flag. No CHANGELOG entry (good). Commits are signed and the squash-subject convention is already followed.

CI: all five checks green against current main (a4a39e75, 0 behind). That proves compilation on three platforms and the static gates. The new test is not in the frozen per-PR allow-list, so it runs in full-suite.yml on main, not on this PR; I ran it myself (below). Constitution VIII is a fair citation: the PR's own limits section does the work of not over-claiming.

What I tried to break

Held:

  • Every writer of the saved ids. Only the two setValue sites in AudioEngine.cpp; DeviceDiagnostics only reads. Of the setter callers, RadioSetupDialog::applyChoice is the only operator path besides the prompt, and it applies one direction per combo change, so changing the input combo can't overwrite an absent saved output (the combos' reseed holds a QSignalBlocker).
  • Stale restore / swap of a present device. A non-null live device always equals the saved one (only Fallback makes them differ, and Fallback writes null or the saved id), so there is nothing to swap under the operator.
  • Prompt predicate on macOS/Windows. Equivalent to main where the transient filter is a no-op.
  • Test. I built the PR's test standalone against Qt 6.12.0 (mingw): 34/34 pass. I then broke the policy four ways in scratch copies: dropping the liveMissing fallback (2 checks fail), the accepted-prompt rule collapsed to always-OperatorChoice (1 fails), the prompt gate ignoring the output (2 fail), and restore without the followsDefault gate (0 fail, see nit).
  • No sockets or peers in the new test; no auto-squash armed; PR head signed.

Broke: blocker 1, as above.

Not verified: any real device unplug/replug on any platform; the Radio Setup combo and hot-plug prompt on screen (the Linux run used an offscreen app and found the dialog through the automation bridge's tree dump); behaviour on macOS or Windows; and Linux beyond PipeWire's pulse shim. The restore path in part (b) was not exercised in-app by anyone.

Non-blocking

  • The followsDefault gate on RestorePreferred is not pinned by any check (mutation above survives). It's also unreachable in the test's model, since a non-null live device always equals the saved one, so this is defensive code; fine to keep, but the comment that a present live device is "never swapped" isn't actually exercised.
  • Agree the handler wiring can't be unit-tested as it stands. Blocker 1 is an example of what that costs: the glue holds a filter the model doesn't, so a one-line comment on presentIds is the cheapest guard.
  • Separate from this PR and not yours to fix here: isTransientAudioDeviceId matches substrings of a node name the operator controls, so whether it fires depends on what an echo-cancel module was called, and its .monitor arm is inert on PipeWire. Probably worth its own issue.
  • Taking the hot-plug-accept hole in this PR rather than a follow-up was the right call, and the disclosed trade-off (the prompt can't make "System default" the saved choice while the saved device is absent) is reasonable.

Both blockers are small. Happy to re-review quickly once they're in. Nice work on the root-cause analysis and the honesty of the limits section.

73,
Ryan NF0T


👨🏼‍💻 Co-authored by Claude Sonnet 5.5

Comment thread src/gui/MainWindow.cpp Outdated
Comment thread src/gui/MainWindow.cpp Outdated
@NF0T NF0T removed their assignment Oct 11, 2026
Review of aethersdr#6378 (NF0T), blocker 1: on Linux the handler passed the
arrival list as presentIds, and that list drops ids the hot-plug monitor
ignores as churn (echo-cancel, combined, ...). A selected echo-cancel
device was enumerable but reported missing: the engine was re-pointed at
the system default on every device-list change, and an unrelated arrival
raised the prompt. presentIds is every enumerable id again, for both
decisions and for the accepted prompt; addedIds stays filtered.

Blocker 2: docs/user/docs/audio-settings.md states the behaviour and no
longer lists aethersdr#6040 as a known issue.

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

on8st commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

@NF0T, thank you. You are right on both, and the first one is a real regression that I introduced and could not see on macOS. Fixed in 061a62e8, a new commit on top (no rebase).

Blocker 1. Confirmed in the source: currentInputIds is audioDeviceIds(inputDevices), which on Linux drops the ids isTransientAudioDeviceId matches, and I passed it as presentIds. On main presence was asked of the unfiltered list.

  • presentIds is now every enumerable id (enumeratedAudioDeviceIds()), for both decideAudioDeviceChange calls and for both acceptedAudioDeviceSelection calls. addedIds and the removed list stay filtered, as on main.
  • AudioDeviceListChange::presentIds carries the comment you asked for.
  • Three new checks at the policy level, with an echo-cancel-source id: an enumerable live device is present whatever counts as an arrival; an unrelated arrival does not raise the prompt; "System default" accepted beside a present saved device is a choice. 37 checks in all.
  • Not done, and I cannot do it here: the in-app A/B on Linux. I have no Linux machine. If your PipeWire box can run the fixed head against the same table, that is the evidence this still lacks.
  • One consequence to know: a saved churn-id device that returns is not in addedIds, so it is not picked up again silently; it is used again at the next launch. That matches how main ignores those arrivals.

Blocker 2. docs/user/docs/audio-settings.md no longer lists #6040 under Known issues, and says what the app does: a saved device that is not connected keeps its saved choice while the app runs on the system default, and the prompt's "System default" beside an absent saved device does not replace it. The sentence about switching back when the device connects follows part (b); if (b) is ruled out I will change it to "at the next launch".

Not changed: the followsDefault gate on RestorePreferred, which you found is not pinned by any check. I agree it is defensive and unreachable in the test's model. And the transient-id filter matching on an operator-chosen node name is a separate issue, as you say.

Full ctest at 061a62e8 on macOS: 758 of 761. The three that fail are the known ones on this Mac: #6008, #6224 (its two known checks) and vkamp_connection_test (#5254, its two known checks). The PR body now names the regression and its fix in the limits.

73, ON8ST

@NF0T NF0T self-assigned this Oct 11, 2026

@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.

Thank you @on8st: both blockers are fixed the way they needed to be, the PR body now says plainly what the first version cost, and the Linux evidence you asked for now exists and agrees. Approving.

Part (b) is accepted. The silent restore of the saved device when it returns is a user-visible behaviour, and you routed it to a reviewer rather than assuming it. My ruling: it stays as written. A prompt on device arrivals is the shape #2864 had to take back out, so silence is the house precedent; the saved id is the operator's own instruction; and it only ever acts for someone whose saved device is currently absent. Detecting a flapping device and backing off is a sensible future improvement, and deliberately not part of this PR.

What I checked on 061a62e8

Blocker 1 (Linux presence). presentIds is now enumeratedAudioDeviceIds() for both decideAudioDeviceChange calls and both acceptedAudioDeviceSelection calls. I read every use of the two id lists in the handler: the filtered audioDeviceIds() result now only feeds m_knownAudio*Ids, addedIds and removedIds, exactly as on main, and nothing that asks "is the selected device still there?" uses it. No other caller of audioDeviceIds() or isTransientAudioDeviceId() asks a presence question.

Tests. I built the policy test standalone against Qt 6.12.0: 37/37 pass. To check the new row means something, I fed it the filtered list the old glue passed ({built-in, usb} without echo-cancel-source): it fails both new live-device checks. The earlier mutations are still caught (dropped liveMissing fallback: 2 fail; accepted-prompt rule collapsed to always-OperatorChoice: 1; prompt gate ignoring the output: 2). As you say, the test pins the policy's contract and not the wiring in MainWindow.cpp, so the filtered-list mistake itself is not something a CTest can catch; the in-app run below is what covers it.

Docs. docs/user/docs/audio-settings.md: the #6040 Known-issues bullet is gone, and the passages say what the code does. Build docs site is green.

CI. All checks green on 061a62e8. It ran after #6375 merged, so it tested the current main; the one file both PRs touch (aetherd-touchpoints.md) is a different row.

In-app evidence (Linux, demo backend)

I did not run this myself. A second session ran it on a Linux desktop (PipeWire 1.4.2, Qt 6.12.0), on the demo radio only, with no TX and no FLEX contacted. It built three binaries with identical flags: this head, the PR's base a4a39e75, and the earlier 60f20deb as a positive control. I read its transcripts and checked them against the code.

Blocker 1 is fixed. With AudioInputDeviceId=echo-cancel-source selected and a default-named echo-cancel source loaded:

case head 061a62e8 base 60f20deb
ordinary device arrives, prompt on no dialog no dialog dialog shown
arrival, prompt off no input re-point no re-point (re-pointed, per the first run)
a removal only, prompt off no input re-point no re-point (re-pointed, per the first run)
control: plain alsa_input selected nothing n/a n/a

The saved id stayed echo-cancel-source in every row. The old build still shows the dialog, so the method is sensitive to the bug it is checking.

Part (a) fixes #6040, and it reproduces on base. A saved device that drops out mid-session:

direction head base
output (restore_probe) engine falls back, saved id kept saved id overwritten with empty
input (restore_src) engine falls back, saved id kept saved id overwritten with empty

Both builds log the identical engine line; only the persistence differs, which is persistsAudioDeviceSelection(Fallback) == false. A saved output that is absent at launch also keeps its id on the head across two unrelated device changes.

Part (b) restores silently. Reloading the saved device:

  • Output direction, prompt off: the engine returns to it within the 5 s observation window, no dialog, saved id unchanged.
  • Output direction, prompt on: also restores with no dialog.
  • Input direction: restores, no dialog, saved id unchanged.
  • Base: no restore (the id was already lost).
  • As the PR states, a saved churn-id device (echo-cancel-source) that returns is not restored live and is used at the next launch, confirmed by relaunching.

The TX-capture restart is real, and it is on the demo. The demo radio reports mic source PC, so pcMicInUse is true. On an input restore the log shows input device set to "restore_src source" and then TX stream started 3 ms later. That shows the branch is taken and startTxStream is called; it does not show audio reaching a real radio, which only a live FLEX would. Restoring the input therefore restarts a running capture, as I noted in my first review.

One case was left out of the evidence: an input-direction "missing at launch" variant tested nothing (its trigger produced no input event), and the session flagged it itself. The input direction is covered by the dropout and restore cases above.

Scope / process

  • Files since my first review: docs, policy header, handler, test. Nothing outside the fix. No CHANGELOG entry. Commits signed.
  • Constitution VIII is a fair citation: the limits section does the work of not claiming what wasn't run.

Not verified

  • macOS and Windows in the app. The code path is the same, but nobody ran those builds against a real device.
  • A real Bluetooth or USB unplug and replug on any platform, including whether the OS reports a returning device under the same id.
  • Real mic audio reaching a real radio after a restore.
  • PipeWire's native filter-chain, JACK, or a true PulseAudio server on Linux.

Forward guidance (not blockers)

  • A saved device whose id matches the churn filter returns at the next launch, not silently. The user docs sentence "switches back … when that device connects" is slightly broader than that on Linux, so it may be worth a clause later.
  • Moving audioDeviceIds() / enumeratedAudioDeviceIds() into a small header would let a test pin "presence uses the unfiltered list" instead of relying on review. Not needed for this PR.

Squash

fix(audio): a saved audio device that is absent is kept, and used again when it returns. Principle VIII. (#6378)

Nice recovery on the Linux regression, and thank you for writing it into the body instead of leaving it for the next reader.

73,
Ryan NF0T


👨🏼‍💻 Co-authored by Claude Sonnet 5.5

@NF0T
NF0T merged commit 7e080e0 into aethersdr:main Oct 11, 2026
7 checks passed
@NF0T NF0T removed their assignment Oct 11, 2026
rfoust pushed a commit that referenced this pull request Oct 12, 2026
…I. (#6387)

Extract the host audio-device ID filtering and change construction into the existing header-only policy. Preserve unfiltered presence checks and filtered arrival/removal checks, and carry the filtered list forward for the next device check.

Add socket-free coverage for Linux filtering and successive snapshots, and document the Linux virtual-device restore exception.

Refs #6378.

Validation: ARM64 macOS build with Qt 6.12 and RADE enabled; policy CTest 63/63; six mutation checks; 134,162 old/new list comparisons; isolated demo connect/audio/disconnect smoke test with TX disabled. Linux physical hot-plug behavior was not exercised locally.
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.

A saved audio device that is missing at launch is forgotten at the next device change, so reconnected Bluetooth headphones are no longer selected

2 participants