[core] Implement full audio device hotplug support with notification dialog#2583
Conversation
There was a problem hiding this comment.
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/removedOutputIdswill both be empty in a stale-handle case.deviceAddedis false, so no dialog.resetOutputis also false (selected device still appears present), soresetMissingAudioDevicesToDefaultreturns 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.mdanddocs/architecture-pipelines.mdchanges 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
left a comment
There was a problem hiding this comment.
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:
TxMicChannelNormalizerintegration inAudioEngine.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_daxRadioTxChannelStatemember additions- The accompanying
tx_mic_channel_normalizer_test.cppregistration 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) containsDeviceIdhelper avoids relying onQByteArrayequality semantics across Qt versions
MainWindow hotplug wiring (setupAudioDeviceChangeMonitor, handleAudioDeviceListChanged)
- 750ms debounce timer — sensible, since
QMediaDevicesfires 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 dispatch —
QMetaObject::invokeMethodto pushsetInputDevice/setOutputDeviceonto 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
QMediaDevicesauto-restart logic atAudioEngine.cpp:309-334is 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 openingQAudioSourceand falls back to system default if stale — defensive code that handles "saved device was removed between sessions"
Docs
- New
docs/audio-pipeline.mdsection 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
- Rebase onto current main (or
git merge origin/mainand resolve carefully — the latter is safer perfeedback_merge_over_rebase_hot_files.md) - Manually verify the rebased AudioEngine.cpp retains:
TxMicChannelNormalizerincludes and namespace usageaccumulatePcMicMeterInt16Stereo()definition and call siteslogTxInputChannelDiagnostics()definition and call sites- Member declarations for
m_txMicChannelMode,m_txMicChannelState,m_daxRadioTxChannelMode,m_daxRadioTxChannelState,m_txInputChannels
- Verify
tests/tx_mic_channel_normalizer_test.cppstill builds and passes (i.e., the test wasn't accidentally dropped) - 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
|
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:
Verified post-merge``` 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
|
Pushed merge commit `b8480a13` to your branch resolving conflicts after #2587 landed. Conflicts resolved
Verified
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
left a comment
There was a problem hiding this comment.
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_audioDeviceDialogOpenre-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::invokeMethodwithQPointer<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:
- 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.
- macOS Bluetooth — pair/unpair an audio device while running; same flow.
- 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). - Active PC mic, then unplug it — expected: AetherSDR falls back to system default, restarts QAudioSource. No dialog (since nothing new was added, just removed).
- 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)
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.mdfirst, then documenting the new hotplug behavior in that pipeline guide.What changed
AudioDeviceChangeDialog, a themed AetherSDR dialog that appears when Qt reports newly added audio input or output devices.MainWindownow owns debouncedQMediaDevicesinput/output change monitoring so user prompting stays on the GUI thread.AudioEngine::setInputDevice()andAudioEngine::setOutputDevice()onto the audio thread.PC, including the existing automatic fallback-to-default path.AudioEngine::startTxStream()now validates the saved input device before openingQAudioSource; stale saved input IDs are cleared and the current system default input is used instead.AudioEngineso 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
QAudioSourcecapture instead of requiring the user to toggle the radio mic source away fromPCand back.Implementation notes
MainWindowbecause Qt device change notifications must ultimately drive GUI prompting.AudioEngineremains the single owner of persisted selected device IDs and stream restarts.setOutputDevice(QAudioDevice{})restart path.Validation
cmake --build build -j8ctest --test-dir buildNotes for review
AudioEngineto 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