Skip to content

Commit dbab6ff

Browse files
Gabry848claude
andcommitted
fix(voice): mic sempre acceso, rimuovi gate client-side
Il mic non viene più fermato/riavviato automaticamente su speech_stopped, agent_start, agent_end, audio_end o interrupted. Rimane sempre attivo per tutta la sessione: è il server gate in voice_bridge.py a bloccare i frame durante l'elaborazione, evitando che l'utente perda l'inizio del proprio parlato nei ~500ms di riavvio mic + dead zone precedenti. La logica di mute/unmute manuale è invariata. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3e54bbc commit dbab6ff

1 file changed

Lines changed: 20 additions & 233 deletions

File tree

src/hooks/useVoiceChat.ts

Lines changed: 20 additions & 233 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,10 @@ export function useVoiceChat() {
6969
const audioPlayerRef = useRef<AudioPlayer | null>(null);
7070
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null);
7171
const shouldAutoStartRecordingRef = useRef<boolean>(false);
72-
const agentEndedRef = useRef<boolean>(true); // true = agent ha finito, possiamo registrare
72+
const agentEndedRef = useRef<boolean>(true);
7373
const isMutedRef = useRef<boolean>(false);
74-
const isManuallyMutedRef = useRef<boolean>(false); // Distingue tra mute manuale e automatico
7574
const isStartingRecordingRef = useRef<boolean>(false); // Previene avvii concorrenti di registrazione
76-
const isReceivingAudioRef = useRef<boolean>(false); // true quando stiamo ricevendo chunk audio dal server
7775
const isMountedRef = useRef<boolean>(true); // Guard per evitare setState dopo unmount
78-
// Quando true il mic è acceso ma i frame NON vengono inviati al server.
79-
// Viene usato subito dopo la fine di una risposta: il mic resta pronto localmente
80-
// ma non invia nulla finché il server non segnala che è pronto ad ascoltare
81-
// (speech_started o nuovo agent_start). Evita il loop interrupted causato da
82-
// audio/silenzio inviato nella finestra tra audio_end e il prossimo turno.
83-
const isAudioGatedRef = useRef<boolean>(false);
84-
const consecutiveEmptyAgentsRef = useRef<number>(0); // Contatore cicli agente vuoti consecutivi
85-
const agentHadAudioRef = useRef<boolean>(false); // True se l'agente corrente ha prodotto audio
8676

8777
/**
8878
* Verifica e richiede i permessi audio
@@ -196,250 +186,67 @@ export function useVoiceChat() {
196186

197187
switch (phase) {
198188
case 'speech_started':
199-
// Utente ha iniziato a parlare (VAD di OpenAI): apri il gate audio.
200-
// Da questo momento i frame vengono inviati al server.
201-
consecutiveEmptyAgentsRef.current = 0; // Reset: l'utente sta davvero parlando
202-
isAudioGatedRef.current = false;
189+
// VAD OpenAI: utente sta parlando
203190
setState('recording');
204191
break;
205192

206193
case 'speech_stopped':
207-
// Utente ha finito di parlare (VAD di OpenAI)
208-
// IMPORTANTE: Fermiamo il microfono QUI e non lo riattiveremo
209-
// finché l'agent non ha completato TUTTO (elaborazione + riproduzione)
210-
211-
// Auto-mute: ferma la registrazione
212-
if (audioRecorderRef.current?.isCurrentlyRecording()) {
213-
audioRecorderRef.current.stopRecording().catch(err => {
214-
console.error('Errore fermando registrazione su speech_stopped:', err);
215-
});
216-
if (recordingIntervalRef.current) {
217-
clearInterval(recordingIntervalRef.current);
218-
recordingIntervalRef.current = null;
219-
}
220-
setRecordingDuration(0);
221-
}
222-
223-
// Aggiorna UI del mute (solo se non è mutato manualmente)
224-
if (!isManuallyMutedRef.current) {
225-
setIsMuted(true);
226-
isMutedRef.current = true;
227-
}
228-
194+
// VAD OpenAI: utente ha smesso di parlare — il mic resta acceso,
195+
// il server gate blocca i frame finché OpenAI non è pronto ad ascoltare
229196
setState('processing');
230197
break;
231198

232199
case 'agent_start':
233-
console.log('[useVoiceChat] Inizio risposta chatbot');
234200
setState('processing');
235-
agentEndedRef.current = false; // Agent sta elaborando
236-
agentHadAudioRef.current = false; // Reset: nuovo agente, nessun audio ancora
237-
// Gate: mentre l'agent elabora il mic è fisicamente fermo (stopRecording sotto),
238-
// quindi il gate non è rilevante qui. Viene chiuso al riavvio del mic dopo agent_end.
239-
240-
// Auto-mute (safety check): assicuriamoci che il microfono sia fermato
241-
if (audioRecorderRef.current?.isCurrentlyRecording()) {
242-
audioRecorderRef.current.stopRecording().catch(err => {
243-
console.error('Errore fermando registrazione su agent_start:', err);
244-
});
245-
if (recordingIntervalRef.current) {
246-
clearInterval(recordingIntervalRef.current);
247-
recordingIntervalRef.current = null;
248-
}
249-
setRecordingDuration(0);
250-
}
251-
252-
// Aggiorna UI del mute (solo se non è mutato manualmente)
253-
if (!isManuallyMutedRef.current) {
254-
setIsMuted(true);
255-
isMutedRef.current = true;
256-
}
201+
agentEndedRef.current = false;
257202
break;
258203

259204
case 'agent_end':
260-
// Agent ha finito di elaborare
261205
agentEndedRef.current = true;
262-
263-
// IMPORTANTE: Non riattivare il microfono se:
264-
// 1. Ci sono chunk audio in coda o in fase di processamento
265-
// 2. L'audio player sta ATTIVAMENTE riproducendo
266-
// 3. Stiamo ancora ricevendo audio dal server
267-
const hasPending = audioPlayerRef.current?.hasPendingOrQueuedChunks();
268-
const isPlaying = audioPlayerRef.current?.isCurrentlyPlaying();
269-
270-
if (!hasPending && !isPlaying && !isReceivingAudioRef.current) {
206+
// Se non ci sono chunk audio pendenti, torna subito a ready
207+
if (!audioPlayerRef.current?.hasPendingOrQueuedChunks() &&
208+
!audioPlayerRef.current?.isCurrentlyPlaying()) {
271209
console.log('[useVoiceChat] Fine risposta chatbot');
272210
setState('ready');
273-
274-
// Auto-unmute: riattiva microfono (solo se non mutato manualmente)
275-
if (!isManuallyMutedRef.current) {
276-
setIsMuted(false);
277-
isMutedRef.current = false;
278-
279-
// Controlla se l'agente ha prodotto audio (risposta legittima) o era vuoto (ciclo spurio).
280-
// Un ciclo agente senza audio indica una fase di pianificazione tool call:
281-
// il modello ha deciso cosa fare ma non ha ancora parlato. Riavviare il mic
282-
// in questo momento crea una finestra (~374ms) in cui il mic invia audio
283-
// mentre il tool è in esecuzione → VAD trigge → risposta del secondo
284-
// ciclo interrotta prima ancora di partire.
285-
// Fix: al primo ciclo senza audio non riavviare il mic; il secondo ciclo
286-
// (con audio) gestirà correttamente il riavvio.
287-
if (!agentHadAudioRef.current) {
288-
consecutiveEmptyAgentsRef.current++;
289-
} else {
290-
consecutiveEmptyAgentsRef.current = 0;
291-
}
292-
293-
// Non riavviare il mic se questo ciclo agente non ha prodotto audio.
294-
// Copre sia i cicli di pianificazione tool (1 ciclo vuoto) sia eventuali
295-
// cicli spurii multipli consecutivi.
296-
if (consecutiveEmptyAgentsRef.current >= 1) {
297-
consecutiveEmptyAgentsRef.current = 0;
298-
// Non riavviare il mic: l'agente sta ancora elaborando (tool call in corso).
299-
// Il ciclo successivo con audio gestirà il riavvio.
300-
break;
301-
}
302-
303-
isAudioGatedRef.current = true;
304-
305-
setTimeout(() => {
306-
if (audioRecorderRef.current && websocketRef.current?.isReady()) {
307-
startRecording();
308-
setTimeout(() => { isAudioGatedRef.current = false; }, 400);
309-
}
310-
}, 100);
311-
}
312-
} else {
313211
}
314212
break;
315213

316214
case 'audio_end':
317-
// Server ha finito di inviare chunk audio per questo segmento.
318-
// Segna che non arriveranno altri chunk.
319-
isReceivingAudioRef.current = false;
320-
321-
// Con TrackPlayer la riproduzione è già in corso in streaming.
322-
// Controlliamo sia i chunk processati che quelli ancora pending.
323-
if (audioPlayerRef.current && audioPlayerRef.current.hasPendingOrQueuedChunks()) {
215+
if (audioPlayerRef.current?.hasPendingOrQueuedChunks()) {
324216
setState('speaking');
325217
audioPlayerRef.current.signalAllChunksReceived(() => {
326-
// Riattiva il microfono SOLO se l'agent ha finito completamente
327218
if (agentEndedRef.current) {
328219
console.log('[useVoiceChat] Fine risposta chatbot');
329220
setState('ready');
330-
331-
// Auto-unmute: riattiva microfono (solo se non mutato manualmente)
332-
if (!isManuallyMutedRef.current) {
333-
setIsMuted(false);
334-
isMutedRef.current = false;
335-
336-
// Chiudi il gate brevemente: nei primi 400ms dopo la fine della risposta
337-
// non inviamo audio per evitare il loop "interrupted".
338-
// Dopo 400ms apriamo il gate automaticamente: il server è pronto ad
339-
// ascoltare e speech_started potrebbe non arrivare mai se non inviamo audio.
340-
isAudioGatedRef.current = true;
341-
342-
// Riavvia registrazione dopo un breve delay
343-
setTimeout(() => {
344-
if (audioRecorderRef.current && websocketRef.current?.isReady()) {
345-
startRecording();
346-
setTimeout(() => { isAudioGatedRef.current = false; }, 400);
347-
}
348-
}, 100);
349-
}
350221
} else {
351-
// Agent non ha ancora finito, torna in processing
352-
// e aspetta altri chunk audio o agent_end
353222
setState('processing');
354223
}
355224
});
356225
} else if (agentEndedRef.current) {
357-
// Nessun audio da riprodurre e agent finito, torna pronto
358226
console.log('[useVoiceChat] Fine risposta chatbot');
359227
setState('ready');
360-
361-
// Auto-unmute: riattiva microfono (solo se non mutato manualmente)
362-
if (!isManuallyMutedRef.current) {
363-
setIsMuted(false);
364-
isMutedRef.current = false;
365-
366-
isAudioGatedRef.current = true;
367-
368-
setTimeout(() => {
369-
if (audioRecorderRef.current && websocketRef.current?.isReady()) {
370-
startRecording();
371-
setTimeout(() => { isAudioGatedRef.current = false; }, 400);
372-
}
373-
}, 100);
374-
}
375228
}
376229
break;
377230

378231
case 'interrupted':
379-
// Risposta interrotta dall'utente, torna pronto
380-
agentEndedRef.current = true; // Reset
381-
isReceivingAudioRef.current = false; // Reset
382-
agentHadAudioRef.current = false; // Reset
383-
384-
// IMPORTANTE: ferma PRIMA il microfono (con await) per evitare che il server
385-
// riceva audio tra interrupted e il prossimo agent_start.
386-
// Il await è critico: senza di esso il mic non è ancora fermo quando il
387-
// setTimeout da 400ms scatta, causando un riavvio quasi immediato.
388-
if (audioRecorderRef.current?.isCurrentlyRecording()) {
389-
try {
390-
await audioRecorderRef.current.stopRecording();
391-
} catch (err) {
392-
console.error('[useVoiceChat] Errore fermando registrazione su interrupted:', err);
393-
}
394-
if (recordingIntervalRef.current) {
395-
clearInterval(recordingIntervalRef.current);
396-
recordingIntervalRef.current = null;
397-
}
398-
setRecordingDuration(0);
399-
}
400-
isStartingRecordingRef.current = false; // Reset mutex per permettere riavvio
401-
232+
agentEndedRef.current = true;
233+
// Ferma solo la riproduzione audio, il mic resta acceso
402234
if (audioPlayerRef.current) {
403235
await audioPlayerRef.current.stopPlayback();
404236
audioPlayerRef.current.clearChunks();
405237
}
406238
setState('ready');
407-
408-
// Auto-unmute: riattiva microfono (solo se non mutato manualmente)
409-
if (!isManuallyMutedRef.current) {
410-
setIsMuted(false);
411-
isMutedRef.current = false;
412-
413-
// Chiudi il gate: il mic si riaccende ma non invia audio per 400ms.
414-
// Questo previene il loop "interrupted → mic invia silenzio → altro interrupted".
415-
// Dopo 400ms dal riavvio del mic il gate si apre automaticamente.
416-
isAudioGatedRef.current = true;
417-
418-
setTimeout(() => {
419-
if (audioRecorderRef.current && websocketRef.current?.isReady()) {
420-
startRecording();
421-
setTimeout(() => { isAudioGatedRef.current = false; }, 400);
422-
}
423-
}, 400);
424-
}
425239
break;
426240
}
427241
},
428242

429243
onAudioChunk: (audioData: string, chunkIndex: number) => {
430244
if (!isMountedRef.current) return;
431245
if (audioPlayerRef.current) {
432-
// Segna che stiamo ricevendo audio SINCRONAMENTE prima dell'async addChunk
433-
isReceivingAudioRef.current = true;
434-
agentHadAudioRef.current = true; // L'agente corrente ha prodotto audio → risposta legittima
435-
436-
// Aggiunge il chunk alla queue di TrackPlayer e avvia riproduzione streaming
437246
audioPlayerRef.current.addChunk(audioData, chunkIndex).catch(err => {
438247
console.error('[useVoiceChat] Errore aggiunta chunk a TrackPlayer:', err);
439248
});
440249
setChunksReceived(prev => prev + 1);
441-
442-
// Transiziona a 'speaking' al primo chunk ricevuto
443250
setState(prev => prev !== 'speaking' ? 'speaking' : prev);
444251
}
445252
},
@@ -529,10 +336,7 @@ export function useVoiceChat() {
529336
setActiveTools([]);
530337
setChunksReceived(0);
531338
shouldAutoStartRecordingRef.current = true;
532-
agentEndedRef.current = true; // Reset per nuova sessione
533-
isManuallyMutedRef.current = false; // Reset mute manuale
534-
isReceivingAudioRef.current = false; // Reset audio reception
535-
isAudioGatedRef.current = false; // Gate aperto all'inizio della sessione
339+
agentEndedRef.current = true;
536340

537341
try {
538342
const connected = await websocketRef.current!.connect();
@@ -580,11 +384,9 @@ export function useVoiceChat() {
580384

581385
try {
582386
// Callback invocato per ogni chunk audio PCM16 a 24kHz.
583-
// Se isAudioGatedRef è true, i frame vengono scartati localmente senza inviarli
584-
// al server. Il gate si chiude subito dopo la fine di una risposta e si apre
585-
// quando il server è pronto ad ascoltare (speech_started o nuovo agent_start).
387+
// Il mic rimane sempre acceso: è il server gate in voice_bridge.py
388+
// a bloccare i frame durante l'elaborazione dell'agente.
586389
const onChunk = (base64Chunk: string) => {
587-
if (isAudioGatedRef.current) return; // gate chiuso: scarta silenziosamente
588390
try {
589391
const arrayBuffer = base64ToArrayBuffer(base64Chunk);
590392
websocketRef.current?.sendAudio(arrayBuffer);
@@ -695,24 +497,16 @@ export function useVoiceChat() {
695497
const mute = useCallback(async (): Promise<void> => {
696498
setIsMuted(true);
697499
isMutedRef.current = true;
698-
isManuallyMutedRef.current = true; // Marca come mute manuale
699500

700-
// Ferma la registrazione se è attiva
701501
if (audioRecorderRef.current?.isCurrentlyRecording()) {
702502
try {
703503
await audioRecorderRef.current.cancelRecording();
704-
705-
// Pulisci il timer della durata
706504
if (recordingIntervalRef.current) {
707505
clearInterval(recordingIntervalRef.current);
708506
recordingIntervalRef.current = null;
709507
}
710-
711508
setRecordingDuration(0);
712-
// Mantieni lo stato 'ready' invece di tornare a 'recording'
713-
if (state === 'recording') {
714-
setState('ready');
715-
}
509+
if (state === 'recording') setState('ready');
716510
} catch (err) {
717511
console.error('Errore durante il mute:', err);
718512
}
@@ -725,15 +519,11 @@ export function useVoiceChat() {
725519
const unmute = useCallback(async (): Promise<void> => {
726520
setIsMuted(false);
727521
isMutedRef.current = false;
728-
isManuallyMutedRef.current = false; // Rimuove il flag di mute manuale
729522

730-
// Riavvia la registrazione se siamo in stato 'ready'
731-
if (state === 'ready' && websocketRef.current?.isReady()) {
732-
setTimeout(() => {
733-
startRecording();
734-
}, 100);
523+
if (websocketRef.current?.isReady()) {
524+
setTimeout(() => startRecording(), 100);
735525
}
736-
}, [state, startRecording]);
526+
}, [startRecording]);
737527

738528
/**
739529
* Disconnette dal servizio
@@ -776,12 +566,9 @@ export function useVoiceChat() {
776566
setTranscripts([]);
777567
setActiveTools([]);
778568
setRecordingDuration(0);
779-
setIsMuted(false); // Reset mute state
569+
setIsMuted(false);
780570
isMutedRef.current = false;
781-
isManuallyMutedRef.current = false; // Reset mute manuale
782-
isStartingRecordingRef.current = false; // Reset mutex registrazione
783-
isReceivingAudioRef.current = false; // Reset ricezione audio
784-
isAudioGatedRef.current = false; // Reset gate audio
571+
isStartingRecordingRef.current = false;
785572
shouldAutoStartRecordingRef.current = false;
786573
agentEndedRef.current = true;
787574
}, []);

0 commit comments

Comments
 (0)