import { useCallback, useEffect, useRef, useState } from "react"; interface RecorderState { isRecording: boolean; durationMs: number; blob: Blob | null; objectUrl: string | null; error: string | null; } interface UseAudioRecorder { state: RecorderState; start: () => Promise; stop: () => Promise; reset: () => void; maxDurationMs: number; } const MAX_DURATION_MS = 15_000; /** * Capture audio from the user's microphone with an enforced max duration. * * Crucial behaviour: the blob is committed via `recorder.onstop` regardless * of WHO triggered the stop — manual button, max-duration auto-stop, or an * error path. Earlier versions only committed the blob if the user clicked * Stop manually, which silently dropped recordings at the 15 s ceiling. */ export function useAudioRecorder(maxDurationMs = MAX_DURATION_MS): UseAudioRecorder { const [state, setState] = useState({ isRecording: false, durationMs: 0, blob: null, objectUrl: null, error: null, }); const mediaRecorderRef = useRef(null); const streamRef = useRef(null); const chunksRef = useRef([]); const startTsRef = useRef(0); const tickIdRef = useRef(null); const stopTimerRef = useRef(null); const stoppedRef = useRef(false); // guards the onstop handler const pendingStopResolverRef = useRef<((blob: Blob | null) => void) | null>(null); const previousObjectUrlRef = useRef(null); const cleanupTimers = () => { if (tickIdRef.current !== null) { window.cancelAnimationFrame(tickIdRef.current); tickIdRef.current = null; } if (stopTimerRef.current !== null) { window.clearTimeout(stopTimerRef.current); stopTimerRef.current = null; } }; const teardownStream = () => { if (streamRef.current) { streamRef.current.getTracks().forEach((t) => { try { t.stop(); } catch { // noop } }); streamRef.current = null; } }; const reset = useCallback(() => { cleanupTimers(); stoppedRef.current = true; // disable the deferred onstop blob commit if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { try { mediaRecorderRef.current.stop(); } catch { // noop } } teardownStream(); setState((prev) => { if (prev.objectUrl) URL.revokeObjectURL(prev.objectUrl); return { isRecording: false, durationMs: 0, blob: null, objectUrl: null, error: null, }; }); chunksRef.current = []; mediaRecorderRef.current = null; pendingStopResolverRef.current = null; }, []); const start = useCallback(async () => { try { reset(); stoppedRef.current = false; const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true, }, }); streamRef.current = stream; const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : ""; const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); mediaRecorderRef.current = recorder; chunksRef.current = []; recorder.ondataavailable = (e) => { if (e.data && e.data.size) chunksRef.current.push(e.data); }; // Single onstop handler. Triggered by: // • user clicking Stop (calls recorder.stop()) // • the maxDuration setTimeout firing at 15 s (also calls recorder.stop()) // • reset() being called externally // In all real-stop cases we commit the chunks into a Blob and update state. // The ``stoppedRef`` guard skips this if the stop was initiated by reset() // (which clears chunks anyway). recorder.onstop = () => { cleanupTimers(); teardownStream(); if (stoppedRef.current) { // reset() path — nothing more to do. if (pendingStopResolverRef.current) { pendingStopResolverRef.current(null); pendingStopResolverRef.current = null; } return; } stoppedRef.current = true; const recordedMs = Math.min( performance.now() - startTsRef.current, maxDurationMs ); const blob = chunksRef.current.length ? new Blob(chunksRef.current, { type: recorder.mimeType || "audio/webm" }) : null; const objectUrl = blob ? URL.createObjectURL(blob) : null; setState((prev) => { // Revoke any previous URL we owned (rare race: re-record). if (prev.objectUrl && prev.objectUrl !== objectUrl) { URL.revokeObjectURL(prev.objectUrl); } previousObjectUrlRef.current = objectUrl; return { isRecording: false, durationMs: recordedMs, blob, objectUrl, error: blob ? null : "no audio captured (microphone returned empty)", }; }); if (pendingStopResolverRef.current) { pendingStopResolverRef.current(blob); pendingStopResolverRef.current = null; } }; recorder.start(100); startTsRef.current = performance.now(); setState((s) => ({ ...s, isRecording: true, error: null, durationMs: 0, blob: null, objectUrl: null, })); // Live duration tick for the progress bar. const tick = () => { const elapsed = performance.now() - startTsRef.current; setState((s) => (s.isRecording ? { ...s, durationMs: Math.min(elapsed, maxDurationMs) } : s)); if (recorder.state === "recording" && elapsed < maxDurationMs) { tickIdRef.current = window.requestAnimationFrame(tick); } }; tickIdRef.current = window.requestAnimationFrame(tick); // Auto-stop at the maximum duration. recorder.stop() will fire // recorder.onstop, which in turn commits the blob — same code path // as the manual Stop button. stopTimerRef.current = window.setTimeout(() => { if (recorder.state === "recording") { try { recorder.stop(); } catch { // noop } } }, maxDurationMs); } catch (e) { teardownStream(); setState((s) => ({ ...s, isRecording: false, error: e instanceof Error ? e.message : "Microphone access failed", })); } }, [maxDurationMs, reset]); const stop = useCallback(async (): Promise => { cleanupTimers(); const rec = mediaRecorderRef.current; if (!rec || rec.state === "inactive") { return null; } return new Promise((resolve) => { pendingStopResolverRef.current = resolve; try { rec.stop(); } catch { pendingStopResolverRef.current = null; resolve(null); } }); }, []); // Component-unmount cleanup. Use a ref to read the latest objectUrl so we // don't lose URLs to closure staleness with []. useEffect(() => { return () => { cleanupTimers(); teardownStream(); if (previousObjectUrlRef.current) { URL.revokeObjectURL(previousObjectUrlRef.current); previousObjectUrlRef.current = null; } }; }, []); return { state, start, stop, reset, maxDurationMs }; }