wellnua-lite/utils/audioSession.ts
Sergei ef533de4d5 Fix Android audio to use speaker instead of earpiece
- Configure LiveKit Expo plugin with audioType: "media" in app.json
  This forces speaker output on Android instead of earpiece
- Remove microphone icon from voice messages in chat
- Remove audio output picker button (no longer needed)
- Clean up audioSession.ts configuration

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:02:27 -08:00

382 lines
13 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';
/**
* 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<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 - 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') {
// ============================================================
// HYPOTHESIS 2: audioStreamType = 'music' (instead of 'voiceCall')
// Theory: STREAM_VOICE_CALL routes to earpiece, STREAM_MUSIC to speaker
// ============================================================
console.log('[AudioSession] === HYPOTHESIS 2: audioStreamType = music ===');
await AudioSession.configureAudio({
android: {
audioTypeOptions: {
manageAudioFocus: true,
audioMode: 'inCommunication', // DEFAULT
audioFocusMode: 'gain',
audioStreamType: 'music', // <-- CHANGED from 'voiceCall'
audioAttributesUsageType: 'voiceCommunication', // DEFAULT
audioAttributesContentType: 'speech', // DEFAULT
},
preferredOutputList: ['speaker'],
},
});
console.log('[AudioSession] Starting Android audio session...');
await AudioSession.startAudioSession();
console.log('[AudioSession] Android audio session STARTED');
}
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 (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: 'normal', // Use normal mode to keep speaker
audioFocusMode: 'gain',
audioStreamType: 'music',
audioAttributesUsageType: 'media',
audioAttributesContentType: 'music',
},
preferredOutputList: ['speaker'],
forceHandleAudioRouting: true,
},
});
// CRITICAL: Force speaker via selectAudioOutput
try {
await AudioSession.selectAudioOutput('speaker');
console.log('[AudioSession] Android selectAudioOutput(speaker) SUCCESS!');
} catch (e) {
console.warn('[AudioSession] selectAudioOutput failed:', e);
}
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<AudioOutputDevice[]> {
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<void> {
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<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: Use selectAudioOutput with force_speaker
try {
await AudioSession.selectAudioOutput(useSpeaker ? 'force_speaker' : 'default');
console.log(`[AudioSession] iOS selectAudioOutput: ${useSpeaker ? 'force_speaker' : 'default'}`);
} catch (e) {
console.warn('[AudioSession] selectAudioOutput failed, using fallback config');
}
// Also configure audio mode
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: Use selectAudioOutput DIRECTLY - this calls setSpeakerphoneOn()
// This is the MOST RELIABLE way to force speaker on Android!
try {
await AudioSession.selectAudioOutput(useSpeaker ? 'speaker' : 'earpiece');
console.log(`[AudioSession] Android selectAudioOutput: ${useSpeaker ? 'speaker' : 'earpiece'}`);
} catch (e) {
console.warn('[AudioSession] selectAudioOutput failed:', e);
}
// Also reconfigure audio settings as backup
await AudioSession.configureAudio({
android: {
audioTypeOptions: {
manageAudioFocus: true,
audioMode: useSpeaker ? 'normal' : 'inCommunication',
audioFocusMode: 'gain',
audioStreamType: useSpeaker ? 'music' : 'voiceCall',
audioAttributesUsageType: useSpeaker ? 'media' : 'voiceCommunication',
audioAttributesContentType: useSpeaker ? 'music' : 'speech',
},
preferredOutputList: useSpeaker ? ['speaker'] : ['earpiece'],
forceHandleAudioRouting: true,
},
});
}
console.log(`[AudioSession] Audio output set to ${useSpeaker ? 'SPEAKER' : 'EARPIECE'}`);
} catch (error) {
console.error('[AudioSession] setAudioOutput error:', error);
}
}