- 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.
224 lines
7.3 KiB
TypeScript
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,
|
|
}
|
|
}
|