From a0e78d2935c4eb465eaab31a0cbbf9f76cb8965c Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:30:41 +0900 Subject: [PATCH 1/8] feat(ios): configure the default audio session natively in the observer When no JS handler is registered for willEnableEngine/didDisableEngine, AudioDeviceModuleObserver now applies an audio-session policy natively on the audio worker thread instead of round-tripping to JS. The policy (an Apple audio configuration per engine state, plus deactivate-on-stop) is pushed once from JS via AudioDeviceModule.setAutomaticAudioSessionConfiguration(), and passing null clears it. Registered JS handlers keep taking precedence. This removes the JS round trip from the default configuration path entirely: the worker thread no longer waits on a JS thread that may itself be blocked in a synchronous bridge call, which previously could stall engine operations until the bounded wait timed out (#89). Session activation is tracked as a hold against RTCAudioSession, which reference-counts activations: the observer activates only when the session is not already active and deactivates only while holding the activation, so repeated policy pushes, switches between the native and JS paths, and interruptions keep the refcount balanced. --- ios/RCTWebRTC/AudioDeviceModuleObserver.h | 11 ++ ios/RCTWebRTC/AudioDeviceModuleObserver.m | 158 ++++++++++++++++++ .../WebRTCModule+RTCAudioDeviceModule.m | 11 ++ src/AudioDeviceModule.ts | 37 ++++ 4 files changed, 217 insertions(+) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.h b/ios/RCTWebRTC/AudioDeviceModuleObserver.h index 1a3702549..affb82c66 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.h +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.h @@ -18,6 +18,17 @@ NS_ASSUME_NONNULL_BEGIN @property(atomic, assign) BOOL isDidDisableEngineActive; @property(atomic, assign) BOOL isWillReleaseEngineActive; +// Native default audio-session configuration policy. When set (non-nil) and no +// custom JS handler is registered for willEnable/didDisable, the observer +// configures the AVAudioSession natively on the worker thread instead of doing a +// JS round trip. Pushed once from JS, and nil disables it. Reassigning the policy +// is safe at any time: activation state is tracked against RTCAudioSession +// itself, not against the policy. Shape: +// @{ @"recording": , @"playout": , @"deactivateOnStop": @(BOOL) } +// where is @{ @"audioCategory": str, @"audioMode": str, +// @"audioCategoryOptions": @[str...] }. +@property(atomic, copy, nullable) NSDictionary *automaticAudioSessionConfig; + // Methods to receive results from JS. requestId echoes the id sent with the // corresponding event so stale responses from timed-out rounds can be dropped. - (void)resolveEngineCreatedWithRequestId:(NSInteger)requestId result:(NSInteger)result; diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index 8b42cf11a..48960f675 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -20,6 +20,11 @@ // outcome is deliberately preferred over the unrecoverable deadlock. static const int64_t kJSResponseTimeoutSeconds = 2; +// Returned from willEnable/didDisable when native automatic audio-session +// configuration fails, so libwebrtc rolls the engine operation back. Mirrors the +// SDK's kAudioEngineErrorFailedToConfigureAudioSession value. +static const NSInteger kAutomaticAudioSessionConfigError = -4100; + static os_log_t ADMObserverLog(void) { static os_log_t log; static dispatch_once_t onceToken; @@ -55,6 +60,15 @@ @interface AudioDeviceModuleObserver () @property(nonatomic, assign) NSInteger requestIdSeq; @property(nonatomic, assign) NSInteger awaitingRequestId; +// Whether the native auto-config path currently holds an +// RTCAudioSession activation. RTCAudioSession reference-counts activations +// (setActive:YES on an already-active session only bumps the count, and +// setActive:NO only deactivates at count 1), so the observer must activate at +// most once per hold and release exactly what it took, or the OS session leaks +// active forever. Only touched on the serial delegate (worker) thread, and kept +// atomic as cheap insurance against future cross-thread reads. +@property(atomic, assign) BOOL autoSessionHoldsActivation; + @end @implementation AudioDeviceModuleObserver @@ -185,6 +199,14 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule willEnableEngine:(AVAudioEngine *)engine isPlayoutEnabled:(BOOL)isPlayoutEnabled isRecordingEnabled:(BOOL)isRecordingEnabled { + // With no custom JS handler registered, configure the audio session natively + // here instead of doing a JS round trip. This is the default path and avoids + // parking the audio worker thread on the JS thread entirely. A custom handler + // (isWillEnableEngineActive == YES) falls through to the bounded round trip. + if (!self.isWillEnableEngineActive && self.automaticAudioSessionConfig != nil) { + return [self applyAutomaticAudioSessionConfigForPlayout:isPlayoutEnabled recording:isRecordingEnabled]; + } + BOOL isActive = self.isWillEnableEngineActive; if (isActive) { @@ -276,6 +298,13 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule didDisableEngine:(AVAudioEngine *)engine isPlayoutEnabled:(BOOL)isPlayoutEnabled isRecordingEnabled:(BOOL)isRecordingEnabled { + // Counterpart of willEnable - apply the native session policy (here the + // deactivate-on-stop edge) without a JS round trip when no custom handler + // is registered. + if (!self.isDidDisableEngineActive && self.automaticAudioSessionConfig != nil) { + return [self applyAutomaticAudioSessionConfigForPlayout:isPlayoutEnabled recording:isRecordingEnabled]; + } + BOOL isActive = self.isDidDisableEngineActive; if (isActive) { @@ -419,6 +448,135 @@ - (void)resolveWillReleaseEngineWithRequestId:(NSInteger)requestId result:(NSInt semaphore:self.willReleaseEngineSemaphore]; } +#pragma mark - Native automatic audio session configuration + +// Applies the pushed audio-session policy on the worker thread with no JS round +// trip: configures on every active state, activates when the session is not +// already active, optionally deactivates on stop, and returns a non-zero error +// code on failure so libwebrtc rolls the operation back. +// +// Activation is decided against RTCAudioSession's own state rather than a mirror +// of past engine states, so a policy re-push mid-call (setupIOSAudioManagement +// called again), a path switch, or an interruption cannot desync it: +// - session already active (previous hold, custom JS path, or the app) -> +// reconfigure only, never a second refcounted activation. +// - session inactive (fresh start, or the custom path deactivated it before a +// switch back to the native path) -> activate and take the hold. +- (NSInteger)applyAutomaticAudioSessionConfigForPlayout:(BOOL)isPlayoutEnabled recording:(BOOL)isRecordingEnabled { + NSDictionary *policy = self.automaticAudioSessionConfig; + if (policy == nil) { + return 0; + } + + BOOL nowActive = isPlayoutEnabled || isRecordingEnabled; + + RTCAudioSession *session = [RTCAudioSession sharedInstance]; + [session lockForConfiguration]; + + NSError *error = nil; + if (!nowActive) { + if (self.autoSessionHoldsActivation && [policy[@"deactivateOnStop"] boolValue]) { + os_log_debug(ADMObserverLog(), "Native auto-config: deactivating audio session"); + [session setActive:NO error:&error]; + // RTCAudioSession decrements its activation count even when the OS + // session is already inactive (e.g. an interruption cleared it) or the + // call fails, so the hold is released in every outcome. + self.autoSessionHoldsActivation = NO; + } + } else { + // Recording uses the duplex (playAndRecord) config, while playout-only uses + // the playback config. Both are supplied by the SDK in automaticAudioSessionConfig. + NSDictionary *cfg = isRecordingEnabled ? policy[@"recording"] : policy[@"playout"]; + RTCAudioSessionConfiguration *rtcConfig = [RTCAudioSessionConfiguration webRTCConfiguration]; + if (cfg[@"audioCategory"] != nil) { + rtcConfig.category = [self avAudioSessionCategoryFromString:cfg[@"audioCategory"]]; + } + if (cfg[@"audioMode"] != nil) { + rtcConfig.mode = [self avAudioSessionModeFromString:cfg[@"audioMode"]]; + } + if (cfg[@"audioCategoryOptions"] != nil) { + rtcConfig.categoryOptions = [self avAudioSessionCategoryOptionsFromStrings:cfg[@"audioCategoryOptions"]]; + } + os_log_debug(ADMObserverLog(), "Native auto-config: setting category %{public}@", rtcConfig.category); + [session setConfiguration:rtcConfig error:&error]; + if (error == nil && !session.isActive) { + os_log_debug(ADMObserverLog(), "Native auto-config: activating audio session"); + [session setActive:YES error:&error]; + if (error == nil) { + self.autoSessionHoldsActivation = YES; + } + } + } + + [session unlockForConfiguration]; + + if (error != nil) { + os_log_error(ADMObserverLog(), "Native auto-config failed: %{public}@", error.localizedDescription); + return kAutomaticAudioSessionConfigError; + } + + return 0; +} + +// TODO: this friendly-string -> AVAudioSession mapping mirrors AudioUtils in the +// LiveKit React Native SDK. Keep the accepted values in sync, or consolidate by +// pushing raw AVAudioSession values across the bridge instead. +- (NSString *)avAudioSessionCategoryFromString:(NSString *)value { + static NSDictionary *map; + static dispatch_once_t once; + dispatch_once(&once, ^{ + map = @{ + @"ambient" : AVAudioSessionCategoryAmbient, + @"soloAmbient" : AVAudioSessionCategorySoloAmbient, + @"playback" : AVAudioSessionCategoryPlayback, + @"record" : AVAudioSessionCategoryRecord, + @"playAndRecord" : AVAudioSessionCategoryPlayAndRecord, + @"multiRoute" : AVAudioSessionCategoryMultiRoute, + }; + }); + return map[value] ?: AVAudioSessionCategoryPlayAndRecord; +} + +- (NSString *)avAudioSessionModeFromString:(NSString *)value { + static NSDictionary *map; + static dispatch_once_t once; + dispatch_once(&once, ^{ + map = @{ + @"default" : AVAudioSessionModeDefault, + @"voiceChat" : AVAudioSessionModeVoiceChat, + @"videoChat" : AVAudioSessionModeVideoChat, + @"gameChat" : AVAudioSessionModeGameChat, + @"videoRecording" : AVAudioSessionModeVideoRecording, + @"measurement" : AVAudioSessionModeMeasurement, + @"moviePlayback" : AVAudioSessionModeMoviePlayback, + @"spokenAudio" : AVAudioSessionModeSpokenAudio, + }; + }); + return map[value] ?: AVAudioSessionModeDefault; +} + +- (AVAudioSessionCategoryOptions)avAudioSessionCategoryOptionsFromStrings:(NSArray *)options { + AVAudioSessionCategoryOptions result = 0; + for (NSString *option in options) { + if ([option isEqualToString:@"mixWithOthers"]) { + result |= AVAudioSessionCategoryOptionMixWithOthers; + } else if ([option isEqualToString:@"duckOthers"]) { + result |= AVAudioSessionCategoryOptionDuckOthers; + } else if ([option isEqualToString:@"allowBluetooth"]) { + result |= AVAudioSessionCategoryOptionAllowBluetooth; + } else if ([option isEqualToString:@"allowBluetoothA2DP"]) { + result |= AVAudioSessionCategoryOptionAllowBluetoothA2DP; + } else if ([option isEqualToString:@"allowAirPlay"]) { + result |= AVAudioSessionCategoryOptionAllowAirPlay; + } else if ([option isEqualToString:@"defaultToSpeaker"]) { + result |= AVAudioSessionCategoryOptionDefaultToSpeaker; + } else if ([option isEqualToString:@"interruptSpokenAudioAndMixWithOthers"]) { + result |= AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers; + } + } + return result; +} + @end NS_ASSUME_NONNULL_END diff --git a/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m index 0eb3d0aab..4405fbd1f 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m +++ b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m @@ -303,4 +303,15 @@ @implementation WebRTCModule (RTCAudioDeviceModule) return nil; } +#pragma mark - Automatic Audio Session Configuration + +// Pushes the native default audio-session policy (or nil to disable). When set, +// the observer configures the session natively in willEnable/didDisable instead +// of doing a JS round trip - removing the JS round trip from the default path. +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetAutomaticAudioSessionConfiguration + : (nullable NSDictionary *)config) { + self.audioDeviceModuleObserver.automaticAudioSessionConfig = config; + return nil; +} + @end diff --git a/src/AudioDeviceModule.ts b/src/AudioDeviceModule.ts index 8d2ab676d..95d126ed8 100644 --- a/src/AudioDeviceModule.ts +++ b/src/AudioDeviceModule.ts @@ -25,11 +25,48 @@ export const AudioEngineAvailability = { }, } as const; +/** + * Apple audio session configuration for a single engine state. Matches the + * AppleAudioConfiguration shape used by AudioSession.setAppleAudioConfiguration. + */ +export interface AutomaticAppleAudioConfiguration { + audioCategory?: string; + audioMode?: string; + audioCategoryOptions?: string[]; +} + +/** + * Native default audio-session policy. When set, the native observer configures + * the AVAudioSession in willEnable/didDisable without a JS round trip. + * `recording` is applied while recording is enabled, `playout` while only + * playout is enabled, and the session is deactivated on full stop when + * `deactivateOnStop` is true. + */ +export interface AutomaticAudioSessionConfiguration { + recording: AutomaticAppleAudioConfiguration; + playout: AutomaticAppleAudioConfiguration; + deactivateOnStop: boolean; +} + /** * Audio Device Module API for controlling audio devices and settings. * iOS/macOS only - will throw on Android. */ export class AudioDeviceModule { + /** + * Push (or clear with null) the native default audio-session configuration + * policy. When set, the native observer configures the session itself in + * willEnable/didDisable, removing the JS round trip from the default path. + * iOS/macOS only, a no-op on Android. + */ + static setAutomaticAudioSessionConfiguration(config: AutomaticAudioSessionConfiguration | null): void { + if (Platform.OS === 'android' || !WebRTCModule) { + return; + } + + WebRTCModule.audioDeviceModuleSetAutomaticAudioSessionConfiguration(config); + } + /** * Start audio playback */ From efa32232274f334e22949f249eb4e68fdf68b18d Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:44:35 +0900 Subject: [PATCH 2/8] fix(ios): rebalance the activation count after interruptions and external deactivation An interruption (or an external deactivation such as CallKit) sets RTCAudioSession.isActive to NO without touching its activation count. The next enable transition then saw an inactive session and activated again, stacking a second count onto the hold the observer already had. The single release at stop left count 1, so the OS session stayed active forever. When the observer still holds an activation but the session reads inactive, reactivate first and then drop the now-extra count. The order matters: releasing first would zero the count whenever the reactivation fails, and RTCAudioSession's interruption-end recovery (and its other system-event handlers) deactivates outright at count zero, killing the session the engine was still using. Activating first leaves the prior hold untouched on failure so that recovery can restore it. If the drop itself deactivates the session, the held count had already been consumed by an external unmatched release (for example a stray stopAudioSession call) and the drop returned the count the reactivation took. Reactivate once more and keep that single fresh count, so even a stale hold converges to a balanced state instead of leaking or oscillating. --- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 31 ++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index 48960f675..187fed7c0 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -500,10 +500,39 @@ - (NSInteger)applyAutomaticAudioSessionConfigForPlayout:(BOOL)isPlayoutEnabled r os_log_debug(ADMObserverLog(), "Native auto-config: setting category %{public}@", rtcConfig.category); [session setConfiguration:rtcConfig error:&error]; if (error == nil && !session.isActive) { + BOOL hadHold = self.autoSessionHoldsActivation; os_log_debug(ADMObserverLog(), "Native auto-config: activating audio session"); [session setActive:YES error:&error]; if (error == nil) { - self.autoSessionHoldsActivation = YES; + if (hadHold) { + // An interruption or an external deactivation (e.g. CallKit) + // cleared isActive while our activation count was still held, so + // the successful reactivation above stacked a second count onto + // the same hold. Drop the extra count. The drop is a pure + // decrement while the count sits above one, so the session stays + // active. + // + // Ordered activate-first deliberately. Releasing before + // reactivating would zero the count whenever the reactivation + // fails, and RTCAudioSession's interruption-end recovery + // deactivates outright at count zero. A failure must leave the + // prior hold untouched for that recovery to restore it. + os_log_debug(ADMObserverLog(), "Native auto-config: dropping extra count after reactivating"); + NSError *dropError = nil; + [session setActive:NO error:&dropError]; + if (!session.isActive) { + // The drop deactivated, which means the held count had + // already been consumed by an external unmatched release and + // the drop just gave back the count the reactivation took. + // Reactivate and keep that single fresh count as the hold, + // so even a stale hold converges to a balanced state. + os_log_debug(ADMObserverLog(), "Native auto-config: reactivating after dropping a stale hold"); + [session setActive:YES error:&error]; + } + } + if (error == nil) { + self.autoSessionHoldsActivation = YES; + } } } } From f4e4eefc228679fe840f9bcd9763b54884142298 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:43:38 +0900 Subject: [PATCH 3/8] fix(ios): release an activation hold orphaned by clearing the policy Clearing the automatic configuration (teardown of a setup, or switching paths) while the observer still held an activation stranded the hold: the didDisable native branch was gated on a non-nil policy, so nothing ever released the count and the OS session stayed active. Enter the native branch also when a hold is outstanding with a nil policy, and release the orphaned hold at the next full stop. No configuration is applied in that case since the native path is disarmed. Deactivate-on-stop semantics do not apply to an abandoned policy, releasing is the only balanced outcome. --- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 30 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index 187fed7c0..f92cd5aa3 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -300,8 +300,11 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule isRecordingEnabled:(BOOL)isRecordingEnabled { // Counterpart of willEnable - apply the native session policy (here the // deactivate-on-stop edge) without a JS round trip when no custom handler - // is registered. - if (!self.isDidDisableEngineActive && self.automaticAudioSessionConfig != nil) { + // is registered. Also entered with a nil policy while an activation hold is + // still outstanding, so a hold orphaned by clearing the policy is released + // at the next full stop instead of leaking. + if (!self.isDidDisableEngineActive && + (self.automaticAudioSessionConfig != nil || self.autoSessionHoldsActivation)) { return [self applyAutomaticAudioSessionConfigForPlayout:isPlayoutEnabled recording:isRecordingEnabled]; } @@ -464,12 +467,31 @@ - (void)resolveWillReleaseEngineWithRequestId:(NSInteger)requestId result:(NSInt // switch back to the native path) -> activate and take the hold. - (NSInteger)applyAutomaticAudioSessionConfigForPlayout:(BOOL)isPlayoutEnabled recording:(BOOL)isRecordingEnabled { NSDictionary *policy = self.automaticAudioSessionConfig; + BOOL nowActive = isPlayoutEnabled || isRecordingEnabled; + if (policy == nil) { + // The policy was cleared while we still hold an activation (a setup was + // torn down mid-call, or a deactivateOnStop:NO policy was abandoned). + // Release the orphaned hold at the next full stop so the shared + // activation count stays balanced. No configuration is applied because + // the native path is disarmed. + if (!nowActive && self.autoSessionHoldsActivation) { + os_log(ADMObserverLog(), "Native auto-config: releasing orphaned activation hold"); + RTCAudioSession *session = [RTCAudioSession sharedInstance]; + [session lockForConfiguration]; + NSError *releaseError = nil; + [session setActive:NO error:&releaseError]; + self.autoSessionHoldsActivation = NO; + [session unlockForConfiguration]; + if (releaseError != nil) { + os_log_error(ADMObserverLog(), + "Native auto-config: orphaned hold release failed (continuing): %{public}@", + releaseError.localizedDescription); + } + } return 0; } - BOOL nowActive = isPlayoutEnabled || isRecordingEnabled; - RTCAudioSession *session = [RTCAudioSession sharedInstance]; [session lockForConfiguration]; From 6f8a4a65a035e333b6253ef6e1e3376d3cdab15b Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:10:22 +0900 Subject: [PATCH 4/8] fix(ios): treat every automatic-policy error in didDisable as completed didDisable fires after the engine's destructive disable work: buffers are stopped, converters disposed, and RTCAudioSession has already decremented its activation count even when setActive:NO fails. Its rollback replays only constructive actions, so nothing it does can restore what the disable removed, and a non-zero return only desyncs libwebrtc's logical engine state from hardware that already changed. This applies to the final deactivation edge and equally to partial transitions such as duplex to playout, where the policy branch reconfigures the session. Swallow and log every non-zero result at the didDisable entry point. willEnable keeps propagating configuration and activation errors, where the enable has not happened yet and a rollback is still meaningful. --- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 28 +++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index f92cd5aa3..82bf4f123 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -305,7 +305,20 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule // at the next full stop instead of leaking. if (!self.isDidDisableEngineActive && (self.automaticAudioSessionConfig != nil || self.autoSessionHoldsActivation)) { - return [self applyAutomaticAudioSessionConfigForPlayout:isPlayoutEnabled recording:isRecordingEnabled]; + NSInteger result = [self applyAutomaticAudioSessionConfigForPlayout:isPlayoutEnabled + recording:isRecordingEnabled]; + if (result != 0) { + // didDisable fires after the engine's disable work has already run, so + // libwebrtc cannot roll this operation back. Propagating an error here + // (e.g. a failed reconfigure on a duplex to playout transition) would + // only desync its logical engine state from hardware that has already + // changed. Log and report the operation as completed. Only willEnable + // propagates configuration and activation errors, where the enable has + // not happened yet and a rollback is still meaningful. + os_log_error( + ADMObserverLog(), "Native auto-config: error %ld in didDisable treated as completed", (long)result); + } + return 0; } BOOL isActive = self.isDidDisableEngineActive; @@ -499,11 +512,22 @@ - (NSInteger)applyAutomaticAudioSessionConfigForPlayout:(BOOL)isPlayoutEnabled r if (!nowActive) { if (self.autoSessionHoldsActivation && [policy[@"deactivateOnStop"] boolValue]) { os_log_debug(ADMObserverLog(), "Native auto-config: deactivating audio session"); - [session setActive:NO error:&error]; + NSError *deactivateError = nil; + [session setActive:NO error:&deactivateError]; // RTCAudioSession decrements its activation count even when the OS // session is already inactive (e.g. an interruption cleared it) or the // call fails, so the hold is released in every outcome. self.autoSessionHoldsActivation = NO; + if (deactivateError != nil) { + // Deliberately not propagated. By the time didDisable fires the + // engine's disable work is already done and the activation count is + // already released, so libwebrtc has nothing it could roll back. A + // non-zero return would only desync its logical engine state from + // hardware that is already stopped. + os_log_error(ADMObserverLog(), + "Native auto-config: deactivation failed (continuing): %{public}@", + deactivateError.localizedDescription); + } } } else { // Recording uses the duplex (playAndRecord) config, while playout-only uses From ce409ea4851b9b723f151cf8507790e95f88b823 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:45:24 +0900 Subject: [PATCH 5/8] fix(ios): harden the automatic audio-session configuration API surface - Gate setAutomaticAudioSessionConfiguration to iOS: the macOS build excludes the audio device module natives, so reaching the bridge there threw on an undefined method. tvOS builds include them and keep working. - Type the configuration with literal unions matching the native maps instead of bare strings, so unknown categories, modes, and options are rejected at compile time rather than silently falling back. - Add voicePrompt to the native mode map, matching AudioUtils in the LiveKit React Native SDK and keeping the SDK's AppleAudioMode assignable to the new union. - Export the configuration types from the package root. --- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 1 + src/AudioDeviceModule.ts | 44 ++++++++++++++++++++--- src/index.ts | 16 ++++++++- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index 82bf4f123..9947c8fe7 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -625,6 +625,7 @@ - (NSString *)avAudioSessionModeFromString:(NSString *)value { @"measurement" : AVAudioSessionModeMeasurement, @"moviePlayback" : AVAudioSessionModeMoviePlayback, @"spokenAudio" : AVAudioSessionModeSpokenAudio, + @"voicePrompt" : AVAudioSessionModeVoicePrompt, }; }); return map[value] ?: AVAudioSessionModeDefault; diff --git a/src/AudioDeviceModule.ts b/src/AudioDeviceModule.ts index 95d126ed8..22733dd9e 100644 --- a/src/AudioDeviceModule.ts +++ b/src/AudioDeviceModule.ts @@ -25,14 +25,47 @@ export const AudioEngineAvailability = { }, } as const; +/** + * Accepted values mirror the native observer's friendly-name maps. Unknown + * values never reach the session: categories fall back to playAndRecord and + * modes to default, and the literal unions below reject them at compile time. + */ +export type AutomaticAppleAudioCategory = + | 'ambient' + | 'soloAmbient' + | 'playback' + | 'record' + | 'playAndRecord' + | 'multiRoute'; + +export type AutomaticAppleAudioMode = + | 'default' + | 'voiceChat' + | 'videoChat' + | 'gameChat' + | 'videoRecording' + | 'measurement' + | 'moviePlayback' + | 'spokenAudio' + | 'voicePrompt'; + +export type AutomaticAppleAudioCategoryOption = + | 'mixWithOthers' + | 'duckOthers' + | 'allowBluetooth' + | 'allowBluetoothA2DP' + | 'allowAirPlay' + | 'defaultToSpeaker' + | 'interruptSpokenAudioAndMixWithOthers'; + /** * Apple audio session configuration for a single engine state. Matches the * AppleAudioConfiguration shape used by AudioSession.setAppleAudioConfiguration. */ export interface AutomaticAppleAudioConfiguration { - audioCategory?: string; - audioMode?: string; - audioCategoryOptions?: string[]; + audioCategory?: AutomaticAppleAudioCategory; + audioMode?: AutomaticAppleAudioMode; + audioCategoryOptions?: AutomaticAppleAudioCategoryOption[]; } /** @@ -57,10 +90,11 @@ export class AudioDeviceModule { * Push (or clear with null) the native default audio-session configuration * policy. When set, the native observer configures the session itself in * willEnable/didDisable, removing the JS round trip from the default path. - * iOS/macOS only, a no-op on Android. + * iOS only (including tvOS), a no-op elsewhere. The macOS build excludes + * the audio device module natives, so this must not reach the bridge there. */ static setAutomaticAudioSessionConfiguration(config: AutomaticAudioSessionConfiguration | null): void { - if (Platform.OS === 'android' || !WebRTCModule) { + if (Platform.OS !== 'ios' || !WebRTCModule) { return; } diff --git a/src/index.ts b/src/index.ts index 3f2634fbf..bf692a673 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,16 @@ if (WebRTCModule === null) { }`); } -import { AudioDeviceModule, AudioEngineMuteMode, AudioEngineAvailability } from './AudioDeviceModule'; +import { + AudioDeviceModule, + AudioEngineMuteMode, + AudioEngineAvailability, + type AutomaticAudioSessionConfiguration, + type AutomaticAppleAudioConfiguration, + type AutomaticAppleAudioCategory, + type AutomaticAppleAudioMode, + type AutomaticAppleAudioCategoryOption, +} from './AudioDeviceModule'; import { audioDeviceModuleEvents } from './AudioDeviceModuleEvents'; import { setupNativeEvents } from './EventEmitter'; import Logger from './Logger'; @@ -87,6 +96,11 @@ export { AudioDeviceModule, AudioEngineMuteMode, AudioEngineAvailability, + type AutomaticAudioSessionConfiguration, + type AutomaticAppleAudioConfiguration, + type AutomaticAppleAudioCategory, + type AutomaticAppleAudioMode, + type AutomaticAppleAudioCategoryOption, audioDeviceModuleEvents, }; From 89dff7dd73ae26d054b558515ad8fe135d4bfd10 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:45:52 +0900 Subject: [PATCH 6/8] chore(ios): log native auto-config decisions at default level The native path's decision logs were os_log_debug, which iOS neither streams to a console attach nor persists to collected log archives. Field verification of this subsystem had to be inferred from audiomxd system records. Log the per-transition decisions (skip, configure, activate, deactivate, hold rebalance) at default level so they survive into log archives. Frequency is a handful of lines per engine transition. --- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index 9947c8fe7..ee53fbae3 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -124,7 +124,7 @@ - (NSInteger)sendEventAndWaitWithName:(NSString *)eventName if (!isActive) { // No handler registered, proceed immediately without JS round trip. // This avoids the deadlock window entirely. - os_log_debug(ADMObserverLog(), "Skipping JS round-trip for %{public}@ (no handler registered)", eventName); + os_log(ADMObserverLog(), "Skipping JS round-trip for %{public}@ (no handler registered)", eventName); return 0; } @@ -511,7 +511,7 @@ - (NSInteger)applyAutomaticAudioSessionConfigForPlayout:(BOOL)isPlayoutEnabled r NSError *error = nil; if (!nowActive) { if (self.autoSessionHoldsActivation && [policy[@"deactivateOnStop"] boolValue]) { - os_log_debug(ADMObserverLog(), "Native auto-config: deactivating audio session"); + os_log(ADMObserverLog(), "Native auto-config: deactivating audio session"); NSError *deactivateError = nil; [session setActive:NO error:&deactivateError]; // RTCAudioSession decrements its activation count even when the OS @@ -543,11 +543,11 @@ - (NSInteger)applyAutomaticAudioSessionConfigForPlayout:(BOOL)isPlayoutEnabled r if (cfg[@"audioCategoryOptions"] != nil) { rtcConfig.categoryOptions = [self avAudioSessionCategoryOptionsFromStrings:cfg[@"audioCategoryOptions"]]; } - os_log_debug(ADMObserverLog(), "Native auto-config: setting category %{public}@", rtcConfig.category); + os_log(ADMObserverLog(), "Native auto-config: setting category %{public}@", rtcConfig.category); [session setConfiguration:rtcConfig error:&error]; if (error == nil && !session.isActive) { BOOL hadHold = self.autoSessionHoldsActivation; - os_log_debug(ADMObserverLog(), "Native auto-config: activating audio session"); + os_log(ADMObserverLog(), "Native auto-config: activating audio session"); [session setActive:YES error:&error]; if (error == nil) { if (hadHold) { @@ -563,7 +563,7 @@ - (NSInteger)applyAutomaticAudioSessionConfigForPlayout:(BOOL)isPlayoutEnabled r // fails, and RTCAudioSession's interruption-end recovery // deactivates outright at count zero. A failure must leave the // prior hold untouched for that recovery to restore it. - os_log_debug(ADMObserverLog(), "Native auto-config: dropping extra count after reactivating"); + os_log(ADMObserverLog(), "Native auto-config: dropping extra count after reactivating"); NSError *dropError = nil; [session setActive:NO error:&dropError]; if (!session.isActive) { @@ -572,7 +572,7 @@ - (NSInteger)applyAutomaticAudioSessionConfigForPlayout:(BOOL)isPlayoutEnabled r // the drop just gave back the count the reactivation took. // Reactivate and keep that single fresh count as the hold, // so even a stale hold converges to a balanced state. - os_log_debug(ADMObserverLog(), "Native auto-config: reactivating after dropping a stale hold"); + os_log(ADMObserverLog(), "Native auto-config: reactivating after dropping a stale hold"); [session setActive:YES error:&error]; } } From c5b2b0e6b3b1390592ca126a05515f2f314e5ef8 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:10:35 +0900 Subject: [PATCH 7/8] docs(ios): document the automatic policy's usage contract - Handler precedence is evaluated per hook, so custom willEnable and didDisable handlers must be registered or cleared as a pair while a policy is set. A JS handler owning one hook while the native policy owns the other can activate a session that the owning regime never releases. registerGlobals() must have reconciled the native handler flags before the policy takes effect. - Clearing a deactivateOnStop:NO policy while the engine is already stopped keeps the session activation held until the next engine cycle, because no callback fires in between. Callers who need an immediate release should stop under a deactivateOnStop:YES policy before clearing. - Update the reassignment note now that a hold orphaned by clearing is released at the next full stop. --- ios/RCTWebRTC/AudioDeviceModuleObserver.h | 16 +++++++++++++--- src/AudioDeviceModule.ts | 6 ++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.h b/ios/RCTWebRTC/AudioDeviceModuleObserver.h index affb82c66..8b440948a 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.h +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.h @@ -21,9 +21,19 @@ NS_ASSUME_NONNULL_BEGIN // Native default audio-session configuration policy. When set (non-nil) and no // custom JS handler is registered for willEnable/didDisable, the observer // configures the AVAudioSession natively on the worker thread instead of doing a -// JS round trip. Pushed once from JS, and nil disables it. Reassigning the policy -// is safe at any time: activation state is tracked against RTCAudioSession -// itself, not against the policy. Shape: +// JS round trip. Pushed once from JS, and nil disables it. Reassigning or +// clearing the policy is safe: activation state is tracked against +// RTCAudioSession itself, not against the policy, and a hold orphaned by +// clearing is released at the next full stop. One consequence: clearing a +// deactivateOnStop:NO policy while the engine is already stopped keeps the +// session activation held (and the OS session active) until the next engine +// cycle, because no callback fires in between. Callers who need an immediate +// release should stop under a deactivateOnStop:YES policy before clearing. +// +// Precedence is evaluated per hook, so custom willEnable and didDisable JS +// handlers must be registered or cleared as a pair while a policy is set. A JS +// handler owning one hook while the native policy owns the other can activate a +// session that the owning regime never releases. Shape: // @{ @"recording": , @"playout": , @"deactivateOnStop": @(BOOL) } // where is @{ @"audioCategory": str, @"audioMode": str, // @"audioCategoryOptions": @[str...] }. diff --git a/src/AudioDeviceModule.ts b/src/AudioDeviceModule.ts index 22733dd9e..1a31490fe 100644 --- a/src/AudioDeviceModule.ts +++ b/src/AudioDeviceModule.ts @@ -92,6 +92,12 @@ export class AudioDeviceModule { * willEnable/didDisable, removing the JS round trip from the default path. * iOS only (including tvOS), a no-op elsewhere. The macOS build excludes * the audio device module natives, so this must not reach the bridge there. + * + * Requires registerGlobals() to have run first, which reconciles the native + * handler flags that decide precedence. Handler precedence is per hook: + * while a policy is set, custom willEnable/didDisable handlers must be + * registered or cleared as a pair, otherwise one regime can activate the + * session while the other never releases it. */ static setAutomaticAudioSessionConfiguration(config: AutomaticAudioSessionConfiguration | null): void { if (Platform.OS !== 'ios' || !WebRTCModule) { From 380e4823067ca7b66d972292d8a60345a87beb06 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:16:38 +0900 Subject: [PATCH 8/8] ci(ios): pin runner to macOS 15 --- .github/workflows/ios_ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ios_ci.yml b/.github/workflows/ios_ci.yml index fef52a18e..0e5a69366 100644 --- a/.github/workflows/ios_ci.yml +++ b/.github/workflows/ios_ci.yml @@ -11,7 +11,7 @@ on: jobs: ios-compile: - runs-on: macos-latest + runs-on: macos-15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0