import { useRef, useState, useCallback, useEffect } from "react"; function getSupportedMimeType() { const candidates = [ "audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/ogg", "audio/mp4", ]; if (typeof MediaRecorder === "undefined") return ""; for (const type of candidates) { if (MediaRecorder.isTypeSupported(type)) return type; } return ""; } export function useAudioRecorder() { const [recording, setRecording] = useState(false); const [audioBlob, setAudioBlob] = useState(null); const [audioURL, setAudioURL] = useState(null); const [micError, setMicError] = useState(null); const [volume, setVolume] = useState(0); // 0.0 to 1.0 const mediaRef = useRef(null); const chunksRef = useRef([]); const audioURLRef = useRef(null); const suppressStopBlobRef = useRef(false); const audioCtxRef = useRef(null); const analyserRef = useRef(null); const rafRef = useRef(null); const revokeAudioURL = useCallback(() => { if (audioURLRef.current) { URL.revokeObjectURL(audioURLRef.current); audioURLRef.current = null; } }, []); const updateVolume = useCallback(() => { if (!analyserRef.current) return; const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount); analyserRef.current.getByteFrequencyData(dataArray); // Calculate average volume let sum = 0; for (let i = 0; i < dataArray.length; i++) { sum += dataArray[i]; } const avg = sum / dataArray.length; setVolume(Math.min(1, avg / 128)); // Normalize roughly to 0-1 // Use mediaRef to check recording state (avoids stale closure) if (mediaRef.current && mediaRef.current.state === "recording") { rafRef.current = requestAnimationFrame(updateVolume); } }, []); const start = useCallback(async () => { setMicError(null); suppressStopBlobRef.current = false; revokeAudioURL(); setAudioBlob(null); setAudioURL(null); setVolume(0); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); chunksRef.current = []; const mimeType = getSupportedMimeType(); const opts = mimeType ? { mimeType } : {}; const mr = new MediaRecorder(stream, opts); // Setup audio analysis for volume const AudioContext = window.AudioContext || window.webkitAudioContext; if (AudioContext) { audioCtxRef.current = new AudioContext(); analyserRef.current = audioCtxRef.current.createAnalyser(); const source = audioCtxRef.current.createMediaStreamSource(stream); source.connect(analyserRef.current); analyserRef.current.fftSize = 256; } mr.ondataavailable = e => { if (e.data && e.data.size > 0) { chunksRef.current.push(e.data); } }; mr.onstop = () => { // Small delay to ensure all dataavailable events have fired setTimeout(() => { try { if (suppressStopBlobRef.current) { return; } if (chunksRef.current.length === 0) { console.warn("No audio chunks collected."); setMicError("No audio was captured. Please speak into the microphone."); setRecording(false); return; } const blob = new Blob(chunksRef.current, { type: mimeType || "audio/webm" }); const newURL = URL.createObjectURL(blob); audioURLRef.current = newURL; setAudioBlob(blob); setAudioURL(newURL); } catch (err) { console.error("Error creating audio blob:", err); setMicError("Failed to process audio recording."); } finally { stream.getTracks().forEach(t => t.stop()); chunksRef.current = []; if (rafRef.current) cancelAnimationFrame(rafRef.current); if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { audioCtxRef.current.close().catch(() => {}); } setVolume(0); setRecording(false); } }, 100); // 100ms delay to collect final chunks }; // Start recording with 1s timeslice to ensure data is periodically pushed mr.start(1000); mediaRef.current = mr; setRecording(true); } catch (e) { console.error("Mic access failed:", e); setMicError("Microphone access denied. Please allow microphone access and try again."); } }, [revokeAudioURL]); useEffect(() => { if (recording) { rafRef.current = requestAnimationFrame(updateVolume); } else { if (rafRef.current) cancelAnimationFrame(rafRef.current); setVolume(0); } return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [recording]); const stop = useCallback(() => { if (mediaRef.current && mediaRef.current.state !== "inactive") { mediaRef.current.stop(); // Don't setRecording(false) here - let onstop callback handle it } }, []); const reset = useCallback(() => { // Stop any active recording first if (mediaRef.current && mediaRef.current.state !== "inactive") { suppressStopBlobRef.current = true; mediaRef.current.stop(); } revokeAudioURL(); setAudioBlob(null); setAudioURL(null); setRecording(false); setMicError(null); setVolume(0); chunksRef.current = []; mediaRef.current = null; }, [revokeAudioURL]); useEffect(() => { return () => { revokeAudioURL(); }; }, [revokeAudioURL]); return { recording, audioBlob, audioURL, micError, volume, startRecording: start, stopRecording: stop, reset, mediaRef }; }