refactor(audio): negotiate QuindarLocalSink format via the factory (Phase 6, #3306)#3631
Conversation
8497228 to
37f99cf
Compare
There was a problem hiding this comment.
Reviewed the Quindar migration commit (the AudioOutputRouter content belongs to the stacked #3630). The factory wiring is clean and the contract handling is careful — thanks for the thorough write-up. CI is green across build/macOS/Windows/CodeQL.
One substantive issue: the Quindar ladder is gated on isFormatSupported, not walked via start()
The PR says this "mirrors the RX-speaker migration," but the consumption pattern differs in a way that matters. The RX speaker (AudioEngine.cpp:1034-1054) walks the Float rungs and tries each with a real sink->start(), falling through to the next rung on failure — which is exactly what formatLadder() is built for. Its own doc comment is explicit:
The full ordered Qt-format ladder, for backends where
isFormatSupported()is unreliable and the caller must try-at-open (Windows WASAPI). … Each rung is tried with a realstart(), so reliable backends and WASAPI's probe-at-open are handled identically.
The new Quindar code instead does:
for (const QAudioFormat& cand : ladder) {
if (cand.sampleFormat() != QAudioFormat::Float) continue;
if (dev.isFormatSupported(cand)) { fmt = cand; haveFormat = true; break; }
}
if (!haveFormat) { /* log + return false */ }i.e. it picks the first isFormatSupported-true rung and then opens it once. Two consequences:
- Windows/WASAPI regression risk.
isFormatSupportedis documented-unreliable there — bypassing it is the whole reasonformatLadderexists. If it returns false for every Float rung, Quindar now bails withreturn falsewithout ever attempting an open, where the old code at least triedpreferredFormatviastart(). So on a WASAPI box this can turn "plays" into "silently never starts." (Windows wasn't part of the test matrix per the description, so this wouldn't have surfaced.) - No open-time fallback. Even on reliable backends, if the chosen rung's
start()fails there's no fall-through to the next rung — undercutting the "graceful fallback ladder" this PR is meant to add.
Suggested fix is to mirror RX literally: drop the isFormatSupported gate and walk the ladder constructing+start()ing each Float rung, keeping the first that yields a non-null QIODevice, and only emit the open-failure summary if all rungs fail. That also lets you delete the separate isFormatSupported check and fold the existing start()-failure logging into the loop.
Smaller notes (non-blocking)
formatLadderalways emitschannels = 2(AudioFormatNegotiator.cpp:96), so the hardcoded stereo write inonTimerTick(frameBytes = 2 * sizeof(float)) stays consistent — good. Worth a one-line comment noting the sink relies on the ladder being stereo, since a future ladder change to honor mono devices would silently corrupt the interleaving here.- Diagnostics/summary logging and the
RegenerateAtRateretune contract look correctly preserved.
Everything else (the router registry, QPointer guard, snapshot-during-fan-out, MainWindow seed-then-register ordering) reads cleanly. The only thing I'd want addressed before merge is the ladder-walk vs. isFormatSupported gating above.
🤖 aethersdr-agent · cost: $6.0724 · model: claude-opus-4-8
…ethersdr#3306) Phase 6, first sink. 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 (the class aethersdr#3306 set out to close; Quindar was the worst offender, with 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). This gives it, in one place, the per-OS preferred rate plus the 44.1 kHz and preferredFormat fallbacks. The generator retunes to the negotiated rate (RegenerateAtRate — no resampler), and the "every transmitted Quindar sample also plays locally" contract is unchanged. Diagnostics/summary logging preserved. Builds & links clean into the full app. Refs aethersdr#3306 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
37f99cf to
8a7dd82
Compare
ten9876
left a comment
There was a problem hiding this comment.
Approving — clean, well-scoped migration (and now rebased to just the Quindar commit).
QuindarLocalSink moves from "48 kHz Float or device-preferredFormat, no real ladder" to walking the shared AudioDeviceNegotiator Float rungs — gaining the per-OS preferred rate + 44.1 kHz + preferredFormat fallbacks. Fixes a real bug: a 44.1k-only output previously failed outright. Correctly skips Int16 rungs (Float-generated tone), fails honestly with a structured OpenFailureSummary when no Float rung is supported, and m_actualRate drives the (unchanged) RegenerateAtRate generator + buffer sizing. Phase-6 scoping is sound (CW sidetone QAudio is fallback-only/48k; Pudu+QSO are Int16-native pending an Int16-first negotiator enabler).
CI green across all six; builds clean on current main. Nice work @jensenpat.
…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>
Phase 6, first sink — QuindarLocalSink → AudioDeviceNegotiator (#3306)
QuindarLocalSinkpreviously 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+QsoRecorderplayback 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