/** * 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 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 /** * 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 /** * 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 } const WORKLET_URL = '/pcm-capture-processor.js' export function useAudioCapture(options: UseAudioCaptureOptions): UseAudioCaptureResult { const [isCapturing, setIsCapturing] = useState(false) const [error, setError] = useState(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(null) const streamRef = useRef(null) const workletNodeRef = useRef(null) const sourceNodeRef = useRef(null) const analyserRef = useRef(null) const mixNodeRef = useRef(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) => { 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, } }