import React, { useState, useEffect, useRef } from 'react'; interface VoiceInputProps { onTranscript: (text: string) => void; isProcessing: boolean; isFullWidth?: boolean; } const VoiceInput: React.FC = ({ onTranscript, isProcessing, isFullWidth = false }) => { const [isListening, setIsListening] = useState(false); const recognitionRef = useRef(null); const isStartingRef = useRef(false); const onTranscriptRef = useRef(onTranscript); // 始终保持 ref 为最新,避免在 useEffect 中依赖 onTranscript 导致重复初始化 useEffect(() => { onTranscriptRef.current = onTranscript; }, [onTranscript]); const initRecognition = () => { if (!('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) return; // 如果已有实例,先清理 if (recognitionRef.current) { try { recognitionRef.current.abort(); } catch (e) {} } const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; const recognition = new SpeechRecognition(); recognition.continuous = true; recognition.lang = 'zh-CN'; recognition.interimResults = true; recognition.onstart = () => { setIsListening(true); isStartingRef.current = false; console.log('Speech recognition started'); }; recognition.onresult = (event: any) => { const results = event.results; for (let i = event.resultIndex; i < results.length; i++) { if (results[i].isFinal) { const transcript = results[i][0].transcript; if (transcript) onTranscriptRef.current(transcript); } } }; recognition.onerror = async (event: any) => { console.error('Speech recognition error', event.error); let message = ''; switch (event.error) { case 'network': message = "语音识别网络连接失败。请检查网络或尝试切换浏览器(推荐 Chrome/Edge)。"; break; case 'not-allowed': case 'service-not-allowed': // 移动端初次授权时可能触发 race condition:用户点击“允许”但 start() 已超时失败 // 此时我们通过 getUserMedia 探测真实权限状态 try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // 如果能获取到流,说明权限其实是有的,只是刚才 start() 冲突了 stream.getTracks().forEach(t => t.stop()); console.log('Permission probe successful, refreshing recognition instance...'); isStartingRef.current = false; setIsListening(false); initRecognition(); // 静默重置实例 return; // 不弹窗,用户再次点击就会成功 } catch (e) { // 探测也失败,说明是真的被拒绝了 message = "麦克风访问被拒绝。\n\n解决办法:\n1. 点击地址栏左侧的“锁”或“设置”图标\n2. 找到“麦克风”并设为“允许”\n3. 刷新页面后重试"; } break; case 'no-speech': return; case 'aborted': return; default: break; } if (message) alert(message); setIsListening(false); isStartingRef.current = false; // 出错后重新初始化,确保下次点击可用 initRecognition(); }; recognition.onend = () => { console.log('Speech recognition ended'); setIsListening(false); isStartingRef.current = false; }; recognitionRef.current = recognition; }; useEffect(() => { initRecognition(); return () => { if (recognitionRef.current) { try { recognitionRef.current.abort(); } catch (e) {} } }; }, []); const startListening = (e: React.PointerEvent) => { e.stopPropagation(); if (e.cancelable) e.preventDefault(); // 如果已经在处理中、正在监听或正在启动,则忽略 if (isProcessing || isListening || isStartingRef.current) return; // 获取指针捕获,确保即使手指移出按钮范围也能触发 onPointerUp try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch (err) { console.warn("Failed to set pointer capture", err); } if (recognitionRef.current) { // 检查是否为安全上下文 (HTTPS 或 localhost) if (!window.isSecureContext) { alert("语音识别失败:浏览器限制在非 HTTPS 环境下使用麦克风。请确保使用 HTTPS 访问,或在 PC 端 Chrome 的 'Insecure origins treated as secure' 中设置白名单。"); return; } try { isStartingRef.current = true; recognitionRef.current.start(); // UI 状态会在 onstart 回调中更新,以保证真实感 } catch (err) { console.error("Failed to start recognition", err); isStartingRef.current = false; setIsListening(false); } } else { if (!window.isSecureContext) { alert("浏览器不支持语音识别:当前环境不是安全上下文 (HTTPS)。"); } else { alert("您的浏览器不支持语音识别,请使用 Chrome 或 Edge。"); } } }; const stopListening = (e: React.PointerEvent) => { e.stopPropagation(); // 只有在正在运行或正在启动时才尝试停止 if (isListening || isStartingRef.current) { try { recognitionRef.current?.stop(); } catch (err) { console.error("Failed to stop recognition", err); // 发生错误时强制重置本地状态 setIsListening(false); isStartingRef.current = false; } } }; return (
{isListening && isFullWidth && (
正在录音...
)}
); }; export default VoiceInput;