Skip to content

[core] Implement full audio device hotplug support with notification dialog#2583

Merged
ten9876 merged 8 commits into
aethersdr:mainfrom
jensenpat:aether/audio-device-change-dialog
May 12, 2026
Merged

[core] Implement full audio device hotplug support with notification dialog#2583
ten9876 merged 8 commits into
aethersdr:mainfrom
jensenpat:aether/audio-device-change-dialog

Conversation

@jensenpat

@jensenpat jensenpat commented May 11, 2026

Copy link
Copy Markdown
Collaborator
Screenshot 2026-05-11 at 5 22 48 AM

Summary

This PR adds a cross-platform Qt audio device hotplug flow for AetherSDR and fixes the missing TX-side reinitialization when PC mic input endpoints change.

It builds on PR #2571 by merging the new docs/audio-pipeline.md first, then documenting the new hotplug behavior in that pipeline guide.

What changed

  • Added AudioDeviceChangeDialog, a themed AetherSDR dialog that appears when Qt reports newly added audio input or output devices.
  • The dialog shows current input/output selections, marks newly detected devices, includes explicit system-default choices, and applies the same visual language as the existing client disconnect dialog.
  • MainWindow now owns debounced QMediaDevices input/output change monitoring so user prompting stays on the GUI thread.
  • Accepting a new selection queues AudioEngine::setInputDevice() and AudioEngine::setOutputDevice() onto the audio thread.
  • Cancel leaves selected devices alone, except when the current selected device disappeared during the same change batch, where AetherSDR falls back to system default.
  • Device removal now re-arms PC mic capture when mic source is PC, including the existing automatic fallback-to-default path.
  • AudioEngine::startTxStream() now validates the saved input device before opening QAudioSource; stale saved input IDs are cleared and the current system default input is used instead.
  • Removed the old automatic output-list restart behavior from AudioEngine so a user can cancel the hotplug dialog without an unexpected RX path reset.

User impact

When a USB audio interface, Bluetooth headset, or other audio device is plugged in, AetherSDR prompts the operator to choose whether to use the new input/output devices immediately. If they click OK or press Enter, the selected PC audio paths are updated and restarted as needed. If they click Cancel or press Escape, AetherSDR keeps the current paths unchanged.

When an active PC mic input is unplugged, AetherSDR now switches/follows the system default input and restarts local QAudioSource capture instead of requiring the user to toggle the radio mic source away from PC and back.

Implementation notes

  • The dialog and hotplug detection live in MainWindow because Qt device change notifications must ultimately drive GUI prompting.
  • AudioEngine remains the single owner of persisted selected device IDs and stream restarts.
  • The PC mic reinitialization path only restarts local Qt capture; it does not toggle the radio mic source or use a hardware mic fallback route.
  • The new dialog intentionally avoids platform-specific audio APIs and relies on Qt Multimedia device IDs/descriptions.
  • Output removal still falls back to the system default through the existing setOutputDevice(QAudioDevice{}) restart path.
  • The docs now describe the RX/TX device hotplug behavior alongside the audio pipeline details from PR [docs] Document AetherSDR audio pipeline #2571.

Validation

  • cmake --build build -j8
  • ctest --test-dir build

Notes for review

  • This branch intentionally includes the audio pipeline documentation commits from PR [docs] Document AetherSDR audio pipeline #2571 because this work was based on that PR as requested.
  • The previous CoreAudio/WASAPI output-device-list restart logic was moved out of AudioEngine to avoid changing audio paths after a user cancels the new dialog.

👨🏼‍💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by @jensenpat

@jensenpat jensenpat changed the title Add audio device hotplug dialog [core] Implement full audio device hotplug support with notification dialog May 11, 2026
@jensenpat jensenpat marked this pull request as ready for review May 11, 2026 12:18

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

Thanks @jensenpat — nice piece of work. The dialog is well-themed, the debounced device-change timer plus re-entrancy guard read cleanly, and the MainWindow/AudioEngine split (GUI thread prompts, audio thread owns persisted IDs and stream restarts) matches the codebase conventions. The startTxStream() saved-input validation in AudioEngine.cpp is a good belt-and-suspenders fix.

A few concerns worth a look before merge:

1. Possible regression of the #1361 WASAPI/USB power-cycle handler

