Skip to content

[mac] Fix macOS Bluetooth headset mic capture rate#2615

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:aether/macos-bluetooth-native-input-rate
May 13, 2026
Merged

[mac] Fix macOS Bluetooth headset mic capture rate#2615
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:aether/macos-bluetooth-native-input-rate

Conversation

@jensenpat

@jensenpat jensenpat commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes macOS Bluetooth headset microphone crackling by opening Bluetooth voice-input devices at their CoreAudio HAL-native capture rate when the HAL reports no high-rate input mode.

The final patch is intentionally small:

  • Add a macOS-only CoreAudio query that resolves the selected Qt input device UID to a HAL AudioDeviceID.
  • Detect Bluetooth/Bluetooth LE inputs whose available nominal sample rates are only low voice rates: 8 kHz, 16 kHz, or 24 kHz.
  • Put that native low rate first in TX mic negotiation, then fall back to the existing macOS order of 48 kHz, 44.1 kHz, and 24 kHz.
  • Keep normal USB/built-in/high-rate devices on the existing 48 kHz-preferred macOS path.
  • Document the Bluetooth native-rate behavior in the audio pipeline docs.

Closes #2600.

Root Cause

We originally suspected the Aetherial/Pudu recorder playback path because the crackle was easy to hear when playing back DSP recorder captures through Bluetooth headsets. That turned out to be incomplete: after changing recorder playback policy, AirPods Pro still crackled.

The useful signal came from comparing macOS CoreAudio HAL state against the Qt-selected capture format. On failing Bluetooth headset runs, CoreAudio reported the headset microphone as a low-rate Bluetooth voice input, while AetherSDR/Qt opened the device at 48 kHz. That forced a duplicate hidden Bluetooth-native-to-48 kHz conversion before AetherSDR's own 48 kHz-to-24 kHz TX conversion.

The clean runs all avoided that hidden conversion:

  • AirPods Pro: HAL exposed only 24 kHz input, and opening at 24 kHz produced clean audio.
  • soundcore Life Q20: HAL exposed only 16 kHz input, and opening at 16 kHz then using AetherSDR's 16 kHz-to-24 kHz resampler produced clean audio.
  • soundcore Liberty 5: same 16 kHz native path as the Q20, also clean.

So the fix is to trust the HAL-native low-rate Bluetooth capture profile instead of forcing those devices through the general macOS 48 kHz-preferred path.

Device Findings

Device Before After Result
AirPods Pro HAL native 24 kHz, app opened 48 kHz, app resampled 48 -> 24 kHz App opened 24 kHz, no app resample Clear
soundcore Life Q20 HAL native 16 kHz, app opened 48 kHz, app resampled 48 -> 24 kHz App opened 16 kHz, app resampled 16 -> 24 kHz Clear
soundcore Liberty 5 Not tested before fix App opened 16 kHz, app resampled 16 -> 24 kHz Clear
Studio Display Microphone HAL supports high-rate USB input, app opened 48 kHz Unchanged Clean control path
Wired USB headset USB path, no Bluetooth voice-profile conversion Unchanged Clean per manual report

Implementation Notes

  • The CoreAudio code is compiled only on macOS.
  • It uses kAudioHardwarePropertyDeviceForUID to resolve Qt's QAudioDevice::id() to the HAL device.
  • It checks kAudioDevicePropertyTransportType, kAudioDevicePropertyAvailableNominalSampleRates, and kAudioDevicePropertyNominalSampleRate.
  • It only changes rate ordering for Bluetooth/Bluetooth LE devices that do not advertise 44.1 kHz or 48 kHz capture.
  • If HAL lookup fails, or if the device advertises high-rate capture, the existing macOS rate order is preserved.

Validation

  • git diff --check
  • cmake --build build -j8
  • ./build/tx_mic_channel_normalizer_test
  • Manual macOS Bluetooth headset validation:
    • AirPods Pro clear after opening at native 24 kHz.
    • soundcore Life Q20 clear after opening at native 16 kHz.
    • soundcore Liberty 5 clear after opening at native 16 kHz.

Risk

Low-to-moderate, isolated to macOS TX mic input format negotiation.

The main behavior change is limited to Bluetooth/Bluetooth LE input devices that HAL reports as low-rate-only voice capture devices. General macOS devices, USB devices, and devices with advertised 44.1/48 kHz capture support continue to follow the existing preference for 48 kHz first.

👨🏼‍💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by @jensenpat

@jensenpat jensenpat changed the title Fix macOS Bluetooth headset mic capture rate [mac] Fix macOS Bluetooth headset mic capture rate May 12, 2026
@jensenpat jensenpat marked this pull request as ready for review May 12, 2026 20:06

@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 for the careful debugging and write-up @jensenpat — the root-cause analysis (HAL-native rate vs. Qt-opened rate forcing a hidden conversion) and per-device validation table make this very easy to review.

The implementation looks good to me:

  • CoreAudio code is properly fenced under Q_OS_MAC, uses kAudioObjectPropertyElementMain (we target macOS 13 in .github/workflows/macos-dmg.yml, so that's fine), and the CFStringRef is released on every path via the explicit CFRelease after AudioObjectGetPropertyData.
  • readMacAudioScalar / readMacAudioArray templates check AudioObjectHasProperty and size match before touching memory — defensive and easy to read.
  • Heuristic for "Bluetooth voice-only" is appropriately conservative: it requires the transport to actually be Bluetooth/BluetoothLE, and bails out to the existing 48/44.1/24 order the moment any advertised range covers 44.1 or 48 kHz. That keeps the high-rate USB and Studio Display paths on the existing code path.
  • Fallback to the global nominal rate when no discrete min==max low-rate is found is a reasonable safety net for devices that advertise a low-rate range rather than a point.

A couple of small observations, none blocking:

  1. src/core/AudioEngine.cpp:3541-3543 — there's a parallel macOS "fallback" path further down that still hard-codes 24000 for both m_txNeedsResample = (rate != 24000) and std::make_unique<Resampler>(rate, 24000, 16384). You updated the primary path to use DEFAULT_SAMPLE_RATE; worth doing the same here for consistency (and to keep grep results matched if the constant ever changes).
  2. When macBluetoothNativeInputRate returns 24000 (AirPods Pro case), the candidate list becomes [24000, 48000, 44100, 24000] — 24 kHz gets attempted twice. Harmless because the first attempt will succeed, but a one-line dedup (if (rates.last() != *nativeRate) ... or pre-removing the duplicate at insert time) would tidy it up.
  3. macBluetoothNativeInputRate queries kAudioDevicePropertyAvailableNominalSampleRates and kAudioDevicePropertyNominalSampleRate at kAudioObjectPropertyScopeGlobal. For HFP/SCO that's symmetric so this works in practice (and the manual validation confirms it), but if you ever see a future Bluetooth device where input and output scopes diverge, swapping to kAudioObjectPropertyScopeInput would be the more precise query for an input device. Fine to leave as-is for now.

Nice work — clear PR description, conservative scope, and three confirmed device validations. LGTM.

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

Approved. Clean, well-isolated macOS-only fix with clear root cause (hidden CoreAudio native→48kHz conversion followed by AetherSDR's 48→24kHz r8brain downsample = crackling). Algorithm correctly gates on transport type, falls through cleanly when high-rate capture is available, and preserves the normal macOS rate order for non-Bluetooth devices. Linux build of the merged tree compiles clean.

@ten9876 ten9876 merged commit 644d118 into aethersdr:main May 13, 2026
5 checks passed
jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jun 5, 2026
…ethersdr#3306)

macOS + Linux: AudioEngine::startTxStream() now drives the mic rate/format
selection from the factory's Int16 Input ladder (AudioDeviceNegotiator),
walking stereo-then-mono to preserve the existing channel fallback. This
removes the per-OS #ifdef rate lists and the bespoke macTxInputRateCandidates()
helper — its policy (preferred-rate-first to dodge the silent-48k-open trap
aethersdr#2930; Bluetooth-HFP native rate first aethersdr#2615) now lives in the one factory
ladder. The macOS CoreAudio-HAL detection the factory can't derive
(macBluetoothNativeInputRate) is fed in via a new preferredRateOverride on the
wrapper, so a BT-HFP mic still opens at its native low rate.

Behaviour preserved: standard macOS mic -> preferred 48k (then 44.1k/24k/16k);
Linux -> native 24k (then 48k/44.1k); BT-HFP mic -> native low rate first. The
existing TX resampler (m_txInputRate -> 24k) and the push/pull open modes,
watchdog, and metering are unchanged.

Windows mic is intentionally left as-is: it already matches the factory's
Windows policy (force 48k + WASAPI probe-at-open) and carries the mono-only
USB-mic channel clamp (aethersdr#2929) that warrants its own soak — the remaining mic
increment.

Builds & links clean into the full app; both negotiation tests still pass.

Refs aethersdr#3306

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

Co-authored-by: Codex <noreply@openai.com>
ten9876 pushed a commit that referenced this pull request Jun 6, 2026
…3397)

## 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

- **`src/core/AudioFormatNegotiator.{h,cpp}`** — dependency-free policy
(links only `Qt6::Core`). One ladder owns:
- Windows force-48k + r8brain (#2120/#2123), macOS 48k/A2DP (#1705),
Linux native-24k — divergences preserved as **data**, not `#ifdef`.
- The **universal 44.1 kHz rung** that RX/Quindar/QSO are missing today
(#3385).
- `Int16`↔`Float32` + `preferredFormat()` catch-all (#2669 / #1090 /
#3231).
- macOS mic preferred-rate-first (#2930) and Bluetooth-HFP native rate
(#2615); Windows probe-at-open (#2929).
- The two stereo resampler strategies — **PreservePan** (RX/QSO) vs
**MonoCollapse** (TCI-TX/RADE) — kept deliberately distinct
(#2403/#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 #3306

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

Co-authored-by: Codex <noreply@openai.com>
ten9876 pushed a commit to jensenpat/AetherSDR that referenced this pull request Jun 6, 2026
Layer 2 of the audio sink factory: the thin, and ONLY, platform-specific
bridge between a real QAudioDevice and the pure AudioFormatNegotiator policy.

- src/core/AudioDeviceNegotiator.{h,cpp}: probe(QAudioDevice) builds a
  DeviceCaps snapshot (rate probing via isFormatSupported, preferredFormat,
  per-OS isFormatSupportedReliable so Windows WASAPI probes-at-open aethersdr#2120/
  aethersdr#2929, caller-fed Bluetooth-HFP hint aethersdr#2615); negotiate() returns a ready-to-
  open QAudioFormat + ResamplerKind; formatLadder() returns the ordered Qt
  format ladder for try-at-open backends. SampleFmt <-> QAudioFormat mapping
  lives here so the policy layer stays Qt-Multimedia-free and headless-testable.

- tests/audio_device_negotiator_test.cpp: smoke test that probes the real
  default output/input devices and round-trips to an openable format; tolerant
  of headless CI runners with no audio hardware. 8/8 pass on macOS (default
  output negotiates 48000/PreservePan, matching the current RX behaviour).

No sink consumes the wrapper yet — the RX/mic migrations land next, per the
incremental plan in docs/audio-sink-factory.md.

Refs aethersdr#3306

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

Co-authored-by: Codex <noreply@openai.com>
ten9876 pushed a commit to jensenpat/AetherSDR that referenced this pull request Jun 6, 2026
…ethersdr#3306)

macOS + Linux: AudioEngine::startTxStream() now drives the mic rate/format
selection from the factory's Int16 Input ladder (AudioDeviceNegotiator),
walking stereo-then-mono to preserve the existing channel fallback. This
removes the per-OS #ifdef rate lists and the bespoke macTxInputRateCandidates()
helper — its policy (preferred-rate-first to dodge the silent-48k-open trap
aethersdr#2930; Bluetooth-HFP native rate first aethersdr#2615) now lives in the one factory
ladder. The macOS CoreAudio-HAL detection the factory can't derive
(macBluetoothNativeInputRate) is fed in via a new preferredRateOverride on the
wrapper, so a BT-HFP mic still opens at its native low rate.

Behaviour preserved: standard macOS mic -> preferred 48k (then 44.1k/24k/16k);
Linux -> native 24k (then 48k/44.1k); BT-HFP mic -> native low rate first. The
existing TX resampler (m_txInputRate -> 24k) and the push/pull open modes,
watchdog, and metering are unchanged.

Windows mic is intentionally left as-is: it already matches the factory's
Windows policy (force 48k + WASAPI probe-at-open) and carries the mono-only
USB-mic channel clamp (aethersdr#2929) that warrants its own soak — the remaining mic
increment.

Builds & links clean into the full app; both negotiation tests still pass.

Refs aethersdr#3306

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

Co-authored-by: Codex <noreply@openai.com>
ten9876 pushed a commit to jensenpat/AetherSDR that referenced this pull request Jun 6, 2026
Layer 2 of the audio sink factory: the thin, and ONLY, platform-specific
bridge between a real QAudioDevice and the pure AudioFormatNegotiator policy.

- src/core/AudioDeviceNegotiator.{h,cpp}: probe(QAudioDevice) builds a
  DeviceCaps snapshot (rate probing via isFormatSupported, preferredFormat,
  per-OS isFormatSupportedReliable so Windows WASAPI probes-at-open aethersdr#2120/
  aethersdr#2929, caller-fed Bluetooth-HFP hint aethersdr#2615); negotiate() returns a ready-to-
  open QAudioFormat + ResamplerKind; formatLadder() returns the ordered Qt
  format ladder for try-at-open backends. SampleFmt <-> QAudioFormat mapping
  lives here so the policy layer stays Qt-Multimedia-free and headless-testable.

- tests/audio_device_negotiator_test.cpp: smoke test that probes the real
  default output/input devices and round-trips to an openable format; tolerant
  of headless CI runners with no audio hardware. 8/8 pass on macOS (default
  output negotiates 48000/PreservePan, matching the current RX behaviour).

No sink consumes the wrapper yet — the RX/mic migrations land next, per the
incremental plan in docs/audio-sink-factory.md.

Refs aethersdr#3306

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

Co-authored-by: Codex <noreply@openai.com>
ten9876 pushed a commit to jensenpat/AetherSDR that referenced this pull request Jun 6, 2026
…ethersdr#3306)

macOS + Linux: AudioEngine::startTxStream() now drives the mic rate/format
selection from the factory's Int16 Input ladder (AudioDeviceNegotiator),
walking stereo-then-mono to preserve the existing channel fallback. This
removes the per-OS #ifdef rate lists and the bespoke macTxInputRateCandidates()
helper — its policy (preferred-rate-first to dodge the silent-48k-open trap
aethersdr#2930; Bluetooth-HFP native rate first aethersdr#2615) now lives in the one factory
ladder. The macOS CoreAudio-HAL detection the factory can't derive
(macBluetoothNativeInputRate) is fed in via a new preferredRateOverride on the
wrapper, so a BT-HFP mic still opens at its native low rate.

Behaviour preserved: standard macOS mic -> preferred 48k (then 44.1k/24k/16k);
Linux -> native 24k (then 48k/44.1k); BT-HFP mic -> native low rate first. The
existing TX resampler (m_txInputRate -> 24k) and the push/pull open modes,
watchdog, and metering are unchanged.

Windows mic is intentionally left as-is: it already matches the factory's
Windows policy (force 48k + WASAPI probe-at-open) and carries the mono-only
USB-mic channel clamp (aethersdr#2929) that warrants its own soak — the remaining mic
increment.

Builds & links clean into the full app; both negotiation tests still pass.

Refs aethersdr#3306

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

Co-authored-by: Codex <noreply@openai.com>
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.

Soundcore Life Q20 (Bluetooth) PC mic capture exhibits recording crackle

2 participants