Skip to content

refactor(audio): negotiate QuindarLocalSink format via the factory (Phase 6, #3306)#3631

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:aether/audio-aux-sinks-rec-3556
Jun 19, 2026
Merged

refactor(audio): negotiate QuindarLocalSink format via the factory (Phase 6, #3306)#3631
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:aether/audio-aux-sinks-rec-3556

Conversation

@jensenpat

@jensenpat jensenpat commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

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

@jensenpat jensenpat force-pushed the aether/audio-aux-sinks-rec-3556 branch from 8497228 to 37f99cf Compare June 16, 2026 23:58
@jensenpat jensenpat changed the title fix(audio): capture TX in Client-Side recordings (#3556) + negotiate Quindar format (Phase 6, #3306) refactor(audio): negotiate QuindarLocalSink format via the factory (Phase 6, #3306) Jun 16, 2026
@jensenpat jensenpat marked this pull request as ready for review June 17, 2026 04:24
@jensenpat jensenpat requested review from a team as code owners June 17, 2026 04:24

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

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 real start(), 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:

  1. Windows/WASAPI regression risk. isFormatSupported is documented-unreliable there — bypassing it is the whole reason formatLadder exists. If it returns false for every Float rung, Quindar now bails with return false without ever attempting an open, where the old code at least tried preferredFormat via start(). 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.)
  2. 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)

  • formatLadder always emits channels = 2 (AudioFormatNegotiator.cpp:96), so the hardcoded stereo write in onTimerTick (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 RegenerateAtRate retune 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>
@ten9876 ten9876 force-pushed the aether/audio-aux-sinks-rec-3556 branch from 37f99cf to 8a7dd82 Compare June 19, 2026 12:21

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

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.

@ten9876 ten9876 enabled auto-merge (squash) June 19, 2026 12:32
@ten9876 ten9876 merged commit b1a8bdf into aethersdr:main Jun 19, 2026
5 checks passed
ten9876 pushed a commit that referenced this pull request Jun 19, 2026
…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>
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