From f8cce0f385631cf465b08b9f8e4f973bd83061c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Tue, 6 Oct 2026 18:48:01 +0200 Subject: [PATCH 1/6] iOS: restart the audio unit after external audio session changes When CallKit deactivates and later reactivates the audio session (e.g. a call put on hold and resumed), the audio unit stops but AudioDeviceIOS keeps treating it as started, so it is never restarted and the call stays silent. Report an interruption begin on external deactivation, and before the interruption end on external activation, so the unit is stopped cleanly and restarted. Claude-Session: https://claude.ai/code/session_01NAQ1TqkaV8fAgzXw48z9Gp --- sdk/objc/components/audio/RTCAudioSession.mm | 4 ++++ sdk/objc/unittests/RTCAudioSessionTest.mm | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/sdk/objc/components/audio/RTCAudioSession.mm b/sdk/objc/components/audio/RTCAudioSession.mm index 641d2ed8c7..7269239dd1 100644 --- a/sdk/objc/components/audio/RTCAudioSession.mm +++ b/sdk/objc/components/audio/RTCAudioSession.mm @@ -844,6 +844,8 @@ - (void)audioSessionDidActivate:(AVAudioSession *)session { RTCLog(@"Clearing interrupted state due to external activation."); self.isInterrupted = NO; } + RTCLog(@"Resetting the audio unit for external activation."); + [self notifyDidBeginInterruption]; // Treat external audio session activation as an end interruption event. [self notifyDidEndInterruptionWithShouldResumeSession:YES]; } @@ -855,6 +857,8 @@ - (void)audioSessionDidDeactivate:(AVAudioSession *)session { RTCLog(@"Audio session was externally deactivated."); self.isActive = NO; [self decrementActivationCount]; + RTCLog(@"Stopping the audio unit for external deactivation."); + [self notifyDidBeginInterruption]; } - (void)observeValueForKeyPath:(NSString *)keyPath diff --git a/sdk/objc/unittests/RTCAudioSessionTest.mm b/sdk/objc/unittests/RTCAudioSessionTest.mm index 407434523f..6432029bed 100644 --- a/sdk/objc/unittests/RTCAudioSessionTest.mm +++ b/sdk/objc/unittests/RTCAudioSessionTest.mm @@ -45,25 +45,30 @@ @implementation MockAVAudioSession @interface RTCAudioSessionTestDelegate : NSObject @property (nonatomic, readonly) float outputVolume; +@property(nonatomic, readonly) NSMutableArray *interruptionEvents; @end @implementation RTCAudioSessionTestDelegate @synthesize outputVolume = _outputVolume; +@synthesize interruptionEvents = _interruptionEvents; - (instancetype)init { if (self = [super init]) { _outputVolume = -1; + _interruptionEvents = [NSMutableArray array]; } return self; } - (void)audioSessionDidBeginInterruption:(RTC_OBJC_TYPE(RTCAudioSession) *)session { + [_interruptionEvents addObject:@"begin"]; } - (void)audioSessionDidEndInterruption:(RTC_OBJC_TYPE(RTCAudioSession) *)session shouldResumeSession:(BOOL)shouldResumeSession { + [_interruptionEvents addObject:@"end"]; } - (void)audioSessionDidChangeRoute:(RTC_OBJC_TYPE(RTCAudioSession) *)session @@ -212,6 +217,21 @@ - (void)testAudioSessionActivation { EXPECT_EQ(0, audioSession.activationCount); } +- (void)testExternalActivationAndDeactivationReportInterruptions { + RTC_OBJC_TYPE(RTCAudioSession) *audioSession = [RTC_OBJC_TYPE(RTCAudioSession) sharedInstance]; + RTCAudioSessionTestDelegate *delegate = [[RTCAudioSessionTestDelegate alloc] init]; + [audioSession addDelegate:delegate]; + + [audioSession audioSessionDidActivate:[AVAudioSession sharedInstance]]; + EXPECT_TRUE([delegate.interruptionEvents isEqualToArray:@[ @"begin", @"end" ]]); + + [delegate.interruptionEvents removeAllObjects]; + [audioSession audioSessionDidDeactivate:[AVAudioSession sharedInstance]]; + EXPECT_TRUE([delegate.interruptionEvents isEqualToArray:@[ @"begin" ]]); + + [audioSession removeDelegate:delegate]; +} + // TODO(b/298960678): Fix crash when running the test on simulators. - (void)DISABLED_testConfigureWebRTCSession { NSError *error = nil; From ee137b11b4adc67b72567909ca4f2ee13859fc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 7 Oct 2026 12:00:09 +0200 Subject: [PATCH 2/6] iOS: restart the audio unit when it is stopped externally --- sdk/objc/native/src/audio/audio_device_ios.h | 2 ++ sdk/objc/native/src/audio/audio_device_ios.mm | 20 +++++++++++++ .../src/audio/voice_processing_audio_unit.h | 10 +++++++ .../src/audio/voice_processing_audio_unit.mm | 29 +++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/sdk/objc/native/src/audio/audio_device_ios.h b/sdk/objc/native/src/audio/audio_device_ios.h index 53645e7a0d..83bfb29aea 100644 --- a/sdk/objc/native/src/audio/audio_device_ios.h +++ b/sdk/objc/native/src/audio/audio_device_ios.h @@ -159,6 +159,7 @@ class AudioDeviceIOS : public AudioDeviceGeneric, UInt32 bus_number, UInt32 num_frames, AudioBufferList* io_data) override; + void OnRunningStateChanged() override; bool IsInterrupted(); @@ -171,6 +172,7 @@ class AudioDeviceIOS : public AudioDeviceGeneric, void HandleSampleRateChange(); void HandlePlayoutGlitchDetected(); void HandleOutputVolumeChange(); + void HandleAudioUnitRunningStateChange(); // Uses current `playout_parameters_` and `record_parameters_` to inform the // audio device buffer (ADB) about our internal audio parameters. diff --git a/sdk/objc/native/src/audio/audio_device_ios.mm b/sdk/objc/native/src/audio/audio_device_ios.mm index aa9a5788ed..eded897d0c 100644 --- a/sdk/objc/native/src/audio/audio_device_ios.mm +++ b/sdk/objc/native/src/audio/audio_device_ios.mm @@ -382,6 +382,11 @@ static void LogDeviceInfo() { thread_->PostTask(SafeTask(safety_, [this] { HandleValidRouteChange(); })); } +void AudioDeviceIOS::OnRunningStateChanged() { + RTC_DCHECK(thread_); + thread_->PostTask(SafeTask(safety_, [this] { HandleAudioUnitRunningStateChange(); })); +} + void AudioDeviceIOS::OnCanPlayOrRecordChange(bool can_play_or_record) { RTC_DCHECK(thread_); thread_->PostTask(SafeTask( @@ -549,6 +554,21 @@ static void LogDeviceInfo() { HandleSampleRateChange(); } +void AudioDeviceIOS::HandleAudioUnitRunningStateChange() { + RTC_DCHECK_RUN_ON(thread_); + // Starting and stopping the unit here also lands in this method. A unit that + // is not running while marked as started was stopped by the system, for + // example because other code deactivated the audio session. + if (!audio_unit_ || audio_unit_->GetState() != VoiceProcessingAudioUnit::kStarted || + audio_unit_->IsRunning()) { + return; + } + RTCLog(@"Audio unit was stopped externally. Restarting it."); + audio_unit_->Stop(); + PrepareForNewStart(); + UpdateAudioUnit([RTC_OBJC_TYPE(RTCAudioSession) sharedInstance].canPlayOrRecord); +} + void AudioDeviceIOS::HandleCanPlayOrRecordChange(bool can_play_or_record) { RTCLog(@"Handling CanPlayOrRecord change to: %d", can_play_or_record); UpdateAudioUnit(can_play_or_record); diff --git a/sdk/objc/native/src/audio/voice_processing_audio_unit.h b/sdk/objc/native/src/audio/voice_processing_audio_unit.h index bbc9f69d9b..d333b83160 100644 --- a/sdk/objc/native/src/audio/voice_processing_audio_unit.h +++ b/sdk/objc/native/src/audio/voice_processing_audio_unit.h @@ -34,6 +34,8 @@ class VoiceProcessingAudioUnitObserver { UInt32 num_frames, AudioBufferList* io_data) = 0; + virtual void OnRunningStateChanged() = 0; + protected: ~VoiceProcessingAudioUnitObserver() {} }; @@ -77,6 +79,8 @@ class VoiceProcessingAudioUnit { VoiceProcessingAudioUnit::State GetState() const; + bool IsRunning() const; + // Initializes the underlying audio unit with the given sample rate. // When enable_input is false, the input bus is disabled so iOS does not // request microphone permission and the orange mic indicator stays off. @@ -115,6 +119,12 @@ class VoiceProcessingAudioUnit { UInt32 num_frames, AudioBufferList* io_data); + static void OnIsRunningChanged(void* in_ref_con, + AudioUnit audio_unit, + AudioUnitPropertyID property_id, + AudioUnitScope scope, + AudioUnitElement element); + // Notifies observer that samples are needed for playback. OSStatus NotifyGetPlayoutData(AudioUnitRenderActionFlags* flags, const AudioTimeStamp* time_stamp, diff --git a/sdk/objc/native/src/audio/voice_processing_audio_unit.mm b/sdk/objc/native/src/audio/voice_processing_audio_unit.mm index 2b19fb30e7..80c147f7b1 100644 --- a/sdk/objc/native/src/audio/voice_processing_audio_unit.mm +++ b/sdk/objc/native/src/audio/voice_processing_audio_unit.mm @@ -112,6 +112,12 @@ static OSStatus GetAGCState(AudioUnit audio_unit, UInt32* enabled) { return false; } + result = AudioUnitAddPropertyListener( + vpio_unit_, kAudioOutputUnitProperty_IsRunning, OnIsRunningChanged, observer_); + if (result != noErr) { + RTCLogError(@"Failed to add the IsRunning listener. Error=%ld.", (long)result); + } + // Enable (or leave disabled) input on the input scope of the input element. // CRITICAL: setting EnableIO=1 on a Voice Processing I/O input bus causes // iOS (via AURemoteIO::SetProperty → AudioSessionRequestRecordPermission) @@ -374,6 +380,18 @@ static OSStatus GetAGCState(AudioUnit audio_unit, UInt32* enabled) { return noErr; } +bool VoiceProcessingAudioUnit::IsRunning() const { + UInt32 is_running = 0; + UInt32 size = sizeof(is_running); + OSStatus result = AudioUnitGetProperty(vpio_unit_, + kAudioOutputUnitProperty_IsRunning, + kAudioUnitScope_Global, + 0, + &is_running, + &size); + return result == noErr && is_running != 0; +} + bool VoiceProcessingAudioUnit::Stop() { RTC_DCHECK_GE(state_, kUninitialized); RTCLog(@"Stopping audio unit."); @@ -447,6 +465,14 @@ static OSStatus GetAGCState(AudioUnit audio_unit, UInt32* enabled) { num_frames, io_data); } +void VoiceProcessingAudioUnit::OnIsRunningChanged(void* in_ref_con, + AudioUnit audio_unit, + AudioUnitPropertyID property_id, + AudioUnitScope scope, + AudioUnitElement element) { + static_cast(in_ref_con)->OnRunningStateChanged(); +} + OSStatus VoiceProcessingAudioUnit::NotifyGetPlayoutData( AudioUnitRenderActionFlags* flags, const AudioTimeStamp* time_stamp, @@ -502,6 +528,9 @@ static OSStatus GetAGCState(AudioUnit audio_unit, UInt32* enabled) { break; } + AudioUnitRemovePropertyListenerWithUserData( + vpio_unit_, kAudioOutputUnitProperty_IsRunning, OnIsRunningChanged, observer_); + RTCLog(@"Disposing audio unit."); OSStatus result = AudioComponentInstanceDispose(vpio_unit_); if (result != noErr) { From 1dace0842f4eef0c7d45733475f38782893b7ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 7 Oct 2026 12:35:30 +0200 Subject: [PATCH 3/6] iOS: replace synthetic interruptions with the IsRunning listener --- sdk/objc/components/audio/RTCAudioSession.mm | 4 ---- sdk/objc/native/src/audio/audio_device_ios.mm | 11 ++++++++-- sdk/objc/unittests/RTCAudioSessionTest.mm | 20 ------------------- 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/sdk/objc/components/audio/RTCAudioSession.mm b/sdk/objc/components/audio/RTCAudioSession.mm index 7269239dd1..641d2ed8c7 100644 --- a/sdk/objc/components/audio/RTCAudioSession.mm +++ b/sdk/objc/components/audio/RTCAudioSession.mm @@ -844,8 +844,6 @@ - (void)audioSessionDidActivate:(AVAudioSession *)session { RTCLog(@"Clearing interrupted state due to external activation."); self.isInterrupted = NO; } - RTCLog(@"Resetting the audio unit for external activation."); - [self notifyDidBeginInterruption]; // Treat external audio session activation as an end interruption event. [self notifyDidEndInterruptionWithShouldResumeSession:YES]; } @@ -857,8 +855,6 @@ - (void)audioSessionDidDeactivate:(AVAudioSession *)session { RTCLog(@"Audio session was externally deactivated."); self.isActive = NO; [self decrementActivationCount]; - RTCLog(@"Stopping the audio unit for external deactivation."); - [self notifyDidBeginInterruption]; } - (void)observeValueForKeyPath:(NSString *)keyPath diff --git a/sdk/objc/native/src/audio/audio_device_ios.mm b/sdk/objc/native/src/audio/audio_device_ios.mm index eded897d0c..764d4e007c 100644 --- a/sdk/objc/native/src/audio/audio_device_ios.mm +++ b/sdk/objc/native/src/audio/audio_device_ios.mm @@ -563,10 +563,17 @@ static void LogDeviceInfo() { audio_unit_->IsRunning()) { return; } - RTCLog(@"Audio unit was stopped externally. Restarting it."); audio_unit_->Stop(); PrepareForNewStart(); - UpdateAudioUnit([RTC_OBJC_TYPE(RTCAudioSession) sharedInstance].canPlayOrRecord); + RTC_OBJC_TYPE(RTCAudioSession)* session = [RTC_OBJC_TYPE(RTCAudioSession) sharedInstance]; + // The session was deactivated through RTCAudioSession, e.g. by CallKit for a held call. The + // unit is restarted when the session is activated again. + if (!session.isActive) { + RTCLog(@"Audio unit was stopped with the audio session."); + return; + } + RTCLog(@"Audio unit was stopped externally. Restarting it."); + UpdateAudioUnit(session.canPlayOrRecord); } void AudioDeviceIOS::HandleCanPlayOrRecordChange(bool can_play_or_record) { diff --git a/sdk/objc/unittests/RTCAudioSessionTest.mm b/sdk/objc/unittests/RTCAudioSessionTest.mm index 6432029bed..407434523f 100644 --- a/sdk/objc/unittests/RTCAudioSessionTest.mm +++ b/sdk/objc/unittests/RTCAudioSessionTest.mm @@ -45,30 +45,25 @@ @implementation MockAVAudioSession @interface RTCAudioSessionTestDelegate : NSObject @property (nonatomic, readonly) float outputVolume; -@property(nonatomic, readonly) NSMutableArray *interruptionEvents; @end @implementation RTCAudioSessionTestDelegate @synthesize outputVolume = _outputVolume; -@synthesize interruptionEvents = _interruptionEvents; - (instancetype)init { if (self = [super init]) { _outputVolume = -1; - _interruptionEvents = [NSMutableArray array]; } return self; } - (void)audioSessionDidBeginInterruption:(RTC_OBJC_TYPE(RTCAudioSession) *)session { - [_interruptionEvents addObject:@"begin"]; } - (void)audioSessionDidEndInterruption:(RTC_OBJC_TYPE(RTCAudioSession) *)session shouldResumeSession:(BOOL)shouldResumeSession { - [_interruptionEvents addObject:@"end"]; } - (void)audioSessionDidChangeRoute:(RTC_OBJC_TYPE(RTCAudioSession) *)session @@ -217,21 +212,6 @@ - (void)testAudioSessionActivation { EXPECT_EQ(0, audioSession.activationCount); } -- (void)testExternalActivationAndDeactivationReportInterruptions { - RTC_OBJC_TYPE(RTCAudioSession) *audioSession = [RTC_OBJC_TYPE(RTCAudioSession) sharedInstance]; - RTCAudioSessionTestDelegate *delegate = [[RTCAudioSessionTestDelegate alloc] init]; - [audioSession addDelegate:delegate]; - - [audioSession audioSessionDidActivate:[AVAudioSession sharedInstance]]; - EXPECT_TRUE([delegate.interruptionEvents isEqualToArray:@[ @"begin", @"end" ]]); - - [delegate.interruptionEvents removeAllObjects]; - [audioSession audioSessionDidDeactivate:[AVAudioSession sharedInstance]]; - EXPECT_TRUE([delegate.interruptionEvents isEqualToArray:@[ @"begin" ]]); - - [audioSession removeDelegate:delegate]; -} - // TODO(b/298960678): Fix crash when running the test on simulators. - (void)DISABLED_testConfigureWebRTCSession { NSError *error = nil; From 5f9a10d0bae0f3f24ffbdc4df31e6fcce2aec6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 7 Oct 2026 13:23:58 +0200 Subject: [PATCH 4/6] iOS: keep the audio unit stopped while CallKit holds the call --- .../audio/RTCAudioSession+Private.h | 14 ++++++ sdk/objc/components/audio/RTCAudioSession.mm | 22 ++++++++++ .../RTCNativeAudioSessionDelegateAdapter.mm | 16 +++++++ sdk/objc/native/src/audio/audio_device_ios.h | 8 ++++ sdk/objc/native/src/audio/audio_device_ios.mm | 43 ++++++++++++++++--- .../native/src/audio/audio_session_observer.h | 5 +++ 6 files changed, 102 insertions(+), 6 deletions(-) diff --git a/sdk/objc/components/audio/RTCAudioSession+Private.h b/sdk/objc/components/audio/RTCAudioSession+Private.h index 2be1b9fb3d..1a2c5cfa76 100644 --- a/sdk/objc/components/audio/RTCAudioSession+Private.h +++ b/sdk/objc/components/audio/RTCAudioSession+Private.h @@ -14,6 +14,18 @@ NS_ASSUME_NONNULL_BEGIN @class RTC_OBJC_TYPE(RTCAudioSessionConfiguration); +/** Implemented by WebRTC's own audio device only. Unlike the public RTCAudioSessionDelegate + * interruption callbacks, these report audioSessionDidActivate: and + * audioSessionDidDeactivate:, i.e. the session being changed outside WebRTC (e.g. by CallKit). + */ +@protocol RTC_OBJC_TYPE +(RTCAudioSessionExternalActivationDelegate) + + - (void)audioSessionDidActivateExternally : (RTC_OBJC_TYPE(RTCAudioSession) *)session; +- (void)audioSessionDidDeactivateExternally:(RTC_OBJC_TYPE(RTCAudioSession) *)session; + +@end + @interface RTC_OBJC_TYPE (RTCAudioSession) () @@ -89,6 +101,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)notifyDidChangeCanPlayOrRecord:(BOOL)canPlayOrRecord; - (void)notifyDidStartPlayOrRecord; - (void)notifyDidStopPlayOrRecord; +- (void)notifyDidActivateExternally; +- (void)notifyDidDeactivateExternally; @end diff --git a/sdk/objc/components/audio/RTCAudioSession.mm b/sdk/objc/components/audio/RTCAudioSession.mm index 641d2ed8c7..e52aec67d0 100644 --- a/sdk/objc/components/audio/RTCAudioSession.mm +++ b/sdk/objc/components/audio/RTCAudioSession.mm @@ -844,6 +844,7 @@ - (void)audioSessionDidActivate:(AVAudioSession *)session { RTCLog(@"Clearing interrupted state due to external activation."); self.isInterrupted = NO; } + [self notifyDidActivateExternally]; // Treat external audio session activation as an end interruption event. [self notifyDidEndInterruptionWithShouldResumeSession:YES]; } @@ -855,6 +856,7 @@ - (void)audioSessionDidDeactivate:(AVAudioSession *)session { RTCLog(@"Audio session was externally deactivated."); self.isActive = NO; [self decrementActivationCount]; + [self notifyDidDeactivateExternally]; } - (void)observeValueForKeyPath:(NSString *)keyPath @@ -887,6 +889,26 @@ - (void)notifyAudioUnitStartFailedWithError:(OSStatus)error { } } +- (void)notifyDidActivateExternally { + for (auto delegate : self.delegates) { + SEL sel = @selector(audioSessionDidActivateExternally:); + if ([delegate respondsToSelector:sel]) { + [(id)delegate + audioSessionDidActivateExternally:self]; + } + } +} + +- (void)notifyDidDeactivateExternally { + for (auto delegate : self.delegates) { + SEL sel = @selector(audioSessionDidDeactivateExternally:); + if ([delegate respondsToSelector:sel]) { + [(id)delegate + audioSessionDidDeactivateExternally:self]; + } + } +} + - (void)notifyDidBeginInterruption { for (auto delegate : self.delegates) { SEL sel = @selector(audioSessionDidBeginInterruption:); diff --git a/sdk/objc/components/audio/RTCNativeAudioSessionDelegateAdapter.mm b/sdk/objc/components/audio/RTCNativeAudioSessionDelegateAdapter.mm index daddf314a4..94701122ee 100644 --- a/sdk/objc/components/audio/RTCNativeAudioSessionDelegateAdapter.mm +++ b/sdk/objc/components/audio/RTCNativeAudioSessionDelegateAdapter.mm @@ -10,10 +10,16 @@ #import "RTCNativeAudioSessionDelegateAdapter.h" +#import "RTCAudioSession+Private.h" + #include "sdk/objc/native/src/audio/audio_session_observer.h" #import "base/RTCLogging.h" +@interface RTCNativeAudioSessionDelegateAdapter () < + RTC_OBJC_TYPE (RTCAudioSessionExternalActivationDelegate)> +@end + @implementation RTCNativeAudioSessionDelegateAdapter { webrtc::AudioSessionObserver *_observer; } @@ -26,6 +32,16 @@ - (instancetype)initWithObserver:(webrtc::AudioSessionObserver *)observer { return self; } +#pragma mark - RTC_OBJC_TYPE(RTCAudioSessionExternalActivationDelegate) + +- (void)audioSessionDidActivateExternally:(RTC_OBJC_TYPE(RTCAudioSession) *)session { + _observer->OnExternalActivation(); +} + +- (void)audioSessionDidDeactivateExternally:(RTC_OBJC_TYPE(RTCAudioSession) *)session { + _observer->OnExternalDeactivation(); +} + #pragma mark - RTC_OBJC_TYPE(RTCAudioSessionDelegate) - (void)audioSessionDidBeginInterruption:(RTC_OBJC_TYPE(RTCAudioSession) *)session { diff --git a/sdk/objc/native/src/audio/audio_device_ios.h b/sdk/objc/native/src/audio/audio_device_ios.h index 83bfb29aea..3062eb6397 100644 --- a/sdk/objc/native/src/audio/audio_device_ios.h +++ b/sdk/objc/native/src/audio/audio_device_ios.h @@ -147,6 +147,8 @@ class AudioDeviceIOS : public AudioDeviceGeneric, void OnValidRouteChange() override; void OnCanPlayOrRecordChange(bool can_play_or_record) override; void OnChangedOutputVolume() override; + void OnExternalActivation() override; + void OnExternalDeactivation() override; // VoiceProcessingAudioUnitObserver methods. OSStatus OnDeliverRecordedData(AudioUnitRenderActionFlags* flags, @@ -173,6 +175,8 @@ class AudioDeviceIOS : public AudioDeviceGeneric, void HandlePlayoutGlitchDetected(); void HandleOutputVolumeChange(); void HandleAudioUnitRunningStateChange(); + void HandleExternalActivation(); + void HandleExternalDeactivation(); // Uses current `playout_parameters_` and `record_parameters_` to inform the // audio device buffer (ADB) about our internal audio parameters. @@ -292,6 +296,10 @@ class AudioDeviceIOS : public AudioDeviceGeneric, // Set to true if audio session is interrupted, false otherwise. bool is_interrupted_; + // Set while the audio session is deactivated outside WebRTC, e.g. by CallKit + // for a held call. The audio unit is not restarted until it is activated. + bool session_deactivated_externally_ = false; + // Audio interruption observer instance. RTCNativeAudioSessionDelegateAdapter* audio_session_observer_ RTC_GUARDED_BY(thread_); diff --git a/sdk/objc/native/src/audio/audio_device_ios.mm b/sdk/objc/native/src/audio/audio_device_ios.mm index 764d4e007c..35376af0ea 100644 --- a/sdk/objc/native/src/audio/audio_device_ios.mm +++ b/sdk/objc/native/src/audio/audio_device_ios.mm @@ -387,6 +387,16 @@ static void LogDeviceInfo() { thread_->PostTask(SafeTask(safety_, [this] { HandleAudioUnitRunningStateChange(); })); } +void AudioDeviceIOS::OnExternalActivation() { + RTC_DCHECK(thread_); + thread_->PostTask(SafeTask(safety_, [this] { HandleExternalActivation(); })); +} + +void AudioDeviceIOS::OnExternalDeactivation() { + RTC_DCHECK(thread_); + thread_->PostTask(SafeTask(safety_, [this] { HandleExternalDeactivation(); })); +} + void AudioDeviceIOS::OnCanPlayOrRecordChange(bool can_play_or_record) { RTC_DCHECK(thread_); thread_->PostTask(SafeTask( @@ -563,17 +573,36 @@ static void LogDeviceInfo() { audio_unit_->IsRunning()) { return; } - audio_unit_->Stop(); + if (!audio_unit_->Stop()) { + RTCLogError(@"Failed to stop the externally stopped audio unit. Not restarting it."); + return; + } PrepareForNewStart(); - RTC_OBJC_TYPE(RTCAudioSession)* session = [RTC_OBJC_TYPE(RTCAudioSession) sharedInstance]; - // The session was deactivated through RTCAudioSession, e.g. by CallKit for a held call. The - // unit is restarted when the session is activated again. - if (!session.isActive) { + if (session_deactivated_externally_) { RTCLog(@"Audio unit was stopped with the audio session."); return; } RTCLog(@"Audio unit was stopped externally. Restarting it."); - UpdateAudioUnit(session.canPlayOrRecord); + UpdateAudioUnit([RTC_OBJC_TYPE(RTCAudioSession) sharedInstance].canPlayOrRecord); +} + +void AudioDeviceIOS::HandleExternalActivation() { + RTC_DCHECK_RUN_ON(thread_); + // Posted before the interruption end that restarts the unit. + session_deactivated_externally_ = false; +} + +void AudioDeviceIOS::HandleExternalDeactivation() { + RTC_DCHECK_RUN_ON(thread_); + session_deactivated_externally_ = true; + // Stop the unit also when it was restarted before this notification arrived. + if (audio_unit_ && audio_unit_->GetState() == VoiceProcessingAudioUnit::kStarted) { + RTCLog(@"Stopping the audio unit for external deactivation."); + if (!audio_unit_->Stop()) { + RTCLogError(@"Failed to stop the audio unit for external deactivation."); + } + PrepareForNewStart(); + } } void AudioDeviceIOS::HandleCanPlayOrRecordChange(bool can_play_or_record) { @@ -927,6 +956,8 @@ static void LogDeviceInfo() { bool AudioDeviceIOS::InitPlayOrRecord(bool enable_input) { LOGI() << "InitPlayOrRecord"; RTC_DCHECK_RUN_ON(thread_); + // A deactivation reported for a previous call does not apply to this one. + session_deactivated_externally_ = false; // There should be no audio unit at this point. Pass enable_input through so // the input bus is only enabled when WebRTC actually wants to record — diff --git a/sdk/objc/native/src/audio/audio_session_observer.h b/sdk/objc/native/src/audio/audio_session_observer.h index f7c44c8184..69afc6a1aa 100644 --- a/sdk/objc/native/src/audio/audio_session_observer.h +++ b/sdk/objc/native/src/audio/audio_session_observer.h @@ -32,6 +32,11 @@ class AudioSessionObserver { virtual void OnChangedOutputVolume() = 0; + // Called when the audio session is activated or deactivated outside WebRTC, + // e.g. by CallKit. + virtual void OnExternalActivation() = 0; + virtual void OnExternalDeactivation() = 0; + protected: virtual ~AudioSessionObserver() {} }; From 44317818eba9efa348ac0e55c2cee5ded9671fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 7 Oct 2026 16:41:52 +0200 Subject: [PATCH 5/6] iOS: leave CallKit hold handling to the app --- .../audio/RTCAudioSession+Private.h | 14 -------- sdk/objc/components/audio/RTCAudioSession.mm | 22 ------------ .../RTCNativeAudioSessionDelegateAdapter.mm | 16 --------- sdk/objc/native/src/audio/audio_device_ios.h | 8 ----- sdk/objc/native/src/audio/audio_device_ios.mm | 35 ------------------- .../native/src/audio/audio_session_observer.h | 5 --- 6 files changed, 100 deletions(-) diff --git a/sdk/objc/components/audio/RTCAudioSession+Private.h b/sdk/objc/components/audio/RTCAudioSession+Private.h index 1a2c5cfa76..2be1b9fb3d 100644 --- a/sdk/objc/components/audio/RTCAudioSession+Private.h +++ b/sdk/objc/components/audio/RTCAudioSession+Private.h @@ -14,18 +14,6 @@ NS_ASSUME_NONNULL_BEGIN @class RTC_OBJC_TYPE(RTCAudioSessionConfiguration); -/** Implemented by WebRTC's own audio device only. Unlike the public RTCAudioSessionDelegate - * interruption callbacks, these report audioSessionDidActivate: and - * audioSessionDidDeactivate:, i.e. the session being changed outside WebRTC (e.g. by CallKit). - */ -@protocol RTC_OBJC_TYPE -(RTCAudioSessionExternalActivationDelegate) - - - (void)audioSessionDidActivateExternally : (RTC_OBJC_TYPE(RTCAudioSession) *)session; -- (void)audioSessionDidDeactivateExternally:(RTC_OBJC_TYPE(RTCAudioSession) *)session; - -@end - @interface RTC_OBJC_TYPE (RTCAudioSession) () @@ -101,8 +89,6 @@ NS_ASSUME_NONNULL_BEGIN - (void)notifyDidChangeCanPlayOrRecord:(BOOL)canPlayOrRecord; - (void)notifyDidStartPlayOrRecord; - (void)notifyDidStopPlayOrRecord; -- (void)notifyDidActivateExternally; -- (void)notifyDidDeactivateExternally; @end diff --git a/sdk/objc/components/audio/RTCAudioSession.mm b/sdk/objc/components/audio/RTCAudioSession.mm index e52aec67d0..641d2ed8c7 100644 --- a/sdk/objc/components/audio/RTCAudioSession.mm +++ b/sdk/objc/components/audio/RTCAudioSession.mm @@ -844,7 +844,6 @@ - (void)audioSessionDidActivate:(AVAudioSession *)session { RTCLog(@"Clearing interrupted state due to external activation."); self.isInterrupted = NO; } - [self notifyDidActivateExternally]; // Treat external audio session activation as an end interruption event. [self notifyDidEndInterruptionWithShouldResumeSession:YES]; } @@ -856,7 +855,6 @@ - (void)audioSessionDidDeactivate:(AVAudioSession *)session { RTCLog(@"Audio session was externally deactivated."); self.isActive = NO; [self decrementActivationCount]; - [self notifyDidDeactivateExternally]; } - (void)observeValueForKeyPath:(NSString *)keyPath @@ -889,26 +887,6 @@ - (void)notifyAudioUnitStartFailedWithError:(OSStatus)error { } } -- (void)notifyDidActivateExternally { - for (auto delegate : self.delegates) { - SEL sel = @selector(audioSessionDidActivateExternally:); - if ([delegate respondsToSelector:sel]) { - [(id)delegate - audioSessionDidActivateExternally:self]; - } - } -} - -- (void)notifyDidDeactivateExternally { - for (auto delegate : self.delegates) { - SEL sel = @selector(audioSessionDidDeactivateExternally:); - if ([delegate respondsToSelector:sel]) { - [(id)delegate - audioSessionDidDeactivateExternally:self]; - } - } -} - - (void)notifyDidBeginInterruption { for (auto delegate : self.delegates) { SEL sel = @selector(audioSessionDidBeginInterruption:); diff --git a/sdk/objc/components/audio/RTCNativeAudioSessionDelegateAdapter.mm b/sdk/objc/components/audio/RTCNativeAudioSessionDelegateAdapter.mm index 94701122ee..daddf314a4 100644 --- a/sdk/objc/components/audio/RTCNativeAudioSessionDelegateAdapter.mm +++ b/sdk/objc/components/audio/RTCNativeAudioSessionDelegateAdapter.mm @@ -10,16 +10,10 @@ #import "RTCNativeAudioSessionDelegateAdapter.h" -#import "RTCAudioSession+Private.h" - #include "sdk/objc/native/src/audio/audio_session_observer.h" #import "base/RTCLogging.h" -@interface RTCNativeAudioSessionDelegateAdapter () < - RTC_OBJC_TYPE (RTCAudioSessionExternalActivationDelegate)> -@end - @implementation RTCNativeAudioSessionDelegateAdapter { webrtc::AudioSessionObserver *_observer; } @@ -32,16 +26,6 @@ - (instancetype)initWithObserver:(webrtc::AudioSessionObserver *)observer { return self; } -#pragma mark - RTC_OBJC_TYPE(RTCAudioSessionExternalActivationDelegate) - -- (void)audioSessionDidActivateExternally:(RTC_OBJC_TYPE(RTCAudioSession) *)session { - _observer->OnExternalActivation(); -} - -- (void)audioSessionDidDeactivateExternally:(RTC_OBJC_TYPE(RTCAudioSession) *)session { - _observer->OnExternalDeactivation(); -} - #pragma mark - RTC_OBJC_TYPE(RTCAudioSessionDelegate) - (void)audioSessionDidBeginInterruption:(RTC_OBJC_TYPE(RTCAudioSession) *)session { diff --git a/sdk/objc/native/src/audio/audio_device_ios.h b/sdk/objc/native/src/audio/audio_device_ios.h index 3062eb6397..83bfb29aea 100644 --- a/sdk/objc/native/src/audio/audio_device_ios.h +++ b/sdk/objc/native/src/audio/audio_device_ios.h @@ -147,8 +147,6 @@ class AudioDeviceIOS : public AudioDeviceGeneric, void OnValidRouteChange() override; void OnCanPlayOrRecordChange(bool can_play_or_record) override; void OnChangedOutputVolume() override; - void OnExternalActivation() override; - void OnExternalDeactivation() override; // VoiceProcessingAudioUnitObserver methods. OSStatus OnDeliverRecordedData(AudioUnitRenderActionFlags* flags, @@ -175,8 +173,6 @@ class AudioDeviceIOS : public AudioDeviceGeneric, void HandlePlayoutGlitchDetected(); void HandleOutputVolumeChange(); void HandleAudioUnitRunningStateChange(); - void HandleExternalActivation(); - void HandleExternalDeactivation(); // Uses current `playout_parameters_` and `record_parameters_` to inform the // audio device buffer (ADB) about our internal audio parameters. @@ -296,10 +292,6 @@ class AudioDeviceIOS : public AudioDeviceGeneric, // Set to true if audio session is interrupted, false otherwise. bool is_interrupted_; - // Set while the audio session is deactivated outside WebRTC, e.g. by CallKit - // for a held call. The audio unit is not restarted until it is activated. - bool session_deactivated_externally_ = false; - // Audio interruption observer instance. RTCNativeAudioSessionDelegateAdapter* audio_session_observer_ RTC_GUARDED_BY(thread_); diff --git a/sdk/objc/native/src/audio/audio_device_ios.mm b/sdk/objc/native/src/audio/audio_device_ios.mm index 35376af0ea..6d8e2b7639 100644 --- a/sdk/objc/native/src/audio/audio_device_ios.mm +++ b/sdk/objc/native/src/audio/audio_device_ios.mm @@ -387,16 +387,6 @@ static void LogDeviceInfo() { thread_->PostTask(SafeTask(safety_, [this] { HandleAudioUnitRunningStateChange(); })); } -void AudioDeviceIOS::OnExternalActivation() { - RTC_DCHECK(thread_); - thread_->PostTask(SafeTask(safety_, [this] { HandleExternalActivation(); })); -} - -void AudioDeviceIOS::OnExternalDeactivation() { - RTC_DCHECK(thread_); - thread_->PostTask(SafeTask(safety_, [this] { HandleExternalDeactivation(); })); -} - void AudioDeviceIOS::OnCanPlayOrRecordChange(bool can_play_or_record) { RTC_DCHECK(thread_); thread_->PostTask(SafeTask( @@ -578,33 +568,10 @@ static void LogDeviceInfo() { return; } PrepareForNewStart(); - if (session_deactivated_externally_) { - RTCLog(@"Audio unit was stopped with the audio session."); - return; - } RTCLog(@"Audio unit was stopped externally. Restarting it."); UpdateAudioUnit([RTC_OBJC_TYPE(RTCAudioSession) sharedInstance].canPlayOrRecord); } -void AudioDeviceIOS::HandleExternalActivation() { - RTC_DCHECK_RUN_ON(thread_); - // Posted before the interruption end that restarts the unit. - session_deactivated_externally_ = false; -} - -void AudioDeviceIOS::HandleExternalDeactivation() { - RTC_DCHECK_RUN_ON(thread_); - session_deactivated_externally_ = true; - // Stop the unit also when it was restarted before this notification arrived. - if (audio_unit_ && audio_unit_->GetState() == VoiceProcessingAudioUnit::kStarted) { - RTCLog(@"Stopping the audio unit for external deactivation."); - if (!audio_unit_->Stop()) { - RTCLogError(@"Failed to stop the audio unit for external deactivation."); - } - PrepareForNewStart(); - } -} - void AudioDeviceIOS::HandleCanPlayOrRecordChange(bool can_play_or_record) { RTCLog(@"Handling CanPlayOrRecord change to: %d", can_play_or_record); UpdateAudioUnit(can_play_or_record); @@ -956,8 +923,6 @@ static void LogDeviceInfo() { bool AudioDeviceIOS::InitPlayOrRecord(bool enable_input) { LOGI() << "InitPlayOrRecord"; RTC_DCHECK_RUN_ON(thread_); - // A deactivation reported for a previous call does not apply to this one. - session_deactivated_externally_ = false; // There should be no audio unit at this point. Pass enable_input through so // the input bus is only enabled when WebRTC actually wants to record — diff --git a/sdk/objc/native/src/audio/audio_session_observer.h b/sdk/objc/native/src/audio/audio_session_observer.h index 69afc6a1aa..f7c44c8184 100644 --- a/sdk/objc/native/src/audio/audio_session_observer.h +++ b/sdk/objc/native/src/audio/audio_session_observer.h @@ -32,11 +32,6 @@ class AudioSessionObserver { virtual void OnChangedOutputVolume() = 0; - // Called when the audio session is activated or deactivated outside WebRTC, - // e.g. by CallKit. - virtual void OnExternalActivation() = 0; - virtual void OnExternalDeactivation() = 0; - protected: virtual ~AudioSessionObserver() {} }; From 5fc916246599a1734d3aabbe6baae3961949a6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 7 Oct 2026 20:02:04 +0200 Subject: [PATCH 6/6] iOS: clarify the IsRunning handler comment --- sdk/objc/native/src/audio/audio_device_ios.mm | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sdk/objc/native/src/audio/audio_device_ios.mm b/sdk/objc/native/src/audio/audio_device_ios.mm index 6d8e2b7639..091d013c72 100644 --- a/sdk/objc/native/src/audio/audio_device_ios.mm +++ b/sdk/objc/native/src/audio/audio_device_ios.mm @@ -556,9 +556,10 @@ static void LogDeviceInfo() { void AudioDeviceIOS::HandleAudioUnitRunningStateChange() { RTC_DCHECK_RUN_ON(thread_); - // Starting and stopping the unit here also lands in this method. A unit that - // is not running while marked as started was stopped by the system, for - // example because other code deactivated the audio session. + // The listener fires on every start and stop, including WebRTC's own and the + // restart below; those runs return here. Only a unit marked as started that + // isn't running was stopped by someone else, e.g. other code deactivating the + // audio session. if (!audio_unit_ || audio_unit_->GetState() != VoiceProcessingAudioUnit::kStarted || audio_unit_->IsRunning()) { return;