Spaces:
Running
Running
| import { useEffect, useRef, useState } from 'react'; | |
| import { apiFetch } from '../api.js'; | |
| import { | |
| VOICE_MAX_RECORDING_MS, | |
| VOICE_MAX_UPLOAD_BYTES, | |
| VOICE_MIME_CANDIDATES | |
| } from '../app-core-utils.js'; | |
| export function useVoiceInputRecorder({ onVoiceSubmit, onRateLimit, onBeforeStart }) { | |
| const mediaRecorderRef = useRef(null); | |
| const voiceChunksRef = useRef([]); | |
| const voiceStreamRef = useRef(null); | |
| const voiceTimerRef = useRef(null); | |
| const voiceErrorTimerRef = useRef(null); | |
| const [voiceState, setVoiceState] = useState('idle'); | |
| const [voiceError, setVoiceError] = useState(''); | |
| const voiceRecording = voiceState === 'recording'; | |
| const voiceTranscribing = voiceState === 'transcribing'; | |
| const voiceSending = voiceState === 'sending'; | |
| useEffect(() => () => { | |
| clearVoiceTimer(); | |
| clearVoiceErrorTimer(); | |
| if (mediaRecorderRef.current?.state === 'recording') { | |
| mediaRecorderRef.current.onstop = null; | |
| mediaRecorderRef.current.stop(); | |
| } | |
| stopVoiceStream(); | |
| }, []); | |
| function setVoiceErrorBriefly(message) { | |
| clearVoiceErrorTimer(); | |
| setVoiceError(message); | |
| voiceErrorTimerRef.current = window.setTimeout(() => { | |
| setVoiceError(''); | |
| voiceErrorTimerRef.current = null; | |
| }, 2600); | |
| } | |
| function clearVoiceErrorTimer() { | |
| if (voiceErrorTimerRef.current) { | |
| window.clearTimeout(voiceErrorTimerRef.current); | |
| voiceErrorTimerRef.current = null; | |
| } | |
| } | |
| function clearVoiceTimer() { | |
| if (voiceTimerRef.current) { | |
| window.clearTimeout(voiceTimerRef.current); | |
| voiceTimerRef.current = null; | |
| } | |
| } | |
| function stopVoiceStream() { | |
| voiceStreamRef.current?.getTracks?.().forEach((track) => track.stop()); | |
| voiceStreamRef.current = null; | |
| } | |
| function voiceMimeType() { | |
| if (!window.MediaRecorder?.isTypeSupported) { | |
| return ''; | |
| } | |
| return VOICE_MIME_CANDIDATES.find((type) => window.MediaRecorder.isTypeSupported(type)) || ''; | |
| } | |
| async function transcribeVoiceBlob(blob) { | |
| if (!blob?.size) { | |
| setVoiceErrorBriefly('没有录到声音'); | |
| return ''; | |
| } | |
| if (blob.size > VOICE_MAX_UPLOAD_BYTES) { | |
| setVoiceErrorBriefly('录音超过 10MB'); | |
| return ''; | |
| } | |
| const formData = new FormData(); | |
| const extension = blob.type.includes('mp4') ? 'm4a' : 'webm'; | |
| formData.append('audio', blob, `voice.${extension}`); | |
| try { | |
| const result = await apiFetch('/api/voice/transcribe', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!result.text?.trim()) { | |
| setVoiceErrorBriefly('没有识别到文字'); | |
| return ''; | |
| } | |
| return result.text.trim(); | |
| } catch (error) { | |
| onRateLimit?.(error, 'voice'); | |
| setVoiceErrorBriefly(error.message || '语音转写失败'); | |
| return ''; | |
| } | |
| } | |
| async function startVoiceRecording() { | |
| onBeforeStart?.(); | |
| clearVoiceErrorTimer(); | |
| setVoiceError(''); | |
| if (window.location.protocol !== 'https:') { | |
| setVoiceErrorBriefly('请使用 HTTPS 地址或 iOS 键盘听写'); | |
| return; | |
| } | |
| if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { | |
| setVoiceErrorBriefly('当前浏览器不支持录音'); | |
| return; | |
| } | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const mimeType = voiceMimeType(); | |
| const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); | |
| voiceStreamRef.current = stream; | |
| voiceChunksRef.current = []; | |
| mediaRecorderRef.current = recorder; | |
| recorder.ondataavailable = (event) => { | |
| if (event.data?.size) { | |
| voiceChunksRef.current.push(event.data); | |
| } | |
| }; | |
| recorder.onerror = () => { | |
| clearVoiceTimer(); | |
| stopVoiceStream(); | |
| setVoiceState('idle'); | |
| setVoiceErrorBriefly('录音失败'); | |
| }; | |
| recorder.onstop = async () => { | |
| clearVoiceTimer(); | |
| stopVoiceStream(); | |
| const recordedType = recorder.mimeType || mimeType || 'audio/webm'; | |
| const blob = new Blob(voiceChunksRef.current, { type: recordedType }); | |
| voiceChunksRef.current = []; | |
| mediaRecorderRef.current = null; | |
| try { | |
| setVoiceState('transcribing'); | |
| const transcript = await transcribeVoiceBlob(blob); | |
| if (transcript) { | |
| setVoiceState('sending'); | |
| await onVoiceSubmit(transcript); | |
| } | |
| } catch (error) { | |
| setVoiceErrorBriefly(error.message || '语音发送失败'); | |
| } finally { | |
| setVoiceState('idle'); | |
| } | |
| }; | |
| recorder.start(); | |
| setVoiceState('recording'); | |
| voiceTimerRef.current = window.setTimeout(() => { | |
| if (mediaRecorderRef.current?.state === 'recording') { | |
| setVoiceState('transcribing'); | |
| mediaRecorderRef.current.stop(); | |
| } | |
| }, VOICE_MAX_RECORDING_MS); | |
| } catch (error) { | |
| clearVoiceTimer(); | |
| stopVoiceStream(); | |
| mediaRecorderRef.current = null; | |
| setVoiceState('idle'); | |
| const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError'; | |
| setVoiceErrorBriefly(denied ? '麦克风权限被拒绝' : '录音启动失败'); | |
| } | |
| } | |
| function stopVoiceRecording() { | |
| if (mediaRecorderRef.current?.state === 'recording') { | |
| clearVoiceErrorTimer(); | |
| setVoiceError(''); | |
| setVoiceState('transcribing'); | |
| mediaRecorderRef.current.stop(); | |
| return; | |
| } | |
| clearVoiceTimer(); | |
| stopVoiceStream(); | |
| setVoiceState('idle'); | |
| } | |
| function toggleVoiceInput() { | |
| if (voiceRecording) { | |
| stopVoiceRecording(); | |
| } else if (!voiceTranscribing && !voiceSending) { | |
| startVoiceRecording(); | |
| } | |
| } | |
| return { | |
| voiceState, | |
| voiceError, | |
| voiceRecording, | |
| voiceTranscribing, | |
| voiceSending, | |
| toggleVoiceInput | |
| }; | |
| } | |