- 新增语音服务可用性状态管理,动态检测浏览器支持情况 - 调整初始输入模式为键盘输入,避免误触启动语音识别 - 合并消息列表时保留已有调试消息,避免信息丢失 - VoiceInput组件新增调试日志回调,增强调试信息输出 - 细化语音识别状态机,区分idle、starting和listening状态 - 优化权限拒绝错误处理,针对iOS给出具体操作提示 - 改进语音识别启动流程,针对iOS增加软启动延迟避免冲突 - 支持多种指针和触摸事件管理,防止冲突导致的识别异常 - 按钮UI根据状态动态调整,显示不同提示文本和样式 - 禁用语音不可用时的按钮操作,避免误操作 - 增加语音识别启动超时保护,防止长时间挂起 - 优化用户交互体验,加入振动反馈及事件防抖处理
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
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<VoiceInputProps> = ({
|
||
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<any>(null);
|
||
const isHoldingRef = useRef(false);
|
||
const startTimestampRef = useRef(0);
|
||
const startTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||
<div className={`${isFullWidth ? 'w-full' : ''} relative select-none touch-none`}>
|
||
<button
|
||
onPointerDown={startListening}
|
||
onPointerUp={stopListening}
|
||
onPointerCancel={handleCancel}
|
||
|
||
// 兼容性备选
|
||
onTouchStart={startListening}
|
||
onTouchEnd={stopListening}
|
||
onTouchCancel={handleCancel}
|
||
|
||
// 禁用右键菜单,防止长按弹出
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
|
||
disabled={isProcessing || isServiceDenied}
|
||
className={`relative flex items-center justify-center transition-all duration-300 shadow-lg ${
|
||
isFullWidth
|
||
? 'w-full py-4 rounded-2xl font-bold text-sm tracking-widest'
|
||
: 'w-14 h-14 rounded-full'
|
||
} ${
|
||
voiceState === 'listening'
|
||
? 'bg-rose-500 text-white shadow-rose-200 scale-105'
|
||
: voiceState === 'starting'
|
||
? 'bg-amber-500 text-white shadow-amber-100 scale-102'
|
||
: isServiceDenied
|
||
? 'bg-gray-300 text-gray-500 cursor-not-allowed shadow-none pointer-events-none'
|
||
: 'bg-indigo-600 text-white shadow-indigo-100'
|
||
} ${isProcessing ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
|
||
style={{
|
||
WebkitTouchCallout: 'none',
|
||
WebkitTapHighlightColor: 'transparent',
|
||
userSelect: 'none',
|
||
WebkitUserSelect: 'none',
|
||
touchAction: 'none'
|
||
}}
|
||
>
|
||
{(voiceState === 'listening' || voiceState === 'starting') && (
|
||
<div className={`absolute inset-0 rounded-2xl animate-ping ${voiceState === 'listening' ? 'bg-rose-400' : 'bg-amber-400'} opacity-20`}></div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 z-10">
|
||
<svg xmlns="http://www.w3.org/2000/svg" className={`${isFullWidth ? 'h-5 w-5' : 'h-6 w-6'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||
</svg>
|
||
{isFullWidth && (
|
||
<span>
|
||
{isServiceDenied
|
||
? '语音不可用'
|
||
: voiceState === 'listening'
|
||
? '松开 结束'
|
||
: voiceState === 'starting'
|
||
? '正在连接...'
|
||
: '按住 说话'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
|
||
{voiceState !== 'idle' && !isServiceDenied && isFullWidth && (
|
||
<div className="absolute -top-16 left-0 right-0 flex justify-center animate-bounce">
|
||
<div className={`${voiceState === 'listening' ? 'bg-rose-500' : 'bg-amber-500'} text-white px-4 py-2 rounded-full text-xs font-bold shadow-xl border-2 border-white`}>
|
||
{voiceState === 'listening' ? '正在录音...' : '准备录音...'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default VoiceInput;
|