diff --git a/.changeset/native-default-audio-session.md b/.changeset/native-default-audio-session.md new file mode 100644 index 00000000..e63ba6a3 --- /dev/null +++ b/.changeset/native-default-audio-session.md @@ -0,0 +1,5 @@ +--- +'@livekit/react-native': minor +--- + +Configure the iOS audio session natively in the default path, removing the audio engine's JS round trip and its deadlock window diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 243c9610..51af5601 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -5,10 +5,10 @@ PODS: - FBLazyVector (0.82.1) - fmt (11.0.2) - glog (0.3.5) - - hermes-engine (0.82.0): - - hermes-engine/Pre-built (= 0.82.0) - - hermes-engine/Pre-built (0.82.0) - - livekit-react-native (2.11.0-beta.1): + - hermes-engine (0.82.1): + - hermes-engine/Pre-built (= 0.82.1) + - hermes-engine/Pre-built (0.82.1) + - livekit-react-native (2.11.0): - boost - DoubleConversion - fast_float @@ -39,7 +39,7 @@ PODS: - Yoga - livekit-react-native-webrtc (144.1.0): - React-Core - - WebRTC-SDK (= 144.7559.04) + - WebRTC-SDK (= 144.7559.10) - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -2530,7 +2530,7 @@ PODS: - SocketRocket - Yoga - SocketRocket (0.7.1) - - WebRTC-SDK (144.7559.04) + - WebRTC-SDK (144.7559.10) - Yoga (0.0.0) DEPENDENCIES: @@ -2789,10 +2789,10 @@ SPEC CHECKSUMS: FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 8642d8f14a548ab718ec112e9bebdfdd154138b5 - livekit-react-native: a051e6aa4c21e1b2429d136a3d544ae0ccfa08a6 - livekit-react-native-webrtc: 6384bffff182166749ea42ccc419cee6785b0603 - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 + livekit-react-native: b8a86fb1c57270437f8a010437c0570585a13625 + livekit-react-native-webrtc: 9add5324b1c13f41577dffc01c2388d8e84d8348 + RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a RCTRequired: e2c574c1b45231f7efb0834936bd609d75072b63 RCTTypeSafety: c693294e3993056955c3010eb1ebc574f1fcded6 @@ -2862,7 +2862,7 @@ SPEC CHECKSUMS: RNCAsyncStorage: 01e4301688a611936e3644746ffe3325d3181952 RNScreens: 0bbf16c074ae6bb1058a7bf2d1ae017f4306797c SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - WebRTC-SDK: ac5965a3cc2c258973466e3b1739ab0308bab0d0 + WebRTC-SDK: 1d7fc2ae91da6edd63e25f3882ff24f86a8d9d8b Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb PODFILE CHECKSUM: a921a659e37cd7dfb12da519493e10c9f38400a1 diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index cddf3bf7..0f1ad372 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -1,7 +1,10 @@ import { Platform } from 'react-native'; import AudioSession, { type AppleAudioConfiguration } from './AudioSession'; import { log } from '../logger'; -import { audioDeviceModuleEvents } from '@livekit/react-native-webrtc'; +import { + audioDeviceModuleEvents, + AudioDeviceModule, +} from '@livekit/react-native-webrtc'; export type AudioEngineConfigurationState = { isPlayoutEnabled: boolean; @@ -10,13 +13,33 @@ export type AudioEngineConfigurationState = { }; const kAudioEngineErrorFailedToConfigureAudioSession = -4100; -let activeAudioManagementSetup: object | undefined; +let activeSetupToken: object | undefined; /** * @inline */ type CleanupFn = () => void; +// Wraps a path-specific teardown so each setup supersedes the previous one +// cleanly: the returned cleanup tears down only while its setup is still the +// active one, so it runs at most once and a stale cleanup from a superseded +// setup is a no-op. A new setup defuses the previous cleanup by replacing the +// token rather than by running its teardown, because each path's body already +// replaces the other path's mechanism in an order that never leaves the hooks +// unowned - running the old teardown first would reopen exactly that window. +function finalizeAudioManagement( + token: object, + teardown: CleanupFn +): CleanupFn { + return () => { + if (activeSetupToken !== token) { + return; + } + activeSetupToken = undefined; + teardown(); + }; +} + /** * Sets up automatic iOS audio session management based on audio engine state. * @@ -24,9 +47,28 @@ type CleanupFn = () => void; * invokes it for you by default unless `autoConfigureAudioSession: false` * is passed. * + * By default the audio session is configured and activated natively as the + * audio engine changes state, with no JavaScript involvement per transition. + * + * When `onConfigureNativeAudio` is provided, it runs inside the audio + * engine's lifecycle callbacks while native code waits for the result, with + * the wait bounded at a few seconds per callback. The callback must return + * quickly and should only derive the configuration to apply. It must not + * call APIs that enter the WebRTC engine or a peer connection (for example + * `addTransceiver`, `getUserMedia`, or device enumeration): those can block + * on the same engine operation the callback is holding up, and the operation + * would stall until the native wait times out. + * + * Calling this again replaces the previous setup, including switching + * between the default and custom paths. Switch while disconnected: a switch + * during an active call is unsupported. In particular, switching away from a + * custom setup mid-call abandons the audio session activation that the custom + * handlers took, so the session can stay active after the call ends. + * * @param preferSpeakerOutput - Whether to prefer speaker output. Defaults to true. * @param onConfigureNativeAudio - Optional custom callback for determining audio configuration. - * @returns A cleanup function that removes the event handlers. + * @returns A cleanup function that removes the installed handlers or native + * configuration. A cleanup function from a superseded setup is a no-op. */ export function setupIOSAudioManagement( preferSpeakerOutput = true, @@ -38,8 +80,48 @@ export function setupIOSAudioManagement( return () => {}; } + // Supersede any previous setup (safe to call repeatedly, and to switch between + // default and custom). Claiming the token defuses the previous cleanup without + // running it. The path bodies below then replace the other path's mechanism in + // an order that keeps the hooks owned throughout the switch. const setupToken = {}; - activeAudioManagementSetup = setupToken; + activeSetupToken = setupToken; + + // Default path: configure the AVAudioSession natively so the engine's + // worker thread never round-trips to JS in willEnable/didDisable - that round + // trip is what can deadlock. The native observer applies `recording` while + // recording, `playout` while playout-only, and deactivates on full stop. + if (!onConfigureNativeAudio) { + AudioDeviceModule.setAutomaticAudioSessionConfiguration({ + recording: getDefaultAppleAudioConfigurationForAudioState({ + isPlayoutEnabled: true, + isRecordingEnabled: true, + preferSpeakerOutput, + }), + playout: getDefaultAppleAudioConfigurationForAudioState({ + isPlayoutEnabled: true, + isRecordingEnabled: false, + preferSpeakerOutput, + }), + deactivateOnStop: true, + }); + + // Set native config first, then clear any handlers a prior custom setup left + // registered, so native (not a stale JS handler) owns the hooks. In the brief + // overlap a still-registered handler wins, so a racing callback is never dropped. + audioDeviceModuleEvents.setWillEnableEngineHandler(null); + audioDeviceModuleEvents.setDidDisableEngineHandler(null); + + return finalizeAudioManagement(setupToken, () => { + AudioDeviceModule.setAutomaticAudioSessionConfiguration(null); + }); + } + + // Custom path: derive + apply the session config in JS via the engine handlers + // (still bounded by the native 2s wait). The native default is cleared *after* + // the handlers are registered (below) so the JS handler, which takes precedence, + // owns the hooks throughout the switch. + let audioEngineState: AudioEngineConfigurationState = { isPlayoutEnabled: false, isRecordingEnabled: false, @@ -106,14 +188,14 @@ export function setupIOSAudioManagement( audioDeviceModuleEvents.setWillEnableEngineHandler(handleEngineStateUpdate); audioDeviceModuleEvents.setDidDisableEngineHandler(handleEngineStateUpdate); - return () => { - if (activeAudioManagementSetup !== setupToken) { - return; - } - activeAudioManagementSetup = undefined; + // Handlers are live now, so clear the native default - the JS handler takes + // precedence, so there is never a window where neither path is active. + AudioDeviceModule.setAutomaticAudioSessionConfiguration(null); + + return finalizeAudioManagement(setupToken, () => { audioDeviceModuleEvents.setWillEnableEngineHandler(null); audioDeviceModuleEvents.setDidDisableEngineHandler(null); - }; + }); } // Kept in sync with `getDefaultAppleAudioConfigurationForMode` in