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 diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.h b/ios/RCTWebRTC/AudioDeviceModuleObserver.h index 1a3702549..8b440948a 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.h +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.h @@ -18,6 +18,27 @@ 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 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...] }. +@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..ee53fbae3 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 @@ -110,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; } @@ -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,29 @@ - (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. 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)) { + 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; if (isActive) { @@ -419,6 +464,195 @@ - (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; + 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; + } + + RTCAudioSession *session = [RTCAudioSession sharedInstance]; + [session lockForConfiguration]; + + NSError *error = nil; + if (!nowActive) { + if (self.autoSessionHoldsActivation && [policy[@"deactivateOnStop"] boolValue]) { + 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 + // 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 + // 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(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(ADMObserverLog(), "Native auto-config: activating audio session"); + [session setActive:YES error:&error]; + if (error == nil) { + 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(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(ADMObserverLog(), "Native auto-config: reactivating after dropping a stale hold"); + [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, + @"voicePrompt" : AVAudioSessionModeVoicePrompt, + }; + }); + 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..1a31490fe 100644 --- a/src/AudioDeviceModule.ts +++ b/src/AudioDeviceModule.ts @@ -25,11 +25,88 @@ 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?: AutomaticAppleAudioCategory; + audioMode?: AutomaticAppleAudioMode; + audioCategoryOptions?: AutomaticAppleAudioCategoryOption[]; +} + +/** + * 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 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) { + return; + } + + WebRTCModule.audioDeviceModuleSetAutomaticAudioSessionConfiguration(config); + } + /** * Start audio playback */ 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, };