import React, { useState, useEffect, useRef, useCallback } from 'react'; interface VoiceInputProps { onTranscript: (text: string) => void; isProcessing: boolean; isFullWidth?: boolean; onDebug?: (message: string) => void; onAvailabilityChange?: (isAvailable: boolean) => void; isServiceAvailable?: boolean; } const VoiceInput: React.FC = ({ onTranscript, isProcessing, isFullWidth = false, onDebug, onAvailabilityChange, isServiceAvailable = true }) => { const [voiceState, setVoiceState] = useState<'idle' | 'starting' | 'listening'>('idle'); const [isInternalDenied, setIsInternalDenied] = useState(false); const isServiceDenied = !isServiceAvailable || isInternalDenied; const recognitionRef = useRef(null); const isHoldingRef = useRef(false); const startTimestampRef = useRef(0); const startTimeoutRef = useRef(null); const lastEventTimeRef = useRef(0); const activeEventRef = useRef<'pointer' | 'touch' | null>(null); const onTranscriptRef = useRef(onTranscript); useEffect(() => { onTranscriptRef.current = onTranscript; }, [onTranscript]); // 初始化语音识别实例 const initRecognition = useCallback(() => { if (typeof window === 'undefined') return null; const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; if (!SpeechRecognition) { onAvailabilityChange?.(false); return null; } // 彻底清理并销毁旧实例,这是 iOS 修复 service-not-allowed 的关键 if (recognitionRef.current) { try { recognitionRef.current.onstart = null; recognitionRef.current.onend = null; recognitionRef.current.onerror = null; recognitionRef.current.onresult = null; recognitionRef.current.abort(); } catch (e) {} } const recognition = new SpeechRecognition(); recognition.continuous = false; // 针对 iOS 优化语言标识符 recognition.lang = 'zh-Hans-CN'; recognition.interimResults = true; recognition.onstart = () => { if (startTimeoutRef.current) clearTimeout(startTimeoutRef.current); setVoiceState('listening'); onDebug?.('DEBUG: recognition.onstart 触发'); }; recognition.onresult = (event: any) => { const results = event.results; onDebug?.(`DEBUG: recognition.onresult 触发, isFinal: ${results[event.resultIndex]?.isFinal}`); 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) => { if (startTimeoutRef.current) clearTimeout(startTimeoutRef.current); const errorType = event.error; onDebug?.(`DEBUG: recognition.onerror 触发, error: ${errorType}`); setVoiceState('idle'); isHoldingRef.current = false; activeEventRef.current = null; if (errorType === 'service-not-allowed' || errorType === 'not-allowed') { setIsInternalDenied(true); onAvailabilityChange?.(false); if (errorType === 'service-not-allowed') { const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); if (isIOS) { alert("语音识别服务不可用。\n\n解决办法:\n1. 确保您的 iPhone 已开启“听写”功能(设置 -> 通用 -> 键盘 -> 启用听写)\n2. 确保您的 Siri 已开启\n3. 检查网络连接是否正常。"); } else { alert("语音识别服务被拒绝,请稍后重试。"); } } else { // not-allowed 处理 onDebug?.(`DEBUG: 检测到错误 not-allowed,尝试探测麦克风硬件...`); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); stream.getTracks().forEach(t => t.stop()); onDebug?.('DEBUG: 麦克风硬件探测成功'); alert("麦克风访问被拒绝。\n\n请在浏览器设置中允许麦克风权限后重试。"); } catch (e: any) { onDebug?.(`DEBUG: 麦克风硬件探测失败: ${e.message || e.name}`); alert("麦克风访问被拒绝,且硬件探测失败。\n\n请检查设备设置并确保浏览器拥有麦克风权限。"); } } } }; recognition.onend = () => { if (startTimeoutRef.current) clearTimeout(startTimeoutRef.current); setVoiceState('idle'); onDebug?.('DEBUG: recognition.onend 触发'); if (isHoldingRef.current) { isHoldingRef.current = false; activeEventRef.current = null; } }; recognitionRef.current = recognition; return recognition; }, [onDebug]); useEffect(() => { initRecognition(); return () => { if (recognitionRef.current) { try { recognitionRef.current.abort(); } catch (e) {} } }; }, [initRecognition]); const stopListening = useCallback((e: React.PointerEvent | React.TouchEvent | React.MouseEvent) => { const eventType = e.type.startsWith('touch') ? 'touch' : 'pointer'; onDebug?.(`DEBUG: stopListening 触发, event: ${e.type}, state: ${voiceState}, isHolding: ${isHoldingRef.current}`); // 如果不是当前激活的事件类型,忽略(除非已经没有激活类型了) if (activeEventRef.current && activeEventRef.current !== eventType) { return; } isHoldingRef.current = false; activeEventRef.current = null; if (startTimeoutRef.current) { clearTimeout(startTimeoutRef.current); startTimeoutRef.current = null; } // 处理极短时间的点击,防止闪现 const duration = Date.now() - startTimestampRef.current; if (duration < 300) { // 如果点击时间太短,稍微延迟停止,或者直接停止 setTimeout(() => { try { recognitionRef.current?.stop(); } catch (e) {} }, 300 - duration); } else { try { recognitionRef.current?.stop(); } catch (e) {} } }, [voiceState, onDebug]); const startListening = useCallback((e: React.PointerEvent | React.TouchEvent | React.MouseEvent) => { if (e.cancelable) e.preventDefault(); e.stopPropagation(); const eventType = e.type.startsWith('touch') ? 'touch' : 'pointer'; onDebug?.(`DEBUG: startListening 触发, type: ${e.type}, currentActive: ${activeEventRef.current}`); if (activeEventRef.current && activeEventRef.current !== eventType) { onDebug?.(`DEBUG: 忽略冲突事件类型: ${eventType}`); return; } const now = Date.now(); if (now - lastEventTimeRef.current < 400) return; // 略微增加防抖 lastEventTimeRef.current = now; if (isProcessing || voiceState !== 'idle' || isHoldingRef.current || isServiceDenied) return; activeEventRef.current = eventType; isHoldingRef.current = true; startTimestampRef.current = now; if ('vibrate' in navigator) { try { navigator.vibrate(20); } catch (e) {} } if (!window.isSecureContext) { alert("语音识别失败:请使用 HTTPS 协议访问以开启麦克风权限。"); isHoldingRef.current = false; activeEventRef.current = null; return; } // 核心修复:针对 iOS,每次点击都强制获取一个全新的实例 onDebug?.('DEBUG: 正在获取全新识别实例...'); const recognition = initRecognition(); if (recognition) { try { // 指针捕获 if ('pointerId' in e && (e.currentTarget as any).setPointerCapture) { try { (e.currentTarget as any).setPointerCapture(e.pointerId); } catch (err) {} } setVoiceState('starting'); // 核心修复:针对 iOS 增加 100ms 软启动延迟,避免手势冲突 setTimeout(() => { if (isHoldingRef.current) { onDebug?.('DEBUG: 延时结束,调用 recognition.start()'); try { recognition.start(); } catch (err: any) { onDebug?.(`DEBUG: 异步 start 失败: ${err.message || err}`); setVoiceState('idle'); isHoldingRef.current = false; activeEventRef.current = null; } } }, 100); startTimeoutRef.current = setTimeout(() => { if (isHoldingRef.current && voiceState === 'starting') { onDebug?.("DEBUG: 启动超时保护触发"); stopListening(e); } }, 5000); } catch (err: any) { onDebug?.(`DEBUG: recognition.start 抛出异常: ${err.message || err}`); setVoiceState('idle'); isHoldingRef.current = false; activeEventRef.current = null; } } else { alert("您的浏览器不支持语音识别,请尝试使用 Chrome 或 Edge。"); isHoldingRef.current = false; activeEventRef.current = null; } }, [isProcessing, voiceState, initRecognition, onDebug, stopListening]); // 处理指针/触摸离开或取消的情况 const handleCancel = useCallback((e: any) => { onDebug?.(`DEBUG: handleCancel 触发, event: ${e.type}, isHolding: ${isHoldingRef.current}`); if (isHoldingRef.current) { stopListening(e); } }, [stopListening, onDebug]); return (
{voiceState !== 'idle' && !isServiceDenied && isFullWidth && (
{voiceState === 'listening' ? '正在录音...' : '准备录音...'}
)}
); }; export default VoiceInput;