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:
Hermann_Kitio 2026-06-29 14:31:38 +03:00
parent 9bf95f5c05
commit 72795e924e
16 changed files with 848 additions and 257 deletions

View file

@ -0,0 +1,61 @@
/**
* pcm-record-processor.js AudioWorklet processor d'ENREGISTREMENT T2 Live
* (Sprint 6e, Voie A tap temps réel).
*
* Branché en dérivation sur le `mixGain` de capture (point de convergence
* micro + voix IA dans le contexte PARTAGÉ). Il LIT le mix au rate NATIF du
* contexte (typiquement 48 kHz), convertit Float32 Int16 little-endian, et
* envoie des chunks (~4096 samples) au thread principal via `port.postMessage`.
*
* Aucun rééchantillonnage : on enregistre au rate natif (le WAV est écrit à ce
* même rate côté useAudioRecording). L'alignement temporel micro/IA est natif
* les deux voix partagent l'horloge unique du contexte (plus de réassemblage
* offline à base d'offsets).
*
* Le node est tiré par le graphe via mixGain recordNode gain(0)
* destination (sink muet) ; ce processor n'écrit rien sur ses sorties (silence),
* il ne fait que prélever l'entrée. Le gain(0) garantit zéro résidu audible.
*
* Vanille JS (pas TS) : les AudioWorklet processors s'exécutent dans un scope
* global isolé qui ne peut pas importer depuis le bundle TS.
*/
const RECORD_CHUNK_SIZE = 4096
class PcmRecordProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.buffer = new Float32Array(0)
}
process(inputs) {
const input = inputs[0]
if (!input || !input[0]) return true
const channelData = input[0] // mono (mix micro + IA)
const merged = new Float32Array(this.buffer.length + channelData.length)
merged.set(this.buffer)
merged.set(channelData, this.buffer.length)
this.buffer = merged
while (this.buffer.length >= RECORD_CHUNK_SIZE) {
const chunk = this.buffer.slice(0, RECORD_CHUNK_SIZE)
this.buffer = this.buffer.slice(RECORD_CHUNK_SIZE)
// Float32 [-1, 1] → Int16 PCM little-endian
const pcm = new ArrayBuffer(chunk.length * 2)
const view = new DataView(pcm)
for (let i = 0; i < chunk.length; i++) {
const s = Math.max(-1, Math.min(1, chunk[i]))
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
this.port.postMessage(pcm, [pcm])
}
return true
}
}
registerProcessor('pcm-record-processor', PcmRecordProcessor)

View file

