Audio_Deepfake_Detection / frontend /src /hooks /useAudioRecorder.ts
vipan-kumar's picture
Initial commit: Audio Deepfake Detector with 8 detectors trained on jay15k
e6a1f55
Raw
History Blame Contribute Delete
7.75 kB
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;
/**
* 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<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); // guards the onstop handler
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 {
// 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<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);
}
});
}, []);
// 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 };
}