Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user