389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
|
|
// =============================================================================
|
|||
|
|
// 企微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<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 设为 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,
|
|||
|
|
}
|
|||
|
|
}
|