refactor: 重构录音启动流程并接入会话序列发送
This commit is contained in:
@@ -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" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user