| 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<void>; |
| stop: () => Promise<Blob | null>; |
| reset: () => void; |
| maxDurationMs: number; |
| } |
|
|
| const MAX_DURATION_MS = 15_000; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function useAudioRecorder(maxDurationMs = MAX_DURATION_MS): UseAudioRecorder { |
| const [state, setState] = useState<RecorderState>({ |
| isRecording: false, |
| durationMs: 0, |
| blob: null, |
| objectUrl: null, |
| error: null, |
| }); |
|
|
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); |
| const streamRef = useRef<MediaStream | null>(null); |
| const chunksRef = useRef<Blob[]>([]); |
| const startTsRef = useRef<number>(0); |
| const tickIdRef = useRef<number | null>(null); |
| const stopTimerRef = useRef<number | null>(null); |
| const stoppedRef = useRef<boolean>(false); |
| const pendingStopResolverRef = useRef<((blob: Blob | null) => void) | null>(null); |
| const previousObjectUrlRef = useRef<string | null>(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 { |
| |
| } |
| }); |
| streamRef.current = null; |
| } |
| }; |
|
|
| const reset = useCallback(() => { |
| cleanupTimers(); |
| stoppedRef.current = true; |
| if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { |
| try { |
| mediaRecorderRef.current.stop(); |
| } catch { |
| |
| } |
| } |
| 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); |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| recorder.onstop = () => { |
| cleanupTimers(); |
| teardownStream(); |
|
|
| if (stoppedRef.current) { |
| |
| 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) => { |
| |
| 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, |
| })); |
|
|
| |
| 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); |
|
|
| |
| |
| |
| stopTimerRef.current = window.setTimeout(() => { |
| if (recorder.state === "recording") { |
| try { |
| recorder.stop(); |
| } catch { |
| |
| } |
| } |
| }, 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<Blob | null> => { |
| cleanupTimers(); |
| const rec = mediaRecorderRef.current; |
| if (!rec || rec.state === "inactive") { |
| return null; |
| } |
| return new Promise<Blob | null>((resolve) => { |
| pendingStopResolverRef.current = resolve; |
| try { |
| rec.stop(); |
| } catch { |
| pendingStopResolverRef.current = null; |
| resolve(null); |
| } |
| }); |
| }, []); |
|
|
| |
| |
| useEffect(() => { |
| return () => { |
| cleanupTimers(); |
| teardownStream(); |
| if (previousObjectUrlRef.current) { |
| URL.revokeObjectURL(previousObjectUrlRef.current); |
| previousObjectUrlRef.current = null; |
| } |
| }; |
| }, []); |
|
|
| return { state, start, stop, reset, maxDurationMs }; |
| } |
|
|