| 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}`; |
| } |
|
|