From 260a722cd958a24d14b3e7d27b6926f3bf91e32c Mon Sep 17 00:00:00 2001 From: Sergei Date: Tue, 27 Jan 2026 16:08:41 -0800 Subject: [PATCH] Remove unused useLiveKitRoom hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This LiveKit hook was no longer used after switching to speech recognition. Also removed the outdated comment referencing it in _layout.tsx. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/_layout.tsx | 3 - hooks/useLiveKitRoom.ts | 707 ---------------------------------------- 2 files changed, 710 deletions(-) delete mode 100644 hooks/useLiveKitRoom.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 2e3522f..3c2be2d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,3 @@ -// WebRTC globals are now registered in useLiveKitRoom hook -// before any LiveKit classes are loaded. - import { useEffect } from 'react'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { Stack, router, useSegments } from 'expo-router'; diff --git a/hooks/useLiveKitRoom.ts b/hooks/useLiveKitRoom.ts deleted file mode 100644 index 85282ee..0000000 --- a/hooks/useLiveKitRoom.ts +++ /dev/null @@ -1,707 +0,0 @@ -/** - * useLiveKitRoom - Hook for LiveKit voice call with Julia AI - * - * IMPORTANT: This hook encapsulates ALL LiveKit logic. - * The UI component should only use the returned state and actions. - * - * LOGGING: Maximum transparency - every step is logged! - */ - -import { useState, useCallback, useRef, useEffect } from 'react'; -import { Platform, AppState, AppStateStatus, NativeModules } from 'react-native'; -import type { Room as RoomType } from 'livekit-client'; - -// Helper to detect iOS Simulator -// Expo Go and production builds both work with this approach -const isIOSSimulator = (): boolean => { - if (Platform.OS !== 'ios') return false; - // Check via DeviceInfo module if available - const { PlatformConstants } = NativeModules; - return PlatformConstants?.interfaceIdiom === 'simulator' || - PlatformConstants?.isSimulator === true; -}; -import { getToken, VOICE_NAME, BeneficiaryData } from '@/services/livekitService'; -import { - configureAudioForVoiceCall, - stopAudioSession, - reconfigureAudioForPlayback, -} from '@/utils/audioSession'; -import { callManager } from '@/services/callManager'; - -// Connection states -export type ConnectionState = - | 'idle' - | 'initializing' - | 'configuring_audio' - | 'requesting_token' - | 'connecting' - | 'connected' - | 'reconnecting' - | 'disconnecting' - | 'disconnected' - | 'error'; - -// Log entry type -export interface LogEntry { - timestamp: string; // Formatted time string (HH:MM:SS.mmm) - level: 'info' | 'warn' | 'error' | 'success'; - message: string; -} - -// Hook options -export interface UseLiveKitRoomOptions { - userId: string; - beneficiaryData?: BeneficiaryData; - onTranscript?: (role: 'user' | 'assistant', text: string) => void; - autoConnect?: boolean; -} - -// Hook return type -export interface UseLiveKitRoomReturn { - // Connection state - state: ConnectionState; - error: string | null; - - // Call info - roomName: string | null; - callDuration: number; - - // Audio state - isMuted: boolean; - isAgentSpeaking: boolean; - canPlayAudio: boolean; - - // Debug info - logs: LogEntry[]; - participantCount: number; - - // Actions - connect: () => Promise; - disconnect: () => Promise; - toggleMute: () => Promise; - clearLogs: () => void; -} - -/** - * Main hook for LiveKit voice calls - */ -export function useLiveKitRoom(options: UseLiveKitRoomOptions): UseLiveKitRoomReturn { - const { userId, beneficiaryData, onTranscript, autoConnect = false } = options; - - // State - const [state, setState] = useState('idle'); - const [error, setError] = useState(null); - const [roomName, setRoomName] = useState(null); - const [callDuration, setCallDuration] = useState(0); - const [isMuted, setIsMuted] = useState(false); - const [isAgentSpeaking, setIsAgentSpeaking] = useState(false); - const [canPlayAudio, setCanPlayAudio] = useState(false); - const [logs, setLogs] = useState([]); - const [participantCount, setParticipantCount] = useState(0); - - // Refs - const roomRef = useRef(null); - const callStartTimeRef = useRef(null); - const connectionIdRef = useRef(0); - const isUnmountingRef = useRef(false); - const appStateRef = useRef(AppState.currentState); - const callIdRef = useRef(null); - - // =================== - // LOGGING FUNCTIONS - // =================== - - const log = useCallback((level: LogEntry['level'], message: string) => { - const now = new Date(); - const timestamp = now.toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }) + '.' + now.getMilliseconds().toString().padStart(3, '0'); - - const entry: LogEntry = { - timestamp, - level, - message, - }; - setLogs((prev) => [...prev, entry]); - - // Also log to console with color - const prefix = `[LiveKit ${timestamp}]`; - switch (level) { - case 'error': - console.error(`${prefix} ERROR: ${message}`); - break; - case 'warn': - console.warn(`${prefix} WARN: ${message}`); - break; - case 'success': - console.log(`${prefix} SUCCESS: ${message}`); - break; - default: - console.log(`${prefix} INFO: ${message}`); - } - }, []); - - const logInfo = useCallback((msg: string) => log('info', msg), [log]); - const logWarn = useCallback((msg: string) => log('warn', msg), [log]); - const logError = useCallback((msg: string) => log('error', msg), [log]); - const logSuccess = useCallback((msg: string) => log('success', msg), [log]); - - const clearLogs = useCallback(() => { - setLogs([]); - }, []); - - // =================== - // CONNECT FUNCTION - // =================== - - const connect = useCallback(async () => { - // Prevent multiple concurrent connection attempts - const currentConnectionId = ++connectionIdRef.current; - - // Generate unique call ID for this session - const callId = `call-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - callIdRef.current = callId; - - logInfo('========== STARTING VOICE CALL =========='); - logInfo(`User ID: ${userId}`); - logInfo(`Platform: ${Platform.OS}`); - logInfo(`Connection ID: ${currentConnectionId}`); - logInfo(`Call ID: ${callId}`); - - // Register with CallManager - this will disconnect any existing call - logInfo('Registering call with CallManager...'); - await callManager.registerCall(callId, async () => { - logInfo('CallManager requested disconnect (another call starting)'); - if (roomRef.current) { - await roomRef.current.disconnect(); - roomRef.current = null; - } - await stopAudioSession(); - }); - logSuccess('Call registered with CallManager'); - - // Check if already connected - if (roomRef.current) { - logWarn('Already connected to a room, disconnecting first...'); - await roomRef.current.disconnect(); - roomRef.current = null; - } - - try { - // ========== STEP 1: Initialize ========== - setState('initializing'); - logInfo('STEP 1/6: Initializing...'); - - // Detect simulator vs real device - const isSimulator = isIOSSimulator(); - logInfo(`Device type: ${isSimulator ? 'SIMULATOR' : 'REAL DEVICE'}`); - logInfo(`Device model: ${Platform.OS} ${Platform.Version}`); - - if (isSimulator) { - logWarn('⚠️ SIMULATOR DETECTED - Microphone will NOT work!'); - logWarn('Simulator can only test: connection, token, agent presence, TTS playback'); - logWarn('For full STT test, use a real iPhone device'); - } - - // Check if connection was cancelled - if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { - logWarn('Connection cancelled (component unmounting or new connection started)'); - return; - } - - // ========== STEP 2: Register WebRTC Globals ========== - logInfo('STEP 2/6: Registering WebRTC globals...'); - - const { registerGlobals } = await import('@livekit/react-native'); - - if (typeof global.RTCPeerConnection === 'undefined') { - logInfo('RTCPeerConnection not found, calling registerGlobals()...'); - registerGlobals(); - logSuccess('WebRTC globals registered!'); - } else { - logInfo('WebRTC globals already registered'); - } - - // Check again - if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { - logWarn('Connection cancelled after registerGlobals'); - return; - } - - // ========== STEP 3: Configure iOS Audio ========== - setState('configuring_audio'); - logInfo('STEP 3/6: Configuring iOS AudioSession...'); - - await configureAudioForVoiceCall(); - logSuccess('iOS AudioSession configured!'); - - // Check again - if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { - logWarn('Connection cancelled after audio config'); - await stopAudioSession(); - return; - } - - // ========== STEP 4: Get Token ========== - setState('requesting_token'); - logInfo('STEP 4/6: Requesting token from server...'); - - const tokenResult = await getToken(userId, beneficiaryData); - - if (!tokenResult.success || !tokenResult.data) { - const errorMsg = tokenResult.error || 'Failed to get token'; - logError(`Token request failed: ${errorMsg}`); - setError(errorMsg); - setState('error'); - return; - } - - const { token, wsUrl, roomName: room } = tokenResult.data; - setRoomName(room); - - logSuccess(`Token received!`); - logInfo(` Room: ${room}`); - logInfo(` WebSocket URL: ${wsUrl}`); - logInfo(` Token length: ${token.length} chars`); - - // Check again - if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { - logWarn('Connection cancelled after token'); - await stopAudioSession(); - return; - } - - // ========== STEP 5: Import LiveKit and Create Room ========== - logInfo('STEP 5/6: Creating LiveKit Room...'); - - const { Room, RoomEvent, ConnectionState: LKConnectionState, Track } = await import( - 'livekit-client' - ); - - logInfo(` Room class available: ${typeof Room === 'function'}`); - logInfo(` RoomEvent available: ${typeof RoomEvent === 'object'}`); - - const lkRoom = new Room(); - roomRef.current = lkRoom; - - logSuccess('Room instance created!'); - - // ========== Setup Event Listeners ========== - logInfo('Setting up event listeners...'); - - // Connection state changes - lkRoom.on(RoomEvent.ConnectionStateChanged, (newState) => { - logInfo(`EVENT: ConnectionStateChanged -> ${newState}`); - - switch (newState) { - case LKConnectionState.Connecting: - setState('connecting'); - break; - case LKConnectionState.Connected: - setState('connected'); - logSuccess('Connected to room!'); - if (!callStartTimeRef.current) { - callStartTimeRef.current = Date.now(); - logInfo('Call timer started'); - } - break; - case LKConnectionState.Reconnecting: - setState('reconnecting'); - logWarn('Reconnecting...'); - break; - case LKConnectionState.Disconnected: - setState('disconnected'); - logInfo('Disconnected from room'); - break; - } - }); - - // Track subscribed (audio from agent) - lkRoom.on(RoomEvent.TrackSubscribed, async (track, publication, participant) => { - logInfo(`EVENT: TrackSubscribed`); - logInfo(` Track kind: ${track.kind}`); - logInfo(` Track source: ${track.source}`); - logInfo(` Participant: ${participant.identity}`); - logInfo(` Publication SID: ${publication.trackSid}`); - - if (track.kind === Track.Kind.Audio) { - logSuccess(`Audio track from ${participant.identity} - should hear voice now!`); - setIsAgentSpeaking(true); - - // Reconfigure audio for playback - logInfo('Reconfiguring audio for playback...'); - await reconfigureAudioForPlayback(); - } - }); - - // Track unsubscribed - lkRoom.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => { - logInfo(`EVENT: TrackUnsubscribed`); - logInfo(` Track kind: ${track.kind}`); - logInfo(` Participant: ${participant.identity}`); - - if (track.kind === Track.Kind.Audio) { - setIsAgentSpeaking(false); - } - }); - - // Track muted/unmuted - lkRoom.on(RoomEvent.TrackMuted, (publication, participant) => { - logInfo(`EVENT: TrackMuted - ${publication.trackSid} by ${participant.identity}`); - }); - - lkRoom.on(RoomEvent.TrackUnmuted, (publication, participant) => { - logInfo(`EVENT: TrackUnmuted - ${publication.trackSid} by ${participant.identity}`); - }); - - // Participants - lkRoom.on(RoomEvent.ParticipantConnected, (participant) => { - logSuccess(`EVENT: ParticipantConnected - ${participant.identity}`); - setParticipantCount((c) => c + 1); - }); - - lkRoom.on(RoomEvent.ParticipantDisconnected, (participant) => { - logInfo(`EVENT: ParticipantDisconnected - ${participant.identity}`); - setParticipantCount((c) => Math.max(0, c - 1)); - }); - - // Active speakers (voice activity) - lkRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => { - if (speakers.length > 0) { - const speakerNames = speakers.map((s: any) => s.identity).join(', '); - logInfo(`EVENT: ActiveSpeakersChanged - ${speakerNames}`); - - // Check if agent is speaking - const agentSpeaking = speakers.some((s: any) => s.identity.startsWith('agent')); - setIsAgentSpeaking(agentSpeaking); - } - }); - - // Local track published (our mic) - lkRoom.on(RoomEvent.LocalTrackPublished, (publication, participant) => { - logSuccess(`EVENT: LocalTrackPublished`); - logInfo(` Track: ${publication.trackSid}`); - logInfo(` Kind: ${publication.kind}`); - logInfo(` Source: ${publication.source}`); - }); - - // Audio playback status - lkRoom.on(RoomEvent.AudioPlaybackStatusChanged, () => { - const canPlay = lkRoom.canPlaybackAudio; - logInfo(`EVENT: AudioPlaybackStatusChanged - canPlaybackAudio: ${canPlay}`); - setCanPlayAudio(canPlay); - }); - - // Data received (transcripts) - lkRoom.on(RoomEvent.DataReceived, (payload, participant) => { - try { - const data = JSON.parse(new TextDecoder().decode(payload)); - logInfo(`EVENT: DataReceived from ${participant?.identity || 'unknown'}`); - logInfo(` Type: ${data.type}`); - - if (data.type === 'transcript' && onTranscript) { - logInfo(` Role: ${data.role}, Text: ${data.text?.substring(0, 50)}...`); - onTranscript(data.role, data.text); - } - } catch (e) { - // Non-JSON data, ignore - } - }); - - // Errors - lkRoom.on(RoomEvent.Disconnected, (reason) => { - logWarn(`EVENT: Disconnected - Reason: ${reason}`); - }); - - // Check again before connect - if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { - logWarn('Connection cancelled before room.connect()'); - await stopAudioSession(); - return; - } - - // ========== STEP 6: Connect to Room ========== - setState('connecting'); - logInfo('STEP 6/6: Connecting to LiveKit room...'); - logInfo(` URL: ${wsUrl}`); - logInfo(` Room: ${room}`); - - await lkRoom.connect(wsUrl, token, { - autoSubscribe: true, - }); - - logSuccess('Connected to room!'); - - // ========== CRITICAL: Start Audio Playback ========== - // This is REQUIRED for audio to play on iOS and Android! - // Without this call, remote audio tracks will NOT be heard. - logInfo('Starting audio playback (room.startAudio)...'); - try { - await lkRoom.startAudio(); - logSuccess(`Audio playback started! canPlaybackAudio: ${lkRoom.canPlaybackAudio}`); - setCanPlayAudio(lkRoom.canPlaybackAudio); - } catch (audioPlaybackErr: any) { - logError(`startAudio failed: ${audioPlaybackErr.message}`); - // Don't fail the whole call - audio might still work on some platforms - } - - // Check if connection was cancelled after connect - if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { - logWarn('Connection cancelled after room.connect()'); - await lkRoom.disconnect(); - await stopAudioSession(); - return; - } - - // ========== Enable Microphone ========== - logInfo('Enabling microphone...'); - - try { - await lkRoom.localParticipant.setMicrophoneEnabled(true); - logSuccess('Microphone enabled!'); - logInfo(` Local participant: ${lkRoom.localParticipant.identity}`); - - // Log track info - CRITICAL for debugging! - const audioTracks = lkRoom.localParticipant.getTrackPublications(); - logInfo(` Published tracks: ${audioTracks.length}`); - - let micTrackFound = false; - audioTracks.forEach((pub) => { - logInfo(` - ${pub.kind}: ${pub.trackSid} (${pub.source})`); - logInfo(` isMuted: ${pub.isMuted}, isSubscribed: ${pub.isSubscribed}`); - - if (pub.kind === 'audio' && pub.source === 'microphone') { - micTrackFound = true; - const track = pub.track; - if (track) { - logInfo(` Track mediaStreamTrack: ${track.mediaStreamTrack?.readyState || 'N/A'}`); - logInfo(` Track enabled: ${track.mediaStreamTrack?.enabled || 'N/A'}`); - } else { - logWarn(` WARNING: No track object on publication!`); - } - } - }); - - if (!micTrackFound) { - // Check if simulator - const isSimulator = isIOSSimulator(); - if (isSimulator) { - logWarn('No microphone track - EXPECTED on simulator'); - logInfo('Simulator test: check if Agent joined and TTS works'); - } else { - logError('CRITICAL: No microphone track published! STT will NOT work!'); - logError('Possible causes: permissions denied, AudioSession not configured, hardware issue'); - } - } else { - logSuccess('Microphone track found and published - STT should work'); - } - - } catch (micError: any) { - logError(`Failed to enable microphone: ${micError.message}`); - logError(`Stack: ${micError.stack || 'N/A'}`); - // This is CRITICAL - user must know! - setError(`Microphone error: ${micError.message}`); - } - - // Set initial participant count - setParticipantCount(lkRoom.remoteParticipants.size); - logInfo(`Remote participants: ${lkRoom.remoteParticipants.size}`); - - logSuccess('========== VOICE CALL STARTED =========='); - } catch (err: any) { - // Ignore errors if unmounting - if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { - logWarn('Error ignored (component unmounting)'); - return; - } - - const errorMsg = err?.message || String(err); - logError(`Connection failed: ${errorMsg}`); - logError(`Stack: ${err?.stack || 'N/A'}`); - - setError(errorMsg); - setState('error'); - - // Cleanup - await stopAudioSession(); - } - }, [userId, beneficiaryData, onTranscript, logInfo, logWarn, logError, logSuccess]); - - // =================== - // DISCONNECT FUNCTION - // =================== - - const disconnect = useCallback(async () => { - logInfo('========== DISCONNECTING =========='); - setState('disconnecting'); - - // Unregister from CallManager - if (callIdRef.current) { - logInfo(`Unregistering call: ${callIdRef.current}`); - callManager.unregisterCall(callIdRef.current); - callIdRef.current = null; - } - - try { - if (roomRef.current) { - logInfo('Disconnecting from room...'); - await roomRef.current.disconnect(); - roomRef.current = null; - logSuccess('Disconnected from room'); - } else { - logInfo('No room to disconnect from'); - } - } catch (err: any) { - logError(`Disconnect error: ${err.message}`); - } - - logInfo('Stopping audio session...'); - await stopAudioSession(); - - // Reset state - setState('disconnected'); - setRoomName(null); - setIsMuted(false); - setIsAgentSpeaking(false); - setParticipantCount(0); - callStartTimeRef.current = null; - - logSuccess('========== DISCONNECTED =========='); - }, [logInfo, logError, logSuccess]); - - // =================== - // TOGGLE MUTE - // =================== - - const toggleMute = useCallback(async () => { - if (!roomRef.current) { - logWarn('Cannot toggle mute - not connected'); - return; - } - - const newMuted = !isMuted; - logInfo(`Toggling mute: ${isMuted} -> ${newMuted}`); - - try { - await roomRef.current.localParticipant.setMicrophoneEnabled(!newMuted); - setIsMuted(newMuted); - logSuccess(`Microphone ${newMuted ? 'muted' : 'unmuted'}`); - } catch (err: any) { - logError(`Failed to toggle mute: ${err.message}`); - } - }, [isMuted, logInfo, logWarn, logError, logSuccess]); - - // =================== - // CALL DURATION TIMER - // =================== - - useEffect(() => { - if (state !== 'connected') return; - - const interval = setInterval(() => { - if (callStartTimeRef.current) { - const elapsed = Math.floor((Date.now() - callStartTimeRef.current) / 1000); - setCallDuration(elapsed); - } - }, 1000); - - return () => clearInterval(interval); - }, [state]); - - // =================== - // APP STATE HANDLING - // =================== - - useEffect(() => { - const handleAppStateChange = (nextAppState: AppStateStatus) => { - const prevState = appStateRef.current; - appStateRef.current = nextAppState; - - if (prevState.match(/inactive|background/) && nextAppState === 'active') { - logInfo('App returned to foreground'); - } else if (prevState === 'active' && nextAppState.match(/inactive|background/)) { - logInfo('App went to background - call continues in background'); - } - }; - - const subscription = AppState.addEventListener('change', handleAppStateChange); - return () => subscription.remove(); - }, [logInfo]); - - // =================== - // CLEANUP ON UNMOUNT - // =================== - - useEffect(() => { - isUnmountingRef.current = false; - - return () => { - isUnmountingRef.current = true; - - // Cleanup - const cleanup = async () => { - // Unregister from CallManager - if (callIdRef.current) { - callManager.unregisterCall(callIdRef.current); - callIdRef.current = null; - } - - if (roomRef.current) { - try { - await roomRef.current.disconnect(); - } catch (e) { - // Ignore - } - roomRef.current = null; - } - await stopAudioSession(); - }; - - cleanup(); - }; - }, []); - - // =================== - // AUTO CONNECT - // =================== - - useEffect(() => { - if (autoConnect && state === 'idle') { - connect(); - } - }, [autoConnect, state, connect]); - - // =================== - // RETURN - // =================== - - return { - // Connection state - state, - error, - - // Call info - roomName, - callDuration, - - // Audio state - isMuted, - isAgentSpeaking, - canPlayAudio, - - // Debug - logs, - participantCount, - - // Actions - connect, - disconnect, - toggleMute, - clearLogs, - }; -} - -export { VOICE_NAME };