/** * 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 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('idle') const [elapsedSeconds, setElapsedSeconds] = useState(0) const [audioBlob, setAudioBlob] = useState(null) const [audioMimeType, setAudioMimeType] = useState(null) const [error, setError] = useState(null) const [permissionDenied, setPermissionDenied] = useState(false) const recorderRef = useRef(null) const streamRef = useRef(null) const chunksRef = useRef([]) const timerRef = useRef | null>(null) const subscribersRef = useRef 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, } }