// ============================================================================= // 企微IT智能服务台 — Web Speech API 语音识别 composable(坐席端) // ============================================================================= // 说明:封装浏览器 Web Speech API 的实时语音转文字功能,提供: // 1. start():开始语音识别(实时转写) // 2. stop():停止语音识别 // 3. reset():重置状态(清空已识别的文字) // // 交互流程: // 用户点击语音按钮 → start() → 实时转写显示在 textarea 中... // 用户再次点击 → stop() → 最终文字填入输入框 → reset() // // 关键陷阱(已在代码中处理): // 1. resultIndex 遍历:onresult 必须从 event.resultIndex 开始,否则重复处理 // 2. onend 自动重启:用户未主动停止时(如超时停止),自动重启识别 // 3. 实例不能重复 start:需等待 onend 后再重启,否则抛错 // 4. 错误类型处理:not-allowed/no-speech/network/aborted 各有不同处理方式 // // 限制: // - 仅支持 Chrome/Edge(webkitSpeechRecognition) // - 不支持 Firefox(坐席端限定 Chrome/Edge) // ============================================================================= import { reactive } from 'vue' // --------------------------------------------------------------------------- // 类型定义 // --------------------------------------------------------------------------- /** * composable 返回的响应式状态 */ interface SpeechRecognitionState { /** 是否正在聆听(识别中) */ isListening: boolean /** 临时识别文字(还在变化中的中间结果,尚未确认) */ interimText: string /** 最终识别文字(已确认的结果,不会再变化) */ finalText: string /** 最近一次错误信息(null 表示无错误) */ error: string | null /** 浏览器是否支持 Web Speech API */ isSupported: boolean } /** * composable 返回的完整接口 */ interface UseSpeechRecognitionReturn { /** 响应式状态对象 */ state: SpeechRecognitionState /** 开始语音识别 */ start: () => void /** 停止语音识别 */ stop: () => void /** 重置状态(清空 finalText 和 interimText) */ reset: () => void } // --------------------------------------------------------------------------- // composable 实现 // --------------------------------------------------------------------------- /** * Web Speech API 语音识别 composable * * 做什么:封装浏览器原生的语音识别功能,提供响应式状态和简单 API * * 为什么用 composable 模式: * - 遵循 Vue3 组合式 API,与项目其他 composable 风格一致 * - 将复杂的 Web Speech API 逻辑封装在独立文件中,组件只关心 UI * - 状态是响应式的,组件可直接绑定到模板 * * 使用示例: * ```typescript * const { state, start, stop, reset } = useSpeechRecognition() * * // 点击语音按钮 * function handleVoiceToggle() { * if (state.isListening) { * stop() * // state.finalText 包含全部识别结果 * inputText.value += state.finalText * reset() * } else { * start() * } * } * ``` */ export function useSpeechRecognition(): UseSpeechRecognitionReturn { // 响应式状态:组件可绑定到模板 const state = reactive({ isListening: false, interimText: '', finalText: '', error: null, // 检测浏览器是否支持 Web Speech API // Chrome/Edge 使用 webkitSpeechRecognition,部分浏览器使用标准 SpeechRecognition isSupported: typeof window !== 'undefined' && (!!window.SpeechRecognition || !!window.webkitSpeechRecognition), }) // -------------------------------------------------------------------------- // 内部变量(非响应式,组件不需要感知) // -------------------------------------------------------------------------- /** SpeechRecognition 实例(通过构造器创建) */ let recognition: SpeechRecognition | null = null /** * 标记用户是否主动要求继续聆听 * * 做什么:区分"用户主动停止"和"浏览器自动停止(如超时)" * * 为什么需要这个标记: * Web Speech API 会在一段时间无语音后自动触发 onend。 * 如果用户没有主动点停止,我们应该自动重启识别(保持持续聆听)。 * 如果用户主动点了停止,shouldKeepListening 设为 false,onend 中不再重启。 */ let shouldKeepListening = false // -------------------------------------------------------------------------- // 内部方法 // -------------------------------------------------------------------------- /** * 创建 SpeechRecognition 实例并绑定事件回调 * * 做什么: * 1. 获取构造器(Chrome 用 webkit 前缀) * 2. 创建实例 * 3. 设置识别参数(语言、连续模式、中间结果等) * 4. 绑定 onresult / onerror / onend 回调 * * 识别参数说明: * - lang = 'zh-CN':中文识别 * - continuous = true:持续识别(不会说一句就停) * - interimResults = true:返回中间结果(实时显示) * - maxAlternatives = 1:只返回最佳结果(不需要多个候选项) */ function createRecognition(): void { // 获取构造器(Chrome/Edge 用 webkit 前缀,标准浏览器用无前缀) const SpeechRecognitionClass = window.SpeechRecognition || window.webkitSpeechRecognition if (!SpeechRecognitionClass) { state.error = '当前浏览器不支持语音识别,请使用 Chrome 或 Edge' return } // 创建实例 recognition = new SpeechRecognitionClass() // 设置识别参数 recognition.lang = 'zh-CN' // 中文识别 recognition.continuous = true // 持续识别模式 recognition.interimResults = true // 返回中间结果(实时转写) recognition.maxAlternatives = 1 // 只返回最佳结果 // 绑定 onresult:处理识别结果 recognition.onresult = (event: SpeechRecognitionEvent) => { let interim = '' // 【关键】从 event.resultIndex 开始遍历,不是从 0 // 为什么:event.results 包含从开始到现在的所有结果, // 但之前的结果已经处理过了,只有 resultIndex 之后的是新增的。 // 如果从 0 开始遍历,会导致 finalText 重复累加! for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i] const transcript = result[0].transcript if (result.isFinal) { // 最终结果(已确认):追加到 finalText state.finalText += transcript } else { // 临时结果(还在变化):收集到 interim interim += transcript } } // 更新临时文字(每次 onresult 都会覆盖上一次的临时结果) state.interimText = interim } // 绑定 onerror:处理识别错误 recognition.onerror = (event: SpeechRecognitionErrorEvent) => { switch (event.error) { case 'not-allowed': // 麦克风权限被拒绝(用户在浏览器弹窗中点了"拒绝") state.error = '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风' shouldKeepListening = false // 权限被拒,不再自动重启 state.isListening = false break case 'service-not-allowed': // 语音识别服务不可用(可能是浏览器策略限制) state.error = '语音识别服务不可用,请检查浏览器设置' shouldKeepListening = false state.isListening = false break case 'network': // 网络错误(Web Speech API 需要联网,因为识别在云端进行) state.error = '网络错误,语音识别服务不可用,请检查网络连接' shouldKeepListening = false state.isListening = false break case 'no-speech': // 没有检测到语音输入(用户长时间没说话) // 不报错,onend 会自动处理重启 break case 'aborted': // 识别被中止(主动调用 abort/stop 导致) // 静默处理,不显示错误 break case 'audio-capture': // 音频采集失败(可能没有麦克风设备) state.error = '音频采集失败,请检查麦克风设备' shouldKeepListening = false state.isListening = false break default: // 其他未知错误 state.error = `语音识别错误: ${event.error}` break } } // 绑定 onend:识别结束回调 recognition.onend = () => { // 清除临时文字(onend 表示一段识别结束了) state.interimText = '' // 判断是否需要自动重启 if (shouldKeepListening) { // 用户未主动停止(可能是超时或 no-speech 导致的自动结束) // 自动重启识别,保持持续聆听 try { recognition!.start() } catch { // start() 可能失败(实例还在 stopping 状态) // 延迟 100ms 后重试一次 setTimeout(() => { if (shouldKeepListening && recognition) { try { recognition.start() } catch (retryErr) { console.error('[SpeechRecognition] 自动重启失败:', retryErr) state.isListening = false state.error = '语音识别自动恢复失败,请重新点击语音按钮' } } }, 100) } } else { // 用户已主动停止,不再重启 state.isListening = false } } } // -------------------------------------------------------------------------- // 公开方法 // -------------------------------------------------------------------------- /** * 开始语音识别 * * 做什么: * 1. 检查是否已支持、是否已在聆听 * 2. 清空之前的状态(error, finalText, interimText) * 3. 创建(或复用)SpeechRecognition 实例 * 4. 调用 start() 开始识别 * * 陷阱处理: * - 不能重复 start():如果实例正在运行,直接返回 * - start() 可能抛错(上一次还没完全停止):延迟 200ms 重试 */ function start(): void { // 防止重复开始 if (state.isListening) { return } // 浏览器不支持则不操作 if (!state.isSupported) { state.error = '当前浏览器不支持语音识别,请使用 Chrome 或 Edge' return } // 清空状态,准备新一轮识别 state.error = null state.finalText = '' state.interimText = '' // 标记用户要求持续聆听(onend 中检查此标记决定是否重启) shouldKeepListening = true // 创建实例(如果还没创建过) if (!recognition) { createRecognition() } if (!recognition) { state.error = '语音识别器创建失败' return } // 调用 start() 开始识别 try { recognition.start() state.isListening = true } catch { // start() 失败:可能上一次识别还没完全停止(onend 未触发) // 延迟 200ms 后重试一次 setTimeout(() => { if (shouldKeepListening && recognition && !state.isListening) { try { recognition.start() state.isListening = true } catch (retryErr) { console.error('[SpeechRecognition] 启动失败:', retryErr) state.error = '语音识别启动失败,请重试' shouldKeepListening = false } } }, 200) } } /** * 停止语音识别 * * 做什么: * 1. 标记用户已主动停止(shouldKeepListening = false) * 2. 调用 recognition.stop() 停止识别 * 3. 清除临时文字 * * 注意: * - stop() 后 onend 会异步触发,isListening 会在 onend 中设为 false * - 但为了 UI 立即响应,这里也同步设置 isListening = false * - finalText 保留(组件需要读取最终结果) */ function stop(): void { // 标记用户主动停止(onend 中不再自动重启) shouldKeepListening = false if (recognition) { try { recognition.stop() } catch (err) { // stop() 失败通常可以忽略(可能实例已经停止了) console.warn('[SpeechRecognition] stop 警告:', err) } } // 同步更新状态(UI 立即响应) state.isListening = false // 清除临时文字(最终文字保留,供组件读取) state.interimText = '' } /** * 重置状态 * * 做什么:清空 finalText 和 interimText,清除 error * * 什么时候调用: * 组件在读取完 finalText 并填入输入框后调用,为下一次识别准备干净状态 */ function reset(): void { state.finalText = '' state.interimText = '' state.error = null } return { state, start, stop, reset, } }