- FAB button now correctly stops session during speaking/processing states - Echo prevention: STT stopped during TTS playback, results ignored during speaking - Chat TTS only speaks when voice session is active (no auto-speak for text chat) - Session stop now aborts in-flight API requests and prevents race conditions - STT restarts after TTS with 800ms delay for audio focus release - Pending interrupt transcript processed after TTS completion - ChatContext added for message persistence across tab navigation - VoiceFAB redesigned with state-based animations - console.error replaced with console.warn across voice pipeline - no-speech STT errors silenced (normal silence behavior)
110 lines
2.9 KiB
TypeScript
110 lines
2.9 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
|
|
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.warn(`[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.warn(`[CallManager] Error force disconnecting:`, err);
|
|
}
|
|
this.activeCallId = null;
|
|
this.disconnectCallback = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const callManager = CallManager.getInstance();
|