refactor: 重构录音启动流程并接入会话序列发送

This commit is contained in:
2026-03-06 06:54:36 +08:00
parent f78c022f75
commit 5a817e6646
2 changed files with 116 additions and 37 deletions

View File

@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { HistoryList } from "./components/HistoryList"; import { HistoryList } from "./components/HistoryList";
import { MicButton } from "./components/MicButton"; import { MicButton } from "./components/MicButton";
@@ -5,13 +6,36 @@ import { PreviewBox } from "./components/PreviewBox";
import { StatusBadge } from "./components/StatusBadge"; import { StatusBadge } from "./components/StatusBadge";
import { useRecorder } from "./hooks/useRecorder"; import { useRecorder } from "./hooks/useRecorder";
import { useWebSocket } from "./hooks/useWebSocket"; import { useWebSocket } from "./hooks/useWebSocket";
import { useAppStore } from "./stores/app-store";
export function App() { export function App() {
const { sendJSON, sendBinary } = useWebSocket(); const { requestStart, sendStop, sendPaste, sendAudioFrame } = useWebSocket();
const { startRecording, stopRecording } = useRecorder({ const { prewarm, startRecording, stopRecording } = useRecorder({
sendJSON, requestStart,
sendBinary, sendStop,
sendAudioFrame,
}); });
const micReady = useAppStore((s) => s.micReady);
useEffect(() => {
const forceStopOnBackground = () => {
const state = useAppStore.getState();
if (state.recording || state.pendingStart) {
stopRecording();
}
};
const onVisibility = () => {
if (document.hidden) forceStopOnBackground();
};
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("pagehide", forceStopOnBackground);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pagehide", forceStopOnBackground);
};
}, [stopRecording]);
return ( return (
<> <>
@@ -24,8 +48,17 @@ export function App() {
</header> </header>
<PreviewBox /> <PreviewBox />
{!micReady ? (
<button
type="button"
onClick={() => void prewarm()}
className="mb-3 rounded-xl border border-edge bg-surface px-4 py-3 font-medium text-fg text-sm"
>
{"先点我准备麦克风(首次会弹权限)"}
</button>
) : null}
<MicButton onStart={startRecording} onStop={stopRecording} /> <MicButton onStart={startRecording} onStop={stopRecording} />
<HistoryList sendJSON={sendJSON} /> <HistoryList sendPaste={sendPaste} />
</div> </div>
<Toaster theme="dark" position="bottom-center" /> <Toaster theme="dark" position="bottom-center" />
</> </>

View File

@@ -3,71 +3,116 @@ import { useCallback, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAppStore } from "../stores/app-store"; import { useAppStore } from "../stores/app-store";
/**
* ~200ms frames at 16kHz = 3200 samples.
* Doubao bigmodel_async recommends 200ms packets for optimal performance.
*/
const FRAME_LENGTH = 3200; const FRAME_LENGTH = 3200;
interface UseRecorderOptions { interface UseRecorderOptions {
sendJSON: (obj: Record<string, unknown>) => void; requestStart: () => Promise<string | null>;
sendBinary: (data: Int16Array) => void; sendStop: (sessionId: string | null) => void;
sendAudioFrame: (sessionId: string, seq: number, data: Int16Array) => boolean;
} }
export function useRecorder({ sendJSON, sendBinary }: UseRecorderOptions) { let optionsInitialized = false;
export function useRecorder({
requestStart,
sendStop,
sendAudioFrame,
}: UseRecorderOptions) {
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const engineRef = useRef<{ onmessage: (e: MessageEvent) => void } | null>( const engineRef = useRef<{ onmessage: (e: MessageEvent) => void } | null>(
null, null,
); );
const warmedRef = useRef(false);
const audioSeqRef = useRef(1);
// Keep stable refs so callbacks never go stale const initOptions = useCallback(() => {
const sendJSONRef = useRef(sendJSON); if (optionsInitialized) return;
const sendBinaryRef = useRef(sendBinary);
sendJSONRef.current = sendJSON;
sendBinaryRef.current = sendBinary;
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 {
// Create an engine that receives Int16Array @ 16kHz from WebVoiceProcessor
const engine = {
onmessage: (e: MessageEvent) => {
if (e.data.command === "process") {
sendBinaryRef.current(e.data.inputFrame as Int16Array);
}
},
};
engineRef.current = engine;
WebVoiceProcessor.setOptions({ WebVoiceProcessor.setOptions({
frameLength: FRAME_LENGTH, frameLength: FRAME_LENGTH,
outputSampleRate: 16000, outputSampleRate: 16000,
}); });
optionsInitialized = true;
}, []);
const prewarm = useCallback(async (): Promise<boolean> => {
if (warmedRef.current) return true;
initOptions();
const warmEngine = {
onmessage: () => {},
};
try {
await WebVoiceProcessor.subscribe(warmEngine);
await WebVoiceProcessor.unsubscribe(warmEngine);
warmedRef.current = true;
useAppStore.getState().setMicReady(true);
return true;
} catch (err) {
const error = err as Error;
toast.error(`麦克风准备失败: ${error.message}`);
return false;
}
}, [initOptions]);
const startRecording = useCallback(async () => {
const store = useAppStore.getState();
if (store.recording || store.pendingStart || store.stopping) return;
store.setPendingStart(true);
store.setStopping(false);
const abort = new AbortController();
abortRef.current = abort;
try {
const warmed = await prewarm();
if (!warmed) {
store.setPendingStart(false);
abortRef.current = null;
return;
}
const sessionId = await requestStart();
if (!sessionId) {
store.setPendingStart(false);
abortRef.current = null;
toast.error("启动会话失败,请重试");
return;
}
const engine = {
onmessage: (e: MessageEvent) => {
if (e.data.command !== "process") return;
const seq = audioSeqRef.current;
audioSeqRef.current += 1;
sendAudioFrame(sessionId, seq, e.data.inputFrame as Int16Array);
},
};
engineRef.current = engine;
audioSeqRef.current = 1;
// subscribe() handles getUserMedia + AudioContext lifecycle internally.
// It checks for closed/suspended AudioContext and re-creates as needed.
await WebVoiceProcessor.subscribe(engine); await WebVoiceProcessor.subscribe(engine);
if (abort.signal.aborted) { if (abort.signal.aborted) {
await WebVoiceProcessor.unsubscribe(engine); await WebVoiceProcessor.unsubscribe(engine);
engineRef.current = null; engineRef.current = null;
sendStop(sessionId);
store.setPendingStart(false); store.setPendingStart(false);
abortRef.current = null;
return; return;
} }
store.setActiveSessionId(sessionId);
store.setPendingStart(false); store.setPendingStart(false);
abortRef.current = null;
store.setRecording(true); store.setRecording(true);
sendJSONRef.current({ type: "start" }); store.setStopping(false);
store.clearPreview(); store.clearPreview();
abortRef.current = null;
} catch (err) { } catch (err) {
useAppStore.getState().setPendingStart(false); store.setPendingStart(false);
store.setRecording(false);
store.setStopping(false);
abortRef.current = null; abortRef.current = null;
engineRef.current = null; engineRef.current = null;
@@ -86,7 +131,7 @@ export function useRecorder({ sendJSON, sendBinary }: UseRecorderOptions) {
toast.error(`麦克风错误: ${error.message}`); toast.error(`麦克风错误: ${error.message}`);
} }
} }
}, []); }, [prewarm, requestStart, sendAudioFrame, sendStop]);
const stopRecording = useCallback(() => { const stopRecording = useCallback(() => {
const store = useAppStore.getState(); const store = useAppStore.getState();
@@ -100,15 +145,16 @@ export function useRecorder({ sendJSON, sendBinary }: UseRecorderOptions) {
if (!store.recording) return; if (!store.recording) return;
store.setRecording(false); store.setRecording(false);
store.setStopping(true);
audioSeqRef.current = 1;
if (engineRef.current) { if (engineRef.current) {
// Fire-and-forget: state is already updated, cleanup is async
WebVoiceProcessor.unsubscribe(engineRef.current); WebVoiceProcessor.unsubscribe(engineRef.current);
engineRef.current = null; engineRef.current = null;
} }
sendJSONRef.current({ type: "stop" }); sendStop(store.activeSessionId);
}, []); }, [sendStop]);
return { startRecording, stopRecording }; return { prewarm, startRecording, stopRecording };
} }