shaml / frontend /src /hooks /useAudioRecorder.ts
Cursor Agent
Fix mic upload: record WAV; light Arabic UI without emojis
e9e9d00 unverified
Raw
History Blame Contribute Delete
4.64 kB
import { useCallback, useRef, useState } from "react";
import { encodeWav, resampleTo16kMono, TARGET_SAMPLE_RATE } from "../utils/wavEncoder";
export type RecorderStatus = "idle" | "recording" | "recorded" | "error";
export function useAudioRecorder() {
const [status, setStatus] = useState<RecorderStatus>("idle");
const [seconds, setSeconds] = useState(0);
const [error, setError] = useState<string | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const contextRef = useRef<AudioContext | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const chunksRef = useRef<Float32Array[]>([]);
const timerRef = useRef<number | null>(null);
const recordingRef = useRef(false);
const cleanupStream = useCallback(() => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
processorRef.current?.disconnect();
processorRef.current = null;
if (contextRef.current?.state !== "closed") {
void contextRef.current?.close();
}
contextRef.current = null;
}, []);
const reset = useCallback(() => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
recordingRef.current = false;
cleanupStream();
chunksRef.current = [];
if (audioUrl) URL.revokeObjectURL(audioUrl);
setAudioUrl(null);
setAudioFile(null);
setSeconds(0);
setError(null);
setStatus("idle");
}, [audioUrl, cleanupStream]);
const start = useCallback(async () => {
reset();
if (!navigator.mediaDevices?.getUserMedia) {
setError("المتصفح لا يدعم التسجيل الصوتي.");
setStatus("error");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const context = new AudioContext();
contextRef.current = context;
const source = context.createMediaStreamSource(stream);
const processor = context.createScriptProcessor(4096, 1, 1);
processorRef.current = processor;
chunksRef.current = [];
recordingRef.current = true;
processor.onaudioprocess = (event) => {
if (!recordingRef.current) return;
const channel = event.inputBuffer.getChannelData(0);
chunksRef.current.push(new Float32Array(channel));
};
source.connect(processor);
processor.connect(context.destination);
setStatus("recording");
setSeconds(0);
timerRef.current = window.setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
} catch {
setError("تعذّر الوصول للميكروفون. تأكّد من منح الإذن.");
setStatus("error");
cleanupStream();
}
}, [cleanupStream, reset]);
const stop = useCallback(async () => {
recordingRef.current = false;
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
const context = contextRef.current;
const chunks = chunksRef.current;
cleanupStream();
if (!context || chunks.length === 0) {
setError("لم يُسجَّل أي صوت.");
setStatus("error");
return;
}
try {
const sampleRate = context.sampleRate;
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
const merged = new Float32Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
const audioBuffer = context.createBuffer(1, merged.length, sampleRate);
audioBuffer.copyToChannel(merged, 0);
const samples = await resampleTo16kMono(audioBuffer);
const wavBlob = encodeWav(samples, TARGET_SAMPLE_RATE);
const url = URL.createObjectURL(wavBlob);
const file = new File([wavBlob], "recording.wav", { type: "audio/wav" });
setAudioUrl(url);
setAudioFile(file);
setStatus("recorded");
} catch {
setError("تعذّر تحضير التسجيل الصوتي.");
setStatus("error");
}
}, [cleanupStream]);
return {
status,
seconds,
error,
audioUrl,
audioFile,
start,
stop,
reset,
};
}
export function formatTime(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60)
.toString()
.padStart(2, "0");
const s = (totalSeconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
}