import { useCallback, useRef } from "react"; import { resampleTo16kInt16 } from "../lib/resample"; import { useAppStore } from "../stores/app-store"; import audioProcessorUrl from "../workers/audio-processor.ts?worker&url"; interface UseRecorderOptions { sendJSON: (obj: Record) => void; sendBinary: (data: Int16Array) => void; } export function useRecorder({ sendJSON, sendBinary }: UseRecorderOptions) { const audioCtxRef = useRef(null); const workletRef = useRef(null); const streamRef = useRef(null); const abortRef = useRef(null); // Keep stable refs so callbacks never go stale const sendJSONRef = useRef(sendJSON); const sendBinaryRef = useRef(sendBinary); sendJSONRef.current = sendJSON; sendBinaryRef.current = sendBinary; const initAudio = useCallback(async () => { if (audioCtxRef.current) return; // Use device native sample rate — we resample to 16kHz in software const ctx = new AudioContext(); // Chrome requires resume() after user gesture if (ctx.state === "suspended") await ctx.resume(); await ctx.audioWorklet.addModule(audioProcessorUrl); audioCtxRef.current = ctx; }, []); const startRecording = useCallback(async () => { const store = useAppStore.getState(); if (store.recording || store.pendingStart) return; store.setPendingStart(true); const abort = new AbortController(); abortRef.current = abort; try { await initAudio(); if (abort.signal.aborted) { store.setPendingStart(false); return; } const ctx = audioCtxRef.current as AudioContext; if (ctx.state === "suspended") await ctx.resume(); if (abort.signal.aborted) { store.setPendingStart(false); return; } const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1, }, }); if (abort.signal.aborted) { stream.getTracks().forEach((t) => { t.stop(); }); store.setPendingStart(false); return; } streamRef.current = stream; const source = ctx.createMediaStreamSource(stream); const worklet = new AudioWorkletNode(ctx, "audio-processor"); worklet.port.onmessage = (e: MessageEvent) => { if (e.data.type === "audio") { sendBinaryRef.current( resampleTo16kInt16(e.data.samples, e.data.sampleRate), ); } }; source.connect(worklet); worklet.port.postMessage({ command: "start" }); workletRef.current = worklet; store.setPendingStart(false); abortRef.current = null; store.setRecording(true); sendJSONRef.current({ type: "start" }); store.clearPreview(); } catch (err) { useAppStore.getState().setPendingStart(false); abortRef.current = null; useAppStore .getState() .showToast(`\u9ea6\u514b\u98ce\u9519\u8bef: ${(err as Error).message}`); } }, [initAudio]); const stopRecording = useCallback(() => { const store = useAppStore.getState(); if (store.pendingStart) { abortRef.current?.abort(); abortRef.current = null; store.setPendingStart(false); return; } if (!store.recording) return; store.setRecording(false); if (workletRef.current) { workletRef.current.port.postMessage({ command: "stop" }); workletRef.current.disconnect(); workletRef.current = null; } if (streamRef.current) { streamRef.current.getTracks().forEach((t) => { t.stop(); }); streamRef.current = null; } sendJSONRef.current({ type: "stop" }); }, []); return { startRecording, stopRecording }; }