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