Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/native-default-audio-session.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 11 additions & 11 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2862,7 +2862,7 @@ SPEC CHECKSUMS:
RNCAsyncStorage: 01e4301688a611936e3644746ffe3325d3181952
RNScreens: 0bbf16c074ae6bb1058a7bf2d1ae017f4306797c
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
WebRTC-SDK: ac5965a3cc2c258973466e3b1739ab0308bab0d0
WebRTC-SDK: 1d7fc2ae91da6edd63e25f3882ff24f86a8d9d8b
Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb

PODFILE CHECKSUM: a921a659e37cd7dfb12da519493e10c9f38400a1
Expand Down
102 changes: 92 additions & 10 deletions src/audio/AudioManager.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,23 +13,62 @@
};

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.
*
* Call this once at app startup (e.g. in index.js). `registerGlobals()`
* 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,
Expand All @@ -38,8 +80,48 @@
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({

Check failure on line 95 in src/audio/AudioManager.ts

View workflow job for this annotation

GitHub Actions / test

Property 'setAutomaticAudioSessionConfiguration' does not exist on type 'typeof AudioDeviceModule'.
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);

Check failure on line 116 in src/audio/AudioManager.ts

View workflow job for this annotation

GitHub Actions / test

Property 'setAutomaticAudioSessionConfiguration' does not exist on type 'typeof AudioDeviceModule'.
});
}

// 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,
Expand Down Expand Up @@ -106,14 +188,14 @@
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);

Check failure on line 193 in src/audio/AudioManager.ts

View workflow job for this annotation

GitHub Actions / test

Property 'setAutomaticAudioSessionConfiguration' does not exist on type 'typeof AudioDeviceModule'.

return finalizeAudioManagement(setupToken, () => {
audioDeviceModuleEvents.setWillEnableEngineHandler(null);
audioDeviceModuleEvents.setDidDisableEngineHandler(null);
};
});
}

// Kept in sync with `getDefaultAppleAudioConfigurationForMode` in
Expand Down
Loading