wellnua-lite/utils/audioSession.ts
Sergei 42e3f389f7 Fix iOS AudioSession OSStatus error -50
- Remove 'defaultToSpeaker' from audioCategoryOptions (conflicts with some iOS versions)
- Use 'videoChat' mode instead of 'voiceChat' for speaker output
- Reorder operations: set config first, start session, then configure output
- Keep audioCategoryOptions minimal to avoid param conflicts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:36:20 -08:00

252 lines
8.5 KiB
TypeScript

/**
* 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';
// 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<any | null> {
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<void> {
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
// IMPORTANT: Use 'videoChat' mode instead of 'voiceChat' to enable speaker by default
// voiceChat + defaultToSpeaker causes OSStatus error -50 on some iOS versions
console.log('[AudioSession] Step 1: Setting Apple audio config...');
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord',
// Only use options that are safe with playAndRecord category
// Removed 'defaultToSpeaker' - it conflicts with some iOS versions
// Instead, using 'videoChat' mode which defaults to speaker
audioCategoryOptions: [
'allowBluetooth',
'mixWithOthers',
],
// Use 'videoChat' instead of 'voiceChat' - it enables speaker by default
audioMode: 'videoChat',
});
console.log('[AudioSession] Step 2: Starting audio session...');
await AudioSession.startAudioSession();
console.log('[AudioSession] Step 3: Setting default output to speaker...');
await AudioSession.configureAudio({
ios: {
defaultOutput: 'speaker',
},
});
} else if (Platform.OS === 'android') {
// Android-specific configuration
// IMPORTANT: Using 'music' stream type to force output to speaker
// 'voiceCall' stream type defaults to earpiece on many Android devices
console.log('[AudioSession] Configuring Android audio for SPEAKER...');
await AudioSession.configureAudio({
android: {
// Use MEDIA mode to ensure speaker output
audioTypeOptions: {
manageAudioFocus: true,
audioMode: 'normal',
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();
}
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<void> {
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<void> {
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
return;
}
console.log(`[AudioSession] Reconfiguring for playback on ${Platform.OS}...`);
try {
const AudioSession = await getAudioSession();
if (!AudioSession) {
return;
}
if (Platform.OS === 'ios') {
// Reconfigure with same safe settings - this "refreshes" the audio routing
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord',
audioCategoryOptions: [
'allowBluetooth',
'mixWithOthers',
],
// Use 'videoChat' - defaults to speaker
audioMode: 'videoChat',
});
} else if (Platform.OS === 'android') {
// Reconfigure Android audio to ensure speaker output
// Using 'music' stream type to force speaker
await AudioSession.configureAudio({
android: {
audioTypeOptions: {
manageAudioFocus: true,
audioMode: 'normal',
audioFocusMode: 'gain',
audioStreamType: 'music',
audioAttributesUsageType: 'media',
audioAttributesContentType: 'music',
},
preferredOutputList: ['speaker'],
forceHandleAudioRouting: true,
},
});
}
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
*/
export async function setAudioOutput(useSpeaker: boolean): Promise<void> {
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: Update configuration based on desired output
// Use 'videoChat' mode for speaker, 'voiceChat' for earpiece
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord',
audioCategoryOptions: ['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);
}
}