feat(audio): AudioOutputRouter registry for output-following sinks (#3306)#3630
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
ten9876
left a comment
There was a problem hiding this comment.
Approving — clean, behavior-identical structural hardening with a headless unit test.
Verified:
- Router core:
setCurrentDevicefans out over a snapshot of the follower list — correct for re-entrant registration (a follower could register another mid-fan-out); the skip-the-mid-add case is already seeded byaddFollower, so it's not a miss. - Lifetime: the template
addFollower(T*)QPointer-guards the sink — a follower destroyed before the router is a harmless no-op. - Threading preserved: the
outputDeviceChanged → setCurrentDeviceforwarder stays aQueuedConnection, so followers are touched on the GUI thread, identical to the hand-wired version. - MainWindow rewiring is behavior-identical with correct ordering (
setCurrentDevicesetsm_devicebeforeaddFollowerseeds each). - Scoping documented and right — AudioEngine-internal sinks and the WFM
WaveOutWriterare intentionally excluded, with "do not register" comments to stop a future reader from re-opening the regression. - New headless
audio_output_router_test+ CI green across all six.
Exactly the right "turn a recurring footgun into a registration" hardening (#2899/#3361/#3378). One non-blocking nit (dead QPointer-null followers are never pruned from m_followers) — negligible for a register-once list of two; I'll file a follow-up rather than gate on it. Nice work @jensenpat.
…hase 6, #3306) (#3631) > **Stacks on #3630** (AudioOutputRouter). The first commit here is #3630's; the new commit is the Quindar migration. (The #3556 recorder fix that was previously bundled here has been split out to its own PR.) ## Phase 6, first sink — QuindarLocalSink → AudioDeviceNegotiator (#3306) `QuindarLocalSink` previously tried only **48 kHz Float then the device preferredFormat** — no 44.1 kHz rung and no graceful fallback — so it failed outright on a 44.1k-only output. It was the worst offender in #3306 (effectively no ladder). It now walks the shared `AudioDeviceNegotiator`'s **Float** rungs (the tone is generated in Float, so Int16 rungs are skipped, mirroring the RX-speaker migration), gaining the per-OS preferred rate plus the 44.1 kHz and preferredFormat fallbacks in one place. RegenerateAtRate (the generator retunes — no resampler); the "every transmitted Quindar sample also plays locally" contract is unchanged. Diagnostics/summary logging preserved. ### Phase 6 scope Quindar was the high-value sink (it had an actual no-fallback bug). The other three aux sinks are deferred with reasons: CW sidetone's QAudio path is only the *fallback* backend (PortAudio is primary and can't use the Qt negotiator) and is started at a constant 48 kHz; `ClientPuduMonitor` + `QsoRecorder` playback are **Int16-native** and already robust, so migrating them cleanly wants a small negotiator *format-preference (Int16-first)* enabler — its own follow-up. ## Test / build All three audio tests green under CTest (negotiation 25/25, device wrapper 8/8, router 9/9). Full macOS app builds & links clean (RelWithDebInfo). Refs #3306 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…3306 6b/6c) (#3655) > **Stacks on #3631 → #3630.** First two commits are #3630 (router) + #3631 (Quindar); the new commits are the enabler + 6b + 6c. Merge the parents first and this collapses to the three new commits. Each is a clean standalone commit. Closes out **Phase 6** of the audio sink factory (#3306) — every auxiliary output sink now negotiates format through the shared factory instead of a bespoke per-sink ladder. ## Commits 1. **Int16-first `FormatPreference` enabler** — the Output ladder is Float-first (right for the float-native RX/sidetone/Quindar). The Int16-native playback sinks want the opposite, so this adds an optional `FormatPreference { Auto, Int16First, Float32First }` threaded through `buildLadder`/`negotiate` + the wrapper. Default `Auto` leaves RX/mic **byte-identical**. +3 golden rows (28/28). 2. **6b — Pudu monitor + QSO playback** negotiate via `AudioDeviceNegotiator(Int16First, PreservePan)`. On a normal device they get **Int16 at the per-OS rate** (no conversion, as before) with the universal 44.1k + preferredFormat fallbacks in one place. `ClientPuduMonitor` keeps its Int16→Float guard (#3231); **`QsoRecorder` playback gains the same guard** — it previously *bailed* on a Float-only WASAPI device, now it works. 3. **6c — CW sidetone (QAudio backend)** walks the Float-first ladder (Int16 fallback preserved for VB-Audio #2629). The **PortAudio backend is untouched** (Qt negotiator can't drive it). ## Behaviour notes (for soak) Normal devices are unchanged **except** Pudu/QSO/CW now prefer **48k on Win/Mac** (was 24k for Pudu/QSO) — this is the factory's deliberate #2120 policy (dodges WASAPI 24k-resampler artifacts, same as the RX sink); playback is resampled up-front, one-shot. Linux stays native 24k. Strictly-additive wins: QSO playback now survives Float-only devices; all three gain the 44.1k rung. ## Test / build - `audio_format_negotiation_test` **28/28**, `audio_device_negotiator_test` 8/8, `audio_output_router_test` 9/9 — all green under CTest. - Full macOS app builds & links clean (RelWithDebInfo); deployed to the macOS test box. After this, the factory covers every device-facing sink. Remaining #3306 items (TCI-TX client-rate, PipeWire DAX resampler, Windows mic) are separate. Refs #3306 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary Follow-up to #3630, closes #3660. `AudioOutputRouter` never pruned followers whose `QPointer` guard had gone null — a destroyed sink lingered in `m_followers` as a no-op closure iterated (and skipped) on every `setCurrentDevice()` fan-out. Harmless for the current register-once-at-startup use (two lifetime-owned followers: `ClientPuduMonitor`, `QsoRecorder`), but it would accumulate unbounded once dynamically created/destroyed followers land — e.g. a future per-receiver or WebSDR-sourced sink per the audio-sink-factory roadmap. ## How - Each follower now carries an optional **liveness predicate** alongside its apply callback. The `QPointer`-guarded template `addFollower(T*)` sets it (`[guarded]{ return !guarded.isNull(); }`); raw `std::function` followers leave it null and are treated as **always alive** (the caller owns their lifetime). - `setCurrentDevice()` prunes dead followers with `std::erase_if` **after** the fan-out, operating on the live vector — so a follower added re-entrantly during the loop is alive and kept. The snapshot fan-out and re-entrancy behavior are unchanged. Public API (`addFollower(std::function)`, `addFollower(T*)`) is unchanged; the two overloads now share an internal `registerFollower(apply, alive)`. ## Test `audio_output_router_test` extended: registers a live + a heap follower, deletes the heap one, fans out, and asserts the dead follower is **pruned** (`followerCount` 2 → 1) while the live one is **kept and still updated**. **14/14** (was 9/9). Full app builds clean. Refs #3306 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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