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 }; }