08c5b22d4a
Web Speech 依赖 Google,国内浏览器常无法转文字;支持麦克风时改为录音后走自有 ASR,并补全网络异常时的降级匹配。 Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|