feat(t2-live): archi audio Voie A + Bugs 4/5/6 + indicateur de prise de parole (Sprint 6e)

- Voie A WAV : AudioContext unique au rate natif, tap AudioWorklet sur mixGain, uplink rate-aware 16k, alignement par horloge unique (fin offset/resample/concat). Anti-echo candidat. Cycle start=ws.onopen / stop=Terminer / cancel=aucun WAV.
- Bug 4 : 'Voir le rapport' route vers le rapport (navigatingAwayRef).
- Bug 5 : 'Annuler' (cancelDialogue) - arret sans evaluation, sans WAV, sans production.
- Bug 6 : 'Nouvelle simulation' route selon le type via champ tache propage (Report).
- Indicateur de prise de parole : state machine USER_SPEAKING/USER_SILENT (RMS + hysteresis).
- Cleanup : retrait instrumentation [BISECT] ; ref VAD renomme lastAiChunkTsRef.
- Removed : code mort mixTracksToInt16, resample16kTo24k + tests.
This commit is contained in:
Hermann_Kitio 2026-06-29 14:31:38 +03:00
parent 9bf95f5c05
commit 72795e924e
16 changed files with 848 additions and 257 deletions

View file

@ -13,7 +13,7 @@
* stop() ou au démontage du composant.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
import { arrayBufferToBase64 } from '@/shared/lib/audio-utils'
export interface UseAudioCaptureOptions {
@ -26,6 +26,32 @@ export interface UseAudioCaptureResult {
stop: () => void
isCapturing: boolean
error: string | null
/**
* MediaStream micro actif (null hors capture). Lecture NON réactive (ref)
* jamais republié en state (cause de la régression Step 4).
*/
stream: MediaStream | null
/**
* AnalyserNode DÉRIVÉ du graphe de capture (source.connect en parallèle du
* worklet) pour visualiser l'amplitude micro sans toucher au flux montant.
* Exposé par REF stable : le consommateur le lit en rAF sans déclencher de
* re-render. null hors capture.
*/
analyserRef: RefObject<AnalyserNode | null>
/**
* AudioContext de capture (rate natif). Exposé par REF pour que la lecture IA
* (useAudioPlayback) et l'enregistrement WAV (useAudioRecording) partagent la
* MÊME horloge condition de l'alignement temporel natif (Voie A). null hors
* capture.
*/
contextRef: RefObject<AudioContext | null>
/**
* GainNode de mixage : point unique convergent le micro et la voix IA. Le
* tap d'enregistrement (Sprint 6e Step 3) s'y branche. Le micro y est routé
* EN PLUS du worklet/analyser ; il n'est PAS connecté au destination (pas
* d'écho de sa propre voix). null hors capture.
*/
mixNodeRef: RefObject<GainNode | null>
}
const WORKLET_URL = '/pcm-capture-processor.js'
@ -33,11 +59,16 @@ const WORKLET_URL = '/pcm-capture-processor.js'
export function useAudioCapture(options: UseAudioCaptureOptions): UseAudioCaptureResult {
const [isCapturing, setIsCapturing] = useState(false)
const [error, setError] = useState<string | null>(null)
// BISECTION 6e — Step 4 neutralisé : plus de state réactif sur le stream
// (aucun setState → aucun re-render déclenché par la publication du stream).
// Le stream reste interne (streamRef) et est exposé en lecture non réactive.
const contextRef = useRef<AudioContext | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const workletNodeRef = useRef<AudioWorkletNode | null>(null)
const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const mixNodeRef = useRef<GainNode | null>(null)
// Capture options dans une ref pour éviter de réabonner les effets
// sur chaque render (l'appelant fournit souvent un onChunk inline).
@ -56,6 +87,22 @@ export function useAudioCapture(options: UseAudioCaptureOptions): UseAudioCaptur
}
workletNodeRef.current = null
}
if (analyserRef.current) {
try {
analyserRef.current.disconnect()
} catch {
/* ignore */
}
analyserRef.current = null
}
if (mixNodeRef.current) {
try {
mixNodeRef.current.disconnect()
} catch {
/* ignore */
}
mixNodeRef.current = null
}
if (sourceNodeRef.current) {
try {
sourceNodeRef.current.disconnect()
@ -98,9 +145,13 @@ export function useAudioCapture(options: UseAudioCaptureOptions): UseAudioCaptur
})
streamRef.current = stream
// Tenter 16 kHz natif (Chrome / Firefox modernes l'acceptent).
// Sinon, le worklet rééchantillonnera.
const ctx = new AudioContext({ sampleRate: 16000 })
// Rate NATIF (Voie A) : on ne force plus 16 kHz. Le worklet uplink
// (pcm-capture-processor) est rate-aware — il lit le sampleRate global du
// contexte et rééchantillonne vers 16 kHz au besoin, donc le flux montant
// reste un vrai 16 kHz quelle que soit la fréquence native. Garder le rate
// natif permet à la lecture IA et à l'enregistrement de partager une seule
// horloge (alignement temporel natif sans resample dans le chemin WAV).
const ctx = new AudioContext()
contextRef.current = ctx
await ctx.audioWorklet.addModule(WORKLET_URL)
@ -122,6 +173,23 @@ export function useAudioCapture(options: UseAudioCaptureOptions): UseAudioCaptur
source.connect(workletNode)
// Pas besoin de connecter au destination — on ne lit pas le micro local.
// DÉRIVATION : branche un analyser EN PARALLÈLE sur la même source. Il
// n'est pas inséré dans le chemin source→worklet→WS (flux montant
// strictement inchangé) et ne se connecte pas au destination.
const analyser = ctx.createAnalyser()
analyser.fftSize = 256
analyser.smoothingTimeConstant = 0.6
source.connect(analyser)
analyserRef.current = analyser
// MIX (Voie A) : point de convergence unique micro + voix IA. Le micro y
// est routé EN PLUS du worklet/analyser. Le mixGain n'est PAS connecté au
// destination ici (pas d'écho de la voix du candidat) ; la voix IA s'y
// branchera (Step 2) et le tap d'enregistrement le captera (Step 3).
const mixGain = ctx.createGain()
source.connect(mixGain)
mixNodeRef.current = mixGain
setIsCapturing(true)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
@ -142,5 +210,15 @@ export function useAudioCapture(options: UseAudioCaptureOptions): UseAudioCaptur
}
}, [cleanup])
return { start, stop, isCapturing, error }
// Lecture non réactive (ref) — stream, analyser, contexte et mix exposés sans setState.
return {
start,
stop,
isCapturing,
error,
stream: streamRef.current,
analyserRef,
contextRef,
mixNodeRef,
}
}