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>
This commit is contained in:
parent
71c1ad3018
commit
d1c8b548bb
34 changed files with 3255 additions and 70 deletions
271
src/features/simulations/hooks/useAudioRecorder.ts
Normal file
271
src/features/simulations/hooks/useAudioRecorder.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue