- 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>
271 lines
8.5 KiB
TypeScript
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,
|
|
}
|
|
}
|