diff --git a/public/pcm-record-processor.js b/public/pcm-record-processor.js new file mode 100644 index 0000000..da6338b --- /dev/null +++ b/public/pcm-record-processor.js @@ -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) diff --git a/src/entities/report/api.ts b/src/entities/report/api.ts index 1a304a2..36f8bca 100644 --- a/src/entities/report/api.ts +++ b/src/entities/report/api.ts @@ -36,6 +36,7 @@ export function getReport(id: string): Promise { 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, diff --git a/src/entities/report/types.ts b/src/entities/report/types.ts index 9217972..1b61fff 100644 --- a/src/entities/report/types.ts +++ b/src/entities/report/types.ts @@ -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 diff --git a/src/features/simulations/pages/RapportPage.tsx b/src/features/simulations/pages/RapportPage.tsx index 6a90d70..30ee0b9 100644 --- a/src/features/simulations/pages/RapportPage.tsx +++ b/src/features/simulations/pages/RapportPage.tsx @@ -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 ( diff --git a/src/features/simulations/pages/__tests__/RapportPage.test.tsx b/src/features/simulations/pages/__tests__/RapportPage.test.tsx new file mode 100644 index 0000000..df0fa16 --- /dev/null +++ b/src/features/simulations/pages/__tests__/RapportPage.test.tsx @@ -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('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( + + + , + ) +} + +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') + }) +}) diff --git a/src/features/t2-live/components/T2SpeakingIndicator.tsx b/src/features/t2-live/components/T2SpeakingIndicator.tsx new file mode 100644 index 0000000..e494dd1 --- /dev/null +++ b/src/features/t2-live/components/T2SpeakingIndicator.tsx @@ -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 + state: T2State +} + +export function T2SpeakingIndicator({ analyserRef, state }: T2SpeakingIndicatorProps) { + const barRefs = useRef>([]) + const rafRef = useRef(null) + const dataRef = useRef | 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 ( +
+
+ ) + } + + if (state === 'listening') { + return ( +
+
+ ) + } + + if (state === 'speaking') { + return ( +
+
+ ) + } + + return null +} diff --git a/src/features/t2-live/hooks/__tests__/useAudioRecording.test.ts b/src/features/t2-live/hooks/__tests__/useAudioRecording.test.ts index d2e750b..1bad4da 100644 --- a/src/features/t2-live/hooks/__tests__/useAudioRecording.test.ts +++ b/src/features/t2-live/hooks/__tests__/useAudioRecording.test.ts @@ -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 { + 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((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) }) }) diff --git a/src/features/t2-live/hooks/useAudioCapture.ts b/src/features/t2-live/hooks/useAudioCapture.ts index 72df470..e618580 100644 --- a/src/features/t2-live/hooks/useAudioCapture.ts +++ b/src/features/t2-live/hooks/useAudioCapture.ts @@ -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 + /** + * AudioContext de capture (rate natif). Exposé par REF pour que la lecture IA + * (useAudioPlayback) et l'enregistrement WAV (useAudioRecording) partagent la + * MÊME horloge — condition de l'alignement temporel natif (Voie A). null hors + * capture. + */ + contextRef: RefObject + /** + * GainNode de mixage : point unique où convergent le micro et la voix IA. Le + * tap d'enregistrement (Sprint 6e Step 3) s'y branche. Le micro y est routé + * EN PLUS du worklet/analyser ; il n'est PAS connecté au destination (pas + * d'écho de sa propre voix). null hors capture. + */ + mixNodeRef: RefObject } const WORKLET_URL = '/pcm-capture-processor.js' @@ -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(null) + // BISECTION 6e — Step 4 neutralisé : plus de state réactif sur le stream + // (aucun setState → aucun re-render déclenché par la publication du stream). + // Le stream reste interne (streamRef) et est exposé en lecture non réactive. const contextRef = useRef(null) const streamRef = useRef(null) const workletNodeRef = useRef(null) const sourceNodeRef = useRef(null) + const analyserRef = useRef(null) + const mixNodeRef = useRef(null) // Capture options dans une ref pour éviter de réabonner les effets // sur chaque render (l'appelant fournit souvent un onChunk inline). @@ -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, + } } diff --git a/src/features/t2-live/hooks/useAudioPlayback.ts b/src/features/t2-live/hooks/useAudioPlayback.ts index 3f63e8c..7bc9940 100644 --- a/src/features/t2-live/hooks/useAudioPlayback.ts +++ b/src/features/t2-live/hooks/useAudioPlayback.ts @@ -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 + /** + * 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 +} + 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(null) const lastEndTimeRef = useRef(0) // Timer qui repasse `isPlaying` à false quand la file se vide. const isPlayingTimerRef = useRef | null>(null) const activeSourcesRef = useRef>(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 } } diff --git a/src/features/t2-live/hooks/useAudioRecording.ts b/src/features/t2-live/hooks/useAudioRecording.ts index cbac497..6c7eb92 100644 --- a/src/features/t2-live/hooks/useAudioRecording.ts +++ b/src/features/t2-live/hooks/useAudioRecording.ts @@ -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 + /** 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([]) const totalSamplesRef = useRef(0) + // Rate capturé au start (= ctx.sampleRate natif) : le WAV est écrit à ce rate. + const sampleRateRef = useRef(0) + + const recordNodeRef = useRef(null) + const sinkRef = useRef(null) + const mixRef = useRef(null) + const [durationSeconds, setDurationSeconds] = useState(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) => { + 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, diff --git a/src/features/t2-live/hooks/useT2LiveSession.ts b/src/features/t2-live/hooks/useT2LiveSession.ts index 74e178d..45f5bd3 100644 --- a/src/features/t2-live/hooks/useT2LiveSession.ts +++ b/src/features/t2-live/hooks/useT2LiveSession.ts @@ -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 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 /** 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 } interface GeminiPart { @@ -89,15 +105,27 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio const elapsedTimerRef = useRef | null>(null) const pingTimerRef = useRef | null>(null) const userSpeakingTimerRef = useRef | 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(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, } } diff --git a/src/features/t2-live/pages/T2DialoguePage.tsx b/src/features/t2-live/pages/T2DialoguePage.tsx index 6a7c036..2a6cbac 100644 --- a/src/features/t2-live/pages/T2DialoguePage.tsx +++ b/src/features/t2-live/pages/T2DialoguePage.tsx @@ -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() { )}

