Files
memind/src/hooks/useVoiceSession.ts
john 08c5b22d4a fix(voice): 浏览器语音默认走服务端 ASR
Web Speech 依赖 Google,国内浏览器常无法转文字;支持麦克风时改为录音后走自有 ASR,并补全网络异常时的降级匹配。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 12:34:34 +08:00

244 lines
7.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { transcribeOneShot } from '../voice/asrTransport';
import { mapMicError } from '../voice/audioAnalyser';
import { shouldFallbackToServerAsr, shouldUseLiveSpeech } from '../voice/capabilities';
import { MicCapture } from '../voice/micCapture';
import { waitForMicRelease } from '../voice/micSession';
import { createLiveSpeechRecognition } from '../voice/speechRecognition';
import type { VoiceSessionPhase } from '../voice/types';
const MIN_RECORD_MS = 500;
export function useVoiceSession({
active,
onError,
}: {
active: boolean;
onError?: (message: string) => void;
}) {
const [phase, setPhase] = useState<VoiceSessionPhase>('idle');
const [text, setText] = useState('');
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
const [liveRecognition, setLiveRecognition] = useState(shouldUseLiveSpeech());
const committedRef = useRef('');
const interimRef = useRef('');
const userEditedRef = useRef(false);
const captureRef = useRef<MicCapture | null>(null);
const recognitionRef = useRef<ReturnType<typeof createLiveSpeechRecognition> | null>(null);
const startedAtRef = useRef(0);
const sessionRef = useRef(0);
const stopTimerRef = useRef<number | null>(null);
const syncDisplayText = useCallback(() => {
if (userEditedRef.current) return;
setText(`${committedRef.current}${interimRef.current}`);
}, []);
const resetSession = useCallback(() => {
committedRef.current = '';
interimRef.current = '';
userEditedRef.current = false;
setText('');
setAnalyser(null);
setPhase('idle');
}, []);
const cleanup = useCallback(async () => {
if (stopTimerRef.current != null) {
window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
}
recognitionRef.current?.abort();
recognitionRef.current = null;
const capture = captureRef.current;
captureRef.current = null;
if (capture) {
await capture.cancel();
}
setAnalyser(null);
}, []);
const stopListening = useCallback(() => {
const recognition = recognitionRef.current;
if (recognition) {
recognition.stop();
recognitionRef.current = null;
if (stopTimerRef.current != null) {
window.clearTimeout(stopTimerRef.current);
}
stopTimerRef.current = window.setTimeout(() => {
stopTimerRef.current = null;
setPhase((current) => (current === 'transcribing' ? current : 'ready'));
}, 220);
}
const capture = captureRef.current;
captureRef.current = null;
if (capture) {
void capture.cancel();
}
setAnalyser(null);
if (!recognition) {
setPhase((current) => (current === 'transcribing' ? current : 'ready'));
}
}, []);
const startServerAsrCapture = useCallback(
async (sessionId: number) => {
recognitionRef.current?.abort();
recognitionRef.current = null;
const capture = new MicCapture();
captureRef.current = capture;
await capture.start();
if (sessionRef.current !== sessionId) {
await capture.cancel();
captureRef.current = null;
return false;
}
setAnalyser(capture.levelAnalyser);
setLiveRecognition(false);
setPhase('listening');
return true;
},
[],
);
const finishFallbackRecording = useCallback(async () => {
const duration = Date.now() - startedAtRef.current;
const capture = captureRef.current;
captureRef.current = null;
setAnalyser(null);
if (duration < MIN_RECORD_MS) {
if (capture) await capture.cancel();
setPhase('ready');
onError?.('说话时间太短');
return;
}
setPhase('transcribing');
try {
if (!capture) throw new Error('未录制到音频');
const blob = await capture.stop();
if (!blob.size) throw new Error('未录制到音频');
const transcript = await transcribeOneShot(blob);
if (!transcript) {
onError?.('未识别到语音,请重试');
} else {
committedRef.current = transcript;
userEditedRef.current = false;
setText(transcript);
}
} catch (err) {
onError?.(err instanceof Error ? err.message : '识别失败');
} finally {
setPhase('ready');
}
}, [onError]);
useEffect(() => {
if (!active) {
void cleanup().then(resetSession);
return undefined;
}
const sessionId = sessionRef.current + 1;
sessionRef.current = sessionId;
resetSession();
setPhase('requesting');
const start = async () => {
try {
await waitForMicRelease();
if (sessionRef.current !== sessionId) return;
startedAtRef.current = Date.now();
const useLiveSpeech = shouldUseLiveSpeech();
if (useLiveSpeech) {
const recognition = createLiveSpeechRecognition({
onInterim: (chunk) => {
interimRef.current = chunk;
syncDisplayText();
},
onFinal: (chunk) => {
committedRef.current += chunk;
interimRef.current = '';
syncDisplayText();
},
onError: (message) => {
if (shouldFallbackToServerAsr(message)) {
void startServerAsrCapture(sessionId)
.then((ok) => {
if (!ok) return;
onError?.('实时识别不可用,已切换为录音识别,说完后点「完成识别」');
})
.catch((err) => onError?.(mapMicError(err)));
return;
}
onError?.(message);
},
});
if (!recognition) {
if (MicCapture.isSupported()) {
const ok = await startServerAsrCapture(sessionId);
if (ok) return;
}
throw new Error('当前浏览器不支持实时语音识别');
}
recognitionRef.current = recognition;
recognition.start();
if (sessionRef.current !== sessionId) {
recognition.abort();
return;
}
setLiveRecognition(true);
setAnalyser(null);
setPhase('listening');
return;
}
const capture = new MicCapture();
captureRef.current = capture;
await capture.start();
if (sessionRef.current !== sessionId) {
await capture.cancel();
return;
}
setAnalyser(capture.levelAnalyser);
setLiveRecognition(false);
setPhase('listening');
} catch (err) {
if (sessionRef.current !== sessionId) return;
await cleanup();
setPhase('error');
onError?.(mapMicError(err));
}
};
void start();
return () => {
void cleanup();
};
}, [active, cleanup, onError, resetSession, startServerAsrCapture, syncDisplayText]);
const updateText = useCallback((value: string) => {
userEditedRef.current = true;
committedRef.current = value;
interimRef.current = '';
setText(value);
}, []);
return {
phase,
text,
analyser,
liveRecognition,
updateText,
stopListening,
finishFallbackRecording,
resetSession,
};
}