Spaces:
Running
Running
| "use client"; | |
| import { useState, useRef, useCallback, useEffect } from "react"; | |
| export type RecorderState = "idle" | "requesting" | "recording" | "stopped" | "error"; | |
| export interface UseAudioRecorderReturn { | |
| state: RecorderState; | |
| seconds: number; | |
| audioBlob: Blob | null; | |
| analyserNode: AnalyserNode | null; | |
| startRecording: () => Promise<void>; | |
| stopRecording: () => void; | |
| reset: () => void; | |
| error: string | null; | |
| } | |
| export function useAudioRecorder(maxSeconds = 60): UseAudioRecorderReturn { | |
| const [state, setState] = useState<RecorderState>("idle"); | |
| const [seconds, setSeconds] = useState(0); | |
| const [audioBlob, setAudioBlob] = useState<Blob | null>(null); | |
| const [analyserNode, setAnalyserNode] = useState<AnalyserNode | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); | |
| const chunksRef = useRef<Blob[]>([]); | |
| const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); | |
| const audioContextRef = useRef<AudioContext | null>(null); | |
| const streamRef = useRef<MediaStream | null>(null); | |
| const cleanup = useCallback(() => { | |
| if (timerRef.current) clearInterval(timerRef.current); | |
| if (audioContextRef.current && audioContextRef.current.state !== "closed") { | |
| audioContextRef.current.close(); | |
| audioContextRef.current = null; | |
| } | |
| if (streamRef.current) { | |
| streamRef.current.getTracks().forEach((t) => t.stop()); | |
| streamRef.current = null; | |
| } | |
| setAnalyserNode(null); | |
| }, []); | |
| const startRecording = useCallback(async () => { | |
| setState("requesting"); | |
| setError(null); | |
| chunksRef.current = []; | |
| setAudioBlob(null); | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| streamRef.current = stream; | |
| // Set up Web Audio API for visualisation | |
| const audioContext = new AudioContext(); | |
| audioContextRef.current = audioContext; | |
| const source = audioContext.createMediaStreamSource(stream); | |
| const analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 256; | |
| source.connect(analyser); | |
| setAnalyserNode(analyser); | |
| const mediaRecorder = new MediaRecorder(stream, { | |
| mimeType: MediaRecorder.isTypeSupported("audio/webm;codecs=opus") | |
| ? "audio/webm;codecs=opus" | |
| : "audio/webm", | |
| }); | |
| mediaRecorderRef.current = mediaRecorder; | |
| mediaRecorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) chunksRef.current.push(e.data); | |
| }; | |
| mediaRecorder.onstop = () => { | |
| const blob = new Blob(chunksRef.current, { type: "audio/webm" }); | |
| setAudioBlob(blob); | |
| cleanup(); | |
| }; | |
| mediaRecorder.start(250); | |
| setState("recording"); | |
| setSeconds(0); | |
| timerRef.current = setInterval(() => { | |
| setSeconds((prev) => { | |
| if (prev + 1 >= maxSeconds) { | |
| mediaRecorderRef.current?.stop(); | |
| if (timerRef.current) clearInterval(timerRef.current); | |
| setState("stopped"); | |
| return maxSeconds; | |
| } | |
| return prev + 1; | |
| }); | |
| }, 1000); | |
| } catch (err) { | |
| setError("Microphone access denied. Please allow microphone access and try again."); | |
| setState("error"); | |
| } | |
| }, [cleanup, maxSeconds]); | |
| const stopRecording = useCallback(() => { | |
| if (mediaRecorderRef.current && state === "recording") { | |
| mediaRecorderRef.current.stop(); | |
| if (timerRef.current) clearInterval(timerRef.current); | |
| setState("stopped"); | |
| } | |
| }, [state]); | |
| const reset = useCallback(() => { | |
| cleanup(); | |
| setState("idle"); | |
| setSeconds(0); | |
| setAudioBlob(null); | |
| setError(null); | |
| chunksRef.current = []; | |
| }, [cleanup]); | |
| useEffect(() => () => cleanup(), [cleanup]); | |
| return { state, seconds, audioBlob, analyserNode, startRecording, stopRecording, reset, error }; | |
| } | |