The PR removes the QMediaDevices::audioOutputsChanged handler in AudioEngine.cpp and replaces it with one in MainWindow that only acts when device IDs are added or removed. The original #1361 comment is specifically about WASAPI session resets after idle/screensaver and USB devices power-cycling — scenarios where Qt may fire audioOutputsChanged without the device IDs actually changing membership, but the underlying handle has gone stale.

In handleAudioDeviceListChanged:

  • addedOutputIds / removedOutputIds will both be empty in a stale-handle case.
  • deviceAdded is false, so no dialog.
  • resetOutput is also false (selected device still appears present), so resetMissingAudioDevicesToDefault returns without restarting RX.

End result: the operator who previously got automatic recovery after a USB-bus glitch on Windows now silently keeps a dead RX sink. If the new dialog UX is the goal, I'd suggest keeping the existing #1361 behavior as a fallback when the change event fires with identical membership (or at minimum on m_audioSink being in a state that indicates a stale handle). The zombie-sink watchdog covers some of this, but it's specifically Linux-oriented per the original comment.

2. Cross-thread read of QAudioDevice getters

MainWindow::handleAudioDeviceListChanged() reads:

const QAudioDevice currentInput = m_audio->inputDevice();
const QAudioDevice currentOutput = m_audio->outputDevice();

from the GUI thread. AudioEngine::inputDevice()/outputDevice() are plain m_inputDevice/m_outputDevice field reads (AudioEngine.h:420-421), and setInputDevice/setOutputDevice mutate those members from the audio thread (your new applyAudioDeviceSelection posts them via QueuedConnection, which is correct). QAudioDevice is implicitly shared via QSharedData — concurrent read + write of the same instance isn't safe (refcount races). Same pattern with m_radioModel.transmitModel().micSelection() if RadioModel lives on a different thread (looks like it doesn't, so just the audio-engine getters).

Options: snapshot the selection in MainWindow when you set it (you already own the source-of-truth selection in m_known* lists; you can keep a parallel m_selectedInput/Output mirror), or fetch via Qt::BlockingQueuedConnection, or add a std::atomic/mutex-guarded snapshot inside AudioEngine.

3. Minor: spurious stream restart on a "no-op accept"

If the user clicks OK in the new dialog without changing the highlighted selection (very common when only an output device was added but they kept the input), applyAudioDeviceSelection still calls audio->setInputDevice(currentInput) and audio->setOutputDevice(currentOutput). Both setters restart their streams unconditionally if running (see AudioEngine.cpp:3951 / 3968). Worth a sameAudioDeviceSelection-guarded skip in the lambda so a no-change accept doesn't cause a glitch in active RX/TX.

Out of scope / scope notes

  • The docs/audio-pipeline.md and docs/architecture-pipelines.md changes are from PR #2571 as you noted in the PR body — fine, that PR is queued ahead.
  • Files touched all fit the stated scope.

Code quality is good — RAII members, scoped helpers in anonymous namespace, AppSettings used for persistence inside setInputDevice/setOutputDevice, no obvious leaks or null-deref. Just the three items above.

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

Claude here, reviewing on Jeremy's behalf.

Verdict

