From e3192ead1277743f09a4c1c0d4cce4dfb02e073d Mon Sep 17 00:00:00 2001 From: Sergei Date: Mon, 19 Jan 2026 23:55:27 -0800 Subject: [PATCH] Voice call improvements: single call limit, hide debug tab, remove speaker toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Add CallManager singleton to ensure only 1 call per device at a time - Hide Debug tab from production (href: null) - Remove speaker/earpiece toggle button (always use speaker) - Agent uses voice_ask API (fast ~1 sec latency) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/(tabs)/_layout.tsx | 7 +- app/voice-call.tsx | 15 +--- hooks/useLiveKitRoom.ts | 32 +++++++++ julia-agent/julia-ai/src/agent.py | 5 +- services/callManager.ts | 111 ++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 services/callManager.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index bbbfb66..ebb4f0e 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -72,14 +72,11 @@ export default function TabLayout() { ), }} /> - {/* Debug tab - for testing audio/voice */} + {/* Debug tab - hidden in production */} ( - - ), + href: null, }} /> {/* Hide explore tab */} diff --git a/app/voice-call.tsx b/app/voice-call.tsx index f4e542a..3dc47ff 100644 --- a/app/voice-call.tsx +++ b/app/voice-call.tsx @@ -288,19 +288,8 @@ export default function VoiceCallScreen() { - {/* Speaker/Earpiece toggle */} - - - {isSpeakerOn ? 'Speaker' : 'Earpiece'} - + {/* Empty placeholder for layout balance */} + ); diff --git a/hooks/useLiveKitRoom.ts b/hooks/useLiveKitRoom.ts index 11ea674..889a808 100644 --- a/hooks/useLiveKitRoom.ts +++ b/hooks/useLiveKitRoom.ts @@ -26,6 +26,7 @@ import { stopAudioSession, reconfigureAudioForPlayback, } from '@/utils/audioSession'; +import { callManager } from '@/services/callManager'; // Connection states export type ConnectionState = @@ -103,6 +104,7 @@ export function useLiveKitRoom(options: UseLiveKitRoomOptions): UseLiveKitRoomRe const connectionIdRef = useRef(0); const isUnmountingRef = useRef(false); const appStateRef = useRef(AppState.currentState); + const callIdRef = useRef(null); // =================== // LOGGING FUNCTIONS @@ -158,10 +160,27 @@ export function useLiveKitRoom(options: UseLiveKitRoomOptions): UseLiveKitRoomRe // 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) { @@ -505,6 +524,13 @@ export function useLiveKitRoom(options: UseLiveKitRoomOptions): UseLiveKitRoomRe 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...'); @@ -603,6 +629,12 @@ export function useLiveKitRoom(options: UseLiveKitRoomOptions): UseLiveKitRoomRe // 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(); diff --git a/julia-agent/julia-ai/src/agent.py b/julia-agent/julia-ai/src/agent.py index 6d14dbd..afb2716 100644 --- a/julia-agent/julia-ai/src/agent.py +++ b/julia-agent/julia-ai/src/agent.py @@ -191,9 +191,10 @@ class WellNuoLLM(llm.LLM): token = await self._ensure_token() async with aiohttp.ClientSession() as session: - # Using ask_wellnuo_ai instead of voice_ask (same params, same response) + # Using voice_ask - MUCH faster than ask_wellnuo_ai (1s vs 27s) + # ask_wellnuo_ai has 20x higher latency and causes timeouts data = { - "function": "ask_wellnuo_ai", + "function": "voice_ask", "clientId": "MA_001", "user_name": WELLNUO_USER, "token": token, diff --git a/services/callManager.ts b/services/callManager.ts new file mode 100644 index 0000000..78306fd --- /dev/null +++ b/services/callManager.ts @@ -0,0 +1,111 @@ +/** + * CallManager - Singleton to manage active voice calls + * + * Ensures only ONE voice call can be active at a time per device. + * If a new call is started while another is active, the old one is disconnected first. + * + * This addresses the LiveKit concurrent agent jobs limit (5 per project). + */ + +type DisconnectCallback = () => Promise; + +class CallManager { + private static instance: CallManager; + private activeCallId: string | null = null; + private disconnectCallback: DisconnectCallback | null = null; + + private constructor() { + // Singleton + } + + static getInstance(): CallManager { + if (!CallManager.instance) { + CallManager.instance = new CallManager(); + } + return CallManager.instance; + } + + /** + * Register a new call. If there's an existing call, disconnect it first. + * @param callId Unique ID for this call + * @param onDisconnect Callback to disconnect this call + * @returns true if this call can proceed + */ + async registerCall( + callId: string, + onDisconnect: DisconnectCallback + ): Promise { + console.log(`[CallManager] Registering call: ${callId}`); + + // If there's an active call, disconnect it first + if (this.activeCallId && this.activeCallId !== callId) { + console.log( + `[CallManager] Active call exists (${this.activeCallId}), disconnecting...` + ); + + if (this.disconnectCallback) { + try { + await this.disconnectCallback(); + console.log(`[CallManager] Previous call disconnected`); + } catch (err) { + console.error(`[CallManager] Error disconnecting previous call:`, err); + } + } + } + + // Register the new call + this.activeCallId = callId; + this.disconnectCallback = onDisconnect; + console.log(`[CallManager] Call ${callId} is now active`); + + return true; + } + + /** + * Unregister a call when it ends + * @param callId The call ID to unregister + */ + unregisterCall(callId: string): void { + if (this.activeCallId === callId) { + console.log(`[CallManager] Unregistering call: ${callId}`); + this.activeCallId = null; + this.disconnectCallback = null; + } else { + console.log( + `[CallManager] Call ${callId} is not active, ignoring unregister` + ); + } + } + + /** + * Check if there's an active call + */ + hasActiveCall(): boolean { + return this.activeCallId !== null; + } + + /** + * Get the current active call ID + */ + getActiveCallId(): string | null { + return this.activeCallId; + } + + /** + * Force disconnect the active call (if any) + */ + async forceDisconnect(): Promise { + if (this.activeCallId && this.disconnectCallback) { + console.log(`[CallManager] Force disconnecting call: ${this.activeCallId}`); + try { + await this.disconnectCallback(); + } catch (err) { + console.error(`[CallManager] Error force disconnecting:`, err); + } + this.activeCallId = null; + this.disconnectCallback = null; + } + } +} + +export const callManager = CallManager.getInstance();