feat(t1-live): T1 Live frontend — Sprint 7b
Some checks failed
CI / quality (push) Has been cancelled

- Add T1 state machine (8 states, presenting ⇄ interrupted)
- Add useT1LiveSession (WS /t1/live, uplink gate by ref, no context msg)
- Add T1PreparationPage, T1DialoguePage, T1SpeakingIndicator
- Add EO_T1_LIVE card in TaskSelector gated via oral_t2_live
- Extract shared t1Questionnaire.ts for batch/live DRY
- Remove T1LiveQuestionnairePage + T1LiveContext (post patch 7a)
- Simplified flow: card → preparation → dialogue
- FTD-44 frozen (cross-feature audio hooks, Sprint 7.5)
- FTD-45/46 frozen (Gemini relance quality + transcription)
- Tests: 301/301 green
This commit is contained in:
Hermann_Kitio 2026-06-30 22:53:57 +03:00
parent eb8987ddb3
commit 3016d909a6
14 changed files with 1385 additions and 68 deletions

View file

@ -0,0 +1,453 @@
/**
* useT1LiveSession Hook orchestrateur du dialogue T1 Live (Sprint 7b).
*
* Calqué sur useT2LiveSession (même discipline Voie A : AudioContext unique,
* jamais de MediaStream en state, flags pilotés par ref dans le chemin audio).
* MAIS la sémantique T1 diffère fondamentalement :
*
* 1. URL `wss://${API_URL}/t1/live?token=<jwt>` PAS de `&sujet=` : la Tâche 1
* n'est PAS subject-based. L'examinateur formule ses relances à partir de ce
* qu'il ENTEND en temps réel (Patch 7a backend plus de questionnaire).
* 2. AUCUN message de contexte. La session audio démarre dès `ws.onopen`
* (WS_OPENED presenting) : le candidat envoie directement son audio.
* 3. AUCUN VAD micro (contrairement à T2). T1 est un MONOLOGUE : c'est le BACKEND
* (horloge probabiliste) qui décide quand l'examinateur interrompt. Le frontend
* réagit aux signaux applicatifs `{type:'interruption_start'}` /
* `{type:'interruption_end'}` dispatch INTERRUPTION_START / INTERRUPTION_END.
* 4. Pendant une interruption, l'uplink micro est COUPÉ (l'examinateur a la
* parole) via un ref (`uplinkMutedRef`) jamais via setState, pour ne pas
* perturber le chemin sourceworkletWS (leçon Voie A).
* 5. Timer dur 180 s côté frontend (redondant avec le backend, warning 150 s
* émis par `{type:'warning'}`).
* 6. Gère les close codes (1000, 4001, 4003, 4005, 4006).
*
* Validation : test manuel uniquement (WebSocket + AudioContext non testables en
* jsdom la logique pure de transition est couverte par t1-machine.test.ts).
*/
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'
// TODO(FTD-44): hooks audio génériques empruntés à features/t2-live/ (violation
// FSD inter-features assumée et tracée). À relocaliser vers shared/lib/audio/
// au Sprint 7.5 (« factorisation Sprint 7 »). Cf. TECH_DEBT.md §3bis.
import { useAudioCapture } from '@/features/t2-live/hooks/useAudioCapture'
import { useAudioPlayback } from '@/features/t2-live/hooks/useAudioPlayback'
import { useAudioRecording } from '@/features/t2-live/hooks/useAudioRecording'
import { transition, T1_INITIAL_STATE, type T1State, type T1Event } from '../state/t1-machine'
const DIALOGUE_TIMEOUT_MS = 180_000 // 3 min
const WS_PING_INTERVAL_MS = 30_000
export interface UseT1LiveSessionOptions {
/** Appelé quand le rapport est reçu — l'appelant décide de la navigation. */
onReportReady?: (simulationId: string) => void
}
export interface UseT1LiveSessionResult {
state: T1State
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 {
inlineData?: { data?: string; mimeType?: string }
}
interface GeminiServerContent {
modelTurn?: { parts?: GeminiPart[] }
inputTranscription?: { text?: string }
outputTranscription?: { text?: string }
interrupted?: boolean
turnComplete?: boolean
}
interface GeminiMessage {
serverContent?: GeminiServerContent
}
interface AppMessage {
type: 'warning' | 'report' | 'error' | 'audio' | 'interruption_start' | 'interruption_end'
data?: { simulation_id?: string } & Record<string, unknown>
message?: string
code?: string
}
function buildWsUrl(token: string): string {
const base = env.VITE_API_URL.replace(/^http/, 'ws')
return `${base}/t1/live?token=${encodeURIComponent(token)}`
}
export function useT1LiveSession(opts: UseT1LiveSessionOptions = {}): UseT1LiveSessionResult {
const { onReportReady } = opts
const navigate = useNavigate()
const [state, setState] = useState<T1State>(T1_INITIAL_STATE)
const [warning, setWarning] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [simulationId, setSimulationId] = useState<string | null>(null)
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const wsRef = useRef<WebSocket | null>(null)
const sessionEndedRef = useRef(false)
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Uplink micro coupé pendant une interruption (l'examinateur a la parole).
// Piloté par ref — JAMAIS setState — pour ne pas perturber le chemin audio
// source→worklet→WS (leçon Voie A : un setState dans ce chemin affame l'uplink).
const uplinkMutedRef = useRef(false)
// Sprint 6d (repris) — token de cancellation pour rendre `startDialogue`
// idempotent sur les appels rapprochés (StrictMode dev double-mount, etc.).
const cancelTokenRef = useRef<{ cancelled: boolean } | null>(null)
const recording = useAudioRecording()
// Déclaré avant `capture` car onChunk en dépend.
const dispatch = useCallback((event: T1Event) => {
setState((prev) => transition(prev, event))
}, [])
// Capture branchée à l'envoi WS. Aucun VAD (T1 = monologue) : on transmet le
// chunk uplink tel quel SAUF pendant une interruption (uplinkMutedRef).
const capture = useAudioCapture({
onChunk: (base64: string) => {
if (uplinkMutedRef.current) return
const ws = wsRef.current
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'audio', data: base64 }))
}
},
})
// Playback déclaré APRÈS capture : il consomme le contexte + le mix PARTAGÉS
// (Voie A — horloge unique, voix examinateur routée vers le mix + destination).
const playback = useAudioPlayback({
contextRef: capture.contextRef,
mixNodeRef: capture.mixNodeRef,
})
const cleanupTimers = useCallback(() => {
if (timeoutTimerRef.current !== null) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
if (elapsedTimerRef.current !== null) {
clearInterval(elapsedTimerRef.current)
elapsedTimerRef.current = null
}
if (pingTimerRef.current !== null) {
clearInterval(pingTimerRef.current)
pingTimerRef.current = null
}
}, [])
const closeAll = useCallback(() => {
sessionEndedRef.current = true
cleanupTimers()
if (cancelTokenRef.current) {
cancelTokenRef.current.cancelled = true
cancelTokenRef.current = null
}
capture.stop()
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
try {
wsRef.current.close()
} catch {
/* ignore */
}
}
// playback continue jusqu'à fin de file pour ne pas couper la dernière phrase.
}, [capture, cleanupTimers])
const handleAudioReceived = useCallback(
(base64: string) => {
// Voie A : playChunk route la voix examinateur vers destination ET vers le
// mixGain ; le tap d'enregistrement la capte sur ce mix en temps réel.
playback.playChunk(base64)
},
[playback],
)
const handleGeminiMessage = useCallback(
(msg: GeminiMessage) => {
const sc = msg.serverContent
if (!sc) return
if (sc.modelTurn?.parts) {
for (const part of sc.modelTurn.parts) {
if (part.inlineData?.data) {
handleAudioReceived(part.inlineData.data)
}
}
}
},
[handleAudioReceived],
)
const handleAppMessage = useCallback(
(msg: AppMessage) => {
if (msg.type === 'warning') {
setWarning(true)
return
}
// Interruption NON DÉTERMINISTE pilotée par le backend (jamais déduite côté
// front). L'examinateur prend / rend la parole : on coupe / rétablit l'uplink
// micro via ref et on dispatche la transition d'état.
if (msg.type === 'interruption_start') {
uplinkMutedRef.current = true
dispatch({ type: 'INTERRUPTION_START' })
return
}
if (msg.type === 'interruption_end') {
uplinkMutedRef.current = false
dispatch({ type: 'INTERRUPTION_END' })
return
}
if (msg.type === 'report' && msg.data) {
const simId = (msg.data.simulation_id as string | undefined) ?? null
if (simId) setSimulationId(simId)
dispatch({ type: 'REPORT_READY' })
if (simId && onReportReady) onReportReady(simId)
return
}
if (msg.type === 'error') {
setErrorMessage(msg.message ?? 'Une erreur est survenue.')
dispatch({ type: 'ERROR', message: msg.message })
}
},
[dispatch, onReportReady],
)
const handleWsMessage = useCallback(
(evt: MessageEvent) => {
let text: string
if (typeof evt.data === 'string') {
text = evt.data
} else if (evt.data instanceof ArrayBuffer) {
text = new TextDecoder().decode(evt.data)
} else if (evt.data instanceof Blob) {
console.warn('[T1] Frame Blob reçu, attendu string/ArrayBuffer')
return
} else {
return
}
try {
const parsed = JSON.parse(text) as GeminiMessage & AppMessage
if (
parsed.type === 'warning' ||
parsed.type === 'report' ||
parsed.type === 'error' ||
parsed.type === 'interruption_start' ||
parsed.type === 'interruption_end'
) {
handleAppMessage(parsed as AppMessage)
} else if (parsed.serverContent) {
handleGeminiMessage(parsed as GeminiMessage)
}
} catch {
/* JSON malformé — ignorer */
}
},
[handleAppMessage, handleGeminiMessage],
)
// Indirection par ref : le binding ws.onmessage appelle TOUJOURS le handler
// courant (immunisé au gel de closure / HMR). Cf. INSTRUMENT REPAIR 6e (T2).
const handleWsMessageRef = useRef(handleWsMessage)
useEffect(() => {
handleWsMessageRef.current = handleWsMessage
}, [handleWsMessage])
const handleWsClose = useCallback(
(evt: CloseEvent) => {
if (sessionEndedRef.current) return
sessionEndedRef.current = true
cleanupTimers()
recording.stop()
capture.stop()
switch (evt.code) {
case 1000:
if (state !== 'ended') {
dispatch({ type: 'ERROR', message: 'Session interrompue' })
}
break
case 4001:
setErrorMessage('Authentification expirée.')
dispatch({ type: 'ERROR', code: 4001 })
navigate('/login')
break
case 4003:
setErrorMessage('La Tâche 1 Live est réservée au plan Premium.')
dispatch({ type: 'ERROR', code: 4003 })
break
case 4005:
setErrorMessage('Service temporairement indisponible.')
dispatch({ type: 'ERROR', code: 4005 })
break
case 4006:
setErrorMessage('Connexion à lexaminateur perdue.')
dispatch({ type: 'ERROR', code: 4006 })
break
default:
setErrorMessage('Une erreur est survenue. Réessayez dans quelques instants.')
dispatch({ type: 'ERROR', code: evt.code })
}
},
[capture, cleanupTimers, dispatch, navigate, recording, state],
)
const startDialogue = useCallback(async () => {
if (wsRef.current || cancelTokenRef.current) return
const localToken = { cancelled: false }
cancelTokenRef.current = localToken
setErrorMessage(null)
setWarning(false)
sessionEndedRef.current = false
uplinkMutedRef.current = false
dispatch({ type: 'START_DIALOGUE' })
const token = await getAccessToken()
if (localToken.cancelled) return
if (!token) {
cancelTokenRef.current = null
setErrorMessage('Authentification requise.')
dispatch({ type: 'ERROR', code: 4001 })
navigate('/login')
return
}
let ws: WebSocket
try {
ws = new WebSocket(buildWsUrl(token))
ws.binaryType = 'arraybuffer'
} catch (err) {
cancelTokenRef.current = null
const message = err instanceof Error ? err.message : 'Connexion impossible'
setErrorMessage(message)
dispatch({ type: 'ERROR' })
return
}
if (localToken.cancelled) {
try {
ws.close()
} catch {
/* ignore */
}
return
}
wsRef.current = ws
ws.onopen = () => {
// Aucun message de contexte (Patch 7a backend) : la session audio démarre
// dès l'ouverture du WS, le candidat a la parole (monologue).
dispatch({ type: 'WS_OPENED' })
// Démarrer la capture micro PUIS brancher le tap d'enregistrement sur le
// contexte + mixGain (qui n'existent qu'après résolution de capture.start()).
void capture.start().then(() => {
const ctx = capture.contextRef.current
const mix = capture.mixNodeRef.current
if (ctx && mix) {
recording.reset()
void recording.start(ctx, mix)
}
})
const startTime = Date.now()
elapsedTimerRef.current = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000))
}, 250)
// Timeout dur frontend (redondance avec le 180 s backend).
timeoutTimerRef.current = setTimeout(() => {
if (sessionEndedRef.current) return
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'end' }))
} catch {
/* ignore */
}
}
dispatch({ type: 'END_REQUESTED' })
}, DIALOGUE_TIMEOUT_MS)
pingTimerRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'ping' }))
} catch {
/* ignore */
}
}
}, WS_PING_INTERVAL_MS)
}
ws.onmessage = (evt) => handleWsMessageRef.current(evt)
ws.onclose = handleWsClose
ws.onerror = () => {
// 'close' suit toujours 'error' — gestion centralisée dans handleWsClose.
}
}, [capture, dispatch, handleWsClose, navigate, recording])
const endDialogue = useCallback(() => {
if (sessionEndedRef.current) return
cleanupTimers()
recording.stop()
capture.stop()
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ type: 'end' }))
} catch {
/* ignore */
}
}
dispatch({ type: 'END_REQUESTED' })
}, [capture, cleanupTimers, dispatch, recording])
// Abandon utilisateur : ferme le WS SANS envoyer `{type:'end'}` → le backend ne
// déclenche NI correction NI persistance. Machine → 'idle' via CANCEL.
const cancelDialogue = useCallback(() => {
if (sessionEndedRef.current) return
closeAll()
playback.stop()
dispatch({ type: 'CANCEL' })
}, [closeAll, playback, dispatch])
// Cleanup au démontage UNIQUEMENT (cf. T2 : ref tenant la dernière version de
// closeAll + effet à deps vides, pour ne pas fermer le WS à chaque render).
const closeAllRef = useRef(closeAll)
useEffect(() => {
closeAllRef.current = closeAll
})
useEffect(() => {
return () => {
closeAllRef.current()
}
}, [])
return {
state,
startDialogue,
endDialogue,
cancelDialogue,
warning,
errorMessage,
simulationId,
recording,
elapsedSeconds,
analyserRef: capture.analyserRef,
}
}