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..22c070d77 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.h +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.h @@ -11,6 +11,10 @@ NS_ASSUME_NONNULL_BEGIN // immediately without a JS round trip, avoiding the deadlock window entirely. // Atomic because they are written on the JS thread (handler registration) and // read on the native audio thread (delegate callbacks). +// +// Warnings: +// - Clear willEnable/didDisable JS handlers as a pair while a policy is set; +// mixed ownership can activate a session that nothing releases. @property(atomic, assign) BOOL isEngineCreatedActive; @property(atomic, assign) BOOL isWillEnableEngineActive; @property(atomic, assign) BOOL isWillStartEngineActive; @@ -18,6 +22,18 @@ NS_ASSUME_NONNULL_BEGIN @property(atomic, assign) BOOL isDidDisableEngineActive; @property(atomic, assign) BOOL isWillReleaseEngineActive; +// Native default AVAudioSession policy. When non-nil and no JS willEnable/ +// didDisable handler is registered, applied on the worker thread. nil clears it. +// Shape: @{ @"recording": , @"playout": , @"deactivateOnStop": @(BOOL) } +// where is @{ @"audioCategory": str, @"audioMode": str, +// @"audioCategoryOptions": @[str...] }. +// +// Warnings: +// - Clearing a deactivateOnStop:NO policy while the engine is already stopped +// keeps the activation held until the next engine cycle. For an immediate +// release, stop under deactivateOnStop:YES before clearing. +@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..7b0fa37ae 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,13 @@ @interface AudioDeviceModuleObserver () @property(nonatomic, assign) NSInteger requestIdSeq; @property(nonatomic, assign) NSInteger awaitingRequestId; +// Whether the native auto-config path currently holds an +// RTCAudioSession activation it must later release. RTCAudioSession refcounts +// setActive, so a mismatched activate/release leaves the OS session stuck active. +// Touched only on the serial worker thread; atomic as insurance against future +// cross-thread reads. +@property(atomic, assign) BOOL autoSessionHoldsActivation; + @end @implementation AudioDeviceModuleObserver @@ -110,7 +122,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 +197,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 +296,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 +462,196 @@ - (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: +// - configures on every active state, +// - activates when the session is not +// - optionally deactivates on stop, +// - 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..773a99482 100644 --- a/src/AudioDeviceModule.ts +++ b/src/AudioDeviceModule.ts @@ -25,11 +25,94 @@ 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` is applied while only playout is enabled, + * - `deactivateOnStop` determines whether the session is deactivated when + * neither recording nor playout is enabled. + */ +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. + * + * 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. + * + * Clearing a `deactivateOnStop: false` policy while the engine is already + * stopped (playout and recording both disabled) keeps the session activation + * held until the next engine cycle, because no callback fires in between. + * For an immediate release, stop under `deactivateOnStop: true` before + * clearing. + */ + 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, };