wellnua-lite-Robert/utils/audioSession.ts
Sergei 0d872a09b7 Fix iOS audio session "status -50" error
Remove allowBluetoothA2DP from audioCategoryOptions. This option is
incompatible with playAndRecord category on some iOS versions.
The allowBluetooth (HFP profile) is sufficient for voice calls.

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

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

255 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
console.log('[AudioSession] Step 1: Setting Apple audio config...');
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord',
// Note: removed 'allowBluetoothA2DP' - it's incompatible with playAndRecord
// on some iOS versions and causes "status -50" error.
// 'allowBluetooth' (HFP profile) is sufficient for voice calls.
audioCategoryOptions: [
'allowBluetooth',
'defaultToSpeaker',
'mixWithOthers',
],
audioMode: 'voiceChat',
});
console.log('[AudioSession] Step 2: Setting default output...');
await AudioSession.configureAudio({
ios: {
defaultOutput: 'speaker',
},
});
console.log('[AudioSession] Step 3: Starting audio session...');
await AudioSession.startAudioSession();
} 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') {
// Just reconfigure the same settings - this "refreshes" the audio routing
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord',
// Note: removed 'allowBluetoothA2DP' - it's incompatible with playAndRecord
// on some iOS versions and causes "status -50" error.
// 'allowBluetooth' (HFP profile) is sufficient for voice calls.
audioCategoryOptions: [
'allowBluetooth',
'defaultToSpeaker',
'mixWithOthers',
],
audioMode: 'voiceChat',
});
} 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: Configure audio output
await AudioSession.configureAudio({
ios: {
defaultOutput: useSpeaker ? 'speaker' : 'earpiece',
},
});
// Also update the full configuration to ensure it takes effect
// Note: removed 'allowBluetoothA2DP' - causes "status -50" error
await AudioSession.setAppleAudioConfiguration({
audioCategory: 'playAndRecord',
audioCategoryOptions: useSpeaker
? ['allowBluetooth', 'defaultToSpeaker', 'mixWithOthers']
: ['allowBluetooth', 'mixWithOthers'],
audioMode: 'voiceChat',
});
} 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);
}
}