feat(t2-live): archi audio Voie A + Bugs 4/5/6 + indicateur de prise de parole (Sprint 6e)

- Voie A WAV : AudioContext unique au rate natif, tap AudioWorklet sur mixGain, uplink rate-aware 16k, alignement par horloge unique (fin offset/resample/concat). Anti-echo candidat. Cycle start=ws.onopen / stop=Terminer / cancel=aucun WAV.
- Bug 4 : 'Voir le rapport' route vers le rapport (navigatingAwayRef).
- Bug 5 : 'Annuler' (cancelDialogue) - arret sans evaluation, sans WAV, sans production.
- Bug 6 : 'Nouvelle simulation' route selon le type via champ tache propage (Report).
- Indicateur de prise de parole : state machine USER_SPEAKING/USER_SILENT (RMS + hysteresis).
- Cleanup : retrait instrumentation [BISECT] ; ref VAD renomme lastAiChunkTsRef.
- Removed : code mort mixTracksToInt16, resample16kTo24k + tests.
This commit is contained in:
Hermann_Kitio 2026-06-29 14:31:38 +03:00
parent 9bf95f5c05
commit 72795e924e
16 changed files with 848 additions and 257 deletions

View file

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