@ -36,6 +36,7 @@ export function getReport(id: string): Promise<Report> {
return {
...state.rapport,
simulation_id: state.simulation_id,
tache: state.tache,
erreurs_codes: state.rapport.erreurs_codes as Report['erreurs_codes'],
exercices: state.exercices as Report['exercices'],
exercices_status: state.exercices_status,

View file

@ -13,6 +13,8 @@
* SEC-05 : textes IA rendus via react-markdown, jamais dangerouslySetInnerHTML.
*/
import type { Tache } from '@/entities/production/types'
/** Codes taxonomie d'erreurs — valeurs exhaustives dans `TAXONOMIE_ERREURS.md` v1.0. */
export type CritereCode =
| 'adequation_tache'
@ -101,6 +103,12 @@ export type NclcCible = 9 | 10
*/
export interface Report {
simulation_id: string
/**
* Tâche d'origine (propagée depuis `SimulationState`). Permet de router le
* retour « Nouvelle simulation » vers la bonne sélection (EO vs EE) sans
* plomberie de query param. Optionnel : absent des payloads `POST /corrections/*`.
*/
tache?: Tache
score: number // /20
nclc: number // NCLC atteint — ex. 8
nclc_cible: NclcCible

View file

@ -27,6 +27,7 @@ import {
getMaxScorePerCritere,
} from '@/entities/report/lib'
import type { Report } from '@/entities/report/types'
import { isOral } from '@/entities/production/lib'
import { useRapport } from '../hooks/useRapport'
import { useSimulation } from '../hooks/useSimulation'
import { Card } from '@/shared/ui/Card'
@ -195,6 +196,12 @@ export function RapportPage() {
const navigate = useNavigate()
const { rapport, isLoading, isError, error, refetch, hasTimedOut } = useRapport(id)
// Bug 6 — route le retour « Nouvelle simulation » selon la tâche d'origine
// (propagée dans Report). EO → hub /simulation/eo ; EE (ou tâche inconnue
// pendant le chargement) → /simulation/ee.
const simulationsPath =
rapport?.tache && isOral(rapport.tache) ? '/simulation/eo' : '/simulation/ee'
const isInProgress = isError && isReportNotReady(error)
const { reset } = useSimulation()
@ -215,7 +222,7 @@ export function RapportPage() {
// retour au TaskSelector ou à /sujets.
function goToSimulations() {
reset()
navigate('/simulation/ee')
navigate(simulationsPath)
}
return (

View file

@ -0,0 +1,106 @@
/**
* Tests RapportPage (Sprint 6e, Bug 6).
*
* Couvre le routage du retour « Nouvelle simulation » selon `Report.tache` :
* - tâche EO (EO_T2_LIVE) /simulation/eo (hub)
* - tâche EE (défaut) /simulation/ee
* - tâche absente /simulation/ee (fallback chargement)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import type { Report } from '@/entities/report/types'
const { navigateMock, useRapportMock, resetMock, usePlanMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
useRapportMock: vi.fn(),
resetMock: vi.fn(),
usePlanMock: vi.fn(),
}))
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return { ...actual, useNavigate: () => navigateMock }
})
vi.mock('../../hooks/useRapport', () => ({
useRapport: useRapportMock,
}))
vi.mock('../../hooks/useSimulation', () => ({
useSimulation: () => ({ reset: resetMock }),
}))
vi.mock('@/features/dashboard/hooks/usePlan', () => ({
usePlan: usePlanMock,
}))
import { RapportPage } from '../RapportPage'
const baseReport: Report = {
simulation_id: 'sim-1',
score: 14,
nclc: 8,
nclc_cible: 9,
revelation: { croyance: 'c', realite: 'r', consequence: 'cs' },
diagnostic: 'diag',
criteres: [],
conseil_nclc: { nclc_cible: 'NCLC 9', ecart: 'e', action_prioritaire: 'a' },
erreurs_codes: [],
exercices: null,
exercices_status: 'error',
modele: null,
modele_status: 'error',
}
function renderWithReport(rapport: Report) {
useRapportMock.mockReturnValue({
rapport,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
isPolling: false,
hasTimedOut: false,
})
usePlanMock.mockReturnValue({ data: { plan: 'premium' }, isLoading: false, isError: false })
return render(
<MemoryRouter>
<RapportPage />
</MemoryRouter>,
)
}
beforeEach(() => {
cleanup()
navigateMock.mockReset()
useRapportMock.mockReset()
usePlanMock.mockReset()
resetMock.mockReset()
})
describe('RapportPage — routage Nouvelle simulation (Bug 6)', () => {
it('tâche EO (EO_T2_LIVE) → /simulation/eo', async () => {
renderWithReport({ ...baseReport, tache: 'EO_T2_LIVE' })
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /Nouvelle simulation/i }))
expect(resetMock).toHaveBeenCalled()
expect(navigateMock).toHaveBeenCalledWith('/simulation/eo')
})
it('tâche EE → /simulation/ee', async () => {
renderWithReport({ ...baseReport, tache: 'EE_T1' })
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /Nouvelle simulation/i }))
expect(navigateMock).toHaveBeenCalledWith('/simulation/ee')
})
it('tâche absente → /simulation/ee (fallback)', async () => {
renderWithReport({ ...baseReport, tache: undefined })
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /Nouvelle simulation/i }))
expect(navigateMock).toHaveBeenCalledWith('/simulation/ee')
})
})

View file

@ -0,0 +1,136 @@
/**
* T2SpeakingIndicator Indicateur de prise de parole T2 Live (Sprint 6e, -impl).
*
* Remplace l'ancien RecordingWaveform (Step 4 neutralisé : il ouvrait son propre
* AudioContext + republiait le stream en state réactif re-renders
* -exécution d'effects famine du flux montant microGemini).
*
* 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">Lexaminateur 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
}

View file

@ -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
}
/** 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', () => {
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)
})
it('addAIChunk : ajoute le chunk tel quel et met à jour durationSeconds', () => {
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.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 () => {
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) => {
/** 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)
})
}
describe('useAudioRecording (Voie A)', () => {
it('durationSeconds initial = 0', () => {
const { result } = renderHook(() => useAudioRecording())
expect(result.current.durationSeconds).toBe(0)
})
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('exportWAV : header RIFF/WAVE/fmt/data valide + rate natif (fallback 48000) LE', async () => {
const { result } = renderHook(() => useAudioRecording())
const blob = result.current.exportWAV()
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("") nincré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)
})
})

View file

@ -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 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,
}
}

View file

@ -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)`
* `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 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 }
}

View file

@ -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, 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,

View file

@ -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,
}
}

View file

@ -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>
)

View file

@ -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')

View file

@ -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'

View file

@ -4,8 +4,8 @@ import {
base64ToArrayBuffer,
int16ToFloat32,
float32ToInt16,
resample16kTo24k,
buildWavHeader,
concatInt16,
} from '../audio-utils'
describe('arrayBufferToBase64 / base64ToArrayBuffer', () => {
@ -58,27 +58,19 @@ describe('int16ToFloat32 / float32ToInt16', () => {
})
})
describe('resample16kTo24k', () => {
it('produit ceil(input.length * 1.5) samples en sortie', () => {
expect(resample16kTo24k(new Int16Array(4)).length).toBe(6)
expect(resample16kTo24k(new Int16Array(10)).length).toBe(15)
expect(resample16kTo24k(new Int16Array(4096)).length).toBe(6144)
describe('concatInt16', () => {
it('concatène plusieurs chunks dans lordre', () => {
const out = concatInt16([new Int16Array([1, 2]), new Int16Array([3]), new Int16Array([4, 5])])
expect(Array.from(out)).toEqual([1, 2, 3, 4, 5])
})
it('interpole linéairement entre samples consécutifs', () => {
// Input : [0, 1000] à 16 kHz → 3 samples à 24 kHz
// i=0 : srcIndex=0 → 0
// i=1 : srcIndex=2/3 → 0 + (2/3)*1000 ≈ 667
// i=2 : srcIndex=4/3 → clamp à idx 1 → 1000
const out = resample16kTo24k(new Int16Array([0, 1000]))
expect(out[0]).toBe(0)
expect(out[1]).toBeGreaterThan(600)
expect(out[1]).toBeLessThan(700)
expect(out[2]).toBe(1000)
it('renvoie un buffer vide pour une liste vide', () => {
expect(concatInt16([]).length).toBe(0)
})
it('renvoie un buffer vide pour un input vide', () => {
expect(resample16kTo24k(new Int16Array(0)).length).toBe(0)
it('préserve un chunk unique à lidentique', () => {
const out = concatInt16([new Int16Array([7, 8, 9])])
expect(Array.from(out)).toEqual([7, 8, 9])
})
})

View file

@ -3,7 +3,7 @@
*
* Conversions entre formats utilisés par Gemini Live et les Web Audio APIs :
* - PCM 16 bits little-endian Float32 [-1, 1]
* - Rééchantillonnage 16 kHz 24 kHz (interpolation linéaire)
* - Concaténation de pistes Int16
* - Encodage WAV mono pour téléchargement de la session
*
* Toutes les fonctions sont pures (sans état, sans side-effect) et
@ -65,25 +65,20 @@ export function float32ToInt16(float32: Float32Array): Int16Array {
}
/**
* Rééchantillonne un buffer Int16 PCM 16 kHz vers 24 kHz par
* interpolation linéaire (ratio 1.5 pour 2 samples in, 3 samples out).
* Concatène une liste de buffers Int16 en un seul buffer contigu. Pur.
*
* Algorithme : pour chaque sample de sortie i, trouver l'index source
* correspondant `i / 1.5`, interpoler entre les deux samples encadrants.
* Utilisé pour reconstituer la piste WAV complète à partir des chunks Int16
* accumulés par le tap d'enregistrement (Sprint 6e Voie A).
*/
export function resample16kTo24k(samples: Int16Array): Int16Array {
const ratio = 24000 / 16000 // 1.5
const outputLength = Math.ceil(samples.length * ratio)
const out = new Int16Array(outputLength)
for (let i = 0; i < outputLength; i++) {
const srcIndex = i / ratio
const srcFloor = Math.floor(srcIndex)
const srcCeil = Math.min(srcFloor + 1, samples.length - 1)
const frac = srcIndex - srcFloor
out[i] = Math.round(samples[srcFloor]! * (1 - frac) + samples[srcCeil]! * frac)
export function concatInt16(chunks: Int16Array[]): Int16Array {
let total = 0
for (const c of chunks) total += c.length
const out = new Int16Array(total)
let offset = 0
for (const c of chunks) {
out.set(c, offset)
offset += c.length
}
return out
}