InnerVoice / frontend /hooks /useAudioRecorder.ts
E5K7's picture
Initial commit: InnerVoice MVP
bf04727
"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 };
}