/** * Audio Session Configuration Helpers (iOS + Android) * * CRITICAL: This must be configured BEFORE connecting to LiveKit room! * Without proper AudioSession setup, microphone won't work on iOS. * On Android, this controls speaker/earpiece routing. */ import { Platform } from 'react-native'; /** * Represents an available audio output device */ export interface AudioOutputDevice { id: string; name: string; type: 'speaker' | 'earpiece' | 'bluetooth' | 'headphones' | 'unknown'; } // AudioSession module - use 'any' to avoid complex typing issues with @livekit/react-native // The actual AudioSession from LiveKit has specific enum types that are hard to match statically let audioSessionModule: any = null; /** * Import AudioSession module lazily * This is needed because @livekit/react-native must be imported after registerGlobals() */ async function getAudioSession(): Promise { if (!audioSessionModule) { const livekit = await import('@livekit/react-native'); audioSessionModule = livekit.AudioSession; } return audioSessionModule; } /** * Configure AudioSession for bidirectional voice call (iOS + Android) * * MUST be called BEFORE connecting to LiveKit room! * * iOS Configuration: * - Category: playAndRecord (both speaker and mic) * - Mode: voiceChat (optimized for voice calls) * - Options: Bluetooth, speaker, mix with others * * Android Configuration: * - audioTypeOptions: communication (for voice calls) * - forceHandleAudioRouting: true (to control speaker/earpiece) */ export async function configureAudioForVoiceCall(): Promise { console.log(`[AudioSession] Configuring for voice call on ${Platform.OS}...`); try { const AudioSession = await getAudioSession(); if (!AudioSession) { console.error('[AudioSession] Failed to get AudioSession module'); return; } if (Platform.OS === 'ios') { // iOS-specific configuration - FORCE SPEAKER OUTPUT // Using videoChat mode + defaultSpeakerOutput option for guaranteed speaker console.log('[AudioSession] Configuring iOS for SPEAKER output...'); try { // Primary config: videoChat mode with defaultSpeakerOutput await AudioSession.setAppleAudioConfiguration({ audioCategory: 'playAndRecord', audioCategoryOptions: [ 'allowBluetooth', 'mixWithOthers', 'defaultToSpeaker', // KEY: Forces speaker as default output ], audioMode: 'videoChat', // videoChat mode uses speaker by default }); console.log('[AudioSession] iOS videoChat + defaultToSpeaker configured!'); } catch (err) { console.warn('[AudioSession] Primary iOS config failed, trying fallback:', err); // Fallback: just videoChat without defaultToSpeaker option await AudioSession.setAppleAudioConfiguration({ audioCategory: 'playAndRecord', audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'], audioMode: 'videoChat', }); } console.log('[AudioSession] Starting iOS audio session...'); await AudioSession.startAudioSession(); // Additionally set default output to speaker (belt and suspenders) try { console.log('[AudioSession] Setting iOS default output to speaker...'); await AudioSession.configureAudio({ ios: { defaultOutput: 'speaker', }, }); console.log('[AudioSession] iOS speaker output set!'); } catch (outputErr) { console.warn('[AudioSession] Could not set speaker output:', outputErr); } } else if (Platform.OS === 'android') { // Android-specific configuration - FORCE SPEAKER OUTPUT // CRITICAL: Use 'inCommunication' mode + 'music' stream for speaker // Many Android devices default to earpiece for voice calls console.log('[AudioSession] Configuring Android audio for SPEAKER...'); await AudioSession.configureAudio({ android: { // Use inCommunication mode but with music stream for speaker audioTypeOptions: { manageAudioFocus: true, // inCommunication gives us more control over audio routing audioMode: 'inCommunication', audioFocusMode: 'gain', // Use 'music' stream - goes to speaker by default! audioStreamType: 'music', audioAttributesUsageType: 'media', audioAttributesContentType: 'music', }, // Force speaker as output preferredOutputList: ['speaker'], // Allow us to control audio routing forceHandleAudioRouting: true, }, }); console.log('[AudioSession] Starting Android audio session...'); await AudioSession.startAudioSession(); // After starting, explicitly set speaker output console.log('[AudioSession] Forcing speaker output...'); try { await AudioSession.showAudioRoutePicker?.(); } catch { // showAudioRoutePicker may not be available, that's ok } console.log('[AudioSession] Android speaker mode configured!'); } console.log('[AudioSession] Configuration complete!'); } catch (error) { console.error('[AudioSession] Configuration error:', error); throw error; } } /** * Stop AudioSession (iOS + Android) * * Should be called when disconnecting from voice call */ export async function stopAudioSession(): Promise { if (Platform.OS !== 'ios' && Platform.OS !== 'android') { return; } console.log(`[AudioSession] Stopping audio session on ${Platform.OS}...`); try { const AudioSession = await getAudioSession(); if (!AudioSession) { return; } await AudioSession.stopAudioSession(); console.log('[AudioSession] Stopped'); } catch (error) { console.error('[AudioSession] Error stopping:', error); // Don't throw - cleanup errors are not critical } } /** * Reconfigure audio session after remote track arrives (iOS + Android) * * Sometimes the OS needs a kick to properly route audio after remote participant joins */ export async function reconfigureAudioForPlayback(): Promise { if (Platform.OS !== 'ios' && Platform.OS !== 'android') { return; } console.log(`[AudioSession] Reconfiguring for playback (SPEAKER) on ${Platform.OS}...`); try { const AudioSession = await getAudioSession(); if (!AudioSession) { return; } if (Platform.OS === 'ios') { // Reconfigure iOS - force speaker output await AudioSession.setAppleAudioConfiguration({ audioCategory: 'playAndRecord', audioCategoryOptions: [ 'allowBluetooth', 'mixWithOthers', 'defaultToSpeaker', // Force speaker ], audioMode: 'videoChat', // videoChat = speaker by default }); // Also set default output to speaker await AudioSession.configureAudio({ ios: { defaultOutput: 'speaker', }, }); console.log('[AudioSession] iOS reconfigured for speaker playback'); } else if (Platform.OS === 'android') { // Reconfigure Android audio to ensure speaker output // Using inCommunication + music stream for reliable speaker routing await AudioSession.configureAudio({ android: { audioTypeOptions: { manageAudioFocus: true, audioMode: 'inCommunication', audioFocusMode: 'gain', audioStreamType: 'music', audioAttributesUsageType: 'media', audioAttributesContentType: 'music', }, preferredOutputList: ['speaker'], forceHandleAudioRouting: true, }, }); console.log('[AudioSession] Android reconfigured for speaker playback'); } console.log('[AudioSession] Reconfigured successfully'); } catch (error) { console.error('[AudioSession] Reconfigure error:', error); // Don't throw - this is a best-effort operation } } /** * Switch audio output between speaker and earpiece (iOS + Android) * * @param useSpeaker - true for speaker, false for earpiece */ /** * Get list of available audio output devices * * @returns Array of available audio output devices */ export async function getAvailableAudioOutputs(): Promise { console.log(`[AudioSession] Getting available audio outputs on ${Platform.OS}...`); try { const AudioSession = await getAudioSession(); if (!AudioSession) { console.error('[AudioSession] Failed to get AudioSession module'); return []; } const outputs = await AudioSession.getAudioOutputs(); console.log('[AudioSession] Available outputs:', outputs); // Map the raw outputs to our AudioOutputDevice interface if (Array.isArray(outputs)) { return outputs.map((output: any) => ({ id: output.id || output.deviceId || String(output), name: output.name || output.deviceName || String(output), type: mapDeviceType(output.type || output.deviceType), })); } return []; } catch (error) { console.error('[AudioSession] getAvailableAudioOutputs error:', error); return []; } } /** * Select a specific audio output device by ID * * @param deviceId - The ID of the device to select */ export async function selectAudioOutput(deviceId: string): Promise { console.log(`[AudioSession] Selecting audio output: ${deviceId} on ${Platform.OS}...`); try { const AudioSession = await getAudioSession(); if (!AudioSession) { console.error('[AudioSession] Failed to get AudioSession module'); return; } await AudioSession.selectAudioOutput(deviceId); console.log(`[AudioSession] Audio output selected: ${deviceId}`); } catch (error) { console.error('[AudioSession] selectAudioOutput error:', error); } } /** * Map raw device type to our AudioOutputDevice type */ function mapDeviceType(rawType: string | undefined): AudioOutputDevice['type'] { if (!rawType) return 'unknown'; const type = rawType.toLowerCase(); if (type.includes('speaker')) return 'speaker'; if (type.includes('earpiece') || type.includes('receiver')) return 'earpiece'; if (type.includes('bluetooth')) return 'bluetooth'; if (type.includes('headphone') || type.includes('headset') || type.includes('wired')) return 'headphones'; return 'unknown'; } /** * Switch audio output between speaker and earpiece (iOS + Android) * * @param useSpeaker - true for speaker, false for earpiece */ export async function setAudioOutput(useSpeaker: boolean): Promise { console.log(`[AudioSession] Setting audio output to ${useSpeaker ? 'SPEAKER' : 'EARPIECE'} on ${Platform.OS}...`); try { const AudioSession = await getAudioSession(); if (!AudioSession) { console.error('[AudioSession] Failed to get AudioSession module'); return; } if (Platform.OS === 'ios') { // iOS: Use videoChat mode + defaultToSpeaker for speaker, voiceChat for earpiece await AudioSession.setAppleAudioConfiguration({ audioCategory: 'playAndRecord', audioCategoryOptions: useSpeaker ? ['allowBluetooth', 'mixWithOthers', 'defaultToSpeaker'] : ['allowBluetooth', 'mixWithOthers'], audioMode: useSpeaker ? 'videoChat' : 'voiceChat', }); // Also set default output await AudioSession.configureAudio({ ios: { defaultOutput: useSpeaker ? 'speaker' : 'earpiece', }, }); } else if (Platform.OS === 'android') { // Android: Switch stream type to control speaker/earpiece // - 'music' stream goes to speaker by default // - 'voiceCall' stream goes to earpiece by default await AudioSession.configureAudio({ android: { audioTypeOptions: { manageAudioFocus: true, audioMode: useSpeaker ? 'normal' : 'inCommunication', audioFocusMode: 'gain', // Key difference: music→speaker, voiceCall→earpiece audioStreamType: useSpeaker ? 'music' : 'voiceCall', audioAttributesUsageType: useSpeaker ? 'media' : 'voiceCommunication', audioAttributesContentType: useSpeaker ? 'music' : 'speech', }, // Also set preferred output list preferredOutputList: useSpeaker ? ['speaker'] : ['earpiece'], forceHandleAudioRouting: true, }, }); } console.log(`[AudioSession] Audio output set to ${useSpeaker ? 'SPEAKER' : 'EARPIECE'}`); } catch (error) { console.error('[AudioSession] setAudioOutput error:', error); } }