feat(t1-live): T1 Live frontend — Sprint 7b
Some checks failed
CI / quality (push) Has been cancelled
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:
parent
eb8987ddb3
commit
3016d909a6
14 changed files with 1385 additions and 68 deletions
453
src/features/t1-live/hooks/useT1LiveSession.ts
Normal file
453
src/features/t1-live/hooks/useT1LiveSession.ts
Normal 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 source→worklet→WS (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 à l’examinateur 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue