Files
wecom_it_smart_desk/frontend-agent/src/composables/useSpeechRecognition.ts
T

389 lines
13 KiB
TypeScript
Raw Normal View History

// =============================================================================
// 企微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/EdgewebkitSpeechRecognition
// - 不支持 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<SpeechRecognitionState>({
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 设为 falseonend 中不再重启。
*/
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,
}
}