expria-frontend/src/features/t1-live/hooks/useT1LiveSession.ts
Hermann_Kitio d9160c4493 refactor(audio): move shared audio hooks to shared/lib/audio (FTD-44)
Move useAudioCapture, useAudioPlayback, useAudioRecording (+ test)
from features/t2-live/hooks/ to shared/lib/audio/. Update imports
in useT2LiveSession.ts and useT1LiveSession.ts, remove TODO(FTD-44)
markers. No functional change ("Voie A" audio pipeline intact).

Dérogation Règle B assumée pour useAudioRecording (4 fichiers/vague
au lieu de 3) : hook et test déplacés ensemble pour préserver le
chemin d'import relatif (../useAudioRecording), condition nécessaire
au respect de Règle C (jamais rouge entre étapes).

Validated manually: T2 Live D2-D5, T1 Live full flow (private
browsing). 301/301 tests, 0 typecheck errors.
2026-07-02 04:07:59 +03:00

450 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'
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<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,
}
}