Skip to content

Fix macOS Bluetooth audio route handling#1486

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
rfoust:codex/fix-macos-coreaudio-cleanup
Apr 18, 2026
Merged

Fix macOS Bluetooth audio route handling#1486
ten9876 merged 1 commit into
aethersdr:mainfrom
rfoust:codex/fix-macos-coreaudio-cleanup

Conversation

@rfoust

@rfoust rfoust commented Apr 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

This fixes a cluster of related macOS CoreAudio/Bluetooth regressions in AetherSDR's RX/TX audio handling.

User-visible symptoms before this patch:

  • Launching AetherSDR with Bluetooth headphones could push the headset into telephony/HFP mode even when the radio mic source was MIC.
  • After quitting, the headset could remain in telephony mode or leave CoreAudio in a bad state, so the next app launch had no audio until coreaudiod was restarted.
  • When mic_selection=PC, switching the selected PC input device on macOS could briefly enter telephony mode, drop the mic, flash the macOS menu-bar microphone indicator, and in some cases crash the app.

Root Causes

There were multiple bugs interacting here:

  1. On macOS, AetherSDR's RX output path preferred opening the Bluetooth output route at 24 kHz. In practice, that could cause CoreAudio to choose the headset's telephony/HFP transport instead of the normal stereo route.
  2. The app could start local PC mic capture just because remote_audio_tx existed, even when the radio mic source was not PC.
  3. Switching away from PC mic selection waited for the radio status echo before stopping local capture, leaving a window where the local mic path kept running unnecessarily.
  4. macOS-only audio route toggles and device switches were able to race with AudioEngine teardown/startup.
  5. The macOS QAudioSource recovery path could restart or tear down the wrong source after an input-device switch because stale stateChanged(StoppedState) callbacks from an old source were still acting on the current one.

What This Changes

RX / Bluetooth output routing on macOS

  • Prefer 48 kHz first for the macOS RX sink so Bluetooth headsets stay on the normal stereo/A2DP route when possible.
  • Only allow the telephony-capable Bluetooth output route when the radio mic selection is actually PC.
  • Queue telephony-route changes onto the audio thread instead of touching the audio engine directly from the GUI thread.

PC mic gating and mic selection handling

  • Only start local PC mic capture when mic_selection == PC.
  • Update the local transmit model immediately when the user changes mic selection, so leaving PC stops local capture right away instead of waiting for the radio echo.

macOS TX lifecycle hardening

  • Do not auto-restart a QAudioSource on macOS if it stopped with an actual backend error.
  • Do not auto-restart a QAudioSource that stops immediately after opening; shut it down instead. This prevents the menu-bar microphone indicator from flashing due to a rapid reopen loop.
  • Track a TX lifecycle generation so stale QAudioSource::stateChanged(StoppedState) signals from a previous input device cannot tear down a newly opened source after a device switch.
  • Null the active TX source/buffer/timer before teardown so intentional device switches cannot race against CoreAudio callbacks.

Scope

This PR is intentionally scoped to the validated audio fixes.

Platform impact:

  • The Bluetooth routing and TX lifecycle changes are macOS-specific.
  • The mic_selection == PC gating and immediate local model update are cross-platform correctness fixes, but they only prevent starting/stopping the local PC mic path at the wrong time.
  • Linux and Windows RX/TX backends are otherwise unchanged.

Verification

Verified manually on macOS by building with:

cmake --build build --target AetherSDR -j4

Manual repro/verification covered:

  • MIC selected with Bluetooth headphones as output: headset stays out of telephony mode and relaunch audio works.
  • PC selected with Bluetooth headset used for input/output: telephony mode is only used when expected.
  • Switching the selected PC input device between an external mic (Yeti X) and the Bluetooth headset no longer causes a TX restart loop, menu-bar mic flashing, or loss of the newly selected microphone.

@rfoust rfoust requested a review from ten9876 as a code owner April 15, 2026 21:49
Copilot AI review requested due to automatic review settings April 15, 2026 21:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes macOS CoreAudio/Bluetooth routing regressions by tightening when local mic capture is started, preferring 48 kHz RX output to keep Bluetooth headsets on A2DP when possible, and hardening macOS TX lifecycle handling to avoid restart loops and stale callbacks.

Changes:

  • Make mic selection updates immediate/optimistic in the transmit model (emit micStateChanged on user change).
  • Gate local PC mic capture to mic_selection == PC and queue macOS Bluetooth telephony-route toggles onto the audio thread.
  • Update AudioEngine macOS RX format/device selection (prefer 48 kHz) and add macOS TX lifecycle generation + restart suppression on backend errors/too-short runtimes.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/models/TransmitModel.cpp Optimistically updates m_micSelection and emits micStateChanged immediately on user selection changes.
src/gui/MainWindow.cpp Starts/stops local TX capture based on mic selection; queues macOS telephony-route allowance changes onto the audio thread.
src/core/AudioEngine.h Adds macOS Bluetooth telephony allowance API + TX lifecycle tracking fields.
src/core/AudioEngine.cpp Prefers 48 kHz RX output on macOS, avoids RX auto-restart on sink errors, and hardens macOS TX teardown/restart behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/core/AudioEngine.cpp
Comment on lines 314 to +322
qCWarning(lcAudio) << "AudioEngine: output device does not support 24kHz stereo Int16, trying 48kHz";
fmt.setSampleRate(48000);
m_resampleTo48k = true;
if (!dev.isFormatSupported(fmt)) {
qCWarning(lcAudio) << "AudioEngine: output device does not support 48kHz stereo Int16 either";
qCWarning(lcAudio) << "No audio device detected";
return false;
candidateFmt.setSampleRate(48000);
if (dev.isFormatSupported(candidateFmt)) {
m_resampleTo48k = true;
return true;
}
} else {
m_resampleTo48k = false;

qCWarning(lcAudio) << "AudioEngine: output device does not support 48kHz stereo Int16 either";
return false;
Comment thread src/gui/MainWindow.cpp
Comment on lines +778 to +781
if (!m_audio->isTxStreaming()) {
audioStartTx(m_radioModel.radioAddress(), 4991);
}
} else if (m_audio->isTxStreaming()) {
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Hey @rfoust, thanks for your contribution!

I took a look at the CI failure on commit 06b6722 and wanted to share what I found.

CI Failure Analysis

The failure is not in your code. The build itself passed cleanly.

What failed

The "Cleanup artifacts" job failed at the "Delete artifacts" step. This is a post-build housekeeping job in our CI pipeline that cleans up temporary build artifacts after the main workflow completes. It's part of our CI infrastructure, not related to compilation or code quality.

Check run summary

Job Status
check-paths Passed
build Passed
Prepare Passed
Agent Passed
Upload results Passed
analyze (cpp) In progress
check-windows Skipped
Cleanup artifacts Failed (Delete artifacts step)

What this means for your PR

  • Your code compiled successfully (the build job passed).
  • The failure is in CI infrastructure — likely a transient GitHub Actions permissions or API rate-limit issue when deleting temporary artifacts.
  • No action needed on your part. A maintainer can re-run the failed job, or it may resolve itself on the next push.

If the analyze (cpp) CodeQL job (still in progress at the time of this check) surfaces anything, I'll follow up separately.

Thanks again for contributing — your changes look good from a build perspective! 🛠️

@jensenpat

Copy link
Copy Markdown
Collaborator

@rfoust Excited for this one. SmartSDR Mac has this issue as well. When BT phone mode kicks in, it also engages the bluetooth headset's noise cancellation, which also degrades RX audio. Thanks for this

@ten9876 ten9876 merged commit 61ae84a into aethersdr:main Apr 18, 2026
8 of 9 checks passed
@ten9876

ten9876 commented Apr 18, 2026

Copy link
Copy Markdown
Collaborator

Claude here — merged to main. Clean rebase, no conflicts, builds clean on Linux. The cross-platform mic_selection=PC gating is a nice correctness fix that benefits all platforms. Great work Robbie!

73, Jeremy KK7GWY & Claude (AI dev partner)

@NF0T NF0T mentioned this pull request May 19, 2026
2 tasks
ten9876 added a commit that referenced this pull request May 23, 2026
…#2973)

## Summary

Implements #2971 by adding a dedicated default-on `aether.audio.summary`
logging path for support-bundle-friendly audio routing and negotiation
summaries.

This adds compact, deduped summary blocks for:

- startup audio environment: selected/default input and output devices,
saved-device presence, PC Audio state, and TX mic route intent
- RX sink startup: actual output device, sample rate, channel count,
sample format, resampling state, and fallback/substitution history
- TX source startup: actual input device, negotiated rate/channels,
mono/stereo state, sample format, resampling-to-24 kHz state, and
fallback history
- CW sidetone startup: backend, actual/backend-selected device, rate,
backend substitution, and fallback history
- auxiliary local sinks when instantiated: Quindar local sink and
Aetherial monitor playback sink
- final open failures: attempted rates/channels/formats, backend,
device, failure reason, and fallback history

## Why

The normal support log already caught many warning paths, but it did not
reliably capture successful runtime sink/source negotiation. That left
recurring audio/CW reports without the details needed to distinguish
wrong endpoint, sample-rate mismatch, resampler path, backend fallback,
and complete open failure cases.

## Important Safety Detail

The startup summary uses a new shallow
`DeviceDiagnostics::buildAudioStartupSnapshot(...)` helper. It
intentionally does not call support-bundle-grade capability probes such
as `preferredFormat()` or `isFormatSupported()` across devices at app
startup. Full device capability probing remains in
`buildAudioDevicesSnapshot(...)` for explicit diagnostics/support flows.

Failure summaries only report negotiation work the audio path already
attempted; they do not add extra probing.

## Historical Cases Covered

The summary path is designed around recurring issue/PR classes
including:

- macOS Bluetooth/HFP route substitution and 24 kHz vs 48 kHz behavior
(#1486, #2634)
- CommonRadioAudio / Float32 vs Int16 format issues (#1070, #1090)
- CW sidetone endpoint/backend routing (#2072, #2075, #2899, #2901)
- CW sidetone missing/distorted reports (#2629, #2694)
- no-audio-with-active-meters / output negotiation support gaps (#1855)

## Implementation Notes

- Adds `AudioSummaryLogger` with canonical one-block formatters and
dedupe by event key.
- Adds `aether.audio.summary` as a default-on info category without
enabling the full `aether.audio` info/debug stream.
- Keeps detailed existing `aether.audio` diagnostics intact.
- Documents the summary logging policy in `docs/audio-pipeline.md`.

## Validation

- `cmake --build build -j22`
- `./build/device_diagnostics_test`
- `./build/cw_sidetone_test`
- `git diff --check`
- Built and deployed a macOS test bundle; the primary test-machine
replacement path is currently blocked by a Desktop delete ACL, so a
separate quarantine-cleared test bundle was prepared.

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

---------

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

## Summary

Implements aethersdr#2971 by adding a dedicated default-on `aether.audio.summary`
logging path for support-bundle-friendly audio routing and negotiation
summaries.

This adds compact, deduped summary blocks for:

- startup audio environment: selected/default input and output devices,
saved-device presence, PC Audio state, and TX mic route intent
- RX sink startup: actual output device, sample rate, channel count,
sample format, resampling state, and fallback/substitution history
- TX source startup: actual input device, negotiated rate/channels,
mono/stereo state, sample format, resampling-to-24 kHz state, and
fallback history
- CW sidetone startup: backend, actual/backend-selected device, rate,
backend substitution, and fallback history
- auxiliary local sinks when instantiated: Quindar local sink and
Aetherial monitor playback sink
- final open failures: attempted rates/channels/formats, backend,
device, failure reason, and fallback history

## Why

The normal support log already caught many warning paths, but it did not
reliably capture successful runtime sink/source negotiation. That left
recurring audio/CW reports without the details needed to distinguish
wrong endpoint, sample-rate mismatch, resampler path, backend fallback,
and complete open failure cases.

## Important Safety Detail

The startup summary uses a new shallow
`DeviceDiagnostics::buildAudioStartupSnapshot(...)` helper. It
intentionally does not call support-bundle-grade capability probes such
as `preferredFormat()` or `isFormatSupported()` across devices at app
startup. Full device capability probing remains in
`buildAudioDevicesSnapshot(...)` for explicit diagnostics/support flows.

Failure summaries only report negotiation work the audio path already
attempted; they do not add extra probing.

## Historical Cases Covered

The summary path is designed around recurring issue/PR classes
including:

- macOS Bluetooth/HFP route substitution and 24 kHz vs 48 kHz behavior
(aethersdr#1486, aethersdr#2634)
- CommonRadioAudio / Float32 vs Int16 format issues (aethersdr#1070, aethersdr#1090)
- CW sidetone endpoint/backend routing (aethersdr#2072, aethersdr#2075, aethersdr#2899, aethersdr#2901)
- CW sidetone missing/distorted reports (aethersdr#2629, aethersdr#2694)
- no-audio-with-active-meters / output negotiation support gaps (aethersdr#1855)

## Implementation Notes

- Adds `AudioSummaryLogger` with canonical one-block formatters and
dedupe by event key.
- Adds `aether.audio.summary` as a default-on info category without
enabling the full `aether.audio` info/debug stream.
- Keeps detailed existing `aether.audio` diagnostics intact.
- Documents the summary logging policy in `docs/audio-pipeline.md`.

## Validation

- `cmake --build build -j22`
- `./build/device_diagnostics_test`
- `./build/cw_sidetone_test`
- `git diff --check`
- Built and deployed a macOS test bundle; the primary test-machine
replacement path is currently blocked by a Desktop delete ACL, so a
separate quarantine-cleared test bundle was prepared.

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

---------

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

4 participants