From 3da6a60729194b5168aba3c86766f9630ed4a8f3 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:30:17 +0900 Subject: [PATCH 1/5] feat: configure the iOS audio session natively in the default path setupIOSAudioManagement's default path now pushes the audio-session policy to native via AudioDeviceModule.setAutomaticAudioSessionConfiguration() instead of registering willEnable/didDisable JS handlers, so the audio engine's worker thread no longer round-trips to JS in the stock path. That round trip could stall or deadlock engine operations when the JS thread was itself blocked in a synchronous bridge call. Custom configurations (onConfigureNativeAudio) keep the JS handler path, still bounded by the native wait timeout. Repeated setup calls and switches between the default and custom paths are safe: each setup supersedes the previous one by claiming a token, stale cleanup functions become no-ops, and each path arms its own mechanism before tearing down the other's so the engine hooks stay owned throughout a switch. --- src/audio/AudioManager.ts | 81 ++++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index cddf3bf..9c2f99e 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. * @@ -38,8 +61,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 +169,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 From 3313d9d0e6958c0b9a49cd6ec07b0ed2f3345841 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:56:21 +0900 Subject: [PATCH 2/5] docs: document the onConfigureNativeAudio contract and path switching Spell out that the custom callback runs inside the audio engine's lifecycle callbacks under a bounded native wait, so it must return quickly and must not call into the WebRTC engine or a peer connection. Doing so can block on the very engine operation the callback is holding up, stalling it until the wait times out. Also note that the default path involves no JavaScript per transition, that repeated setup calls supersede the previous one, and that switching paths is best done while disconnected. --- src/audio/AudioManager.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 9c2f99e..4f4358f 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -47,9 +47,27 @@ function finalizeAudioManagement( * 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. Prefer switching while disconnected. + * A switch during an active call only takes full effect from the next audio + * engine transition onward. + * * @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, From b7c8e8f50e4c275522d2eb1446fe5aa4792b66c2 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:15:54 +0900 Subject: [PATCH 3/5] chore(example): sync Podfile.lock with WebRTC-SDK 144.7559.10 --- example/ios/Podfile.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 243c961..51af560 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 From da3d1c5db3652cb9e734e828e9d7ce415a1ed067 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:07:05 +0900 Subject: [PATCH 4/5] chore: add changeset --- .changeset/native-default-audio-session.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/native-default-audio-session.md diff --git a/.changeset/native-default-audio-session.md b/.changeset/native-default-audio-session.md new file mode 100644 index 0000000..e63ba6a --- /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 From 80f04c52e912bc0c14d29ceb3bbdc6b56c74c42a Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:47:38 +0900 Subject: [PATCH 5/5] docs: mid-call path switching is unsupported, not merely delayed The previous wording claimed a switch during an active call takes full effect from the next engine transition. That undersold the failure mode: switching away from a custom setup mid-call abandons the audio session activation its handlers took, and with the shared activation count nobody releases it, so the session can stay active after the call ends. State it as unsupported with the concrete consequence. --- src/audio/AudioManager.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 4f4358f..0f1ad37 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -60,9 +60,10 @@ function finalizeAudioManagement( * would stall until the native wait times out. * * Calling this again replaces the previous setup, including switching - * between the default and custom paths. Prefer switching while disconnected. - * A switch during an active call only takes full effect from the next audio - * engine transition onward. + * 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.