Files
app_02_bill/components/VoiceInput.tsx
karlcarlo 5367a4107e feat(voice): 优化语音识别功能及增强可用性提示
- 新增语音服务可用性状态管理,动态检测浏览器支持情况
- 调整初始输入模式为键盘输入,避免误触启动语音识别
- 合并消息列表时保留已有调试消息,避免信息丢失
- VoiceInput组件新增调试日志回调,增强调试信息输出
- 细化语音识别状态机,区分idle、starting和listening状态
- 优化权限拒绝错误处理,针对iOS给出具体操作提示
- 改进语音识别启动流程,针对iOS增加软启动延迟避免冲突
- 支持多种指针和触摸事件管理,防止冲突导致的识别异常
- 按钮UI根据状态动态调整,显示不同提示文本和样式
- 禁用语音不可用时的按钮操作,避免误操作
- 增加语音识别启动超时保护,防止长时间挂起
- 优化用户交互体验,加入振动反馈及事件防抖处理
2026-01-23 14:00:39 +08:00

329 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;