2e14873f2d
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
189 lines
5.4 KiB
TypeScript
189 lines
5.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { transcribeOneShot } from '../voice/asrTransport';
|
|
import { mapMicError } from '../voice/audioAnalyser';
|
|
import { MicCapture } from '../voice/micCapture';
|
|
import { waitForMicRelease } from '../voice/micSession';
|
|
import { createLiveSpeechRecognition, isSpeechRecognitionSupported } 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(isSpeechRecognitionSupported());
|
|
|
|
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 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 () => {
|
|
recognitionRef.current?.abort();
|
|
recognitionRef.current = null;
|
|
const capture = captureRef.current;
|
|
captureRef.current = null;
|
|
if (capture) {
|
|
await capture.cancel();
|
|
}
|
|
setAnalyser(null);
|
|
}, []);
|
|
|
|
const stopListening = useCallback(() => {
|
|
recognitionRef.current?.stop();
|
|
recognitionRef.current = null;
|
|
const capture = captureRef.current;
|
|
captureRef.current = null;
|
|
if (capture) {
|
|
void capture.cancel();
|
|
}
|
|
setAnalyser(null);
|
|
setPhase((current) => (current === 'transcribing' ? current : 'ready'));
|
|
}, []);
|
|
|
|
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 = isSpeechRecognitionSupported();
|
|
|
|
if (useLiveSpeech) {
|
|
const recognition = createLiveSpeechRecognition({
|
|
onInterim: (chunk) => {
|
|
interimRef.current = chunk;
|
|
syncDisplayText();
|
|
},
|
|
onFinal: (chunk) => {
|
|
committedRef.current += chunk;
|
|
interimRef.current = '';
|
|
syncDisplayText();
|
|
},
|
|
onError: (message) => onError?.(message),
|
|
});
|
|
if (!recognition) 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, 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,
|
|
};
|
|
}
|