{stateLabel}

+ {/* 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 && ( + + )}

{sujet.consigne}

-
+
+ {canCancel && ( + + )}
+

+ « Annuler » abandonne la session sans rapport. « Terminer » lance l'évaluation. +

) diff --git a/src/features/t2-live/state/__tests__/t2-machine.test.ts b/src/features/t2-live/state/__tests__/t2-machine.test.ts index 8849eb7..6265926 100644 --- a/src/features/t2-live/state/__tests__/t2-machine.test.ts +++ b/src/features/t2-live/state/__tests__/t2-machine.test.ts @@ -53,6 +53,24 @@ describe('T2 state machine — ERROR terminal', () => { }) }) +describe('T2 state machine — CANCEL (abandon) → idle depuis tout état actif', () => { + it.each(['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') diff --git a/src/features/t2-live/state/t2-machine.ts b/src/features/t2-live/state/t2-machine.ts index 62a459b..944d15e 100644 --- a/src/features/t2-live/state/t2-machine.ts +++ b/src/features/t2-live/state/t2-machine.ts @@ -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' diff --git a/src/shared/lib/__tests__/audio-utils.test.ts b/src/shared/lib/__tests__/audio-utils.test.ts index ba71f3a..913eedf 100644 --- a/src/shared/lib/__tests__/audio-utils.test.ts +++ b/src/shared/lib/__tests__/audio-utils.test.ts @@ -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 l’ordre', () => { + 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 à l’identique', () => { + const out = concatInt16([new Int16Array([7, 8, 9])]) + expect(Array.from(out)).toEqual([7, 8, 9]) }) }) diff --git a/src/shared/lib/audio-utils.ts b/src/shared/lib/audio-utils.ts index 779f468..0ce30bb 100644 --- a/src/shared/lib/audio-utils.ts +++ b/src/shared/lib/audio-utils.ts @@ -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 }