Hold on merge — needs rebase first. The feature itself is well-engineered, but the branch is silently reverting at least one already-landed PR (#2572). Once rebased and conflicts resolved correctly, this is a clean merge.

The blocking issue

The PR was branched from #2571 with the explicit note "this work was based on that PR as requested." Since then, #2572 (TX mic stereo channel canonicalization) merged with substantial AudioEngine.cpp changes, and this branch doesn't have them.

Confirmed by grep:

$ grep -n "TxMicChannelNormalizer\|accumulatePcMicMeterInt16Stereo" src/core/AudioEngine.cpp
# (nothing — entirely absent on this PR branch)

What's missing if this PR merges as-is:

  • TxMicChannelNormalizer integration in AudioEngine.cpp:3663-3672 (canonicalize right after capture)
  • accumulatePcMicMeterInt16Stereo() member function (left-channel-only meter fix)
  • logTxInputChannelDiagnostics() one-sided-stereo diagnostic
  • DAX radio-native collapseFloat32ToInt16MonoBigEndian() call site
  • m_txInputChannels, m_txMicChannelMode, m_txMicChannelState, m_daxRadioTxChannelMode, m_daxRadioTxChannelState member additions
  • The accompanying tx_mic_channel_normalizer_test.cpp registration in CMakeLists.txt

That's the work that closes #2347, #1417, #2565, #2567. Merging this PR without rebase would silently undo those four fixes.

Confirming with a merge attempt against current main:

Auto-merging CMakeLists.txt
Auto-merging docs/audio-pipeline.md
CONFLICT (add/add): Merge conflict in docs/audio-pipeline.md
Auto-merging src/core/AudioEngine.cpp
Auto-merging src/core/AudioEngine.h
Auto-merging src/gui/MainWindow.cpp
Auto-merging src/gui/MainWindow.h

The auto-merges look clean superficially but git diff origin/main shows accumulatePcMicMeterInt16Stereo and logTxInputChannelDiagnostics as "removed" relative to current main — confirming the auto-merge does NOT preserve the post-#2572 state. Per feedback_merge_over_rebase_hot_files.md, this is exactly the silently-drops-adjacent-additions failure mode the project's stale-base protocol warns about.

Other PRs that may overlap

Touching the same files since this branch was forked from #2571:

PR File Risk
#2572 src/core/AudioEngine.{h,cpp}, docs/audio-pipeline.md High — confirmed missing here
#2580 src/gui/MainWindow.{h,cpp} Frameless popout wiring; need to check overlap
#2577 CMakeLists.txt HID AUTOMOC fix; low overlap risk
#2579 CMakeLists.txt icns 1024 fix; low overlap risk

The rebase needs to preserve all of those.

The feature itself (would-be-clean review)

Setting the rebase issue aside, the feature is well-engineered:

AudioDeviceChangeDialog

  • Clean Qt-styled dialog matching the project's existing visual language (gradient header, cyan section labels, monospace lists)
  • selectedInputDevice() / selectedOutputDevice() accessors decouple model from view
  • Device-id role storage via Qt::UserRole + 1 (standard Qt pattern)
  • containsDeviceId helper avoids relying on QByteArray equality semantics across Qt versions

MainWindow hotplug wiring (setupAudioDeviceChangeMonitor, handleAudioDeviceListChanged)

  • 750ms debounce timer — sensible, since QMediaDevices fires multiple change signals when a single USB device exposes both an input and output endpoint
  • Re-entrancy guard via m_audioDeviceDialogOpen — restarts the debounce timer if more changes arrive while the dialog is open, instead of stacking dialogs
  • Silent-reset shortcut — when devices disappear with no new additions, falls back to system default without prompting. Right UX call: the user has no choice to make
  • Cross-thread dispatchQMetaObject::invokeMethod to push setInputDevice / setOutputDevice onto the audio thread. QPointer<AudioEngine> captured for safety against teardown
  • PC mic reinitialize gate — only fires when micSelection() == "PC" and connected. Tight scope

AudioEngine changes (excluding the rebase concern)

  • Removal of the old Windows/macOS QMediaDevices auto-restart logic at AudioEngine.cpp:309-334 is intentional and correct — the new dialog handles user choice, and the auto-restart was the "Cancel button does nothing because RX restarts anyway" bug the PR description mentions
  • startTxStream() now validates the saved input device ID before opening QAudioSource and falls back to system default if stale — defensive code that handles "saved device was removed between sessions"

Docs

  • New docs/audio-pipeline.md section describing hotplug behavior is well-written — extends #2571's pipeline doc rather than duplicating it
  • The PR will need to resolve the audio-pipeline.md add/add conflict against current main (which has post-#2572 content)

What needs to happen

  1. Rebase onto current main (or git merge origin/main and resolve carefully — the latter is safer per feedback_merge_over_rebase_hot_files.md)
  2. Manually verify the rebased AudioEngine.cpp retains:
    • TxMicChannelNormalizer includes and namespace usage
    • accumulatePcMicMeterInt16Stereo() definition and call sites
    • logTxInputChannelDiagnostics() definition and call sites
    • Member declarations for m_txMicChannelMode, m_txMicChannelState, m_daxRadioTxChannelMode, m_daxRadioTxChannelState, m_txInputChannels
  3. Verify tests/tx_mic_channel_normalizer_test.cpp still builds and passes (i.e., the test wasn't accidentally dropped)
  4. Re-run the local build + test suite

Sanity-check post-rebase: grep -c "TxMicChannelNormalizer" src/core/AudioEngine.cpp should report at least 8 hits.

CI

All 5 checks green on the current branch state (build, analyze (cpp), check-paths, check-windows, CodeQL). That confirms the PR compiles in isolation — but doesn't catch the silent revert because CI doesn't know what main looked like 24 hours ago.

Recommendation

Hold on merge. Ask @jensenpat to rebase onto current main and verify the rebase preserves #2572's TX mic canonicalization changes. Once that's done, this is a clean and valuable feature.

Thanks Pat — the dialog design and the wiring discipline are exactly the right shape for a user-facing audio device change handler. The 750ms debounce + re-entrancy guard + thread-affinity-correct dispatch is genuinely good Qt engineering. The rebase situation is just a calendar coincidence (#2572 merged in the window between this branch being forked and posted).

73, Jeremy KK7GWY & Claude (AI dev partner)

…hange-dialog

# Conflicts:
#	docs/audio-pipeline.md
@ten9876

ten9876 commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Pushed merge commit `fae7d674` to your branch resolving the conflict with current main. Per the project's hot-files protocol, used `git merge origin/main` (not rebase) to preserve adjacent additions.

Conflict resolution

`docs/audio-pipeline.md` — single add/add conflict with 18 conflict regions:

  • Took main's version (`git checkout --theirs`) since main now contains the post-[core] Fix TX mic stereo channel canonicalization #2572 corrected description ("canonical mono voice signal using Auto Left/Right/Average") that supersedes the pre-[core] Fix TX mic stereo channel canonicalization #2572 description ("`Resampler::processStereoToStereo()` first averages L/R") on this branch
  • Manually re-inserted your new `### Audio device hotplug handling` section (~25 lines) in the right place — between the macOS Bluetooth telephony guard and the `## Local sidetone` section

Verified post-merge

```
grep -c "TxMicChannelNormalizer\|accumulatePcMicMeterInt16Stereo" src/core/AudioEngine.cpp → 16
grep -c "AudioDeviceChangeDialog\|m_audioDeviceMonitor\|m_audioDeviceChangeTimer" src/gui/MainWindow.cpp → 10
grep -c "m_txBandDialog\|setupAudioDeviceChangeMonitor" src/gui/MainWindow.cpp → 8
```

So all three layers are preserved: #2572's TX mic canonicalization, your new hotplug dialog wiring, and #2580's frameless popout MainWindow changes.

Build + test

CI should re-run on the push. Once green this looks ready to land.

73, Jeremy KK7GWY & Claude (AI dev partner)

# Conflicts:
#	src/core/AudioEngine.cpp
#	src/gui/MainWindow.h
@ten9876

ten9876 commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Pushed merge commit `b8480a13` to your branch resolving conflicts after #2587 landed.

Conflicts resolved

  1. `src/core/AudioEngine.cpp` — your PR removes the entire `m_mediaDevices` connection block at line 312, but [ux] Add PC Audio device selection tooltip #2587 had added `emit outputDeviceChanged()`/`emit inputDeviceChanged()` calls inside that same block. Took HEAD's version (block removed). The signal emissions are preserved via [ux] Add PC Audio device selection tooltip #2587's setter-path emits at `setOutputDevice()` line 3961 and `setInputDevice()` line 3982 — which are the canonical sites and cover all the hotplug-dialog accept flows.

  2. `src/gui/MainWindow.h` — both PRs declared new private methods in the same block. Kept both: `updatePcAudioTooltip` from [ux] Add PC Audio device selection tooltip #2587 plus `setupAudioDeviceChangeMonitor`/`scheduleAudioDeviceChangeCheck`/`handleAudioDeviceListChanged`/`applyAudioDeviceSelection`/`resetMissingAudioDevicesToDefault` from this PR.

Verified

  • `grep -c "m_mediaDevices" src/core/AudioEngine.{h,cpp}` → 0 (no orphaned references)
  • `grep "emit inputDeviceChanged\|emit outputDeviceChanged" src/core/AudioEngine.cpp` → 2 hits at the setter paths (3961, 3982) ✓
  • `cmake --build build -j$(nproc)` — clean

The setter-path emits mean #2587's PC Audio tooltip auto-refresh continues to work after this PR lands — it fires whenever your hotplug dialog accept calls `setInputDevice` / `setOutputDevice` on the audio thread.

CI should re-run on the push. Once green this should be ready to land.

73, Jeremy KK7GWY & Claude (AI dev partner)

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

Claude here, follow-up review.

Status

All five CI checks now green: `build`, `analyze (cpp)`, `check-paths`, `check-windows`, CodeQL. The only remaining gate is the CODEOWNERS review.

The PR is now at `b8480a13` after two merge commits I pushed during review:

  • `fae7d674` — initial rebase, resolved audio-pipeline.md add/add conflict from #2571/#2572
  • `b8480a13` — second rebase after #2587 landed; coordination-point conflict in AudioEngine.cpp removed-block-vs-added-emits

Re-verified post-merge

Check Count Status
`TxMicChannelNormalizer` in AudioEngine.cpp 13 #2572 work preserved
`AudioDeviceChangeDialog` / `m_audioDeviceMonitor` in MainWindow.cpp 5 ✓ PR's new feature intact
Setter-path `emit inputDeviceChanged`/`outputDeviceChanged` 2 #2587's tooltip refresh wired
`m_mediaDevices` orphans 0 ✓ old block cleanly removed

Built locally, `tx_mic_channel_normalizer_test` passes 17/17 (which is the canary that #2572 survived the merges).

Architecture review (now that conflicts are settled)

The feature implementation is well-engineered, separating concerns correctly:

AudioDeviceChangeDialog — pure view layer:

  • Receives device lists, current selections, and newly-added device IDs as constructor args
  • Marks new devices visually, includes explicit "system default" choices
  • selectedInputDevice()/selectedOutputDevice() accessors for the result
  • Standard Qt design language matching the project

MainWindow hotplug wiring — controller:

  • 750ms debounce on QMediaDevices::audioInputsChanged/audioOutputsChanged. Sensible — Qt fires multiple signals when a single USB device exposes both endpoints
  • m_audioDeviceDialogOpen re-entrancy guard; if more changes arrive while dialog is open, the timer restarts to re-check after close
  • Silent-reset shortcut when devices disappear with no additions — right UX call, no choice to prompt about
  • Cross-thread dispatch via QMetaObject::invokeMethod with QPointer<AudioEngine> for safety

AudioEngine — model:

  • Old Windows/macOS auto-restart block removed (the bug it caused: user couldn't cancel the dialog without an RX path reset)
  • Setter-path signal emits at lines 3961 (setOutputDevice) and 3982 (setInputDevice) cover all flows: Radio Setup manual picks, hotplug dialog accept, device-removal fallback
  • startTxStream() validates saved input ID before opening QAudioSource; stale IDs are cleared

What I'd want from Jeremy before merge

This is a substantial feature with cross-platform device-change behavior. Manual verification matters:

  1. macOS — plug a USB audio interface mid-session; expect the dialog with both the current and new device listed, new device marked. Click OK with new device selected → audio routes through it. Click Cancel → audio stays on previous device.
  2. macOS Bluetooth — pair/unpair an audio device while running; same flow.
  3. Linux PipeWire — same hotplug test; verify the dialog appears (the old AudioEngine block was Windows/macOS-only via Q_OS_WIN || Q_OS_MAC, but the new MainWindow path is platform-agnostic so Linux should now also get the dialog).
  4. Active PC mic, then unplug it — expected: AetherSDR falls back to system default, restarts QAudioSource. No dialog (since nothing new was added, just removed).
  5. PR #2587 coordination — open the PC Audio tooltip on the title bar, change devices via this PR's dialog → tooltip should refresh automatically via the setter-path signals.

Recommendation

Merge once Jeremy validates the four hotplug scenarios above. The conflict situation is resolved, code is correct, tests pass, all CI green.

Worth noting for future similar PRs: this is the third PR in the sequence (#2571#2572#2583) that branched from a non-merged precursor. The pattern caused two rounds of merge-conflict resolution. The lesson is that a branch should rebase onto upstream main as upstream PRs land, not wait until review time. Not blocking — just a process note.

73, Jeremy KK7GWY & Claude (AI dev partner)

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

Approved — all CI green, post-merge audit confirms #2572 + #2587 preserved.

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