From 8ee458284929077f9ebdee52bb4e7315711fe821 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:50:19 +0900 Subject: [PATCH 1/9] migrate from track counting to event based --- example/index.js | 4 +- example/src/RoomPage.tsx | 2 - ios/LiveKitReactNativeModule.swift | 2 +- src/audio/AudioManager.ts | 177 ++++++++++------------------- src/audio/AudioSession.ts | 31 ----- 5 files changed, 65 insertions(+), 151 deletions(-) diff --git a/example/index.js b/example/index.js index 231dc300..f6d69392 100644 --- a/example/index.js +++ b/example/index.js @@ -1,7 +1,7 @@ import { AppRegistry } from 'react-native'; import App from './src/App'; import { name as appName } from './app.json'; -import { registerGlobals, setLogLevel } from '@livekit/react-native'; +import { registerGlobals, setLogLevel, useIOSAudioManagement } from '@livekit/react-native'; import { LogLevel } from 'livekit-client'; import { setupErrorLogHandler } from './src/utils/ErrorLogHandler'; import { setupCallService } from './src/callservice/CallService'; @@ -16,3 +16,5 @@ setupCallService(); // Required React-Native setup for app registerGlobals(); AppRegistry.registerComponent(appName, () => App); + +useIOSAudioManagement(); diff --git a/example/src/RoomPage.tsx b/example/src/RoomPage.tsx index b114f5b3..cdadec45 100644 --- a/example/src/RoomPage.tsx +++ b/example/src/RoomPage.tsx @@ -106,8 +106,6 @@ const RoomView = ({ navigation, e2ee }: RoomViewProps) => { return () => {}; }, [room, e2ee]); - useIOSAudioManagement(room, true); - // Setup room listeners useEffect(() => { room.registerTextStreamHandler('lk.chat', async (reader, participant) => { diff --git a/ios/LiveKitReactNativeModule.swift b/ios/LiveKitReactNativeModule.swift index b3c3ef47..feb6d415 100644 --- a/ios/LiveKitReactNativeModule.swift +++ b/ios/LiveKitReactNativeModule.swift @@ -29,7 +29,7 @@ public class LivekitReactNativeModule: RCTEventEmitter { super.init() let config = RTCAudioSessionConfiguration() config.category = AVAudioSession.Category.playAndRecord.rawValue - config.categoryOptions = [.allowAirPlay, .allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker] + config.categoryOptions = [.allowAirPlay, .allowBluetoothHFP, .allowBluetoothA2DP, .defaultToSpeaker] config.mode = AVAudioSession.Mode.videoChat.rawValue RTCAudioSessionConfiguration.setWebRTC(config) diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index f227bd7c..708341c6 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -1,141 +1,86 @@ -import { useState, useEffect, useMemo } from 'react'; -import { Platform } from 'react-native'; -import { - RoomEvent, - Room, - type LocalTrackPublication, - type RemoteTrackPublication, -} from 'livekit-client'; import AudioSession, { - getDefaultAppleAudioConfigurationForMode, type AppleAudioConfiguration, - type AudioTrackState, } from './AudioSession'; import { log } from '..'; +import { audioDeviceModuleEvents } from '@livekit/react-native-webrtc'; + +export type AudioEngineConfigurationState = { + isPlayoutEnabled: boolean; + isRecordingEnabled: boolean; + preferSpeakerOutput: boolean; +}; /** * Handles setting the appropriate AVAudioSession options automatically * depending on the audio track states of the Room. * - * @param room * @param preferSpeakerOutput * @param onConfigureNativeAudio A custom method for determining options used. */ export function useIOSAudioManagement( - room: Room, - preferSpeakerOutput: boolean = true, - onConfigureNativeAudio?: ( - trackState: AudioTrackState, - preferSpeakerOutput: boolean - ) => AppleAudioConfiguration + preferSpeakerOutput = true, + onConfigureNativeAudio?: (configurationState: AudioEngineConfigurationState) => AppleAudioConfiguration ) { - const [localTrackCount, setLocalTrackCount] = useState(0); - const [remoteTrackCount, setRemoteTrackCount] = useState(0); - const trackState = useMemo( - () => computeAudioTrackState(localTrackCount, remoteTrackCount), - [localTrackCount, remoteTrackCount] - ); - - useEffect(() => { - let recalculateTrackCounts = () => { - setLocalTrackCount(getLocalAudioTrackCount(room)); - setRemoteTrackCount(getRemoteAudioTrackCount(room)); - }; - - recalculateTrackCounts(); - - room.on(RoomEvent.Connected, recalculateTrackCounts); - - return () => { - room.off(RoomEvent.Connected, recalculateTrackCounts); - }; - }, [room]); - useEffect(() => { - if (Platform.OS !== 'ios') { - return () => {}; - } + let audioEngineState: AudioEngineConfigurationState = { + isPlayoutEnabled: false, + isRecordingEnabled: false, + preferSpeakerOutput: preferSpeakerOutput, + }; - let onLocalPublished = (publication: LocalTrackPublication) => { - if (publication.kind === 'audio') { - setLocalTrackCount(localTrackCount + 1); - } - }; - let onLocalUnpublished = (publication: LocalTrackPublication) => { - if (publication.kind === 'audio') { - if (localTrackCount - 1 < 0) { - log.warn( - 'mismatched local audio track count! attempted to reduce track count below zero.' - ); - } - setLocalTrackCount(Math.max(localTrackCount - 1, 0)); - } - }; - let onRemotePublished = (publication: RemoteTrackPublication) => { - if (publication.kind === 'audio') { - setRemoteTrackCount(remoteTrackCount + 1); + const tryConfigure = async (newState: AudioEngineConfigurationState, oldState: AudioEngineConfigurationState) => { + if ((!newState.isPlayoutEnabled && !newState.isRecordingEnabled) && (oldState.isPlayoutEnabled || oldState.isRecordingEnabled)) { + log.info("AudioSession deactivating...") + await AudioSession.stopAudioSession() + } else if (newState.isRecordingEnabled || newState.isPlayoutEnabled) { + const config = onConfigureNativeAudio ? onConfigureNativeAudio(newState) : getDefaultAppleAudioConfigurationForAudioState(newState); + log.info("AudioSession configuring category:", config.audioCategory) + await AudioSession.setAppleAudioConfiguration(config) + if (!oldState.isPlayoutEnabled && !oldState.isRecordingEnabled) { + log.info("AudioSession activating...") + await AudioSession.startAudioSession() } - }; - let onRemoteUnpublished = (publication: RemoteTrackPublication) => { - if (publication.kind === 'audio') { - if (remoteTrackCount - 1 < 0) { - log.warn( - 'mismatched remote audio track count! attempted to reduce track count below zero.' - ); - } - setRemoteTrackCount(Math.max(remoteTrackCount - 1, 0)); - } - }; - - room - .on(RoomEvent.LocalTrackPublished, onLocalPublished) - .on(RoomEvent.LocalTrackUnpublished, onLocalUnpublished) - .on(RoomEvent.TrackPublished, onRemotePublished) - .on(RoomEvent.TrackUnpublished, onRemoteUnpublished); + } + }; - return () => { - room - .off(RoomEvent.LocalTrackPublished, onLocalPublished) - .off(RoomEvent.LocalTrackUnpublished, onLocalUnpublished) - .off(RoomEvent.TrackPublished, onRemotePublished) - .off(RoomEvent.TrackUnpublished, onRemoteUnpublished); + const handleEngineStateUpdate = async ({ isPlayoutEnabled, isRecordingEnabled }: { isPlayoutEnabled: boolean, isRecordingEnabled: boolean }) => { + const oldState = audioEngineState; + const newState = { + isPlayoutEnabled, + isRecordingEnabled, + preferSpeakerOutput: audioEngineState.preferSpeakerOutput, }; - }, [room, localTrackCount, remoteTrackCount]); - useEffect(() => { - if (Platform.OS !== 'ios') { - return; - } + // If this throws, the audio engine will not continue it's operation + await tryConfigure(newState, oldState); + // Update the audio state only if configure succeeds + audioEngineState = newState; + }; - let configFunc = - onConfigureNativeAudio ?? getDefaultAppleAudioConfigurationForMode; - let audioConfig = configFunc(trackState, preferSpeakerOutput); - AudioSession.setAppleAudioConfiguration(audioConfig); - }, [trackState, onConfigureNativeAudio, preferSpeakerOutput]); + // Attach audio engine events + audioDeviceModuleEvents.setWillEnableEngineHandler(handleEngineStateUpdate); + audioDeviceModuleEvents.setDidDisableEngineHandler(handleEngineStateUpdate); } -function computeAudioTrackState( - localTracks: number, - remoteTracks: number -): AudioTrackState { - if (localTracks > 0 && remoteTracks > 0) { - return 'localAndRemote'; - } else if (localTracks > 0 && remoteTracks === 0) { - return 'localOnly'; - } else if (localTracks === 0 && remoteTracks > 0) { - return 'remoteOnly'; - } else { - return 'none'; +function getDefaultAppleAudioConfigurationForAudioState( + configurationState: AudioEngineConfigurationState, +): AppleAudioConfiguration { + if (configurationState.isRecordingEnabled) { + return { + audioCategory: 'playAndRecord', + audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'], + audioMode: configurationState.preferSpeakerOutput ? 'videoChat' : 'voiceChat', + }; + } else if (configurationState.isPlayoutEnabled) { + return { + audioCategory: 'playback', + audioCategoryOptions: ['mixWithOthers'], + audioMode: 'spokenAudio', + }; } -} - -function getLocalAudioTrackCount(room: Room): number { - return room.localParticipant.audioTrackPublications.size; -} -function getRemoteAudioTrackCount(room: Room): number { - var audioTracks = 0; - room.remoteParticipants.forEach((participant) => { - audioTracks += participant.audioTrackPublications.size; - }); - return audioTracks; + return { + audioCategory: 'soloAmbient', + audioCategoryOptions: [], + audioMode: 'default', + }; } diff --git a/src/audio/AudioSession.ts b/src/audio/AudioSession.ts index ed3b9a9c..83bba8cc 100644 --- a/src/audio/AudioSession.ts +++ b/src/audio/AudioSession.ts @@ -197,37 +197,6 @@ export type AppleAudioConfiguration = { audioMode?: AppleAudioMode; }; -export type AudioTrackState = - | 'none' - | 'remoteOnly' - | 'localOnly' - | 'localAndRemote'; - -export function getDefaultAppleAudioConfigurationForMode( - mode: AudioTrackState, - preferSpeakerOutput: boolean = true -): AppleAudioConfiguration { - if (mode === 'remoteOnly') { - return { - audioCategory: 'playback', - audioCategoryOptions: ['mixWithOthers'], - audioMode: 'spokenAudio', - }; - } else if (mode === 'localAndRemote' || mode === 'localOnly') { - return { - audioCategory: 'playAndRecord', - audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'], - audioMode: preferSpeakerOutput ? 'videoChat' : 'voiceChat', - }; - } - - return { - audioCategory: 'soloAmbient', - audioCategoryOptions: [], - audioMode: 'default', - }; -} - export default class AudioSession { /** * Applies the provided audio configuration to the underlying AudioSession. From 388b144f1fc5e7c1662c5143693bdc45408c32ab Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:42:06 +0900 Subject: [PATCH 2/9] port --- src/components/VideoTrack.tsx | 7 +++++-- src/index.tsx | 16 +++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/VideoTrack.tsx b/src/components/VideoTrack.tsx index cd26f1c9..df360b78 100644 --- a/src/components/VideoTrack.tsx +++ b/src/components/VideoTrack.tsx @@ -22,7 +22,9 @@ import { useEffect, useMemo, useState, + type ForwardRefExoticComponent, type ReactNode, + type RefAttributes, } from 'react'; import { RemoteVideoTrack } from 'livekit-client'; import ViewPortDetector from './ViewPortDetector'; @@ -132,7 +134,9 @@ type RTCViewInstance = InstanceType; * @returns A React component that renders the given video track. * @public */ -export const VideoTrack = forwardRef( +export const VideoTrack: ForwardRefExoticComponent< + VideoTrackProps & RefAttributes +> = forwardRef( ( { style = {}, @@ -224,7 +228,6 @@ export const VideoTrack = forwardRef( objectFit={objectFit} zOrder={zOrder} mirror={mirror} - // TODO: fix this up in react-native-webrtc side. // @ts-expect-error iosPIP={iosPIP} ref={ref} diff --git a/src/index.tsx b/src/index.tsx index 7a13b434..290733d9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,13 @@ import 'well-known-symbols/Symbol.asyncIterator/auto'; import 'well-known-symbols/Symbol.iterator/auto'; import './polyfills/MediaRecorderShim'; -import { registerGlobals as webrtcRegisterGlobals } from '@livekit/react-native-webrtc'; +import { + registerGlobals as webrtcRegisterGlobals, + AudioDeviceModule, + AudioEngineMuteMode, + AudioEngineAvailability, + audioDeviceModuleEvents, +} from '@livekit/react-native-webrtc'; import { setupURLPolyfill } from 'react-native-url-polyfill'; import './polyfills/EncoderDecoderTogether.min.js'; import AudioSession, { @@ -11,8 +17,6 @@ import AudioSession, { type AppleAudioCategoryOption, type AppleAudioConfiguration, type AppleAudioMode, - type AudioTrackState, - getDefaultAppleAudioConfigurationForMode, } from './audio/AudioSession'; import type { AudioConfiguration } from './audio/AudioSession'; import { PixelRatio, Platform } from 'react-native'; @@ -164,10 +168,13 @@ export * from './audio/AudioManager'; export { AudioSession, + AudioDeviceModule, + AudioEngineMuteMode, + AudioEngineAvailability, + audioDeviceModuleEvents, RNE2EEManager, RNKeyProvider, AndroidAudioTypePresets, - getDefaultAppleAudioConfigurationForMode, }; export type { AudioConfiguration, @@ -176,7 +183,6 @@ export type { AppleAudioCategoryOption, AppleAudioConfiguration, AppleAudioMode, - AudioTrackState, LogLevel, SetLogLevelOptions, RNKeyProviderOptions, From 8c98afb375a8198381d80dadbf28c7627d497ddd Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:55:54 +0900 Subject: [PATCH 3/9] backward compatible --- example/index.js | 4 +- src/audio/AudioManager.ts | 79 ++++++++++++++++++++--------- src/audio/AudioManagerLegacy.ts | 89 +++++++++++++++++++++++++++++++++ src/audio/AudioSession.ts | 9 ++++ src/index.tsx | 3 ++ 5 files changed, 159 insertions(+), 25 deletions(-) create mode 100644 src/audio/AudioManagerLegacy.ts diff --git a/example/index.js b/example/index.js index f6d69392..f0cef846 100644 --- a/example/index.js +++ b/example/index.js @@ -1,7 +1,7 @@ import { AppRegistry } from 'react-native'; import App from './src/App'; import { name as appName } from './app.json'; -import { registerGlobals, setLogLevel, useIOSAudioManagement } from '@livekit/react-native'; +import { registerGlobals, setLogLevel, setupIOSAudioManagement } from '@livekit/react-native'; import { LogLevel } from 'livekit-client'; import { setupErrorLogHandler } from './src/utils/ErrorLogHandler'; import { setupCallService } from './src/callservice/CallService'; @@ -17,4 +17,4 @@ setupCallService(); registerGlobals(); AppRegistry.registerComponent(appName, () => App); -useIOSAudioManagement(); +setupIOSAudioManagement(); diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 708341c6..e17ffbc9 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -1,3 +1,4 @@ +import { Platform } from 'react-native'; import AudioSession, { type AppleAudioConfiguration, } from './AudioSession'; @@ -10,65 +11,97 @@ export type AudioEngineConfigurationState = { preferSpeakerOutput: boolean; }; +type CleanupFn = () => void; + /** - * Handles setting the appropriate AVAudioSession options automatically - * depending on the audio track states of the Room. + * Sets up automatic iOS audio session management based on audio engine state. + * + * Call this once at app startup (e.g. in index.js). For usage inside React + * components, use {@link useIOSAudioManagement} instead. * - * @param preferSpeakerOutput - * @param onConfigureNativeAudio A custom method for determining options used. + * @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. */ -export function useIOSAudioManagement( +export function setupIOSAudioManagement( preferSpeakerOutput = true, - onConfigureNativeAudio?: (configurationState: AudioEngineConfigurationState) => AppleAudioConfiguration -) { + onConfigureNativeAudio?: ( + configurationState: AudioEngineConfigurationState + ) => AppleAudioConfiguration +): CleanupFn { + if (Platform.OS !== 'ios') { + return () => {}; + } + let audioEngineState: AudioEngineConfigurationState = { isPlayoutEnabled: false, isRecordingEnabled: false, - preferSpeakerOutput: preferSpeakerOutput, + preferSpeakerOutput, }; - const tryConfigure = async (newState: AudioEngineConfigurationState, oldState: AudioEngineConfigurationState) => { - if ((!newState.isPlayoutEnabled && !newState.isRecordingEnabled) && (oldState.isPlayoutEnabled || oldState.isRecordingEnabled)) { - log.info("AudioSession deactivating...") - await AudioSession.stopAudioSession() + const tryConfigure = async ( + newState: AudioEngineConfigurationState, + oldState: AudioEngineConfigurationState + ) => { + if ( + !newState.isPlayoutEnabled && + !newState.isRecordingEnabled && + (oldState.isPlayoutEnabled || oldState.isRecordingEnabled) + ) { + log.info('AudioSession deactivating...'); + await AudioSession.stopAudioSession(); } else if (newState.isRecordingEnabled || newState.isPlayoutEnabled) { - const config = onConfigureNativeAudio ? onConfigureNativeAudio(newState) : getDefaultAppleAudioConfigurationForAudioState(newState); - log.info("AudioSession configuring category:", config.audioCategory) - await AudioSession.setAppleAudioConfiguration(config) + const config = onConfigureNativeAudio + ? onConfigureNativeAudio(newState) + : getDefaultAppleAudioConfigurationForAudioState(newState); + log.info('AudioSession configuring category:', config.audioCategory); + await AudioSession.setAppleAudioConfiguration(config); if (!oldState.isPlayoutEnabled && !oldState.isRecordingEnabled) { - log.info("AudioSession activating...") - await AudioSession.startAudioSession() + log.info('AudioSession activating...'); + await AudioSession.startAudioSession(); } } }; - const handleEngineStateUpdate = async ({ isPlayoutEnabled, isRecordingEnabled }: { isPlayoutEnabled: boolean, isRecordingEnabled: boolean }) => { + const handleEngineStateUpdate = async ({ + isPlayoutEnabled, + isRecordingEnabled, + }: { + isPlayoutEnabled: boolean; + isRecordingEnabled: boolean; + }) => { const oldState = audioEngineState; - const newState = { + const newState: AudioEngineConfigurationState = { isPlayoutEnabled, isRecordingEnabled, preferSpeakerOutput: audioEngineState.preferSpeakerOutput, }; - // If this throws, the audio engine will not continue it's operation + // If this throws, the audio engine will not continue its operation await tryConfigure(newState, oldState); // Update the audio state only if configure succeeds audioEngineState = newState; }; - // Attach audio engine events audioDeviceModuleEvents.setWillEnableEngineHandler(handleEngineStateUpdate); audioDeviceModuleEvents.setDidDisableEngineHandler(handleEngineStateUpdate); + + return () => { + audioDeviceModuleEvents.setWillEnableEngineHandler(null); + audioDeviceModuleEvents.setDidDisableEngineHandler(null); + }; } function getDefaultAppleAudioConfigurationForAudioState( - configurationState: AudioEngineConfigurationState, + configurationState: AudioEngineConfigurationState ): AppleAudioConfiguration { if (configurationState.isRecordingEnabled) { return { audioCategory: 'playAndRecord', audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'], - audioMode: configurationState.preferSpeakerOutput ? 'videoChat' : 'voiceChat', + audioMode: configurationState.preferSpeakerOutput + ? 'videoChat' + : 'voiceChat', }; } else if (configurationState.isPlayoutEnabled) { return { diff --git a/src/audio/AudioManagerLegacy.ts b/src/audio/AudioManagerLegacy.ts new file mode 100644 index 00000000..bfcc84f7 --- /dev/null +++ b/src/audio/AudioManagerLegacy.ts @@ -0,0 +1,89 @@ +/** + * Backward-compatible wrappers for the legacy AudioManager API. + * + * These exports preserve the old `useIOSAudioManagement(room, ...)` signature + * and the removed `getDefaultAppleAudioConfigurationForMode` function so that + * existing consumers continue to compile without changes. + * + * New code should use `setupIOSAudioManagement` from `./AudioManager` instead. + */ +import { useEffect } from 'react'; +import type { Room } from 'livekit-client'; +import type { AppleAudioConfiguration, AudioTrackState } from './AudioSession'; +import { + setupIOSAudioManagement, + type AudioEngineConfigurationState, +} from './AudioManager'; + +/** + * @deprecated Use {@link setupIOSAudioManagement} instead. + * The `room` parameter is ignored — audio session is now managed + * via audio engine events, not room track counts. + */ +export function useIOSAudioManagement( + room: Room, + preferSpeakerOutput: boolean = true, + onConfigureNativeAudio?: ( + trackState: AudioTrackState, + preferSpeakerOutput: boolean + ) => AppleAudioConfiguration +) { + useEffect(() => { + let wrappedOnConfig: + | ((state: AudioEngineConfigurationState) => AppleAudioConfiguration) + | undefined; + + if (onConfigureNativeAudio) { + const legacyCb = onConfigureNativeAudio; + wrappedOnConfig = (state: AudioEngineConfigurationState) => + legacyCb(engineStateToTrackState(state), state.preferSpeakerOutput); + } + + const cleanup = setupIOSAudioManagement( + preferSpeakerOutput, + wrappedOnConfig + ); + return cleanup; + }, [preferSpeakerOutput, onConfigureNativeAudio]); +} + +/** + * @deprecated Use the default behavior of `setupIOSAudioManagement` instead. + */ +export function getDefaultAppleAudioConfigurationForMode( + mode: AudioTrackState, + preferSpeakerOutput: boolean = true +): AppleAudioConfiguration { + if (mode === 'remoteOnly') { + return { + audioCategory: 'playback', + audioCategoryOptions: ['mixWithOthers'], + audioMode: 'spokenAudio', + }; + } else if (mode === 'localAndRemote' || mode === 'localOnly') { + return { + audioCategory: 'playAndRecord', + audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'], + audioMode: preferSpeakerOutput ? 'videoChat' : 'voiceChat', + }; + } + + return { + audioCategory: 'soloAmbient', + audioCategoryOptions: [], + audioMode: 'default', + }; +} + +function engineStateToTrackState( + state: AudioEngineConfigurationState +): AudioTrackState { + if (state.isRecordingEnabled && state.isPlayoutEnabled) { + return 'localAndRemote'; + } else if (state.isRecordingEnabled) { + return 'localOnly'; + } else if (state.isPlayoutEnabled) { + return 'remoteOnly'; + } + return 'none'; +} diff --git a/src/audio/AudioSession.ts b/src/audio/AudioSession.ts index 83bba8cc..ce910b6c 100644 --- a/src/audio/AudioSession.ts +++ b/src/audio/AudioSession.ts @@ -197,6 +197,15 @@ export type AppleAudioConfiguration = { audioMode?: AppleAudioMode; }; +/** + * @deprecated Use `AudioEngineConfigurationState` from `AudioManager` instead. + */ +export type AudioTrackState = + | 'none' + | 'remoteOnly' + | 'localOnly' + | 'localAndRemote'; + export default class AudioSession { /** * Applies the provided audio configuration to the underlying AudioSession. diff --git a/src/index.tsx b/src/index.tsx index 290733d9..e7ea846d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,7 @@ import AudioSession, { type AppleAudioCategoryOption, type AppleAudioConfiguration, type AppleAudioMode, + type AudioTrackState, } from './audio/AudioSession'; import type { AudioConfiguration } from './audio/AudioSession'; import { PixelRatio, Platform } from 'react-native'; @@ -165,6 +166,7 @@ export * from './useParticipant'; // deprecated export * from './useRoom'; // deprecated export * from './logger'; export * from './audio/AudioManager'; +export * from './audio/AudioManagerLegacy'; export { AudioSession, @@ -183,6 +185,7 @@ export type { AppleAudioCategoryOption, AppleAudioConfiguration, AppleAudioMode, + AudioTrackState, LogLevel, SetLogLevelOptions, RNKeyProviderOptions, From 2605f864b97bd0d0180e27f38e3ed63f5762949b Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:11:10 +0900 Subject: [PATCH 4/9] log --- src/audio/AudioManager.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index e17ffbc9..037b1b6a 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -77,8 +77,15 @@ export function setupIOSAudioManagement( preferSpeakerOutput: audioEngineState.preferSpeakerOutput, }; - // If this throws, the audio engine will not continue its operation - await tryConfigure(newState, oldState); + // If tryConfigure throws, the error propagates to the native audio engine + // observer which converts it to a non-zero error code, causing the engine + // to stop/rollback (matching the Swift SDK's error propagation pattern). + try { + await tryConfigure(newState, oldState); + } catch (error) { + log.error('AudioSession configuration failed, stopping audio engine:', error); + throw error; + } // Update the audio state only if configure succeeds audioEngineState = newState; }; From 9a048ab6de23526fa03882c1700f4270d4312106 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:16:57 +0900 Subject: [PATCH 5/9] error code --- src/audio/AudioManager.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 037b1b6a..4ee4c227 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -11,6 +11,8 @@ export type AudioEngineConfigurationState = { preferSpeakerOutput: boolean; }; +const kAudioEngineErrorFailedToConfigureAudioSession = -4100; + type CleanupFn = () => void; /** @@ -84,7 +86,10 @@ export function setupIOSAudioManagement( await tryConfigure(newState, oldState); } catch (error) { log.error('AudioSession configuration failed, stopping audio engine:', error); - throw error; + // Throw the error code so the native AudioDeviceModuleObserver returns it + // to the WebRTC engine, which will stop/rollback the operation. + // eslint-disable-next-line no-throw-literal + throw kAudioEngineErrorFailedToConfigureAudioSession; } // Update the audio state only if configure succeeds audioEngineState = newState; From 1a944f79d2c2169744dbee9f826ca3f5134e0730 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 31 Mar 2026 18:40:44 +0900 Subject: [PATCH 6/9] update to webrtc 144.1.0-beta.0 --- ci/ios/Podfile.lock | 8 ++++---- ci/package.json | 2 +- ci/yarn.lock | 10 +++++----- example/ios/Podfile.lock | 8 ++++---- example/package.json | 2 +- package.json | 4 ++-- src/audio/AudioManagerLegacy.ts | 2 +- yarn.lock | 14 +++++++------- 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/ci/ios/Podfile.lock b/ci/ios/Podfile.lock index 3b8b5d1c..8bdfa446 100644 --- a/ci/ios/Podfile.lock +++ b/ci/ios/Podfile.lock @@ -8,7 +8,7 @@ PODS: - hermes-engine (0.82.0): - hermes-engine/Pre-built (= 0.82.0) - hermes-engine/Pre-built (0.82.0) - - livekit-react-native (2.9.8): + - livekit-react-native (2.10.0): - boost - DoubleConversion - fast_float @@ -37,7 +37,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - livekit-react-native-webrtc (144.0.0): + - livekit-react-native-webrtc (144.1.0-beta.0): - React-Core - WebRTC-SDK (= 144.7559.01) - RCT-Folly (2024.11.18.00): @@ -2692,8 +2692,8 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 8642d8f14a548ab718ec112e9bebdfdd154138b5 - livekit-react-native: 16923c9c9cdf21c68cbac52f9a5222c9f05c2f06 - livekit-react-native-webrtc: a9a45c67543105a40192b144809513e5ab266d0a + livekit-react-native: 0d36ebbf20e663c7d1abb7079e1cf1237f3bcb02 + livekit-react-native-webrtc: 2023c6774526d44024e77f98e37d233f8df1a326 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a RCTRequired: e2c574c1b45231f7efb0834936bd609d75072b63 diff --git a/ci/package.json b/ci/package.json index 362acd2d..25bff5d3 100644 --- a/ci/package.json +++ b/ci/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@livekit/react-native": "*", - "@livekit/react-native-webrtc": "^144.0.0", + "@livekit/react-native-webrtc": "^144.1.0-beta.0", "@react-native/new-app-screen": "0.82.1", "livekit-client": "^2.15.8", "react": "19.1.1", diff --git a/ci/yarn.lock b/ci/yarn.lock index 0db52754..1d773ae2 100644 --- a/ci/yarn.lock +++ b/ci/yarn.lock @@ -1953,15 +1953,15 @@ __metadata: languageName: node linkType: hard -"@livekit/react-native-webrtc@npm:^144.0.0": - version: 144.0.0 - resolution: "@livekit/react-native-webrtc@npm:144.0.0" +"@livekit/react-native-webrtc@npm:^144.1.0-beta.0": + version: 144.1.0-beta.0 + resolution: "@livekit/react-native-webrtc@npm:144.1.0-beta.0" dependencies: base64-js: "npm:1.5.1" debug: "npm:4.3.4" peerDependencies: react-native: ">=0.60.0" - checksum: 10/d97454e8bcb5ab0ae98e8a9d134f8486276b12bdf993052b5b01ac3e8da375e63bb66c84061201bd4499266afbe383a9a065c85c19b0734d659bc21fcf156508 + checksum: 10/d03b01c97bd6bad3e27908abbc2889e0c7e47af953e1f1d7824d8bc8babf4a2b91f59d719c9f5603329aa9fc1f8fcdac9f2f431b5e32ab88095168317f797b41 languageName: node linkType: hard @@ -3587,7 +3587,7 @@ __metadata: "@babel/preset-env": "npm:^7.25.3" "@babel/runtime": "npm:^7.25.0" "@livekit/react-native": "npm:*" - "@livekit/react-native-webrtc": "npm:^144.0.0" + "@livekit/react-native-webrtc": "npm:^144.1.0-beta.0" "@react-native-community/cli": "npm:20.0.0" "@react-native-community/cli-platform-android": "npm:20.0.0" "@react-native-community/cli-platform-ios": "npm:20.0.0" diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 8b3e6389..5b88ef49 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -8,7 +8,7 @@ PODS: - hermes-engine (0.82.0): - hermes-engine/Pre-built (= 0.82.0) - hermes-engine/Pre-built (0.82.0) - - livekit-react-native (2.9.8): + - livekit-react-native (2.10.0): - boost - DoubleConversion - fast_float @@ -37,7 +37,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - livekit-react-native-webrtc (144.0.0): + - livekit-react-native-webrtc (144.1.0-beta.0): - React-Core - WebRTC-SDK (= 144.7559.01) - RCT-Folly (2024.11.18.00): @@ -2790,8 +2790,8 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 8642d8f14a548ab718ec112e9bebdfdd154138b5 - livekit-react-native: 16923c9c9cdf21c68cbac52f9a5222c9f05c2f06 - livekit-react-native-webrtc: a9a45c67543105a40192b144809513e5ab266d0a + livekit-react-native: 0d36ebbf20e663c7d1abb7079e1cf1237f3bcb02 + livekit-react-native-webrtc: 2023c6774526d44024e77f98e37d233f8df1a326 RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a RCTRequired: e2c574c1b45231f7efb0834936bd609d75072b63 diff --git a/example/package.json b/example/package.json index 8efc933d..ff500672 100644 --- a/example/package.json +++ b/example/package.json @@ -10,7 +10,7 @@ "postinstall": "patch-package" }, "dependencies": { - "@livekit/react-native-webrtc": "^144.0.0", + "@livekit/react-native-webrtc": "^144.1.0-beta.0", "@react-native-async-storage/async-storage": "^1.17.10", "@react-navigation/native": "^7.1.18", "@react-navigation/native-stack": "^7.3.27", diff --git a/package.json b/package.json index d38b5ef2..44d4478e 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.35.0", "@livekit/changesets-changelog-github": "^0.0.4", - "@livekit/react-native-webrtc": "^144.0.0", + "@livekit/react-native-webrtc": "^144.1.0-beta.0", "@react-native/babel-preset": "0.83.0", "@react-native/eslint-config": "0.83.0", "@release-it/conventional-changelog": "10.0.1", @@ -94,7 +94,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@livekit/react-native-webrtc": "^144.0.0", + "@livekit/react-native-webrtc": "^144.1.0-beta.0", "livekit-client": "^2.15.8", "react": "*", "react-native": "*" diff --git a/src/audio/AudioManagerLegacy.ts b/src/audio/AudioManagerLegacy.ts index bfcc84f7..701d66e2 100644 --- a/src/audio/AudioManagerLegacy.ts +++ b/src/audio/AudioManagerLegacy.ts @@ -21,7 +21,7 @@ import { * via audio engine events, not room track counts. */ export function useIOSAudioManagement( - room: Room, + _room: Room, preferSpeakerOutput: boolean = true, onConfigureNativeAudio?: ( trackState: AudioTrackState, diff --git a/yarn.lock b/yarn.lock index 875b798f..376a1b59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3578,15 +3578,15 @@ __metadata: languageName: node linkType: hard -"@livekit/react-native-webrtc@npm:^144.0.0": - version: 144.0.0 - resolution: "@livekit/react-native-webrtc@npm:144.0.0" +"@livekit/react-native-webrtc@npm:^144.1.0-beta.0": + version: 144.1.0-beta.0 + resolution: "@livekit/react-native-webrtc@npm:144.1.0-beta.0" dependencies: base64-js: "npm:1.5.1" debug: "npm:4.3.4" peerDependencies: react-native: ">=0.60.0" - checksum: 10/d97454e8bcb5ab0ae98e8a9d134f8486276b12bdf993052b5b01ac3e8da375e63bb66c84061201bd4499266afbe383a9a065c85c19b0734d659bc21fcf156508 + checksum: 10/d03b01c97bd6bad3e27908abbc2889e0c7e47af953e1f1d7824d8bc8babf4a2b91f59d719c9f5603329aa9fc1f8fcdac9f2f431b5e32ab88095168317f797b41 languageName: node linkType: hard @@ -3602,7 +3602,7 @@ __metadata: "@livekit/changesets-changelog-github": "npm:^0.0.4" "@livekit/components-react": "npm:^2.9.17" "@livekit/mutex": "npm:^1.1.1" - "@livekit/react-native-webrtc": "npm:^144.0.0" + "@livekit/react-native-webrtc": "npm:^144.1.0-beta.0" "@react-native/babel-preset": "npm:0.83.0" "@react-native/eslint-config": "npm:0.83.0" "@release-it/conventional-changelog": "npm:10.0.1" @@ -3632,7 +3632,7 @@ __metadata: web-streams-polyfill: "npm:^4.1.0" well-known-symbols: "npm:^4.1.0" peerDependencies: - "@livekit/react-native-webrtc": ^144.0.0 + "@livekit/react-native-webrtc": ^144.1.0-beta.0 livekit-client: ^2.15.8 react: "*" react-native: "*" @@ -10344,7 +10344,7 @@ __metadata: "@babel/core": "npm:^7.25.2" "@babel/preset-env": "npm:^7.25.3" "@babel/runtime": "npm:^7.25.0" - "@livekit/react-native-webrtc": "npm:^144.0.0" + "@livekit/react-native-webrtc": "npm:^144.1.0-beta.0" "@react-native-async-storage/async-storage": "npm:^1.17.10" "@react-native-community/cli": "npm:20.0.0" "@react-native-community/cli-platform-android": "npm:20.0.0" From 7a8c7b4077e39325bd670d64c423be97225ef1af Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 31 Mar 2026 18:48:12 +0900 Subject: [PATCH 7/9] lint fix --- example/index.js | 6 +++++- example/src/RoomPage.tsx | 1 - src/audio/AudioManager.ts | 11 ++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/example/index.js b/example/index.js index f0cef846..619c8ea2 100644 --- a/example/index.js +++ b/example/index.js @@ -1,7 +1,11 @@ import { AppRegistry } from 'react-native'; import App from './src/App'; import { name as appName } from './app.json'; -import { registerGlobals, setLogLevel, setupIOSAudioManagement } from '@livekit/react-native'; +import { + registerGlobals, + setLogLevel, + setupIOSAudioManagement, +} from '@livekit/react-native'; import { LogLevel } from 'livekit-client'; import { setupErrorLogHandler } from './src/utils/ErrorLogHandler'; import { setupCallService } from './src/callservice/CallService'; diff --git a/example/src/RoomPage.tsx b/example/src/RoomPage.tsx index cdadec45..b96e57a5 100644 --- a/example/src/RoomPage.tsx +++ b/example/src/RoomPage.tsx @@ -25,7 +25,6 @@ import { useTracks, type TrackReferenceOrPlaceholder, AndroidAudioTypePresets, - useIOSAudioManagement, useRNE2EEManager, } from '@livekit/react-native'; import { Platform } from 'react-native'; diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 4ee4c227..68894ded 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -1,7 +1,5 @@ import { Platform } from 'react-native'; -import AudioSession, { - type AppleAudioConfiguration, -} from './AudioSession'; +import AudioSession, { type AppleAudioConfiguration } from './AudioSession'; import { log } from '..'; import { audioDeviceModuleEvents } from '@livekit/react-native-webrtc'; @@ -85,10 +83,13 @@ export function setupIOSAudioManagement( try { await tryConfigure(newState, oldState); } catch (error) { - log.error('AudioSession configuration failed, stopping audio engine:', error); + log.error( + 'AudioSession configuration failed, stopping audio engine:', + error + ); // Throw the error code so the native AudioDeviceModuleObserver returns it // to the WebRTC engine, which will stop/rollback the operation. - // eslint-disable-next-line no-throw-literal + throw kAudioEngineErrorFailedToConfigureAudioSession; } // Update the audio state only if configure succeeds From a0eafe1aaa9f42494a6e0c1334f5323ac7c1830c Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:47:09 +0800 Subject: [PATCH 8/9] support older swift vers --- ios/LiveKitReactNativeModule.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ios/LiveKitReactNativeModule.swift b/ios/LiveKitReactNativeModule.swift index feb6d415..e9e07b40 100644 --- a/ios/LiveKitReactNativeModule.swift +++ b/ios/LiveKitReactNativeModule.swift @@ -29,7 +29,11 @@ public class LivekitReactNativeModule: RCTEventEmitter { super.init() let config = RTCAudioSessionConfiguration() config.category = AVAudioSession.Category.playAndRecord.rawValue + #if swift(>=6.2) config.categoryOptions = [.allowAirPlay, .allowBluetoothHFP, .allowBluetoothA2DP, .defaultToSpeaker] + #else + config.categoryOptions = [.allowAirPlay, .allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker] + #endif config.mode = AVAudioSession.Mode.videoChat.rawValue RTCAudioSessionConfiguration.setWebRTC(config) From 81e49ac9793a3bf167a2b9f097a1cf8ddc9c4c90 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 31 Mar 2026 20:58:19 +0900 Subject: [PATCH 9/9] Fix doc errors --- src/audio/AudioManager.ts | 3 +++ src/components/VideoTrack.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 68894ded..2ed92918 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -11,6 +11,9 @@ export type AudioEngineConfigurationState = { const kAudioEngineErrorFailedToConfigureAudioSession = -4100; +/** + * @inline + */ type CleanupFn = () => void; /** diff --git a/src/components/VideoTrack.tsx b/src/components/VideoTrack.tsx index df360b78..01a85382 100644 --- a/src/components/VideoTrack.tsx +++ b/src/components/VideoTrack.tsx @@ -124,6 +124,9 @@ export type VideoTrackProps = { }; }; +/** + * @inline + */ type RTCViewInstance = InstanceType; /**