Skip to content

Commit 7efa7a4

Browse files
committed
feat: enhance audio playback handling with improved chunk processing and setup management
1 parent 7930dda commit 7efa7a4

3 files changed

Lines changed: 141 additions & 18 deletions

File tree

src/hooks/useVoiceChat.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function useVoiceChat() {
7272
const isMutedRef = useRef<boolean>(false);
7373
const isManuallyMutedRef = useRef<boolean>(false); // Distingue tra mute manuale e automatico
7474
const isStartingRecordingRef = useRef<boolean>(false); // Previene avvii concorrenti di registrazione
75+
const isReceivingAudioRef = useRef<boolean>(false); // true quando stiamo ricevendo chunk audio dal server
7576

7677
/**
7778
* Verifica e richiede i permessi audio
@@ -149,7 +150,7 @@ export function useVoiceChat() {
149150
}
150151
},
151152

152-
onStatus: (phase: VoiceServerPhase, message: string) => {
153+
onStatus: async (phase: VoiceServerPhase, message: string) => {
153154
console.log(`[useVoiceChat] onStatus: phase=${phase}, message=${message}`);
154155
setServerStatus({ phase, message });
155156

@@ -217,12 +218,13 @@ export function useVoiceChat() {
217218
agentEndedRef.current = true;
218219

219220
// IMPORTANTE: Non riattivare il microfono se:
220-
// 1. Ci sono chunk audio in coda da riprodurre
221+
// 1. Ci sono chunk audio in coda o in fase di processamento
221222
// 2. L'audio player sta ATTIVAMENTE riproducendo
222-
const hasQueuedChunks = audioPlayerRef.current && audioPlayerRef.current.getChunksCount() > 0;
223+
// 3. Stiamo ancora ricevendo audio dal server
224+
const hasPending = audioPlayerRef.current?.hasPendingOrQueuedChunks();
223225
const isPlaying = audioPlayerRef.current?.isCurrentlyPlaying();
224226

225-
if (!hasQueuedChunks && !isPlaying) {
227+
if (!hasPending && !isPlaying && !isReceivingAudioRef.current) {
226228
console.log('[useVoiceChat] Nessun audio in riproduzione, auto-unmute');
227229
setState('ready');
228230

@@ -239,17 +241,20 @@ export function useVoiceChat() {
239241
}, 100);
240242
}
241243
} else {
242-
console.log(`[useVoiceChat] Audio in corso (chunks: ${audioPlayerRef.current?.getChunksCount() || 0}, playing: ${isPlaying}), mantengo mute fino a fine riproduzione`);
244+
console.log(`[useVoiceChat] Audio in corso (pending: ${hasPending}, playing: ${isPlaying}, receiving: ${isReceivingAudioRef.current}), mantengo mute fino a fine riproduzione`);
243245
}
244246
break;
245247

246248
case 'audio_end':
247249
// Server ha finito di inviare chunk audio per questo segmento.
250+
// Segna che non arriveranno altri chunk.
251+
isReceivingAudioRef.current = false;
252+
248253
// Con TrackPlayer la riproduzione è già in corso in streaming.
249-
// Segnaliamo che non arriveranno altri chunk per gestire il completamento.
250-
if (audioPlayerRef.current && audioPlayerRef.current.getChunksCount() > 0) {
254+
// Controlliamo sia i chunk processati che quelli ancora pending.
255+
if (audioPlayerRef.current && audioPlayerRef.current.hasPendingOrQueuedChunks()) {
251256
setState('speaking');
252-
console.log(`[useVoiceChat] audio_end ricevuto (${audioPlayerRef.current.getChunksCount()} chunk) - riproduzione streaming in corso`);
257+
console.log(`[useVoiceChat] audio_end ricevuto (${audioPlayerRef.current.getChunksCount()} chunk processati, pending: ${audioPlayerRef.current.hasPendingOrQueuedChunks()}) - avvio completamento`);
253258
audioPlayerRef.current.signalAllChunksReceived(() => {
254259
console.log('[useVoiceChat] Riproduzione streaming completata');
255260
// Riattiva il microfono SOLO se l'agent ha finito completamente
@@ -300,8 +305,9 @@ export function useVoiceChat() {
300305
// Risposta interrotta dall'utente, torna pronto
301306
console.log('[useVoiceChat] Risposta interrotta, auto-unmute');
302307
agentEndedRef.current = true; // Reset
308+
isReceivingAudioRef.current = false; // Reset
303309
if (audioPlayerRef.current) {
304-
audioPlayerRef.current.stopPlayback();
310+
await audioPlayerRef.current.stopPlayback();
305311
audioPlayerRef.current.clearChunks();
306312
}
307313
setState('ready');
@@ -324,6 +330,9 @@ export function useVoiceChat() {
324330

325331
onAudioChunk: (audioData: string, chunkIndex: number) => {
326332
if (audioPlayerRef.current) {
333+
// Segna che stiamo ricevendo audio SINCRONAMENTE prima dell'async addChunk
334+
isReceivingAudioRef.current = true;
335+
327336
// Aggiunge il chunk alla queue di TrackPlayer e avvia riproduzione streaming
328337
audioPlayerRef.current.addChunk(audioData, chunkIndex).catch(err => {
329338
console.error('[useVoiceChat] Errore aggiunta chunk a TrackPlayer:', err);
@@ -375,6 +384,11 @@ export function useVoiceChat() {
375384
audioPlayerRef.current = new AudioPlayer();
376385
websocketRef.current = new VoiceBotWebSocket(websocketCallbacks);
377386

387+
// Pre-inizializza TrackPlayer per evitare ritardi al primo chunk audio.
388+
// Questo elimina la race condition dove audio_end/agent_end arrivano
389+
// prima che TrackPlayer abbia finito il setup.
390+
await audioPlayerRef.current.preSetup();
391+
378392
return true;
379393
} catch (err) {
380394
console.error('Errore inizializzazione:', err);
@@ -401,6 +415,7 @@ export function useVoiceChat() {
401415
shouldAutoStartRecordingRef.current = true;
402416
agentEndedRef.current = true; // Reset per nuova sessione
403417
isManuallyMutedRef.current = false; // Reset mute manuale
418+
isReceivingAudioRef.current = false; // Reset audio reception
404419

405420
try {
406421
const connected = await websocketRef.current!.connect();

src/services/voiceBotService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export enum WebSocketAuthState {
101101
* Callback per gestire i diversi tipi di risposta dal WebSocket vocale
102102
*/
103103
export interface VoiceChatCallbacks {
104-
onStatus?: (phase: VoiceServerPhase, message: string) => void;
104+
onStatus?: (phase: VoiceServerPhase, message: string) => void | Promise<void>;
105105
onAudioChunk?: (audioData: string, chunkIndex: number) => void;
106106
onTranscript?: (role: 'user' | 'assistant', content: string) => void;
107107
onToolCall?: (toolName: string, args: string) => void;

src/utils/audioUtils.ts

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -281,18 +281,40 @@ export class AudioRecorder {
281281
export class AudioPlayer {
282282
private isPlaying: boolean = false;
283283
private isSetup: boolean = false;
284+
private isSettingUp: boolean = false;
285+
private setupPromise: Promise<void> | null = null;
284286
private tempFiles: string[] = [];
285287
private chunkCounter: number = 0;
288+
private pendingChunks: number = 0; // Incrementato SINCRONAMENTE prima dell'async
286289
private onCompleteCallback: (() => void) | null = null;
287290
private queueEndedListener: any = null;
288291
private allChunksReceived: boolean = false;
289292

290293
/**
291-
* Inizializza TrackPlayer se non ancora configurato
294+
* Inizializza TrackPlayer se non ancora configurato.
295+
* Usa un pattern singleton per evitare setup multipli concorrenti.
292296
*/
293297
async setup(): Promise<void> {
294298
if (this.isSetup) return;
295299

300+
// Se è già in corso un setup, attendi quello
301+
if (this.isSettingUp && this.setupPromise) {
302+
await this.setupPromise;
303+
return;
304+
}
305+
306+
this.isSettingUp = true;
307+
this.setupPromise = this._doSetup();
308+
309+
try {
310+
await this.setupPromise;
311+
} finally {
312+
this.isSettingUp = false;
313+
this.setupPromise = null;
314+
}
315+
}
316+
317+
private async _doSetup(): Promise<void> {
296318
try {
297319
await TrackPlayer.setupPlayer({
298320
autoHandleInterruptions: true,
@@ -311,17 +333,38 @@ export class AudioPlayer {
311333
}
312334
}
313335

336+
/**
337+
* Pre-inizializza TrackPlayer anticipatamente (da chiamare all'avvio sessione).
338+
* Evita il ritardo del primo addChunk().
339+
*/
340+
async preSetup(): Promise<void> {
341+
try {
342+
await this.setup();
343+
} catch (error) {
344+
console.error('TrackPlayer: Errore pre-setup:', error);
345+
}
346+
}
347+
314348
/**
315349
* Aggiunge un chunk PCM16 base64 direttamente alla queue di TrackPlayer.
316350
* Il chunk viene wrappato in WAV, scritto su file e aggiunto alla queue.
317351
* Se è il primo chunk, avvia la riproduzione immediatamente.
318352
*/
319353
async addChunk(base64Data: string, chunkIndex?: number): Promise<boolean> {
354+
// Incrementa il contatore SINCRONAMENTE prima di qualsiasi operazione async.
355+
// Questo garantisce che getChunksCount() e hasPendingChunks() riflettano
356+
// immediatamente la presenza di chunk in arrivo, evitando race conditions
357+
// con agent_end/audio_end che controllano se c'è audio da riprodurre.
358+
this.pendingChunks++;
359+
320360
try {
321361
await this.setup();
322362

323363
const binary = decodeBase64(base64Data);
324-
if (binary.length === 0) return false;
364+
if (binary.length === 0) {
365+
this.pendingChunks--;
366+
return false;
367+
}
325368

326369
// Wrappa in WAV
327370
const wavData = wrapPcm16InWav(binary, AUDIO_CONFIG.SAMPLE_RATE);
@@ -343,6 +386,7 @@ export class AudioPlayer {
343386
});
344387

345388
this.chunkCounter++;
389+
this.pendingChunks--;
346390

347391
// Avvia la riproduzione al primo chunk
348392
if (!this.isPlaying) {
@@ -351,34 +395,88 @@ export class AudioPlayer {
351395
console.log('TrackPlayer: Riproduzione streaming avviata');
352396
}
353397

398+
// Se tutti i chunk sono stati segnalati (audio_end ricevuto) e non ci sono
399+
// altri chunk in attesa, ora possiamo configurare il listener di completamento.
400+
// Questo gestisce il caso in cui audio_end arriva mentre i chunk sono ancora
401+
// in fase di scrittura su file.
402+
if (this.allChunksReceived && this.pendingChunks === 0 && !this.queueEndedListener) {
403+
console.log(`TrackPlayer: Ultimo chunk pending processato, configuro listener completamento (${this.chunkCounter} chunk totali)`);
404+
this.setupQueueEndedListener();
405+
}
406+
354407
return true;
355408
} catch (error) {
409+
this.pendingChunks--;
410+
411+
// Anche in caso di errore, se era l'ultimo pending e allChunksReceived,
412+
// dobbiamo configurare il completamento
413+
if (this.allChunksReceived && this.pendingChunks === 0 && !this.queueEndedListener) {
414+
this.setupQueueEndedListener();
415+
}
416+
356417
console.error('TrackPlayer: Errore aggiunta chunk:', error);
357418
return false;
358419
}
359420
}
360421

361422
/**
362423
* Segnala che tutti i chunk sono stati ricevuti (audio_end).
363-
* Registra il listener per la fine della queue per invocare onComplete.
424+
* Se ci sono chunk ancora in fase di processamento (pendingChunks > 0),
425+
* il listener di completamento verrà configurato da addChunk() quando
426+
* l'ultimo chunk pending viene processato.
364427
*/
365428
async signalAllChunksReceived(onComplete?: () => void): Promise<void> {
366429
this.allChunksReceived = true;
367430
this.onCompleteCallback = onComplete || null;
368431

369-
// Se non sta riproducendo (nessun chunk ricevuto), completa subito
370-
if (!this.isPlaying) {
432+
// Se ci sono chunk ancora in fase di processamento async (scrittura file WAV),
433+
// NON configurare il listener ora. addChunk() lo farà dopo aver processato
434+
// l'ultimo chunk pending.
435+
if (this.pendingChunks > 0) {
436+
console.log(`TrackPlayer: ${this.pendingChunks} chunk ancora in elaborazione, listener differito`);
437+
return;
438+
}
439+
440+
// Nessun chunk pending. Se non sta nemmeno riproducendo (nessun chunk mai ricevuto),
441+
// completa subito.
442+
if (!this.isPlaying && this.chunkCounter === 0) {
443+
console.log('TrackPlayer: Nessun chunk ricevuto, completamento immediato');
371444
this.handlePlaybackComplete();
372445
return;
373446
}
374447

375-
// Controlla se la riproduzione è già terminata
376-
const state = await TrackPlayer.getPlaybackState();
377-
if (state.state === State.Ended || state.state === State.Stopped) {
448+
// Tutti i chunk sono stati aggiunti alla queue, configura il listener
449+
this.setupQueueEndedListener();
450+
}
451+
452+
/**
453+
* Configura il listener per la fine della riproduzione della queue.
454+
* Controlla prima se la riproduzione è già terminata.
455+
*/
456+
private async setupQueueEndedListener(): Promise<void> {
457+
// Safety: rimuovi listener precedente se presente
458+
if (this.queueEndedListener) {
459+
this.queueEndedListener.remove();
460+
this.queueEndedListener = null;
461+
}
462+
463+
// Se non sta riproducendo (tutti i chunk sono falliti?), completa subito
464+
if (!this.isPlaying) {
378465
this.handlePlaybackComplete();
379466
return;
380467
}
381468

469+
// Controlla se la riproduzione è già terminata
470+
try {
471+
const state = await TrackPlayer.getPlaybackState();
472+
if (state.state === State.Ended || state.state === State.Stopped) {
473+
this.handlePlaybackComplete();
474+
return;
475+
}
476+
} catch (error) {
477+
console.error('TrackPlayer: Errore verifica stato:', error);
478+
}
479+
382480
// Registra listener per la fine della queue
383481
this.queueEndedListener = TrackPlayer.addEventListener(
384482
Event.PlaybackQueueEnded,
@@ -440,6 +538,7 @@ export class AudioPlayer {
440538
clearChunks(): void {
441539
this.allChunksReceived = false;
442540
this.chunkCounter = 0;
541+
this.pendingChunks = 0;
443542
}
444543

445544
async stopPlayback(): Promise<void> {
@@ -458,6 +557,7 @@ export class AudioPlayer {
458557
this.onCompleteCallback = null;
459558
this.allChunksReceived = false;
460559
this.chunkCounter = 0;
560+
this.pendingChunks = 0;
461561
await this.cleanupTempFiles();
462562
}
463563

@@ -469,6 +569,14 @@ export class AudioPlayer {
469569
return this.chunkCounter;
470570
}
471571

572+
/**
573+
* Restituisce true se ci sono chunk in fase di processamento (non ancora aggiunti alla queue)
574+
* o già aggiunti alla queue. Utile per evitare premature auto-unmute.
575+
*/
576+
hasPendingOrQueuedChunks(): boolean {
577+
return this.pendingChunks > 0 || this.chunkCounter > 0;
578+
}
579+
472580
async destroy(): Promise<void> {
473581
await this.stopPlayback();
474582
}

0 commit comments

Comments
 (0)