feat(audio): AudioOutputRouter registry for output-following sinks (#3306)#3630
feat(audio): AudioOutputRouter registry for output-following sinks (#3306)#3630jensenpat wants to merge 1 commit into
Conversation
…ethersdr#3306) Replace the hand-wired "connect each external playback sink to outputDeviceChanged" lambda in MainWindow with a single registry. External sinks that own their own QAudioSink (the post-DSP Pudu monitor, QSO playback) already followed the user-selected output device correctly — every device-change path (user selection, device removal, OS-default change) flows through AudioEngine::setOutputDevice() -> outputDeviceChanged. The risk was purely structural: each NEW external sink had to remember to join that lambda or it would "uncouple" and keep playing on the old/default endpoint (the recurring class behind aethersdr#2899 / aethersdr#3361 / aethersdr#3378). AudioOutputRouter (src/core/AudioOutputRouter.{h,cpp}) makes following a registration instead of hand-wiring: - addFollower(sink) takes any object with setOutputDevice(QAudioDevice) (or a std::function); the follower is seeded immediately and re-seeded on every change, QPointer-guarded against early destruction. - One QueuedConnection forwards outputDeviceChanged to setCurrentDevice(), which fans out to all followers on the GUI thread (matching prior behaviour) over a snapshot so a follower mutating the list mid-fan-out can't invalidate it. - Depends only on Qt Core/Multimedia, so the registry/fan-out is unit-tested headless (tests/audio_output_router_test, 11/11) without a live AudioEngine, including the QPointer-guard and reentrancy paths. MainWindow registers m_finalMonitor + m_qsoRecorder; a future external sink follows correctly just by registering — no new connect to forget. Behaviour- identical hardening. Intentionally NOT routed (documented): the AudioEngine- internal sinks (RX speaker, CW sidetone, Quindar — follow via the RX restart), and the WFM demodulator's WaveOutWriter (plays to its own WfmDeviceDialog device by design, aethersdr#3407). Builds & links clean into the full app; all three audio tests pass under CTest. Refs aethersdr#3306 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7832a59 to
a6a4ccb
Compare
Defensive review (self) — completeness + correctnessRan a two-front review before this is reviewed: a sweep of the last two weeks of audio PRs/issues, and an exhaustive codebase sweep of every output sink. Summary so others don't have to redo it: Follower completeness — confirmed exhaustive. Every
Behaviour equivalence — confirmed. All device-change paths (user selection via Hardening added after review:
Heads-up (not addressed here, by design):
CI: build / macOS / accessibility green; Windows + analyze pending. |
There was a problem hiding this comment.
Reviewed the full diff against AetherSDR conventions, correctness, scope, and error handling. This is a clean, well-targeted refactor — the behaviour-identical claim holds up and the structural hardening is real. Nice work, @jensenpat.
Strengths
- Conventions — RAII throughout: router is parent-owned (
new AudioOutputRouter(this)), followers areQPointer-guarded against early destruction, and the template/std::functionoverload split keeps the common case to one self-documenting line. NoQSettings/AppSettingssurface touched. Namespacing is consistent (AetherSDRfor router, sinks, andMainWindow). - Reentrancy — the snapshot copy in
setCurrentDevice()before the fan-out is the correct guard against a follower mutatingm_followersmid-loop, and the comment correctly reasons that an added-during-fan-out follower is already seeded byaddFollower()so skipping it isn't a miss. The test (add-during-fan-out) exercises exactly this. - Threading — bridging
outputDeviceChanged(audio worker thread) →setCurrentDeviceviaQueuedConnectionkeeps allm_followers/m_deviceaccess on the GUI thread, matching the prior hand-wired behaviour; no data race introduced. - Behaviour parity — seeding
setCurrentDevice()before the twoaddFollower()calls reproduces the old seed-then-resubscribe ordering exactly. - Scope & docs — every changed file is in scope;
docs/audio-sink-factory.mdis updated to mark Layer 3 landed and, helpfully, documents the intentionally-not-routed sinks (AudioEngine-internal + WFMWaveOutWriter) so a future reader doesn't "fix" them by registering.
Minor (non-blocking)
- The
std::functionaddFolloweroverload has no lifetime guard, unlike the template — correct by design (the template is the guarded convenience path), and the header comment makes that explicit. Worth keeping in mind if a future caller registers a lambda capturing a raw sink pointer; the guardedaddFollower(T*)form should stay the default.
No bugs, null-pointer risks, or resource leaks found; all 6 CI checks (build, macOS, Windows, CodeQL, accessibility) are green, and the headless 9/9 unit test covers seeding, fan-out, the QPointer-guard path, and reentrancy. Thanks for the thorough PR description and the test coverage.
🤖 aethersdr-agent · cost: $3.0307 · model: claude-opus-4-8
There was a problem hiding this comment.
Reviewed the full diff against the four review axes (conventions, bugs/null/leak risks, scope, boundary error handling). This is a tidy, well-targeted refactor and I have no blocking concerns — all six checks are green (build, check-macos, check-windows, CodeQL, Qt a11y, cpp analysis).
What I verified holds up:
- Seeding / ordering in
MainWindow. The sequence — register theQueuedConnectionforwarder, thensetCurrentDevice(...), thenaddFollower(...)for each sink — is correct. The queued forwarder can't fire synchronously during construction,setCurrentDeviceupdatesm_deviceagainst an empty follower list, and eachaddFollowerthen seeds exactly once. No double-seed, no missed seed. - Reentrancy.
setCurrentDevicefanning out over a copy ofm_followersis the right call, and the reasoning that an add-during-fan-out follower is already seeded byaddFollower(sincem_deviceis updated first) is sound. The test covers this path explicitly. - Lifetime. The
QPointer<T>guard in the template overload is correct, and the forwarder lambda bindsthisas context so it auto-disconnects withMainWindow. No leak (router is parented tothis), no dangling-follower crash. - Threading.
QueuedConnectionkeeps the fan-out on the GUI thread, matching the prior hand-wired behaviour — no regression.
Scope: every file earns its place — the two sink headers are comment-only doc updates, and the docs/audio-sink-factory.md changes accurately reflect what landed. Nothing out of scope.
One small note (non-blocking): the forwarder re-reads m_audio->outputDevice() at delivery time rather than capturing the value at emit time. This is identical to the previous behaviour and harmless (last-write-wins on coalesced changes), so no change needed — just flagging that the router's setCurrentDevice(const QAudioDevice&) slot signature invites connecting outputDeviceChanged directly if it ever carries the device as an argument, which would remove the read-back hop.
The headless unit test (9/9, including the QPointer-guard and reentrancy paths) is exactly the right level for this logic, and turning "remember to join the lambda" into "register once" genuinely closes the uncoupling class behind #2899/#3361/#3378. Nice, careful work — thanks @jensenpat.
🤖 aethersdr-agent · cost: $2.1699 · model: claude-opus-4-8
What
Layer 3 of the consolidated audio sink factory (#3306): a single registry for playback sinks that must follow the user-selected output device, replacing the hand-wired per-sink
connect-to-outputDeviceChangedlambda inMainWindow.Why
The external playback sinks that own their own
QAudioSink— the post-DSP Pudu monitor (ClientPuduMonitor) and QSO playback (QsoRecorder) — already followed the selected device correctly: every device-change path (user selection, device removal, OS-default change) flows throughAudioEngine::setOutputDevice()→outputDeviceChanged, and aMainWindowlambda re-seeded both. The risk was purely structural — every new external sink had to remember to join that lambda, or it would "uncouple" and keep playing on the old/default endpoint. That's the recurring class behind #2899 (CW sidetone) and #3361/#3378 (monitor + QSO).How
AudioOutputRouter(src/core/AudioOutputRouter.{h,cpp}) turns following into registration:addFollower(sink)accepts any object exposingsetOutputDevice(const QAudioDevice&)(or astd::function). The follower is seeded immediately and re-seeded on every change,QPointer-guarded against early destruction.QueuedConnectionforwardsAudioEngine::outputDeviceChanged→setCurrentDevice(), which fans out to all followers on the GUI thread (matching the previous behaviour).tests/audio_output_router_test, 9/9) without a liveAudioEngine— including the QPointer-guard path.MainWindownow registersm_finalMonitor+m_qsoRecorderwith the router instead of hand-wiring; a future external sink follows correctly just by registering. The AudioEngine-internal sinks (RX speaker, CW sidetone, Quindar) already follow via the RX restart and are intentionally not routed here.Behaviour
Identical to the hand-wired version — this is the hardening that stops the uncoupling class from silently reappearing, not a behaviour change.
Test / build
audio_output_router_test9/9;audio_format_negotiation_test25/25;audio_device_negotiator_test8/8 — all green under CTest.Refs #3306
💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat