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('idle'); const [text, setText] = useState(''); const [analyser, setAnalyser] = useState(null); const [liveRecognition, setLiveRecognition] = useState(shouldUseLiveSpeech()); const committedRef = useRef(''); const interimRef = useRef(''); const userEditedRef = useRef(false); const captureRef = useRef(null); const recognitionRef = useRef | null>(null); const startedAtRef = useRef(0); const sessionRef = useRef(0); const stopTimerRef = useRef(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, }; }