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.
450 lines
15 KiB
TypeScript
450 lines
15 KiB
TypeScript
/**
|
||
* 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 à 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,
|
||
}
|
||
}
|