From dcd93abfc9e0ca87b1133db6d8e6d41f24573255 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:26:36 +0200 Subject: [PATCH] Fixes the broken audio output if bluetooth headset is muted --- webrtc-sys/include/livekit/adm_proxy.h | 43 ++++++ webrtc-sys/src/adm_proxy.cpp | 191 ++++++++++++++++++++++++- 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/webrtc-sys/include/livekit/adm_proxy.h b/webrtc-sys/include/livekit/adm_proxy.h index d099f916e..b54f27ee5 100644 --- a/webrtc-sys/include/livekit/adm_proxy.h +++ b/webrtc-sys/include/livekit/adm_proxy.h @@ -25,6 +25,10 @@ #include "modules/audio_device/include/audio_device_defines.h" #include "rtc_base/synchronization/mutex.h" +#if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) +#include +#endif + namespace webrtc { class Thread; } // namespace webrtc @@ -272,6 +276,45 @@ class AdmProxy : public webrtc::AudioDeviceModule { uint16_t selected_recording_device_ = 0; std::string selected_playout_guid_; std::string selected_recording_guid_; + +#if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) + // macOS only: the precompiled CoreAudio ADM does not re-read the output device's + // nominal sample rate when it changes underneath an active playout (e.g. a Bluetooth + // headset flipping HFP<->A2DP when the mic stream opens/closes). That leaves playout + // emitting samples at the old rate into a device now running at a new rate, which + // pitch-shifts remote audio ("squeaky"). We install a Core Audio property listener on + // the default output device's nominal sample rate (and on default-output-device + // changes) and re-init platform playout when the rate changes so the ADM rebuilds its + // resampler at the correct rate. + + // Installs the output sample-rate / default-output-device listeners. Idempotent. + // Must be called with mutex_ held. + void InstallOutputRateListenerLocked(); + + // Removes the listeners installed by InstallOutputRateListenerLocked(). Idempotent. + // Must be called with mutex_ held. + void RemoveOutputRateListenerLocked(); + + // Re-initializes platform playout (StopPlayout -> InitPlayout -> StartPlayout) so the + // precompiled ADM re-reads the current output device rate. Runs on worker_thread_. + void RebouncePlayoutForRateChange(); + + // Core Audio property-listener trampoline. Fires on a Core Audio managed thread for + // default-output-device and nominal-sample-rate changes; posts the rebounce to + // worker_thread_. inClientData is the owning AdmProxy*. + static OSStatus OnOutputPropertyChanged( + AudioObjectID inObjectID, + UInt32 inNumberAddresses, + const AudioObjectPropertyAddress* inAddresses, + void* inClientData); + + bool output_listener_installed_ = false; + // Device the nominal-rate listener is currently attached to (kAudioObjectUnknown when + // none). Tracked so we can re-point the listener when the default output device changes. + AudioObjectID listened_output_device_ = kAudioObjectUnknown; + // Guards against re-entrant rebounces triggered by our own StopPlayout/StartPlayout. + bool rebouncing_ = false; +#endif // defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) }; } // namespace livekit_ffi diff --git a/webrtc-sys/src/adm_proxy.cpp b/webrtc-sys/src/adm_proxy.cpp index 209f39d53..2362c377c 100644 --- a/webrtc-sys/src/adm_proxy.cpp +++ b/webrtc-sys/src/adm_proxy.cpp @@ -71,6 +71,16 @@ AdmProxy::AdmProxy(const webrtc::Environment& env, webrtc::Thread* worker_thread AdmProxy::~AdmProxy() { RTC_LOG(LS_VERBOSE) << "AdmProxy::~AdmProxy()"; +#if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) + // Ensure no Core Audio callback can fire into a destroyed instance. Destruction runs + // on worker_thread_ (see PeerConnectionFactory), the same thread RebouncePlayout tasks + // run on, so a removal here serializes ahead of any later callback-posted task. + { + webrtc::MutexLock lock(&mutex_); + RemoveOutputRateListenerLocked(); + } +#endif + if (synthetic_adm_) { synthetic_adm_->Terminate(); synthetic_adm_ = nullptr; @@ -246,8 +256,14 @@ void AdmProxy::SwitchPlayoutModeIfNeeded() { platform_adm_->InitPlayout(); platform_adm_->StartPlayout(); } +#if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) + InstallOutputRateListenerLocked(); +#endif } else { // Switch to synthetic mode - stop platform ADM, start synthetic ADM +#if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) + RemoveOutputRateListenerLocked(); +#endif if (platform_adm_) { platform_adm_->StopPlayout(); } @@ -273,6 +289,167 @@ void AdmProxy::SwitchRecordingAdmIfNeeded() { } } +// ============================================================================= +// macOS: Output Sample-Rate Change Handling +// ============================================================================= +// +// The precompiled CoreAudio ADM does not re-read the output device's nominal sample +// rate when it changes underneath active playout. A Bluetooth headset flips HFP<->A2DP +// when the microphone stream opens/closes (e.g. on mute, which stops recording), +// changing the output rate. The ADM keeps emitting samples at the old rate into a +// device now running at the new rate, pitch-shifting remote audio. We listen for the +// rate change and re-init platform playout so the ADM rebuilds its resampler. + +#if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) + +namespace { + +constexpr AudioObjectPropertyAddress kDefaultOutputDeviceAddress = { + kAudioHardwarePropertyDefaultOutputDevice, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, +}; + +constexpr AudioObjectPropertyAddress kNominalSampleRateAddress = { + kAudioDevicePropertyNominalSampleRate, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, +}; + +AudioObjectID CurrentDefaultOutputDevice() { + AudioObjectID device = kAudioObjectUnknown; + UInt32 size = sizeof(device); + OSStatus status = + AudioObjectGetPropertyData(kAudioObjectSystemObject, &kDefaultOutputDeviceAddress, + 0, nullptr, &size, &device); + if (status != noErr) { + RTC_LOG(LS_WARNING) << "AdmProxy: failed to read default output device, status=" + << status; + return kAudioObjectUnknown; + } + return device; +} + +double OutputNominalSampleRate(AudioObjectID device) { + if (device == kAudioObjectUnknown) return 0.0; + Float64 rate = 0.0; + UInt32 size = sizeof(rate); + OSStatus status = AudioObjectGetPropertyData(device, &kNominalSampleRateAddress, 0, + nullptr, &size, &rate); + if (status != noErr) return 0.0; + return static_cast(rate); +} + +} // namespace + +OSStatus AdmProxy::OnOutputPropertyChanged( + AudioObjectID /*inObjectID*/, + UInt32 /*inNumberAddresses*/, + const AudioObjectPropertyAddress* /*inAddresses*/, + void* inClientData) { + auto* self = static_cast(inClientData); + if (!self) return noErr; + // Core Audio invokes this on its own thread. Never touch the ADM or mutex_ here; + // hop to worker_thread_, which owns ADM playout lifecycle. + webrtc::Thread* worker = self->worker_thread_; + if (worker) { + worker->PostTask([self] { self->RebouncePlayoutForRateChange(); }); + } + return noErr; +} + +void AdmProxy::InstallOutputRateListenerLocked() { + if (output_listener_installed_) return; + + // Default-output-device changes (headphones plug/unplug, Bluetooth connect) so we can + // re-point the per-device rate listener. + OSStatus status = AudioObjectAddPropertyListener( + kAudioObjectSystemObject, &kDefaultOutputDeviceAddress, + &AdmProxy::OnOutputPropertyChanged, this); + if (status != noErr) { + RTC_LOG(LS_WARNING) + << "AdmProxy: failed to add default-output-device listener, status=" << status; + } + + // In-place nominal sample-rate changes on the current output device (the BT HFP<->A2DP + // flip is the motivating case). + listened_output_device_ = CurrentDefaultOutputDevice(); + if (listened_output_device_ != kAudioObjectUnknown) { + status = AudioObjectAddPropertyListener(listened_output_device_, + &kNominalSampleRateAddress, + &AdmProxy::OnOutputPropertyChanged, this); + if (status != noErr) { + RTC_LOG(LS_WARNING) + << "AdmProxy: failed to add nominal-sample-rate listener, status=" << status; + } + } + + output_listener_installed_ = true; + RTC_LOG(LS_INFO) << "AdmProxy: installed output rate listeners (device=" + << listened_output_device_ + << ", rate=" << OutputNominalSampleRate(listened_output_device_) + << ")"; +} + +void AdmProxy::RemoveOutputRateListenerLocked() { + if (!output_listener_installed_) return; + + AudioObjectRemovePropertyListener(kAudioObjectSystemObject, &kDefaultOutputDeviceAddress, + &AdmProxy::OnOutputPropertyChanged, this); + + if (listened_output_device_ != kAudioObjectUnknown) { + AudioObjectRemovePropertyListener(listened_output_device_, &kNominalSampleRateAddress, + &AdmProxy::OnOutputPropertyChanged, this); + listened_output_device_ = kAudioObjectUnknown; + } + + output_listener_installed_ = false; + RTC_LOG(LS_INFO) << "AdmProxy: removed output rate listeners"; +} + +void AdmProxy::RebouncePlayoutForRateChange() { + webrtc::MutexLock lock(&mutex_); + + // Ignore irrelevant or re-entrant notifications: our own StopPlayout/StartPlayout below + // can themselves emit property-change callbacks. + if (rebouncing_ || !playing_ || !is_platform_playout_active() || !platform_adm_) { + return; + } + rebouncing_ = true; + + // If the default output device changed, move the nominal-rate listener to the new one. + if (output_listener_installed_) { + AudioObjectID current = CurrentDefaultOutputDevice(); + if (current != listened_output_device_) { + if (listened_output_device_ != kAudioObjectUnknown) { + AudioObjectRemovePropertyListener(listened_output_device_, + &kNominalSampleRateAddress, + &AdmProxy::OnOutputPropertyChanged, this); + } + listened_output_device_ = current; + if (current != kAudioObjectUnknown) { + AudioObjectAddPropertyListener(current, &kNominalSampleRateAddress, + &AdmProxy::OnOutputPropertyChanged, this); + } + } + } + + double rate = OutputNominalSampleRate(listened_output_device_); + + // Re-init platform playout so the precompiled CoreAudio ADM re-queries the device's + // current nominal rate and rebuilds its playout resampler. + platform_adm_->StopPlayout(); + platform_adm_->InitPlayout(); + platform_adm_->StartPlayout(); + + RTC_LOG(LS_INFO) << "AdmProxy: rebounced platform playout after output rate change " + << "(device=" << listened_output_device_ << ", rate=" << rate << ")"; + + rebouncing_ = false; +} + +#endif // defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) + // ============================================================================= // AudioDeviceModule Interface Implementation // ============================================================================= @@ -308,6 +485,10 @@ int32_t AdmProxy::Init() { int32_t AdmProxy::Terminate() { webrtc::MutexLock lock(&mutex_); +#if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) + RemoveOutputRateListenerLocked(); +#endif + int32_t result = 0; if (synthetic_adm_) { result = synthetic_adm_->Terminate(); @@ -503,7 +684,11 @@ int32_t AdmProxy::StartPlayout() { if (is_platform_playout_active()) { if (platform_adm_) { - return platform_adm_->StartPlayout(); + int32_t result = platform_adm_->StartPlayout(); +#if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) + if (result == 0) InstallOutputRateListenerLocked(); +#endif + return result; } return -1; } @@ -519,6 +704,10 @@ int32_t AdmProxy::StopPlayout() { webrtc::MutexLock lock(&mutex_); playing_ = false; +#if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) + RemoveOutputRateListenerLocked(); +#endif + // Stop both ADMs if (synthetic_adm_) { synthetic_adm_->StopPlayout();