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
136
src/features/t2-live/components/T2SpeakingIndicator.tsx
Normal file
136
src/features/t2-live/components/T2SpeakingIndicator.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* T2SpeakingIndicator — Indicateur de prise de parole T2 Live (Sprint 6e, ré-impl).
|
||||
*
|
||||
* Remplace l'ancien RecordingWaveform (Step 4 neutralisé : il ouvrait son propre
|
||||
* AudioContext + republiait le stream en state réactif → re-renders →
|
||||
* ré-exécution d'effects → famine du flux montant micro→Gemini).
|
||||
*
|
||||
* Garde-fous (la régression précédente venait de leur violation) :
|
||||
* 1. Le stream micro reste en ref dans useAudioCapture, jamais en state.
|
||||
* 2. L'AnalyserNode est une DÉRIVATION du graphe de capture (source.connect en
|
||||
* parallèle du worklet) ; il ne s'insère PAS dans le chemin montant.
|
||||
* 3. La lecture d'amplitude se fait en requestAnimationFrame, lue par ref, et
|
||||
* écrit la hauteur des barres DIRECTEMENT dans le DOM → aucun setState,
|
||||
* aucun re-render du parent.
|
||||
* 4. Le rAF s'arrête au changement d'état et au démontage (pas d'orphelin) ;
|
||||
* l'analyser lui-même est libéré par le cleanup de useAudioCapture.
|
||||
*
|
||||
* Comportement par état :
|
||||
* - 'ready' → signal de départ « À vous de parler » (point pulsant).
|
||||
* - 'speaking' → barres pilotées par l'amplitude micro RÉELLE (analyser).
|
||||
* - 'listening' → barres décoratives pilotées par l'ÉTAT (CSS), sans sonde audio.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
import { Mic, Volume2 } from 'lucide-react'
|
||||
import type { T2State } from '../state/t2-machine'
|
||||
|
||||
const BAR_COUNT = 5
|
||||
|
||||
interface T2SpeakingIndicatorProps {
|
||||
/** Analyser dérivé du graphe de capture (par ref, jamais en state). */
|
||||
analyserRef: RefObject<AnalyserNode | null>
|
||||
state: T2State
|
||||
}
|
||||
|
||||
export function T2SpeakingIndicator({ analyserRef, state }: T2SpeakingIndicatorProps) {
|
||||
const barRefs = useRef<Array<HTMLSpanElement | null>>([])
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const dataRef = useRef<Uint8Array<ArrayBuffer> | null>(null)
|
||||
|
||||
// rAF actif UNIQUEMENT en 'speaking' : lit l'analyser micro par ref et écrit
|
||||
// la hauteur des barres directement dans le DOM (aucun setState).
|
||||
useEffect(() => {
|
||||
if (state !== 'speaking') return
|
||||
let active = true
|
||||
|
||||
const tick = () => {
|
||||
if (!active) return
|
||||
const analyser = analyserRef.current
|
||||
if (analyser) {
|
||||
if (!dataRef.current || dataRef.current.length !== analyser.fftSize) {
|
||||
dataRef.current = new Uint8Array(analyser.fftSize)
|
||||
}
|
||||
analyser.getByteTimeDomainData(dataRef.current)
|
||||
// RMS de l'onde temporelle (centrée sur 128) → 0..1.
|
||||
let sumSq = 0
|
||||
for (let i = 0; i < dataRef.current.length; i++) {
|
||||
const v = (dataRef.current[i]! - 128) / 128
|
||||
sumSq += v * v
|
||||
}
|
||||
const rms = Math.sqrt(sumSq / dataRef.current.length)
|
||||
const now = performance.now()
|
||||
for (let i = 0; i < barRefs.current.length; i++) {
|
||||
const el = barRefs.current[i]
|
||||
if (!el) continue
|
||||
// Onde par barre pour un rendu vivant, modulée par l'amplitude réelle.
|
||||
const wave = 0.55 + 0.45 * Math.sin(now / 110 + i * 0.9)
|
||||
const h = Math.max(14, Math.min(100, 14 + rms * 260 * wave))
|
||||
el.style.height = `${h}%`
|
||||
}
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [state, analyserRef])
|
||||
|
||||
if (state === 'ready') {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 rounded-md border border-success/40 bg-success-soft px-4 py-3">
|
||||
<span className="relative flex size-3" aria-hidden="true">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-success/60 motion-reduce:hidden" />
|
||||
<span className="relative inline-flex size-3 rounded-full bg-success" />
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-success">À vous de parler</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'listening') {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 rounded-md border border-border bg-surface px-4 py-3">
|
||||
<Volume2 className="size-4 text-brand-text" aria-hidden="true" />
|
||||
<div className="flex h-6 items-end gap-1" aria-hidden="true">
|
||||
{Array.from({ length: BAR_COUNT }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-1 animate-pulse rounded-full bg-brand-text/70 motion-reduce:animate-none"
|
||||
style={{ height: `${40 + (i % 3) * 25}%`, animationDelay: `${i * 110}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-ink-secondary">L’examinateur parle…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'speaking') {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 rounded-md border border-success/40 bg-success-soft px-4 py-3">
|
||||
<Mic className="size-4 text-success" aria-hidden="true" />
|
||||
<div className="flex h-6 items-center gap-1" aria-label="Niveau de votre voix">
|
||||
{Array.from({ length: BAR_COUNT }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
barRefs.current[i] = el
|
||||
}}
|
||||
className="w-1 rounded-full bg-success"
|
||||
style={{ height: '14%' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,80 +1,46 @@
|
|||
/**
|
||||
* Tests — useAudioRecording (Sprint 6e, Voie A — tap temps réel).
|
||||
*
|
||||
* L'accumulation passe désormais par un AudioWorklet branché sur le mix du
|
||||
* contexte partagé (start(ctx, mixNode)). Ni AudioContext ni AudioWorklet ne
|
||||
* sont matérialisables en jsdom : l'enregistrement réel est validé À L'OREILLE
|
||||
* (objectif de la session). On couvre ici la surface pure et testable :
|
||||
* l'export WAV (header RIFF/WAVE valide, rate natif) et reset.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useAudioRecording } from '../useAudioRecording'
|
||||
import { arrayBufferToBase64 } from '@/shared/lib/audio-utils'
|
||||
|
||||
/** Crée un ArrayBuffer Int16 LE à partir d'un tableau de samples. */
|
||||
function makePcm16(samples: number[]): ArrayBuffer {
|
||||
return new Int16Array(samples).buffer
|
||||
/** Lit un Blob en ArrayBuffer via FileReader (fiable en jsdom). */
|
||||
function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsArrayBuffer(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** Crée un base64 PCM 24 kHz Int16 LE à partir d'un tableau de samples. */
|
||||
function makePcm24Base64(samples: number[]): string {
|
||||
return arrayBufferToBase64(new Int16Array(samples).buffer)
|
||||
}
|
||||
|
||||
describe('useAudioRecording', () => {
|
||||
it('addCandidateChunk : rééchantillonne 16 → 24 kHz et met à jour durationSeconds', () => {
|
||||
describe('useAudioRecording (Voie A)', () => {
|
||||
it('durationSeconds initial = 0', () => {
|
||||
const { result } = renderHook(() => useAudioRecording())
|
||||
|
||||
// 16 samples à 16 kHz = 1 ms → après resample : 24 samples à 24 kHz = 1 ms
|
||||
act(() => {
|
||||
result.current.addCandidateChunk(makePcm16(new Array(16).fill(1000)))
|
||||
})
|
||||
// 24 samples / 24000 = 0.001 s
|
||||
expect(result.current.durationSeconds).toBeCloseTo(0.001, 4)
|
||||
expect(result.current.durationSeconds).toBe(0)
|
||||
})
|
||||
|
||||
it('addAIChunk : ajoute le chunk tel quel et met à jour durationSeconds', () => {
|
||||
it('exportWAV sans chunks : Blob avec uniquement le header (44 octets)', () => {
|
||||
const { result } = renderHook(() => useAudioRecording())
|
||||
|
||||
// 24 samples à 24 kHz = 1 ms (déjà au bon sample rate)
|
||||
act(() => {
|
||||
result.current.addAIChunk(makePcm24Base64(new Array(24).fill(500)))
|
||||
})
|
||||
expect(result.current.durationSeconds).toBeCloseTo(0.001, 4)
|
||||
})
|
||||
|
||||
it('alternance candidat + IA : durée cumulée correcte, ordre chronologique préservé', () => {
|
||||
const { result } = renderHook(() => useAudioRecording())
|
||||
|
||||
act(() => {
|
||||
// Candidat : 16 samples 16k → 24 samples 24k
|
||||
result.current.addCandidateChunk(makePcm16(new Array(16).fill(100)))
|
||||
// IA : 48 samples 24k
|
||||
result.current.addAIChunk(makePcm24Base64(new Array(48).fill(200)))
|
||||
// Candidat : 32 samples 16k → 48 samples 24k
|
||||
result.current.addCandidateChunk(makePcm16(new Array(32).fill(300)))
|
||||
})
|
||||
|
||||
// Total : 24 + 48 + 48 = 120 samples à 24 kHz = 5 ms
|
||||
expect(result.current.durationSeconds).toBeCloseTo(120 / 24000, 5)
|
||||
|
||||
// Vérifier que exportWAV produit le buffer dans le bon ordre.
|
||||
const blob = result.current.exportWAV()
|
||||
expect(blob.size).toBe(44)
|
||||
expect(blob.type).toBe('audio/wav')
|
||||
expect(blob.size).toBe(44 + 120 * 2) // header + 120 samples × 2 octets
|
||||
})
|
||||
|
||||
it('exportWAV : header valide RIFF/WAVE/fmt/data + sampleRate 24000 LE', async () => {
|
||||
it('exportWAV : header RIFF/WAVE/fmt/data valide + rate natif (fallback 48000) LE', async () => {
|
||||
const { result } = renderHook(() => useAudioRecording())
|
||||
|
||||
act(() => {
|
||||
result.current.addAIChunk(makePcm24Base64([1, 2, 3, 4]))
|
||||
})
|
||||
|
||||
const blob = result.current.exportWAV()
|
||||
// jsdom : Response/blob.arrayBuffer() peuvent ne pas matérialiser les
|
||||
// parts ArrayBuffer ; on lit via FileReader qui est plus fiable.
|
||||
const buf = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsArrayBuffer(blob)
|
||||
})
|
||||
const buf = await blobToArrayBuffer(blob)
|
||||
const view = new DataView(buf)
|
||||
|
||||
// Magic strings
|
||||
const readString = (off: number, len: number) => {
|
||||
let s = ''
|
||||
for (let i = 0; i < len; i++) s += String.fromCharCode(view.getUint8(off + i))
|
||||
|
|
@ -85,48 +51,18 @@ describe('useAudioRecording', () => {
|
|||
expect(readString(12, 4)).toBe('fmt ')
|
||||
expect(readString(36, 4)).toBe('data')
|
||||
|
||||
// Sample rate (offset 24, uint32 LE)
|
||||
expect(view.getUint32(24, true)).toBe(24000)
|
||||
// Data length (offset 40) = 4 samples × 2 octets
|
||||
expect(view.getUint32(40, true)).toBe(8)
|
||||
|
||||
// PCM data : les 4 samples
|
||||
expect(view.getInt16(44, true)).toBe(1)
|
||||
expect(view.getInt16(46, true)).toBe(2)
|
||||
expect(view.getInt16(48, true)).toBe(3)
|
||||
expect(view.getInt16(50, true)).toBe(4)
|
||||
// Sans start() (pas de contexte en jsdom) → rate par défaut 48000.
|
||||
expect(view.getUint32(24, true)).toBe(48000)
|
||||
// Aucun chunk → dataLength = 0.
|
||||
expect(view.getUint32(40, true)).toBe(0)
|
||||
})
|
||||
|
||||
it('reset : vide le buffer et remet durationSeconds à 0', () => {
|
||||
it('reset : remet durationSeconds à 0 et exportWAV au header seul', () => {
|
||||
const { result } = renderHook(() => useAudioRecording())
|
||||
|
||||
act(() => {
|
||||
result.current.addAIChunk(makePcm24Base64([1, 2, 3, 4]))
|
||||
})
|
||||
expect(result.current.durationSeconds).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
expect(result.current.durationSeconds).toBe(0)
|
||||
const blob = result.current.exportWAV()
|
||||
expect(blob.size).toBe(44) // juste le header
|
||||
})
|
||||
|
||||
it('exportWAV sans chunks : Blob avec uniquement le header (44 octets)', () => {
|
||||
const { result } = renderHook(() => useAudioRecording())
|
||||
const blob = result.current.exportWAV()
|
||||
expect(blob.size).toBe(44)
|
||||
expect(blob.type).toBe('audio/wav')
|
||||
})
|
||||
|
||||
it('chunks vides ignorés : addCandidateChunk(empty) et addAIChunk("") n’incrémentent pas la durée', () => {
|
||||
const { result } = renderHook(() => useAudioRecording())
|
||||
|
||||
act(() => {
|
||||
result.current.addCandidateChunk(new ArrayBuffer(0))
|
||||
result.current.addAIChunk('')
|
||||
})
|
||||
expect(result.current.durationSeconds).toBe(0)
|
||||
expect(result.current.exportWAV().size).toBe(44)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,65 @@
|
|||
/**
|
||||
* useAudioPlayback — Hook de lecture audio pour T2 Live (Sprint 6b).
|
||||
* useAudioPlayback — Hook de lecture audio pour T2 Live (Sprint 6b ; Voie A 6e).
|
||||
*
|
||||
* Reçoit des chunks PCM 24 kHz Int16 LE encodés en base64 (format Gemini Live)
|
||||
* et les joue séquentiellement sans gaps via AudioContext + AudioBufferSourceNode.
|
||||
* et les joue séquentiellement sans gaps via AudioBufferSourceNode.
|
||||
*
|
||||
* Stratégie : chaque chunk est programmé via `source.start(nextStartTime)`
|
||||
* où `nextStartTime = max(ctx.currentTime, lastEndTime)`. Cela garantit une
|
||||
* lecture continue même si les chunks arrivent par bursts.
|
||||
* Sprint 6e (Voie A) : ce hook ne crée PLUS son propre AudioContext. Il utilise
|
||||
* le contexte PARTAGÉ de la capture (rate natif), exposé par ref. La voix IA est
|
||||
* connectée à `ctx.destination` (audible) ET au `mixNode` de capture (point de
|
||||
* convergence où le tap d'enregistrement Step 3 prélèvera le mix). Partager une
|
||||
* seule horloge est la condition de l'alignement temporel natif du WAV.
|
||||
*
|
||||
* Le hook ne touche pas au WebSocket — l'appelant (Sprint 6c) appelle
|
||||
* `playChunk(base64)` à chaque message audio reçu.
|
||||
* Le buffer reste créé au rate Gemini (24 kHz) ; le contexte (rate natif, ex.
|
||||
* 48 kHz) le rééchantillonne automatiquement à la lecture.
|
||||
*
|
||||
* Stratégie de planification : chaque chunk est programmé via
|
||||
* `source.start(max(ctx.currentTime, lastEndTime))` → lecture continue même si
|
||||
* les chunks arrivent par bursts.
|
||||
*
|
||||
* Le hook ne touche pas au WebSocket — l'appelant appelle `playChunk(base64)`
|
||||
* à chaque message audio reçu.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { base64ToArrayBuffer, int16ToFloat32 } from '@/shared/lib/audio-utils'
|
||||
|
||||
const PLAYBACK_SAMPLE_RATE = 24000
|
||||
|
||||
export interface UseAudioPlaybackOptions {
|
||||
/**
|
||||
* Contexte de capture PARTAGÉ (rate natif). null tant que la capture n'a pas
|
||||
* démarré — un chunk IA reçu avant est ignoré (cf. race dans playChunk).
|
||||
*/
|
||||
contextRef: RefObject<AudioContext | null>
|
||||
/**
|
||||
* Point de mixage de la capture : on y route la voix IA EN PLUS du
|
||||
* destination, pour que le tap d'enregistrement (Step 3) capte le mix. null
|
||||
* hors capture.
|
||||
*/
|
||||
mixNodeRef: RefObject<GainNode | null>
|
||||
}
|
||||
|
||||
export interface UseAudioPlaybackResult {
|
||||
playChunk: (base64: string) => void
|
||||
stop: () => void
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
export function useAudioPlayback(): UseAudioPlaybackResult {
|
||||
export function useAudioPlayback({
|
||||
contextRef,
|
||||
mixNodeRef,
|
||||
}: UseAudioPlaybackOptions): UseAudioPlaybackResult {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const contextRef = useRef<AudioContext | null>(null)
|
||||
const lastEndTimeRef = useRef<number>(0)
|
||||
// Timer qui repasse `isPlaying` à false quand la file se vide.
|
||||
const isPlayingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const activeSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set())
|
||||
|
||||
const ensureContext = useCallback((): AudioContext => {
|
||||
if (contextRef.current && contextRef.current.state !== 'closed') {
|
||||
return contextRef.current
|
||||
}
|
||||
const ctx = new AudioContext({ sampleRate: PLAYBACK_SAMPLE_RATE })
|
||||
contextRef.current = ctx
|
||||
lastEndTimeRef.current = 0
|
||||
return ctx
|
||||
}, [])
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
// Arrête les sources IA en cours SANS fermer le contexte (la capture en est
|
||||
// propriétaire — le fermer ici couperait l'uplink et l'enregistrement).
|
||||
const stopSources = useCallback(() => {
|
||||
if (isPlayingTimerRef.current !== null) {
|
||||
clearTimeout(isPlayingTimerRef.current)
|
||||
isPlayingTimerRef.current = null
|
||||
|
|
@ -56,21 +73,20 @@ export function useAudioPlayback(): UseAudioPlaybackResult {
|
|||
}
|
||||
})
|
||||
activeSourcesRef.current.clear()
|
||||
if (contextRef.current) {
|
||||
try {
|
||||
void contextRef.current.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
contextRef.current = null
|
||||
}
|
||||
lastEndTimeRef.current = 0
|
||||
}, [])
|
||||
|
||||
const playChunk = useCallback(
|
||||
(base64: string) => {
|
||||
const ctx = contextRef.current
|
||||
// Race : un chunk IA peut arriver avant que la capture ait fini de créer
|
||||
// le contexte partagé (addModule). Dans ce cas on ignore le chunk plutôt
|
||||
// que d'ouvrir un contexte concurrent (qui casserait l'horloge unique).
|
||||
if (!ctx || ctx.state === 'closed') {
|
||||
return
|
||||
}
|
||||
const mix = mixNodeRef.current
|
||||
try {
|
||||
const ctx = ensureContext()
|
||||
const arrayBuffer = base64ToArrayBuffer(base64)
|
||||
const int16 = new Int16Array(arrayBuffer)
|
||||
const float32 = int16ToFloat32(int16)
|
||||
|
|
@ -82,7 +98,10 @@ export function useAudioPlayback(): UseAudioPlaybackResult {
|
|||
|
||||
const source = ctx.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
// Audible via destination ET routé vers le mix de capture (le tap
|
||||
// d'enregistrement Step 3 y prélève le mix micro + voix IA).
|
||||
source.connect(ctx.destination)
|
||||
if (mix) source.connect(mix)
|
||||
|
||||
const startTime = Math.max(ctx.currentTime, lastEndTimeRef.current)
|
||||
source.start(startTime)
|
||||
|
|
@ -113,20 +132,21 @@ export function useAudioPlayback(): UseAudioPlaybackResult {
|
|||
/* ignore — ne pas casser l'app sur un chunk malformé */
|
||||
}
|
||||
},
|
||||
[ensureContext],
|
||||
[contextRef, mixNodeRef],
|
||||
)
|
||||
|
||||
const stop = useCallback(() => {
|
||||
cleanup()
|
||||
stopSources()
|
||||
setIsPlaying(false)
|
||||
}, [cleanup])
|
||||
}, [stopSources])
|
||||
|
||||
// Cleanup au démontage.
|
||||
// Cleanup au démontage : on arrête seulement les sources (le contexte est
|
||||
// fermé par la capture, propriétaire de l'horloge partagée).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup()
|
||||
stopSources()
|
||||
}
|
||||
}, [cleanup])
|
||||
}, [stopSources])
|
||||
|
||||
return { playChunk, stop, isPlaying }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,87 +1,136 @@
|
|||
/**
|
||||
* useAudioRecording — Hook d'accumulation audio pour téléchargement (Sprint 6b).
|
||||
* useAudioRecording — Hook d'enregistrement WAV pour T2 Live
|
||||
* (Sprint 6b ; réécrit Sprint 6e — Voie A, tap temps réel).
|
||||
*
|
||||
* Buffer chronologique unique des chunks candidat (PCM 16 kHz, ArrayBuffer brut
|
||||
* sortant du worklet) et IA (PCM 24 kHz, base64 reçu du WS Gemini). Les chunks
|
||||
* candidat sont rééchantillonnés à 24 kHz à l'ajout pour homogénéiser le buffer.
|
||||
* Abandon du réassemblage offline deux pistes (offsets + resample + concat +
|
||||
* mix) qui collapsait les tours IA (Bug 3, ancrage unique). Nouvelle approche :
|
||||
* un AudioWorklet d'enregistrement (`pcm-record-processor`) est branché EN
|
||||
* DÉRIVATION sur le `mixGain` du contexte PARTAGÉ de capture, où convergent déjà
|
||||
* le micro et la voix IA. Il prélève le mix au rate NATIF du contexte en temps
|
||||
* réel → alignement temporel natif, une seule horloge, zéro resample.
|
||||
*
|
||||
* En fin de session, `exportWAV()` produit un Blob `audio/wav` mono 24 kHz
|
||||
* concaténant tous les chunks dans l'ordre d'arrivée — adapté pour téléchargement.
|
||||
* Graphe du tap (le sink gain(0) garantit le pull du graphe SANS résidu
|
||||
* audible) : mixGain → recordNode → gain(0) → destination.
|
||||
*
|
||||
* Le hook ne touche pas au WebSocket. L'appelant (Sprint 6c) appelle :
|
||||
* - `addCandidateChunk(arrayBuffer)` à chaque chunk reçu du worklet
|
||||
* - `addAIChunk(base64)` à chaque chunk reçu du WS Gemini
|
||||
* Cycle de vie (piloté par useT2LiveSession) :
|
||||
* - start(ctx, mixNode) = à l'ouverture du WS, une fois capture.start() résolu
|
||||
* (contexte + mixGain existants).
|
||||
* - stop() = « Terminer le dialogue » UNIQUEMENT : on débranche le
|
||||
* tap, mais le buffer Int16 accumulé SURVIT (il vit dans une ref hors du
|
||||
* cycle de vie du contexte) → exportWAV() reste appelable après fermeture du
|
||||
* contexte.
|
||||
* - « Annuler » = pas d'export ; closeAll() ferme le contexte, le
|
||||
* buffer est simplement abandonné.
|
||||
*
|
||||
* Le hook ne touche pas au WebSocket.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { base64ToArrayBuffer, buildWavHeader, resample16kTo24k } from '@/shared/lib/audio-utils'
|
||||
import { buildWavHeader, concatInt16 } from '@/shared/lib/audio-utils'
|
||||
|
||||
const RECORDING_SAMPLE_RATE = 24000
|
||||
const RECORD_WORKLET_URL = '/pcm-record-processor.js'
|
||||
const FALLBACK_SAMPLE_RATE = 48000
|
||||
|
||||
export interface UseAudioRecordingResult {
|
||||
/** Ajoute un chunk candidat (PCM 16 kHz Int16 LE). Rééchantillonné à 24 kHz. */
|
||||
addCandidateChunk: (pcm16k: ArrayBuffer) => void
|
||||
/** Ajoute un chunk IA (PCM 24 kHz Int16 LE encodé en base64). */
|
||||
addAIChunk: (base64: string) => void
|
||||
/** Construit un Blob WAV mono 24 kHz à partir du buffer accumulé. */
|
||||
/** Branche le tap d'enregistrement sur le mix du contexte partagé. */
|
||||
start: (ctx: AudioContext, mixNode: GainNode) => Promise<void>
|
||||
/** Débranche le tap. Le buffer accumulé survit pour exportWAV(). */
|
||||
stop: () => void
|
||||
/** Construit un Blob WAV mono au rate natif du contexte d'enregistrement. */
|
||||
exportWAV: () => Blob
|
||||
/** Durée totale en secondes (mise à jour à chaque ajout). */
|
||||
/** Durée totale en secondes (mise à jour à chaque chunk reçu du worklet). */
|
||||
durationSeconds: number
|
||||
/** Vide le buffer. */
|
||||
/** Vide le buffer accumulé. */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export function useAudioRecording(): UseAudioRecordingResult {
|
||||
// Buffer Int16 accumulé HORS du cycle de vie du contexte (ref pure) : il
|
||||
// survit à la fermeture du contexte sur endDialogue → exportWAV reste
|
||||
// appelable. Jamais une closure du worklet (qui meurt avec le contexte).
|
||||
const chunksRef = useRef<Int16Array[]>([])
|
||||
const totalSamplesRef = useRef<number>(0)
|
||||
// Rate capturé au start (= ctx.sampleRate natif) : le WAV est écrit à ce rate.
|
||||
const sampleRateRef = useRef<number>(0)
|
||||
|
||||
const recordNodeRef = useRef<AudioWorkletNode | null>(null)
|
||||
const sinkRef = useRef<GainNode | null>(null)
|
||||
const mixRef = useRef<GainNode | null>(null)
|
||||
|
||||
const [durationSeconds, setDurationSeconds] = useState<number>(0)
|
||||
|
||||
const updateDuration = useCallback((addedSamples: number) => {
|
||||
totalSamplesRef.current += addedSamples
|
||||
setDurationSeconds(totalSamplesRef.current / RECORDING_SAMPLE_RATE)
|
||||
const start = useCallback(async (ctx: AudioContext, mixNode: GainNode) => {
|
||||
// Idempotent : un tap déjà actif n'est pas redoublé.
|
||||
if (recordNodeRef.current) return
|
||||
sampleRateRef.current = ctx.sampleRate
|
||||
|
||||
await ctx.audioWorklet.addModule(RECORD_WORKLET_URL)
|
||||
|
||||
const recordNode = new AudioWorkletNode(ctx, 'pcm-record-processor')
|
||||
recordNode.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
|
||||
const int16 = new Int16Array(e.data)
|
||||
if (int16.length === 0) return
|
||||
chunksRef.current.push(int16)
|
||||
totalSamplesRef.current += int16.length
|
||||
setDurationSeconds(totalSamplesRef.current / (sampleRateRef.current || FALLBACK_SAMPLE_RATE))
|
||||
}
|
||||
|
||||
// Tap : mix → recordNode → sink(gain 0) → destination. Le gain est
|
||||
// STRICTEMENT 0 : il force le graphe à tirer le recordNode (pull
|
||||
// cross-navigateur) sans laisser passer le moindre résidu audible vers les
|
||||
// haut-parleurs (pas d'écho du mix).
|
||||
const sink = ctx.createGain()
|
||||
sink.gain.value = 0
|
||||
|
||||
mixNode.connect(recordNode)
|
||||
recordNode.connect(sink)
|
||||
sink.connect(ctx.destination)
|
||||
|
||||
recordNodeRef.current = recordNode
|
||||
sinkRef.current = sink
|
||||
mixRef.current = mixNode
|
||||
}, [])
|
||||
|
||||
const addCandidateChunk = useCallback(
|
||||
(pcm16k: ArrayBuffer) => {
|
||||
if (pcm16k.byteLength === 0) return
|
||||
const int16 = new Int16Array(pcm16k)
|
||||
const resampled = resample16kTo24k(int16)
|
||||
chunksRef.current.push(resampled)
|
||||
updateDuration(resampled.length)
|
||||
},
|
||||
[updateDuration],
|
||||
)
|
||||
|
||||
const addAIChunk = useCallback(
|
||||
(base64: string) => {
|
||||
if (base64.length === 0) return
|
||||
const arrayBuffer = base64ToArrayBuffer(base64)
|
||||
if (arrayBuffer.byteLength === 0) return
|
||||
const int16 = new Int16Array(arrayBuffer)
|
||||
// Copie défensive — base64ToArrayBuffer renvoie un buffer dont la
|
||||
// vue Int16 partage la mémoire ; on duplique pour éviter tout effet
|
||||
// de bord si l'appelant réutilise le base64.
|
||||
const copy = new Int16Array(int16)
|
||||
chunksRef.current.push(copy)
|
||||
updateDuration(copy.length)
|
||||
},
|
||||
[updateDuration],
|
||||
)
|
||||
const stop = useCallback(() => {
|
||||
// Débranche le tap proprement. Le buffer (chunksRef) n'est PAS touché : il
|
||||
// survit pour exportWAV(), y compris après fermeture du contexte.
|
||||
if (mixRef.current && recordNodeRef.current) {
|
||||
try {
|
||||
mixRef.current.disconnect(recordNodeRef.current)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (recordNodeRef.current) {
|
||||
try {
|
||||
recordNodeRef.current.port.onmessage = null
|
||||
recordNodeRef.current.disconnect()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
recordNodeRef.current = null
|
||||
}
|
||||
if (sinkRef.current) {
|
||||
try {
|
||||
sinkRef.current.disconnect()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
sinkRef.current = null
|
||||
}
|
||||
mixRef.current = null
|
||||
}, [])
|
||||
|
||||
const exportWAV = useCallback((): Blob => {
|
||||
// Concaténer tous les chunks en un seul Int16Array.
|
||||
const total = totalSamplesRef.current
|
||||
const merged = new Int16Array(total)
|
||||
let offset = 0
|
||||
for (const chunk of chunksRef.current) {
|
||||
merged.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
const dataLength = merged.byteLength // = total * 2
|
||||
const header = buildWavHeader(dataLength, RECORDING_SAMPLE_RATE)
|
||||
// Utiliser des Uint8Array : certains environnements (jsdom) ne gèrent pas
|
||||
// correctement les ArrayBuffer bruts dans le constructeur Blob.
|
||||
return new Blob([new Uint8Array(header), new Uint8Array(merged.buffer)], { type: 'audio/wav' })
|
||||
const pcm = concatInt16(chunksRef.current)
|
||||
const rate = sampleRateRef.current || FALLBACK_SAMPLE_RATE
|
||||
const dataLength = pcm.byteLength
|
||||
const header = buildWavHeader(dataLength, rate)
|
||||
// Uint8Array : certains environnements (jsdom) ne gèrent pas les ArrayBuffer
|
||||
// bruts dans le constructeur Blob. `pcm.buffer` est un ArrayBuffer exact
|
||||
// (alloué par concatInt16) — le cast resserre le type ArrayBufferLike.
|
||||
const pcmBytes = new Uint8Array(pcm.buffer as ArrayBuffer)
|
||||
return new Blob([new Uint8Array(header), pcmBytes], { type: 'audio/wav' })
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
|
|
@ -91,8 +140,8 @@ export function useAudioRecording(): UseAudioRecordingResult {
|
|||
}, [])
|
||||
|
||||
return {
|
||||
addCandidateChunk,
|
||||
addAIChunk,
|
||||
start,
|
||||
stop,
|
||||
exportWAV,
|
||||
durationSeconds,
|
||||
reset,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
* Validation : test manuel uniquement (WebSocket + AudioContext non testables en jsdom).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { env } from '@/shared/config/env'
|
||||
import { getAccessToken } from '@/shared/lib/auth-client'
|
||||
|
|
@ -30,6 +30,15 @@ import { transition, T2_INITIAL_STATE, type T2State, type T2Event } from '../sta
|
|||
const DIALOGUE_TIMEOUT_MS = 210_000 // 3 min 30
|
||||
const WS_PING_INTERVAL_MS = 30_000
|
||||
|
||||
// Sprint 6e — VAD micro qui pilote les états speaking/listening de la machine.
|
||||
// RMS sur l'Int16 brut (déjà décodé pour le recording), avec hystérésis pour
|
||||
// éviter le flapping : on entre en 'speaking' au-dessus de SPEAK_RMS, on ne
|
||||
// déclare USER_SILENT qu'après SILENCE_DEBOUNCE_MS *soutenus* sous SILENCE_RMS.
|
||||
// La zone morte [SILENCE_RMS, SPEAK_RMS] absorbe les micro-pauses intra-phrase.
|
||||
const VAD_SPEAK_RMS = 500
|
||||
const VAD_SILENCE_RMS = 250
|
||||
const VAD_SILENCE_DEBOUNCE_MS = 700
|
||||
|
||||
export interface UseT2LiveSessionOptions {
|
||||
sujetId: string
|
||||
/** Appelé quand le rapport est reçu — l'appelant décide de la navigation. */
|
||||
|
|
@ -40,12 +49,19 @@ export interface UseT2LiveSessionResult {
|
|||
state: T2State
|
||||
startDialogue: () => Promise<void>
|
||||
endDialogue: () => void
|
||||
/** Abandon : ferme le WS sans déclencher d'évaluation ni de persistance. */
|
||||
cancelDialogue: () => void
|
||||
warning: boolean
|
||||
errorMessage: string | null
|
||||
simulationId: string | null
|
||||
recording: ReturnType<typeof useAudioRecording>
|
||||
/** Secondes écoulées depuis l'ouverture du WS — pour le timer affiché. */
|
||||
elapsedSeconds: number
|
||||
/**
|
||||
* AnalyserNode dérivé du graphe de capture (par ref stable) — pour
|
||||
* l'indicateur de prise de parole, sans re-render ni sonde sur le flux montant.
|
||||
*/
|
||||
analyserRef: RefObject<AnalyserNode | null>
|
||||
}
|
||||
|
||||
interface GeminiPart {
|
||||
|
|
@ -89,15 +105,27 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
|
|||
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const userSpeakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Edge-tracking du VAD micro : true = on a déjà dispatché USER_SPEAKING pour
|
||||
// la prise de parole en cours. Garantit un dispatch par FRONT (pas par chunk).
|
||||
const micSpeakingRef = useRef(false)
|
||||
// Sprint 6d — token de cancellation pour rendre `startDialogue` idempotent
|
||||
// sur les appels rapprochés (StrictMode dev double-mount, double-clic, etc.).
|
||||
// Si une connexion est déjà en cours (token non null), un second appel est
|
||||
// no-op. Le cleanup d'unmount invalide le token + ferme tout WS en flight.
|
||||
const cancelTokenRef = useRef<{ cancelled: boolean } | null>(null)
|
||||
|
||||
const playback = useAudioPlayback()
|
||||
const recording = useAudioRecording()
|
||||
|
||||
// Horodatage du dernier chunk audio IA reçu. Sert au VAD : un nouveau chunk
|
||||
// après > 800 ms de silence IA marque le début d'une réplique de l'examinateur
|
||||
// (newTurn) → on réaligne l'edge-tracking micro et on quitte 'speaking'.
|
||||
const lastAiChunkTsRef = useRef<number>(0)
|
||||
|
||||
// Déclaré avant `capture` car onChunk infère les transitions de la machine.
|
||||
const dispatch = useCallback((event: T2Event) => {
|
||||
setState((prev) => transition(prev, event))
|
||||
}, [])
|
||||
|
||||
// Capture branchée à l'envoi WS + au buffer recording.
|
||||
const capture = useAudioCapture({
|
||||
onChunk: (base64: string) => {
|
||||
|
|
@ -105,21 +133,57 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
|
|||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'audio', data: base64 }))
|
||||
}
|
||||
// Décoder pour le buffer recording — base64 → ArrayBuffer 16k Int16 LE.
|
||||
// Décoder le chunk uplink pour le VAD micro UNIQUEMENT (Sprint 6e Voie A :
|
||||
// l'enregistrement WAV ne passe plus par ici — il est prélevé en temps réel
|
||||
// par le tap sur le mixGain, cf. useAudioRecording).
|
||||
try {
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
recording.addCandidateChunk(bytes.buffer)
|
||||
|
||||
// VAD micro → pilote speaking/listening. RMS sur l'Int16 brut (lecture
|
||||
// seule du buffer décodé : aucun setState dans le chemin source→worklet→WS,
|
||||
// qui reste strictement inchangé). Dispatch par FRONT
|
||||
// uniquement (micSpeakingRef) + debounce franc avant USER_SILENT, donc
|
||||
// pas de rafale de re-renders (garde-fou Step 4).
|
||||
const samples = new Int16Array(bytes.buffer)
|
||||
let sumSq = 0
|
||||
for (let i = 0; i < samples.length; i++) sumSq += samples[i]! * samples[i]!
|
||||
const rms = Math.sqrt(sumSq / Math.max(1, samples.length))
|
||||
if (rms > VAD_SPEAK_RMS) {
|
||||
// Voix présente : annule un éventuel armement de silence et, si c'est
|
||||
// un nouveau front, passe en 'speaking'.
|
||||
if (userSpeakingTimerRef.current !== null) {
|
||||
clearTimeout(userSpeakingTimerRef.current)
|
||||
userSpeakingTimerRef.current = null
|
||||
}
|
||||
if (!micSpeakingRef.current) {
|
||||
micSpeakingRef.current = true
|
||||
dispatch({ type: 'USER_SPEAKING' })
|
||||
}
|
||||
} else if (rms < VAD_SILENCE_RMS) {
|
||||
// Sous le plancher : arme une seule fois un timer de silence soutenu.
|
||||
if (micSpeakingRef.current && userSpeakingTimerRef.current === null) {
|
||||
userSpeakingTimerRef.current = setTimeout(() => {
|
||||
userSpeakingTimerRef.current = null
|
||||
micSpeakingRef.current = false
|
||||
dispatch({ type: 'USER_SILENT' })
|
||||
}, VAD_SILENCE_DEBOUNCE_MS)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const dispatch = useCallback((event: T2Event) => {
|
||||
setState((prev) => transition(prev, event))
|
||||
}, [])
|
||||
// Playback déclaré APRÈS capture : il consomme le contexte + le mix PARTAGÉS
|
||||
// exposés par la capture (Voie A — horloge unique, voix IA routée vers le mix
|
||||
// en plus du destination).
|
||||
const playback = useAudioPlayback({
|
||||
contextRef: capture.contextRef,
|
||||
mixNodeRef: capture.mixNodeRef,
|
||||
})
|
||||
|
||||
const cleanupTimers = useCallback(() => {
|
||||
if (timeoutTimerRef.current !== null) {
|
||||
|
|
@ -162,13 +226,31 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
|
|||
|
||||
const handleAudioReceived = useCallback(
|
||||
(base64: string) => {
|
||||
// Heuristique d'activité IA = on est en 'listening' (l'IA parle).
|
||||
// L'évènement USER_SPEAKING est inféré côté capture micro plutôt — ici on
|
||||
// signale au moins qu'on quitte 'speaking' si on y était.
|
||||
// Début de réplique IA = 1er chunk après > 800 ms sans audio IA.
|
||||
const _now = performance.now()
|
||||
const newTurn = lastAiChunkTsRef.current === 0 || _now - lastAiChunkTsRef.current > 800
|
||||
if (newTurn) {
|
||||
// L'IA prend la parole → on quitte 'speaking' si on y était encore (le
|
||||
// debounce micro y mène aussi, mais l'audio IA tranche). On réaligne
|
||||
// l'edge-tracking du VAD : USER_SILENT est no-op depuis 'ready'/'listening'
|
||||
// (machine idempotente → pas de re-render superflu).
|
||||
if (userSpeakingTimerRef.current !== null) {
|
||||
clearTimeout(userSpeakingTimerRef.current)
|
||||
userSpeakingTimerRef.current = null
|
||||
}
|
||||
if (micSpeakingRef.current) {
|
||||
micSpeakingRef.current = false
|
||||
dispatch({ type: 'USER_SILENT' })
|
||||
}
|
||||
}
|
||||
lastAiChunkTsRef.current = _now
|
||||
|
||||
// Sprint 6e Voie A : on ne pousse plus la voix IA dans le recording ici.
|
||||
// playChunk la route vers destination ET vers le mixGain ; le tap
|
||||
// d'enregistrement la capte sur ce mix en temps réel.
|
||||
playback.playChunk(base64)
|
||||
recording.addAIChunk(base64)
|
||||
},
|
||||
[playback, recording],
|
||||
[dispatch, playback],
|
||||
)
|
||||
|
||||
const handleGeminiMessage = useCallback(
|
||||
|
|
@ -241,11 +323,24 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
|
|||
[handleAppMessage, handleGeminiMessage],
|
||||
)
|
||||
|
||||
// INSTRUMENT REPAIR 6e — la socket est ouverte UNE fois (startDialogue) et
|
||||
// `ws.onmessage` y était affecté en DUR → closure GELÉ : sous HMR, la socket
|
||||
// continuait d'appeler l'ancien `handleAudioReceived` (audio joué mais log
|
||||
// jamais émis). On passe par une ref toujours à jour : le binding appelle
|
||||
// TOUJOURS le handler courant. Aucun changement de logique audio.
|
||||
const handleWsMessageRef = useRef(handleWsMessage)
|
||||
useEffect(() => {
|
||||
handleWsMessageRef.current = handleWsMessage
|
||||
}, [handleWsMessage])
|
||||
|
||||
const handleWsClose = useCallback(
|
||||
(evt: CloseEvent) => {
|
||||
if (sessionEndedRef.current) return
|
||||
sessionEndedRef.current = true
|
||||
cleanupTimers()
|
||||
// Débrancher le tap AVANT de fermer le contexte (capture.stop) ; le buffer
|
||||
// WAV survit pour exportWAV (déclenché à la réception du rapport).
|
||||
recording.stop()
|
||||
capture.stop()
|
||||
|
||||
switch (evt.code) {
|
||||
|
|
@ -281,7 +376,7 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
|
|||
dispatch({ type: 'ERROR', code: evt.code })
|
||||
}
|
||||
},
|
||||
[capture, cleanupTimers, dispatch, navigate, state],
|
||||
[capture, cleanupTimers, dispatch, navigate, recording, state],
|
||||
)
|
||||
|
||||
const startDialogue = useCallback(async () => {
|
||||
|
|
@ -330,8 +425,18 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
|
|||
|
||||
ws.onopen = () => {
|
||||
dispatch({ type: 'WS_OPENED' })
|
||||
// Démarrer la capture micro UNE FOIS le WS ouvert.
|
||||
void capture.start()
|
||||
// Démarrer la capture micro UNE FOIS le WS ouvert, PUIS brancher le tap
|
||||
// d'enregistrement sur le contexte + mixGain (qui n'existent qu'après que
|
||||
// capture.start() ait résolu : getUserMedia + addModule). Cycle de vie
|
||||
// Voie A : start = ici ; stop = endDialogue / fermeture WS uniquement.
|
||||
void capture.start().then(() => {
|
||||
const ctx = capture.contextRef.current
|
||||
const mix = capture.mixNodeRef.current
|
||||
if (ctx && mix) {
|
||||
recording.reset()
|
||||
void recording.start(ctx, mix)
|
||||
}
|
||||
})
|
||||
|
||||
// Timer écoulé pour l'UI.
|
||||
const startTime = Date.now()
|
||||
|
|
@ -363,16 +468,21 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
|
|||
}
|
||||
}, WS_PING_INTERVAL_MS)
|
||||
}
|
||||
ws.onmessage = handleWsMessage
|
||||
// Indirection par ref : appelle TOUJOURS le handleWsMessage courant (immunisé
|
||||
// au gel de closure / HMR). Cf. INSTRUMENT REPAIR 6e.
|
||||
ws.onmessage = (evt) => handleWsMessageRef.current(evt)
|
||||
ws.onclose = handleWsClose
|
||||
ws.onerror = () => {
|
||||
// 'close' suit toujours 'error' — gestion centralisée dans handleWsClose.
|
||||
}
|
||||
}, [capture, dispatch, handleWsClose, handleWsMessage, navigate, sujetId])
|
||||
}, [capture, dispatch, handleWsClose, navigate, recording, sujetId])
|
||||
|
||||
const endDialogue = useCallback(() => {
|
||||
if (sessionEndedRef.current) return
|
||||
cleanupTimers()
|
||||
// Cycle de vie Voie A : « Terminer le dialogue » débranche le tap (le buffer
|
||||
// WAV survit pour exportWAV) PUIS ferme le contexte de capture.
|
||||
recording.stop()
|
||||
capture.stop()
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
|
|
@ -382,7 +492,20 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
|
|||
}
|
||||
}
|
||||
dispatch({ type: 'END_REQUESTED' })
|
||||
}, [capture, cleanupTimers, dispatch])
|
||||
}, [capture, cleanupTimers, dispatch, recording])
|
||||
|
||||
// Bug 5 — Abandon utilisateur. Contrairement à `endDialogue`, on ferme le WS
|
||||
// SANS envoyer `{type:'end'}` : le backend (geminiLive.ts close handler) ne
|
||||
// déclenche alors NI correction NI persistance. `closeAll()` invalide tout
|
||||
// `startDialogue` en flight, coupe la capture (micro libéré via tracks.stop())
|
||||
// et ferme le WS ; on stoppe en plus la lecture IA en cours (pas d'attente de
|
||||
// fin de file, c'est un abandon). La machine revient à 'idle' via CANCEL.
|
||||
const cancelDialogue = useCallback(() => {
|
||||
if (sessionEndedRef.current) return
|
||||
closeAll()
|
||||
playback.stop()
|
||||
dispatch({ type: 'CANCEL' })
|
||||
}, [closeAll, playback, dispatch])
|
||||
|
||||
// Cleanup au démontage UNIQUEMENT.
|
||||
//
|
||||
|
|
@ -408,10 +531,14 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
|
|||
state,
|
||||
startDialogue,
|
||||
endDialogue,
|
||||
cancelDialogue,
|
||||
warning,
|
||||
errorMessage,
|
||||
simulationId,
|
||||
recording,
|
||||
elapsedSeconds,
|
||||
// Indicateur de prise de parole : analyser dérivé, exposé par ref stable
|
||||
// (jamais de stream en state → pas de famine du flux montant).
|
||||
analyserRef: capture.analyserRef,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@
|
|||
* écran terminal avec deux boutons : "Télécharger l'audio" et "Voir le rapport".
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Mic, Download, FileText, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { T2SpeakingIndicator } from '../components/T2SpeakingIndicator'
|
||||
import { useT2LiveContext } from '../state/T2LiveContext'
|
||||
import { useT2LiveSession } from '../hooks/useT2LiveSession'
|
||||
|
||||
|
|
@ -27,14 +28,20 @@ export function T2DialoguePage() {
|
|||
const navigate = useNavigate()
|
||||
const { sujet, reset: resetContext } = useT2LiveContext()
|
||||
const [autoStarted, setAutoStarted] = useState(false)
|
||||
// Bug 4 — neutralise le garde-fou `!sujet` lors d'une navigation volontaire
|
||||
// (Voir le rapport, Retour aux sujets) : sinon resetContext() déclenche la
|
||||
// redirection parasite vers /simulation/eo/t2 et écrase la navigation voulue.
|
||||
const navigatingAwayRef = useRef(false)
|
||||
|
||||
const session = useT2LiveSession({
|
||||
sujetId: sujet?.id ?? '',
|
||||
})
|
||||
|
||||
// Garde-fou : pas de sujet → retour à la sélection.
|
||||
// Garde-fou : pas de sujet → retour à la sélection (sauf navigation volontaire).
|
||||
useEffect(() => {
|
||||
if (!sujet) navigate('/simulation/eo/t2', { replace: true })
|
||||
if (!sujet && !navigatingAwayRef.current) {
|
||||
navigate('/simulation/eo/t2', { replace: true })
|
||||
}
|
||||
}, [sujet, navigate])
|
||||
|
||||
// Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
|
||||
|
|
@ -83,15 +90,34 @@ export function T2DialoguePage() {
|
|||
|
||||
function handleViewReport() {
|
||||
if (!session.simulationId) return
|
||||
// Bug 4 — neutralise le garde-fou avant resetContext() pour que la
|
||||
// navigation vers le rapport aboutisse. Le routage EO/EE du retour est
|
||||
// géré par RapportPage via `Report.tache` (Bug 6, voie B).
|
||||
navigatingAwayRef.current = true
|
||||
resetContext()
|
||||
navigate(`/rapport/${session.simulationId}`)
|
||||
}
|
||||
|
||||
function handleBackToSujets() {
|
||||
navigatingAwayRef.current = true
|
||||
resetContext()
|
||||
navigate('/simulation/eo/t2')
|
||||
}
|
||||
|
||||
// Bug 5 — Abandon : ferme la session sans évaluation (cancelDialogue ne
|
||||
// déclenche ni correction ni persistance), puis retour à la sélection T2.
|
||||
function handleCancel() {
|
||||
navigatingAwayRef.current = true
|
||||
session.cancelDialogue()
|
||||
resetContext()
|
||||
navigate('/simulation/eo/t2')
|
||||
}
|
||||
|
||||
// « Annuler » n'a de sens que pendant le dialogue actif (pas en connexion
|
||||
// ni en évaluation).
|
||||
const canCancel =
|
||||
session.state === 'ready' || session.state === 'speaking' || session.state === 'listening'
|
||||
|
||||
if (!sujet) return null
|
||||
|
||||
// ── État terminal : rapport prêt ─────────────────────────────────────────
|
||||
|
|
@ -194,10 +220,27 @@ export function T2DialoguePage() {
|
|||
)}
|
||||
<p className="text-sm font-semibold text-ink-primary">{stateLabel}</p>
|
||||
</div>
|
||||
{/* Indicateur de prise de parole. 'speaking' = amplitude micro réelle
|
||||
(analyser dérivé du graphe de capture, lu par ref en rAF) ;
|
||||
'listening' = animation décorative pilotée par l'état (pas de sonde
|
||||
playback) ; 'ready' = signal « À vous de parler ». */}
|
||||
{canCancel && (
|
||||
<T2SpeakingIndicator analyserRef={session.analyserRef} state={session.state} />
|
||||
)}
|
||||
<p className="text-xs text-ink-secondary">{sujet.consigne}</p>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{canCancel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-danger hover:bg-danger-soft hover:text-danger"
|
||||
onClick={handleCancel}
|
||||
title="Abandonner sans générer de rapport"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => session.endDialogue()}
|
||||
|
|
@ -206,6 +249,9 @@ export function T2DialoguePage() {
|
|||
Terminer le dialogue
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-center text-xs text-ink-tertiary">
|
||||
« Annuler » abandonne la session sans rapport. « Terminer » lance l'évaluation.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,24 @@ describe('T2 state machine — ERROR terminal', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('T2 state machine — CANCEL (abandon) → idle depuis tout état actif', () => {
|
||||
it.each<T2State>(['preparing', 'connecting', 'ready', 'speaking', 'listening', 'processing'])(
|
||||
'transition %s → idle sur CANCEL',
|
||||
(from) => {
|
||||
expect(transition(from, { type: 'CANCEL' })).toBe('idle')
|
||||
},
|
||||
)
|
||||
|
||||
it('CANCEL en idle reste idle (no-op)', () => {
|
||||
expect(transition('idle', { type: 'CANCEL' })).toBe('idle')
|
||||
})
|
||||
|
||||
it('états terminaux (ended, error) sont protégés contre CANCEL', () => {
|
||||
expect(transition('ended', { type: 'CANCEL' })).toBe('ended')
|
||||
expect(transition('error', { type: 'CANCEL' })).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('T2 state machine — événements invalides ignorés', () => {
|
||||
it('USER_SPEAKING en idle est ignoré', () => {
|
||||
expect(transition('idle', { type: 'USER_SPEAKING' })).toBe('idle')
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ export type T2Event =
|
|||
| { type: 'USER_SILENT' }
|
||||
| { type: 'END_REQUESTED' }
|
||||
| { type: 'REPORT_READY' }
|
||||
// CANCEL — abandon utilisateur (bouton « Annuler ») : la session est fermée
|
||||
// SANS déclencher d'évaluation (cf. useT2LiveSession.cancelDialogue, qui ferme
|
||||
// le WS sans envoyer `{type:'end'}`). La machine revient à 'idle'.
|
||||
| { type: 'CANCEL' }
|
||||
| { type: 'ERROR'; code?: number; message?: string }
|
||||
|
||||
/**
|
||||
|
|
@ -56,6 +60,13 @@ export function transition(state: T2State, event: T2Event): T2State {
|
|||
return 'error'
|
||||
}
|
||||
|
||||
// CANCEL (abandon) bypasse les guards depuis tout état non-terminal et
|
||||
// ramène la machine à 'idle' (aucune évaluation déclenchée). Les états
|
||||
// terminaux ('ended', 'error') sont protégés.
|
||||
if (event.type === 'CANCEL' && state !== 'ended' && state !== 'error') {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
if (event.type === 'START_PREPARATION') return 'preparing'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue