[mac] Fix macOS Bluetooth headset mic capture rate#2615
Conversation
There was a problem hiding this comment.
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, useskAudioObjectPropertyElementMain(we target macOS 13 in.github/workflows/macos-dmg.yml, so that's fine), and theCFStringRefis released on every path via the explicitCFReleaseafterAudioObjectGetPropertyData. readMacAudioScalar/readMacAudioArraytemplates checkAudioObjectHasPropertyand 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
nominalrate when no discretemin==maxlow-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:
src/core/AudioEngine.cpp:3541-3543— there's a parallel macOS "fallback" path further down that still hard-codes24000for bothm_txNeedsResample = (rate != 24000)andstd::make_unique<Resampler>(rate, 24000, 16384). You updated the primary path to useDEFAULT_SAMPLE_RATE; worth doing the same here for consistency (and to keep grep results matched if the constant ever changes).- When
macBluetoothNativeInputRatereturns24000(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. macBluetoothNativeInputRatequerieskAudioDevicePropertyAvailableNominalSampleRatesandkAudioDevicePropertyNominalSampleRateatkAudioObjectPropertyScopeGlobal. 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 tokAudioObjectPropertyScopeInputwould 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
left a comment
There was a problem hiding this comment.
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.
…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>
…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>
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>
…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>
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>
…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>
…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>
…#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>
…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>
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:
AudioDeviceID.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:
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
Implementation Notes
kAudioHardwarePropertyDeviceForUIDto resolve Qt'sQAudioDevice::id()to the HAL device.kAudioDevicePropertyTransportType,kAudioDevicePropertyAvailableNominalSampleRates, andkAudioDevicePropertyNominalSampleRate.Validation
git diff --checkcmake --build build -j8./build/tx_mic_channel_normalizer_testRisk
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