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. Deprecate the unsafe `onConfigureNativeAudio` callback form of `setupIOSAudioManagement`. Developers can instead pass in a static `IOSAudioSessionPolicy`.
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
183 changes: 166 additions & 17 deletions src/audio/AudioManager.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,196 @@
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;
isRecordingEnabled: boolean;
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.
*
* 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.
*
* 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({

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

View workflow job for this annotation

GitHub Actions / test

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

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

View workflow job for this annotation

GitHub Actions / test

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

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,
Expand All @@ -58,9 +209,7 @@
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) {
Expand Down Expand Up @@ -106,14 +255,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 260 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
23 changes: 14 additions & 9 deletions src/audio/AudioManagerLegacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]);
}
Expand Down
8 changes: 4 additions & 4 deletions src/audio/AudioSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading