diff --git a/.changeset/native-default-audio-session.md b/.changeset/native-default-audio-session.md new file mode 100644 index 00000000..11cff52f --- /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. Deprecate the unsafe `onConfigureNativeAudio` callback form of `setupIOSAudioManagement`. Developers can instead pass in a static `IOSAudioSessionPolicy`. diff --git a/README.md b/README.md index 3bc8c7e2..38653824 100644 --- a/README.md +++ b/README.md @@ -353,19 +353,26 @@ If your app manages `AVAudioSession` directly, disable the automatic setup: registerGlobals({ autoConfigureAudioSession: false }); ``` -For custom automatic behavior, disable the default setup and call -`setupIOSAudioManagement` once during app startup: +For custom categories/modes, disable the default setup and pass a static +policy to `setupIOSAudioManagement` once during app startup: ```js import { registerGlobals, setupIOSAudioManagement } from '@livekit/react-native'; registerGlobals({ autoConfigureAudioSession: false }); -const cleanup = setupIOSAudioManagement(true, (state) => ({ - audioCategory: state.isRecordingEnabled ? 'playAndRecord' : 'playback', - audioCategoryOptions: ['mixWithOthers'], - audioMode: state.preferSpeakerOutput ? 'videoChat' : 'voiceChat', -})); +const cleanup = setupIOSAudioManagement(true, { + recording: { + audioCategory: 'playAndRecord', + audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'], + audioMode: 'videoChat', + }, + playout: { + audioCategory: 'playback', + audioCategoryOptions: ['mixWithOthers'], + audioMode: 'spokenAudio', + }, +}); ``` The older `useIOSAudioManagement(room, ...)` hook is still exported for 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..05e0c4bc 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; @@ -9,14 +12,55 @@ export type AudioEngineConfigurationState = { preferSpeakerOutput: boolean; }; +/** + * Static recording/playout policy for {@link setupIOSAudioManagement}. + */ +export type IOSAudioSessionPolicy = { + recording: AppleAudioConfiguration; + playout: AppleAudioConfiguration; + /** + * Whether to deactivate the audio session when both playout and recording + * are disabled. Defaults to true. + */ + deactivateOnStop?: boolean; +}; + +/** + * @deprecated Unsafe. Prefer {@link IOSAudioSessionPolicy} or the default + * native path. See {@link setupIOSAudioManagement}. + */ +export type OnConfigureNativeAudio = ( + configurationState: AudioEngineConfigurationState +) => AppleAudioConfiguration; + 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,22 +68,129 @@ 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. + * + * For custom categories/modes, pass a static {@link IOSAudioSessionPolicy}. + * That policy is pushed to native once and applied on the audio worker thread. + * + * 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 callback setup mid-call abandons the audio session activation that + * the custom handlers took, so the session may 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?: boolean +): CleanupFn; +/** + * Sets up automatic iOS audio session management with a static native policy. + * + * @param preferSpeakerOutput - Unused when a full policy is provided; kept for + * call-site consistency with the default overload. + * @param policy - Static recording/playout configuration applied natively. + * @see {@link setupIOSAudioManagement} for the default native path and usage notes. */ +export function setupIOSAudioManagement( + preferSpeakerOutput: boolean, + policy: IOSAudioSessionPolicy +): CleanupFn; +/** + * @deprecated The `onConfigureNativeAudio` callback is unsafe. It runs inside + * the audio engine's lifecycle callbacks while native code waits for the + * result (bounded at a few seconds). The callback must return quickly and + * 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. + * Prefer the default native path, or pass an {@link IOSAudioSessionPolicy}. + */ +export function setupIOSAudioManagement( + preferSpeakerOutput: boolean, + onConfigureNativeAudio: OnConfigureNativeAudio +): CleanupFn; export function setupIOSAudioManagement( preferSpeakerOutput = true, - onConfigureNativeAudio?: ( - configurationState: AudioEngineConfigurationState - ) => AppleAudioConfiguration + policyOrCallback?: IOSAudioSessionPolicy | OnConfigureNativeAudio ): CleanupFn { if (Platform.OS !== 'ios') { 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; + + if (typeof policyOrCallback === 'function') { + return setupCustomCallbackPath( + setupToken, + preferSpeakerOutput, + policyOrCallback + ); + } + + return setupNativePolicyPath(setupToken, preferSpeakerOutput, policyOrCallback); +} + +function setupNativePolicyPath( + setupToken: object, + preferSpeakerOutput: boolean, + policy?: IOSAudioSessionPolicy +): CleanupFn { + // 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 when requested. + AudioDeviceModule.setAutomaticAudioSessionConfiguration({ + recording: + policy?.recording ?? + getDefaultAppleAudioConfigurationForAudioState({ + isPlayoutEnabled: true, + isRecordingEnabled: true, + preferSpeakerOutput, + }), + playout: + policy?.playout ?? + getDefaultAppleAudioConfigurationForAudioState({ + isPlayoutEnabled: true, + isRecordingEnabled: false, + preferSpeakerOutput, + }), + deactivateOnStop: policy?.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); + }); +} + +function setupCustomCallbackPath( + setupToken: object, + preferSpeakerOutput: boolean, + onConfigureNativeAudio: OnConfigureNativeAudio +): CleanupFn { + log.warn( + 'setupIOSAudioManagement(onConfigureNativeAudio) is deprecated and unsafe. ' + + 'Prefer the default native path, or pass an IOSAudioSessionPolicy object.' + ); + + // 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, @@ -58,9 +209,7 @@ export function setupIOSAudioManagement( log.info('AudioSession deactivating...'); await AudioSession.stopAudioSession(); } else if (newState.isRecordingEnabled || newState.isPlayoutEnabled) { - const config = onConfigureNativeAudio - ? onConfigureNativeAudio(newState) - : getDefaultAppleAudioConfigurationForAudioState(newState); + const config = onConfigureNativeAudio(newState); log.info('AudioSession configuring category:', config.audioCategory); await AudioSession.setAppleAudioConfiguration(config); if (!oldState.isPlayoutEnabled && !oldState.isRecordingEnabled) { @@ -106,14 +255,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 diff --git a/src/audio/AudioManagerLegacy.ts b/src/audio/AudioManagerLegacy.ts index 2d815299..b1a69264 100644 --- a/src/audio/AudioManagerLegacy.ts +++ b/src/audio/AudioManagerLegacy.ts @@ -20,12 +20,16 @@ import { * The `room` parameter is ignored — audio session is now managed * via audio engine events, not room track counts. * + * The `onConfigureNativeAudio` callback is also deprecated and unsafe: it + * runs under a bounded native wait on the audio worker thread. Prefer the + * default native path, or pass an `IOSAudioSessionPolicy` to + * `setupIOSAudioManagement`. + * * Note: the `trackState` passed to `onConfigureNativeAudio` is now * derived from the audio engine's playout/recording state, not from * publication counts. Edge cases can differ. For example, a * published-but-muted local audio track that previously yielded - * `localOnly` may now appear as `remoteOnly` or `none`. Callers with - * nuanced per-state logic should migrate to `setupIOSAudioManagement`. + * `localOnly` may now appear as `remoteOnly` or `none`. */ export function useIOSAudioManagement( _room: Room, @@ -43,19 +47,20 @@ export function useIOSAudioManagement( callbackRef.current = onConfigureNativeAudio; useEffect(() => { + // Without a custom callback, use the safe native default path. + if (!callbackRef.current) { + return setupIOSAudioManagement(preferSpeakerOutput); + } + const wrapped = ( state: AudioEngineConfigurationState ): AppleAudioConfiguration => { - const cb = callbackRef.current; + const cb = callbackRef.current!; const trackState = engineStateToTrackState(state); - return cb - ? cb(trackState, state.preferSpeakerOutput) - : getDefaultAppleAudioConfigurationForMode( - trackState, - state.preferSpeakerOutput - ); + return cb(trackState, state.preferSpeakerOutput); }; + // setupIOSAudioManagement warns that the callback form is deprecated. return setupIOSAudioManagement(preferSpeakerOutput, wrapped); }, [preferSpeakerOutput]); } diff --git a/src/audio/AudioSession.ts b/src/audio/AudioSession.ts index 0169fd71..56916c6d 100644 --- a/src/audio/AudioSession.ts +++ b/src/audio/AudioSession.ts @@ -199,10 +199,10 @@ export type AppleAudioConfiguration = { /** * @deprecated Retained only for the legacy `useIOSAudioManagement` hook and - * `getDefaultAppleAudioConfigurationForMode`. New code should pass an - * `onConfigureNativeAudio` callback to `setupIOSAudioManagement`, which - * receives an `AudioEngineConfigurationState` (playout/recording/speaker - * booleans). That shape has no direct `AudioTrackState` equivalent. + * `getDefaultAppleAudioConfigurationForMode`. New code should use + * `setupIOSAudioManagement` (optionally with an `IOSAudioSessionPolicy`). + * That API is driven by playout/recording engine state, which has no direct + * `AudioTrackState` equivalent. */ export type AudioTrackState = | 'none'