/** * 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=` — 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' import { useAudioCapture } from '@/shared/lib/audio/useAudioCapture' import { useAudioPlayback } from '@/shared/lib/audio/useAudioPlayback' import { useAudioRecording } from '@/shared/lib/audio/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 endDialogue: () => void /** Abandon : ferme le WS sans déclencher d'évaluation ni de persistance. */ cancelDialogue: () => void warning: boolean errorMessage: string | null simulationId: string | null recording: ReturnType /** Secondes écoulées depuis l'ouverture du WS — pour le timer affiché. */ elapsedSeconds: number /** * AnalyserNode dérivé du graphe de capture (par ref stable) — pour * l'indicateur de prise de parole, sans re-render ni sonde sur le flux montant. */ analyserRef: RefObject } interface GeminiPart { 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 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(T1_INITIAL_STATE) const [warning, setWarning] = useState(false) const [errorMessage, setErrorMessage] = useState(null) const [simulationId, setSimulationId] = useState(null) const [elapsedSeconds, setElapsedSeconds] = useState(0) const wsRef = useRef(null) const sessionEndedRef = useRef(false) const timeoutTimerRef = useRef | null>(null) const elapsedTimerRef = useRef | null>(null) const pingTimerRef = useRef | 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, } }