diff --git a/example/index.js b/example/index.js index 231dc30..f6d6939 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 b114f5b..cdadec4 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 b3c3ef4..feb6d41 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 f227bd7..708341c 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 ed3b9a9..83bba8c 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.