Skip to content

feat(audio): consolidated sink/rate negotiation foundation (#3306)#3397

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:aether/audio-sink-factory-3306
Jun 6, 2026
Merged

feat(audio): consolidated sink/rate negotiation foundation (#3306)#3397
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:aether/audio-sink-factory-3306

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

What

First, pure-addition step of the consolidated audio sink factory (#3306): a single shared, OS-parameterized format/rate negotiation policy that every audio sink and source will migrate onto, replacing the ~9 divergent per-sink fallback ladders and per-OS #ifdef branches that are the root of a recurring class of platform audio bugs.

Zero behaviour change — this is the maintainer-endorsed "land the helper first, migrate sinks incrementally" approach (#3306, and the #3194 thread's "sensitive audio-stack changes must be separate PRs with soak time").

Why this shape

The historical bugs escaped CI because the per-OS rate ladders were compiled behind Q_OS_*, so a Linux runner could only ever exercise the Linux path. The new policy is a pure function over an injected DeviceCaps snapshot with TargetOs as a parameter, not an #ifdef — so one headless, hardware-free test binary exercises the Windows, macOS and Linux ladders on any runner.

Contents

Test

$ ./build/audio_format_negotiation_test
...
25/25 checks passed

Follow-ups (per the migration plan in the doc)

Live Qt wrapper → migrate RX speaker → migrate PC mic → AudioOutputRouter (closes the uncoupling class) → TCI-TX client-rate → PipeWire DAX shared resampler. Each a separate, soakable PR.

Refs #3306

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

…#3306)

Introduce the pure, OS-parameterized audio format/rate negotiation policy
that every audio sink and source will share, replacing the ~9 divergent
per-sink fallback ladders and per-OS #ifdef branches that are the root of a
recurring class of platform audio bugs.

This first step is a pure addition with zero behaviour change — the
maintainer-endorsed "land the helper first" approach (aethersdr#3306, aethersdr#3194 thread).

- src/core/AudioFormatNegotiator.{h,cpp}: dependency-free policy (links only
  Qt6::Core) over an injected DeviceCaps snapshot with TargetOs as a
  PARAMETER, not an #ifdef. One ladder owns the Windows-48k-prefer (aethersdr#2120),
  macOS-48k/A2DP (aethersdr#1705), Linux-24k-native divergences, the universal 44.1k
  rung (aethersdr#3385), Int16<->Float32 + preferredFormat fallbacks (aethersdr#2669/aethersdr#1090/
  aethersdr#3231), macOS mic preferred-first (aethersdr#2930) and Bluetooth-HFP native rate
  (aethersdr#2615), and the Windows probe-at-open rule (aethersdr#2929). The two stereo
  resampler strategies (PreservePan vs MonoCollapse) are kept deliberately
  distinct (aethersdr#2403/aethersdr#2459).

- tests/audio_format_negotiation_test.cpp: headless, hardware-free golden
  matrix. Because TargetOs is data, one binary exercises all three OS ladders
  on any CI runner — the reason the historical bugs escaped CI. 25/25 pass,
  including the 44.1k-only-device regression guard that RX/Quindar fail today.

- docs/audio-sink-factory.md: the full three-layer factory design (pure
  policy / live Qt wrapper / device-ownership router), the device-following
  "uncoupling" fix, the regression-guard invariants, and the incremental
  one-sink-per-PR migration plan.

Refs aethersdr#3306

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Codex <noreply@openai.com>
@jensenpat jensenpat marked this pull request as ready for review June 5, 2026 14:44
@jensenpat jensenpat requested review from a team as code owners June 5, 2026 14:44

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

Thanks @jensenpat — this is a really clean foundation PR.

What I checked

  • Scope. The five files (CMake test target, design doc, AudioFormatNegotiator.{h,cpp}, golden-matrix test) are all in scope; no drive-by changes touch live sinks. Behaviour-change is genuinely zero because nothing consumes the new module yet.
  • Conventions. No QSettings use (irrelevant here — pure policy module), no raw owning pointers, value-semantics throughout. The single #if defined(Q_OS_*) lives in hostTargetOs() exactly as the design doc commits to.
  • Bugs / leaks / null-deref. Nothing. Pure value-based code over QList/QString, no allocation, no I/O.
  • Error handling at boundaries. No system boundaries in this layer (the live wrapper lands in a follow-up PR). The total-failure case is correctly surfaced via NegotiatedFormat::ok = false with a reason string — the test exercises it ("empty reliable device -> negotiation fails").

Things I particularly liked

  • TargetOs as a parameter rather than #ifdef makes the golden matrix actually able to assert all three OS ladders from any runner — the right primitive for catching the historical bug class.
  • The preferredFirst rung for macOS input correctly precedes the per-OS ladder so BT-HFP and 16k-native mics aren't forced to 48k, and the test covers both #2615 and #2930 explicitly.
  • The nothingProbeable heuristic for Windows probe-at-open is well-contained in negotiate() and has a dedicated test row.
  • resamplerKindFor() keeps PreservePan and MonoCollapse deliberately distinct — the unit checks at the bottom of the test guard #2403 from coming back.

Tiny nit (non-blocking, no need to spin)

primaryRateOrder() falls through to return {internalRate} after the switches; that's only reachable if a new TargetOs value is added later. A Q_UNREACHABLE() or default: would document the invariant for whoever touches the enum next, but the current shape is fine.

Looking forward to the live wrapper. The migration plan in the doc is exactly the cadence the audio stack needs.


🤖 aethersdr-agent · cost: $13.4278 · model: claude-opus-4-7

@ten9876 ten9876 self-assigned this Jun 6, 2026
@ten9876 ten9876 merged commit 204585b into aethersdr:main Jun 6, 2026
5 checks passed
ten9876 added a commit that referenced this pull request Jun 6, 2026
…ry (#3306) (#3398)

> **Stacks on #3397** (the pure foundation). The first two commits here
are #3397's (foundation + live wrapper); the last two are the RX and
TX-mic migrations. Merge #3397 first and this collapses to just those
deltas. Kept as separate commits per the maintainer's
one-change-at-a-time / soak rule (#3194 thread).

## What

Layer 2 + the first two sink migrations of the consolidated audio sink
factory (#3306):

1. **Live Qt-Multimedia wrapper** `AudioDeviceNegotiator` — the thin,
and only, platform-specific bridge. `probe(QAudioDevice)` → pure
`DeviceCaps`; `negotiate()`/`formatLadder()` → openable `QAudioFormat`s.
Smoke-tested against the real default devices
(`audio_device_negotiator_test`, 8/8).
2. **RX speaker** — `startRxStream()` walks the factory's Float ladder
with real `start()` attempts instead of two forked per-OS `#ifdef`
blocks (**net −87 lines**). `m_resampleTo48k` (bool) → `m_rxOutputRate`
(int).
3. **PC mic (TX), macOS + Linux** — `startTxStream()` drives mic
rate/format from the factory's Int16 Input ladder (stereo-then-mono),
removing the per-OS rate lists and the bespoke
`macTxInputRateCandidates()` helper.

## Behaviour (preserved)

- **RX:** Windows/macOS → 48k (WASAPI 24k-resampler artifacts #2120;
macOS A2DP off HFP), Linux → 24k native. New strictly-additive wins: a
44.1k-only output now works (#3306/#3385); the Quindar local monitor now
opens on Windows (old branch `return`ed before
`startQuindarLocalSink()`).
- **TX mic:** standard macOS mic → 48k; Linux → 24k native;
**Bluetooth-HFP mic → native low rate first (#2615)**, fed in via
`macBluetoothNativeInputRate()` + the wrapper's new
`preferredRateOverride`; preferred-rate-first avoids the silent-48k-open
trap (#2930).

## Scope notes / invariants

- Two stereo resampler strategies kept distinct: **PreservePan** (RX,
dual L/R, VITA-49 pan #2403/#2459) vs **MonoCollapse** (RADE/TCI-TX).
- macOS telephony/HFP **output** substitution (#1705) unchanged (device
selection, before the ladder).
- **Windows mic left as-is** — already matches the factory's force-48k +
probe-at-open policy and carries the mono-only USB-mic channel clamp
(#2929) that needs its own soak. That + the device-following
`AudioOutputRouter`, TCI-TX client-rate, and PipeWire DAX are the
remaining increments (see `docs/audio-sink-factory.md`).

## Test / build

- `audio_format_negotiation_test` 25/25, `audio_device_negotiator_test`
8/8.
- Full macOS app builds & links clean (RelWithDebInfo); deployed to the
macOS test box. Soak on Windows/Linux requested before un-drafting.

Refs #3306

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…#3306) (aethersdr#3397)

## What

First, **pure-addition** step of the consolidated audio sink factory
(aethersdr#3306): a single shared, OS-parameterized format/rate **negotiation
policy** that every audio sink and source will migrate onto, replacing
the ~9 divergent per-sink fallback ladders and per-OS `#ifdef` branches
that are the root of a recurring class of platform audio bugs.

Zero behaviour change — this is the maintainer-endorsed *"land the
helper first, migrate sinks incrementally"* approach (aethersdr#3306, and the
aethersdr#3194 thread's "sensitive audio-stack changes must be separate PRs with
soak time").

## Why this shape

The historical bugs escaped CI because the per-OS rate ladders were
compiled behind `Q_OS_*`, so a Linux runner could only ever exercise the
Linux path. The new policy is a **pure function over an injected
`DeviceCaps` snapshot with `TargetOs` as a parameter, not an `#ifdef`**
— so one headless, hardware-free test binary exercises the Windows,
macOS *and* Linux ladders on any runner.

## Contents

- **`src/core/AudioFormatNegotiator.{h,cpp}`** — dependency-free policy
(links only `Qt6::Core`). One ladder owns:
- Windows force-48k + r8brain (aethersdr#2120/aethersdr#2123), macOS 48k/A2DP (aethersdr#1705),
Linux native-24k — divergences preserved as **data**, not `#ifdef`.
- The **universal 44.1 kHz rung** that RX/Quindar/QSO are missing today
(aethersdr#3385).
- `Int16`↔`Float32` + `preferredFormat()` catch-all (aethersdr#2669 / aethersdr#1090 /
aethersdr#3231).
- macOS mic preferred-rate-first (aethersdr#2930) and Bluetooth-HFP native rate
(aethersdr#2615); Windows probe-at-open (aethersdr#2929).
- The two stereo resampler strategies — **PreservePan** (RX/QSO) vs
**MonoCollapse** (TCI-TX/RADE) — kept deliberately distinct
(aethersdr#2403/aethersdr#2459).
- **`tests/audio_format_negotiation_test.cpp`** — table-driven golden
matrix, registered under CTest. **25/25 pass**, including the
44.1k-only-device regression guard.
- **`docs/audio-sink-factory.md`** — the full three-layer design (pure
policy → live Qt wrapper → device-ownership router), the
device-following **"uncoupling"** fix (CW / RADE / Aetherial-Audio
"Pudu" recorder following the selected output), the regression-guard
invariants, and the one-sink-per-PR migration plan.

## Test

```
$ ./build/audio_format_negotiation_test
...
25/25 checks passed
```

## Follow-ups (per the migration plan in the doc)

Live Qt wrapper → migrate RX speaker → migrate PC mic →
`AudioOutputRouter` (closes the uncoupling class) → TCI-TX client-rate →
PipeWire DAX shared resampler. Each a separate, soakable PR.

Refs aethersdr#3306

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

Co-authored-by: Codex <noreply@openai.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…ry (aethersdr#3306) (aethersdr#3398)

> **Stacks on aethersdr#3397** (the pure foundation). The first two commits here
are aethersdr#3397's (foundation + live wrapper); the last two are the RX and
TX-mic migrations. Merge aethersdr#3397 first and this collapses to just those
deltas. Kept as separate commits per the maintainer's
one-change-at-a-time / soak rule (aethersdr#3194 thread).

## What

Layer 2 + the first two sink migrations of the consolidated audio sink
factory (aethersdr#3306):

1. **Live Qt-Multimedia wrapper** `AudioDeviceNegotiator` — the thin,
and only, platform-specific bridge. `probe(QAudioDevice)` → pure
`DeviceCaps`; `negotiate()`/`formatLadder()` → openable `QAudioFormat`s.
Smoke-tested against the real default devices
(`audio_device_negotiator_test`, 8/8).
2. **RX speaker** — `startRxStream()` walks the factory's Float ladder
with real `start()` attempts instead of two forked per-OS `#ifdef`
blocks (**net −87 lines**). `m_resampleTo48k` (bool) → `m_rxOutputRate`
(int).
3. **PC mic (TX), macOS + Linux** — `startTxStream()` drives mic
rate/format from the factory's Int16 Input ladder (stereo-then-mono),
removing the per-OS rate lists and the bespoke
`macTxInputRateCandidates()` helper.

## Behaviour (preserved)

- **RX:** Windows/macOS → 48k (WASAPI 24k-resampler artifacts aethersdr#2120;
macOS A2DP off HFP), Linux → 24k native. New strictly-additive wins: a
44.1k-only output now works (aethersdr#3306/aethersdr#3385); the Quindar local monitor now
opens on Windows (old branch `return`ed before
`startQuindarLocalSink()`).
- **TX mic:** standard macOS mic → 48k; Linux → 24k native;
**Bluetooth-HFP mic → native low rate first (aethersdr#2615)**, fed in via
`macBluetoothNativeInputRate()` + the wrapper's new
`preferredRateOverride`; preferred-rate-first avoids the silent-48k-open
trap (aethersdr#2930).

## Scope notes / invariants

- Two stereo resampler strategies kept distinct: **PreservePan** (RX,
dual L/R, VITA-49 pan aethersdr#2403/aethersdr#2459) vs **MonoCollapse** (RADE/TCI-TX).
- macOS telephony/HFP **output** substitution (aethersdr#1705) unchanged (device
selection, before the ladder).
- **Windows mic left as-is** — already matches the factory's force-48k +
probe-at-open policy and carries the mono-only USB-mic channel clamp
(aethersdr#2929) that needs its own soak. That + the device-following
`AudioOutputRouter`, TCI-TX client-rate, and PipeWire DAX are the
remaining increments (see `docs/audio-sink-factory.md`).

## Test / build

- `audio_format_negotiation_test` 25/25, `audio_device_negotiator_test`
8/8.
- Full macOS app builds & links clean (RelWithDebInfo); deployed to the
macOS test box. Soak on Windows/Linux requested before un-drafting.

Refs aethersdr#3306

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.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