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:
parent
9bf95f5c05
commit
72795e924e
16 changed files with 848 additions and 257 deletions
|
|
@ -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 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'
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue