expria-frontend/src/features/simulations/hooks/useAudioRecorder.ts
Hermann_Kitio d1c8b548bb feat(eo): complete EO simulation flow (T1 + T3) with Gemini transcription
- Gemini batch transcription (no Deepgram live)
- blobToBase64 helper (shared/lib/audio.ts)
- AudioRecorder: remove onChunk, add maxSeconds/onMaxReached auto-submit
- Timer stops at maxSeconds and triggers auto-submission
- EnregistrementEOPage: audioBase64 to backend, fix race condition step=done
- SimulationFlowProvider: submitEoAudio(audioBase64, mimeType, nclcCible)
- MIME normalization (strip codec params)
- Split CORRECTION_EE_TIMEOUT_MS (60s) / CORRECTION_EO_TIMEOUT_MS (120s)
- PresentationGenereeT1Page: localStorage persistence

Typecheck: OK · Tests: 159/159 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:28:51 +03:00

271 lines
8.5 KiB
TypeScript

/**
* Hook MediaRecorder pour les productions orales — Sprint 4c-1.
*
* Capture le micro via getUserMedia + MediaRecorder, expose un timer montant
* et un Blob webm/opus à l'arrêt. Permet aussi de s'abonner aux chunks
* (timeslice 250 ms) pour streamer en parallèle vers Deepgram.
*
* Compat : préfère `audio/webm;codecs=opus`, fallback `audio/webm`, puis
* `audio/mp4` (Safari iOS — cf. FTD audio iOS).
*
* Le hook ne stocke pas l'audio côté serveur — la sauvegarde locale via
* `downloadAudio` est une commodité utilisateur (cf. Sprint 4b backend).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'stopped' | 'error'
export interface UseAudioRecorderOptions {
/**
* Sprint 4b.3 — durée maximale d'enregistrement en secondes. Quand
* `elapsedSeconds` atteint cette valeur, le hook stoppe automatiquement
* le MediaRecorder et appelle `onMaxReached` une fois.
*/
maxSeconds?: number
onMaxReached?: () => void
}
export interface UseAudioRecorderResult {
status: RecorderStatus
elapsedSeconds: number
audioBlob: Blob | null
audioMimeType: string | null
error: string | null
permissionDenied: boolean
start: () => Promise<void>
stop: () => void
cancel: () => void
downloadAudio: (filename: string) => void
/** S'abonne aux chunks (timeslice). Retourne un unsubscribe. */
subscribeChunks: (cb: (chunk: Blob) => void) => () => void
}
/** Choisit le mimeType supporté par le navigateur, par ordre de préférence. */
function pickMimeType(): string | null {
if (typeof MediaRecorder === 'undefined') return null
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']
for (const m of candidates) {
if (MediaRecorder.isTypeSupported(m)) return m
}
return null
}
const TIMESLICE_MS = 250
export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudioRecorderResult {
const [status, setStatus] = useState<RecorderStatus>('idle')
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const [audioBlob, setAudioBlob] = useState<Blob | null>(null)
const [audioMimeType, setAudioMimeType] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [permissionDenied, setPermissionDenied] = useState(false)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const subscribersRef = useRef<Set<(chunk: Blob) => void>>(new Set())
// Capture options dans une ref pour éviter de réabonner les effets sur
// chaque render (les callers fournissent souvent des fonctions inline).
const optionsRef = useRef(options)
optionsRef.current = options
const maxReachedFiredRef = useRef(false)
const cleanupTimer = useCallback(() => {
if (timerRef.current !== null) {
clearInterval(timerRef.current)
timerRef.current = null
}
}, [])
const cleanupStream = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop())
streamRef.current = null
}, [])
const start = useCallback(async () => {
if (status === 'recording' || status === 'requesting') return
setError(null)
setPermissionDenied(false)
setAudioBlob(null)
setElapsedSeconds(0)
chunksRef.current = []
setStatus('requesting')
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
setError('Votre navigateur ne supporte pas la capture audio.')
setStatus('error')
return
}
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
const name = err instanceof Error ? err.name : ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
setPermissionDenied(true)
setError("L'accès au micro est refusé. Autorisez-le dans les réglages du navigateur.")
} else {
setError("Impossible d'accéder au micro. Vérifiez vos périphériques.")
}
setStatus('error')
return
}
streamRef.current = stream
const mimeType = pickMimeType()
if (!mimeType) {
cleanupStream()
setError('Aucun format audio supporté par votre navigateur.')
setStatus('error')
return
}
setAudioMimeType(mimeType)
let recorder: MediaRecorder
try {
recorder = new MediaRecorder(stream, { mimeType })
} catch {
cleanupStream()
setError("Impossible d'initialiser l'enregistreur audio.")
setStatus('error')
return
}
recorderRef.current = recorder
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
chunksRef.current.push(event.data)
subscribersRef.current.forEach((cb) => cb(event.data))
}
}
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mimeType })
setAudioBlob(blob)
cleanupStream()
cleanupTimer()
setStatus('stopped')
}
recorder.onerror = () => {
cleanupStream()
cleanupTimer()
setError("L'enregistrement a échoué.")
setStatus('error')
}
recorder.start(TIMESLICE_MS)
setStatus('recording')
maxReachedFiredRef.current = false
timerRef.current = setInterval(() => {
setElapsedSeconds((s) => {
const next = s + 1
const max = optionsRef.current.maxSeconds
// Cap visuel à `max` et arrête d'incrémenter au-delà. L'auto-stop
// est déclenché par l'effet observant `elapsedSeconds`.
return max && next >= max ? max : next
})
}, 1000)
}, [status, cleanupStream, cleanupTimer])
const stop = useCallback(() => {
// Arrêter le timer SYNCHRONE — sinon il continue d'incrémenter pendant
// les ~50-200 ms entre l'appel à `recorder.stop()` et la réception du
// callback `onstop` (qui appelle aussi cleanupTimer en sécurité).
cleanupTimer()
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
recorder.stop()
}
}, [cleanupTimer])
const cancel = useCallback(() => {
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
// Vide les chunks AVANT le stop pour produire un blob nul.
chunksRef.current = []
recorder.stop()
}
cleanupStream()
cleanupTimer()
setStatus('idle')
setElapsedSeconds(0)
setAudioBlob(null)
}, [cleanupStream, cleanupTimer])
const downloadAudio = useCallback(
(filename: string) => {
if (!audioBlob) return
const url = URL.createObjectURL(audioBlob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
[audioBlob],
)
const subscribeChunks = useCallback((cb: (chunk: Blob) => void) => {
subscribersRef.current.add(cb)
return () => {
subscribersRef.current.delete(cb)
}
}, [])
// Sprint 4b.3 — auto-stop à expiration de la durée recommandée.
// Quand le timer atteint `maxSeconds`, on stoppe le MediaRecorder (ce qui
// déclenche `onstop` → audioBlob, status='stopped') et on notifie le caller
// une seule fois via `onMaxReached`. Le composant parent peut câbler son
// onSubmit sur le passage en status='stopped' (cf. AudioRecorder).
useEffect(() => {
if (status !== 'recording') return
const max = optionsRef.current.maxSeconds
if (!max || elapsedSeconds < max) return
if (maxReachedFiredRef.current) return
maxReachedFiredRef.current = true
cleanupTimer()
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
recorder.stop()
}
optionsRef.current.onMaxReached?.()
}, [elapsedSeconds, status, cleanupTimer])
// Cleanup global au démontage : libère le micro même si l'utilisateur
// navigue ailleurs sans cliquer sur Stop ou Annuler.
useEffect(() => {
return () => {
cleanupTimer()
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
try {
recorder.stop()
} catch {
/* noop */
}
}
cleanupStream()
}
}, [cleanupStream, cleanupTimer])
return {
status,
elapsedSeconds,
audioBlob,
audioMimeType,
error,
permissionDenied,
start,
stop,
cancel,
downloadAudio,
subscribeChunks,
}
}