Voice call improvements: single call limit, hide debug tab, remove speaker toggle

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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-19 23:55:27 -08:00
parent 57577b42c9
commit e3192ead12
5 changed files with 150 additions and 20 deletions

View File

@ -72,14 +72,11 @@ export default function TabLayout() {
),
}}
/>
{/* Debug tab - for testing audio/voice */}
{/* Debug tab - hidden in production */}
<Tabs.Screen
name="debug"
options={{
title: 'Debug',
tabBarIcon: ({ color, size }) => (
<Feather name="terminal" size={22} color={color} />
),
href: null,
}}
/>
{/* Hide explore tab */}

View File

@ -288,19 +288,8 @@ export default function VoiceCallScreen() {
<Ionicons name="call" size={32} color={AppColors.white} />
</TouchableOpacity>
{/* Speaker/Earpiece toggle */}
<TouchableOpacity
style={[styles.controlButton, isSpeakerOn && styles.controlButtonActive]}
onPress={handleToggleSpeaker}
disabled={!isActive}
>
<Ionicons
name={isSpeakerOn ? 'volume-high' : 'ear'}
size={28}
color={isSpeakerOn ? AppColors.success : AppColors.white}
/>
<Text style={styles.controlLabel}>{isSpeakerOn ? 'Speaker' : 'Earpiece'}</Text>
</TouchableOpacity>
{/* Empty placeholder for layout balance */}
<View style={styles.controlButton} />
</View>
</SafeAreaView>
);

View File

@ -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<AppStateStatus>(AppState.currentState);
const callIdRef = useRef<string | null>(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();

View File

@ -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,

111
services/callManager.ts Normal file
View File

@ -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<void>;
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<boolean> {
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<void> {
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();