expria-frontend/src/features/t2-live/hooks/useAudioCapture.ts
Hermann_Kitio 72795e924e 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.
2026-06-29 14:31:38 +03:00

224 lines
7.3 KiB
TypeScript

/**
* useAudioCapture — Hook de capture micro pour T2 Live (Sprint 6b).
*
* Encapsule le pipeline :
* getUserMedia → AudioContext → AudioWorklet (pcm-capture-processor.js)
* → chunks PCM 16 kHz Int16 LE → base64 → onChunk()
*
* Le worklet gère le rééchantillonnage si le sample rate natif diffère de 16 kHz.
* Le hook ne touche pas au WebSocket — l'appelant (Sprint 6c) branche `onChunk`
* sur `ws.send`.
*
* Cleanup garanti : tracks.stop(), worklet.disconnect(), context.close() au
* stop() ou au démontage du composant.
*/
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
import { arrayBufferToBase64 } from '@/shared/lib/audio-utils'
export interface UseAudioCaptureOptions {
/** Callback invoqué pour chaque chunk PCM 16 kHz encodé en base64. */
onChunk: (base64: string) => void
}
export interface UseAudioCaptureResult {
start: () => Promise<void>
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 où 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'
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).
const optionsRef = useRef(options)
useEffect(() => {
optionsRef.current = options
})
const cleanup = useCallback(() => {
if (workletNodeRef.current) {
try {
workletNodeRef.current.port.onmessage = null
workletNodeRef.current.disconnect()
} catch {
/* ignore */
}
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()
} catch {
/* ignore */
}
sourceNodeRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => {
try {
t.stop()
} catch {
/* ignore */
}
})
streamRef.current = null
}
if (contextRef.current) {
try {
void contextRef.current.close()
} catch {
/* ignore */
}
contextRef.current = null
}
}, [])
const start = useCallback(async () => {
if (isCapturing) return
setError(null)
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
})
streamRef.current = stream
// 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)
const source = ctx.createMediaStreamSource(stream)
sourceNodeRef.current = source
const workletNode = new AudioWorkletNode(ctx, 'pcm-capture-processor')
workletNodeRef.current = workletNode
workletNode.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
try {
optionsRef.current.onChunk(arrayBufferToBase64(e.data))
} catch {
/* ignore — ne pas casser le worklet sur callback throw */
}
}
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'
setError(message)
cleanup()
}
}, [cleanup, isCapturing])
const stop = useCallback(() => {
cleanup()
setIsCapturing(false)
}, [cleanup])
// Cleanup au démontage.
useEffect(() => {
return () => {
cleanup()
}
}, [cleanup])
// 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,
}
}