Spaces:
Running
Running
| import { useCallback, useRef } from 'react'; | |
| import { apiBlobFetch } from '../api.js'; | |
| import { spokenReplyText } from '../app-voice-utils.js'; | |
| import { clearVoiceDialogAudio, playAudioBlob, speakWithBrowser, unlockVoiceDialogAudio } from './voice-recorded-audio.js'; | |
| import { | |
| clearVoiceDialogTimer, | |
| startVoiceDialogCapture, | |
| stopVoiceDialogStream, | |
| transcribeVoiceDialogBlob | |
| } from './voice-recorded-capture.js'; | |
| export function useRecordedVoiceDialog(common, realtime) { | |
| const recorderRef = useRef(null); | |
| const chunksRef = useRef([]); | |
| const streamRef = useRef(null); | |
| const timerRef = useRef(null); | |
| const silenceFrameRef = useRef(null); | |
| const audioContextRef = useRef(null); | |
| const audioSourceRef = useRef(null); | |
| const speechStartedRef = useRef(false); | |
| const lastSoundAtRef = useRef(0); | |
| const audioRef = useRef(null); | |
| const audioUnlockedRef = useRef(false); | |
| const audioUrlRef = useRef(''); | |
| const audio = { audioRef, audioUnlockedRef, audioUrlRef }; | |
| const clearAudio = useCallback((options = {}) => { | |
| clearVoiceDialogAudio(audio, options); | |
| }, [audio]); | |
| const unlockAudio = useCallback(() => { | |
| unlockVoiceDialogAudio(audio); | |
| }, [audio]); | |
| const scheduleNextTurn = useCallback(() => { | |
| if (!common.openRef.current || !common.autoListenRef.current) { | |
| common.setMode('idle'); | |
| return; | |
| } | |
| common.setMode('idle'); | |
| window.setTimeout(() => { | |
| if (common.openRef.current && common.autoListenRef.current) { | |
| startRecording(); | |
| } | |
| }, 220); | |
| }, [common]); | |
| const playReply = useCallback(async (message) => { | |
| const text = spokenReplyText(message?.content); | |
| if (!text) { | |
| scheduleNextTurn(); | |
| return; | |
| } | |
| common.setAssistantText(text); | |
| common.setError(''); | |
| common.setMode('speaking'); | |
| try { | |
| const blob = await apiBlobFetch('/api/voice/speech', { | |
| method: 'POST', | |
| body: { text } | |
| }); | |
| await playAudioBlob(audio, blob); | |
| } catch (error) { | |
| common.rememberRelayOperationLock('voiceDialog', error); | |
| try { | |
| await speakWithBrowser(text); | |
| } catch { | |
| common.setError(error.message || '朗读失败'); | |
| } | |
| } finally { | |
| clearAudio(); | |
| scheduleNextTurn(); | |
| } | |
| }, [audio, clearAudio, common, scheduleNextTurn]); | |
| const handleRecorderStop = useCallback(async (recorder, mimeType) => { | |
| clearVoiceDialogTimer(timerRef); | |
| stopVoiceDialogStream(captureContext); | |
| const recordedType = recorder.mimeType || mimeType || 'audio/webm'; | |
| const blob = new Blob(chunksRef.current, { type: recordedType }); | |
| chunksRef.current = []; | |
| recorderRef.current = null; | |
| try { | |
| common.setMode('transcribing'); | |
| const transcript = await transcribeVoiceDialogBlob(blob); | |
| common.setTranscript(transcript); | |
| common.setMode('sending'); | |
| const turn = await common.onVoiceSubmit(transcript); | |
| common.awaitingTurnRef.current = { | |
| turnId: turn?.turnId, | |
| message: transcript, | |
| startedAt: Date.now() | |
| }; | |
| common.setMode('waiting'); | |
| } catch (error) { | |
| common.awaitingTurnRef.current = null; | |
| common.rememberRelayOperationLock('voiceDialog', error); | |
| common.setErrorBriefly(error.message || '语音对话失败'); | |
| } | |
| }, [common]); | |
| const captureContext = { | |
| ...common, | |
| selectedProject: common.selectedProject, | |
| selectedProjectRef: common.selectedProjectRef, | |
| recorderRef, | |
| chunksRef, | |
| streamRef, | |
| timerRef, | |
| silenceFrameRef, | |
| audioContextRef, | |
| audioSourceRef, | |
| speechStartedRef, | |
| lastSoundAtRef, | |
| audio, | |
| handleRecorderStop | |
| }; | |
| function startRecording() { | |
| if (common.realtimeRef.current) { | |
| realtime.startRealtime(); | |
| return; | |
| } | |
| startVoiceDialogCapture(captureContext); | |
| } | |
| function stopRecording() { | |
| if (common.realtimeRef.current) { | |
| realtime.stopRealtime({ keepPanel: true }); | |
| common.setMode('idle'); | |
| return; | |
| } | |
| if (recorderRef.current?.state === 'recording') { | |
| common.setError(''); | |
| common.setMode('transcribing'); | |
| recorderRef.current.stop(); | |
| return; | |
| } | |
| clearVoiceDialogTimer(timerRef); | |
| stopVoiceDialogStream(captureContext); | |
| common.setMode('idle'); | |
| } | |
| function closeRecorded() { | |
| if (recorderRef.current?.state === 'recording') { | |
| recorderRef.current.onstop = null; | |
| recorderRef.current.stop(); | |
| } | |
| recorderRef.current = null; | |
| clearVoiceDialogTimer(timerRef); | |
| stopVoiceDialogStream(captureContext); | |
| clearAudio({ release: true }); | |
| } | |
| return { | |
| clearAudio, | |
| unlockAudio, | |
| playReply, | |
| startRecording, | |
| stopRecording, | |
| closeRecorded | |
| }; | |
| } | |