451 lines
15 KiB
JavaScript
451 lines
15 KiB
JavaScript
import { ref, onUnmounted } from 'vue'
|
||
|
||
const MIN_HOLD_MS = 280
|
||
|
||
/** 短按/过早松手时插件常见错误,不应打扰用户 */
|
||
function isBenignSttError(msg, retcode) {
|
||
const m = String(msg || '').toLowerCase()
|
||
if (/internal voice data failed/i.test(m)) return true
|
||
if (/voice data failed/i.test(m)) return true
|
||
if (/recordfailed|record failed|record manager/i.test(m)) return true
|
||
if (/please stop after start/i.test(m)) return true
|
||
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
|
||
const code = Number(retcode)
|
||
if (code === -30002 || code === -30003 || code === -30012) return true
|
||
return false
|
||
}
|
||
|
||
function friendlySttError(msg) {
|
||
const m = String(msg || '').trim()
|
||
if (!m || isBenignSttError(m)) return ''
|
||
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
|
||
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
|
||
return '语音识别失败,请重试'
|
||
}
|
||
|
||
// #ifdef MP-WEIXIN
|
||
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
|
||
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
|
||
// 回调仍指向已销毁页面的闭包,新页面收不到 onStart/onStop,
|
||
// 其本地状态卡在 recording,表现为一直录制/无法再次语音。
|
||
let sharedManager = null
|
||
let sharedManagerInited = false
|
||
let sharedRecording = false
|
||
let activeCtl = null
|
||
|
||
function getSharedRecordManager() {
|
||
if (sharedManagerInited) return sharedManager
|
||
sharedManagerInited = true
|
||
try {
|
||
// eslint-disable-next-line no-undef
|
||
const plugin = requirePlugin('WechatSI')
|
||
sharedManager = plugin.getRecordRecognitionManager()
|
||
} catch (e) {
|
||
console.warn('WechatSI record recognition not available', e)
|
||
sharedManager = null
|
||
return null
|
||
}
|
||
if (!sharedManager) return null
|
||
|
||
sharedManager.onStart = () => {
|
||
sharedRecording = true
|
||
if (activeCtl) activeCtl.handleStart()
|
||
}
|
||
sharedManager.onRecognize = (res) => {
|
||
if (activeCtl) activeCtl.handleRecognize(res)
|
||
}
|
||
sharedManager.onStop = (res) => {
|
||
sharedRecording = false
|
||
if (activeCtl) activeCtl.handleStop(res)
|
||
}
|
||
sharedManager.onError = (res) => {
|
||
sharedRecording = false
|
||
if (activeCtl) activeCtl.handleError(res)
|
||
}
|
||
return sharedManager
|
||
}
|
||
// #endif
|
||
|
||
/**
|
||
* 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。
|
||
*
|
||
* @param {{
|
||
* onResult?: (text: string) => void,
|
||
* onSettle?: (text: string, info: { heldMs: number }) => boolean | void,
|
||
* onPartial?: (text: string) => void,
|
||
* onError?: (msg: string) => boolean | void,
|
||
* showToast?: (msg: string) => void
|
||
* }} options
|
||
*
|
||
* onSettle 在每次录音结束时都会触发(含空结果),用于语音一问一答等需要
|
||
* 完全接管识别结果的场景。若 onSettle 返回 true,则跳过默认的 onResult 回调
|
||
* 与「没听清」提示,避免与上层流程重复处理。
|
||
*
|
||
* onPartial 在录音过程中实时返回中间识别结果(微信 onRecognize / H5 interim),
|
||
* 用于「抢答打断」等场景:检测到用户已开口作答即可提前结束播报/录音。
|
||
*
|
||
* onError 在录音/识别出错时触发(如 "record manager recordfailed")。
|
||
* 若返回 true,则跳过默认的错误 Toast,由上层自行处理(如自动重试)。
|
||
*/
|
||
export function useSpeechToText({ onResult, onSettle, onPartial, onError, showToast } = {}) {
|
||
const sttListening = ref(false)
|
||
const sttHolding = ref(false)
|
||
const sttSupported = ref(false)
|
||
|
||
let wxRecordManager = null
|
||
let h5Recognition = null
|
||
let h5GotResult = false
|
||
let holdStartTs = 0
|
||
let holdSessionId = 0
|
||
let startRequested = false
|
||
let recordAuthorized = null
|
||
// 快速点按可能残留会话:用排队标记把 start/stop 串行化,
|
||
// 避免 "please stop after start"
|
||
let pendingStartSession = 0
|
||
|
||
function notify(msg) {
|
||
if (!msg) return
|
||
if (typeof showToast === 'function') {
|
||
showToast(msg)
|
||
return
|
||
}
|
||
uni.showToast({ title: msg, icon: 'none', duration: 2000 })
|
||
}
|
||
|
||
function emitResult(text) {
|
||
const t = String(text || '').trim()
|
||
const heldMs = Date.now() - holdStartTs
|
||
// onSettle 总会收到结果(含空),返回 true 表示已完全接管
|
||
if (typeof onSettle === 'function') {
|
||
const handled = onSettle(t, { heldMs })
|
||
if (handled === true) return
|
||
}
|
||
if (!t) {
|
||
if (heldMs < MIN_HOLD_MS) return
|
||
notify('没听清,请再试一次')
|
||
return
|
||
}
|
||
if (typeof onResult === 'function') {
|
||
onResult(t)
|
||
}
|
||
}
|
||
|
||
function stopListening() {
|
||
// #ifdef MP-WEIXIN
|
||
const ownsRecording = sharedRecording && activeCtl === controller
|
||
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
|
||
if (wxRecordManager && (sttListening.value || ownsRecording)) {
|
||
try {
|
||
wxRecordManager.stop()
|
||
} catch (e) {}
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef H5
|
||
if (h5Recognition && sttListening.value) {
|
||
try {
|
||
h5Recognition.stop()
|
||
} catch (e) {}
|
||
}
|
||
// #endif
|
||
|
||
startRequested = false
|
||
sttHolding.value = false
|
||
sttListening.value = false
|
||
}
|
||
|
||
function ensureRecordAuth(onGranted) {
|
||
// #ifdef MP-WEIXIN
|
||
if (recordAuthorized === true) {
|
||
onGranted()
|
||
return
|
||
}
|
||
uni.authorize({
|
||
scope: 'scope.record',
|
||
success() {
|
||
recordAuthorized = true
|
||
onGranted()
|
||
},
|
||
fail() {
|
||
recordAuthorized = false
|
||
sttHolding.value = false
|
||
uni.showModal({
|
||
title: '需要麦克风权限',
|
||
content: '请在设置中允许使用麦克风,以便长按说话',
|
||
confirmText: '去设置',
|
||
success(res) {
|
||
if (res.confirm) {
|
||
uni.openSetting({})
|
||
}
|
||
}
|
||
})
|
||
}
|
||
})
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
onGranted()
|
||
// #endif
|
||
}
|
||
|
||
// #ifdef MP-WEIXIN
|
||
function actuallyStartWechat(session) {
|
||
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
||
return
|
||
}
|
||
try {
|
||
startRequested = true
|
||
wxRecordManager.start({
|
||
duration: 60000,
|
||
lang: 'zh_CN'
|
||
})
|
||
} catch (e) {
|
||
startRequested = false
|
||
sharedRecording = false
|
||
sttHolding.value = false
|
||
notify('无法开始录音')
|
||
}
|
||
}
|
||
// #endif
|
||
|
||
function startWechatRecord(session) {
|
||
// #ifdef MP-WEIXIN
|
||
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
||
return
|
||
}
|
||
// 上一段会话还没结束(含全局单例残留):先停止并排队,
|
||
// 待 onStop 回调后再真正开始,避免 "please stop after start"
|
||
if (sharedRecording || startRequested) {
|
||
pendingStartSession = session
|
||
try {
|
||
wxRecordManager.stop()
|
||
} catch (e) {}
|
||
return
|
||
}
|
||
actuallyStartWechat(session)
|
||
// #endif
|
||
}
|
||
|
||
function startH5Record() {
|
||
// #ifdef H5
|
||
if (!h5Recognition) {
|
||
notify('当前浏览器不支持语音输入')
|
||
sttHolding.value = false
|
||
return
|
||
}
|
||
try {
|
||
startRequested = true
|
||
h5GotResult = false
|
||
h5Recognition.start()
|
||
sttListening.value = true
|
||
} catch (e) {
|
||
startRequested = false
|
||
sttHolding.value = false
|
||
notify('无法开始语音识别,请检查麦克风权限')
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
/** 长按开始:手指按下 */
|
||
function beginHoldSpeech() {
|
||
if (!sttSupported.value) {
|
||
notify('当前环境不支持语音输入')
|
||
return
|
||
}
|
||
if (sttHolding.value) return
|
||
|
||
const session = ++holdSessionId
|
||
holdStartTs = Date.now()
|
||
sttHolding.value = true
|
||
|
||
// #ifdef MP-WEIXIN
|
||
// 把全局录音回调切到当前实例
|
||
activeCtl = controller
|
||
ensureRecordAuth(() => startWechatRecord(session))
|
||
// #endif
|
||
// #ifdef H5
|
||
startH5Record()
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
// #ifndef H5
|
||
sttHolding.value = false
|
||
notify('当前端暂不支持语音输入')
|
||
// #endif
|
||
// #endif
|
||
}
|
||
|
||
/** 松手结束:手指抬起;force=true 时强制取消尚未真正开始的按住会话 */
|
||
function endHoldSpeech(force = false) {
|
||
let recording = false
|
||
// #ifdef MP-WEIXIN
|
||
recording = sharedRecording && activeCtl === controller
|
||
// #endif
|
||
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||
return
|
||
}
|
||
sttHolding.value = false
|
||
// 松手后不再补开排队的录音
|
||
pendingStartSession = 0
|
||
if (sttListening.value || recording) {
|
||
stopListening()
|
||
return
|
||
}
|
||
if (startRequested) {
|
||
// start 已发出但 onStart 未到:取消会话,勿调 stop()
|
||
startRequested = false
|
||
holdSessionId += 1
|
||
return
|
||
}
|
||
holdSessionId += 1
|
||
}
|
||
|
||
// #ifdef MP-WEIXIN
|
||
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
|
||
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
|
||
const controller = {
|
||
handleStart() {
|
||
if (!sttHolding.value) {
|
||
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
|
||
startRequested = false
|
||
sttListening.value = false
|
||
return
|
||
}
|
||
startRequested = false
|
||
sttListening.value = true
|
||
},
|
||
handleRecognize(res) {
|
||
if (typeof onPartial !== 'function') return
|
||
const t = String(res?.result || '').trim()
|
||
if (t) onPartial(t)
|
||
},
|
||
handleStop(res) {
|
||
sttListening.value = false
|
||
startRequested = false
|
||
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
|
||
if (pendingStartSession && pendingStartSession === holdSessionId && sttHolding.value) {
|
||
const s = pendingStartSession
|
||
pendingStartSession = 0
|
||
actuallyStartWechat(s)
|
||
return
|
||
}
|
||
pendingStartSession = 0
|
||
emitResult(res?.result)
|
||
},
|
||
handleError(res) {
|
||
sttListening.value = false
|
||
startRequested = false
|
||
const msg = (res && res.msg) || ''
|
||
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
|
||
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
|
||
if (/please stop after start/i.test(msg)) {
|
||
try {
|
||
wxRecordManager.stop()
|
||
} catch (e) {}
|
||
pendingStartSession = sttHolding.value ? holdSessionId : 0
|
||
return
|
||
}
|
||
pendingStartSession = 0
|
||
sttHolding.value = false
|
||
const heldMs = Date.now() - holdStartTs
|
||
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
|
||
if (typeof onError === 'function') onError(msg || '')
|
||
return
|
||
}
|
||
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
|
||
if (typeof onError === 'function') {
|
||
const handled = onError(msg || '')
|
||
if (handled === true) return
|
||
}
|
||
const tip = friendlySttError(msg)
|
||
if (tip) notify(tip)
|
||
}
|
||
}
|
||
|
||
wxRecordManager = getSharedRecordManager()
|
||
sttSupported.value = !!wxRecordManager
|
||
// #endif
|
||
|
||
// #ifdef H5
|
||
try {
|
||
if (typeof window !== 'undefined') {
|
||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
|
||
if (SR) {
|
||
h5Recognition = new SR()
|
||
h5Recognition.lang = 'zh-CN'
|
||
h5Recognition.interimResults = true
|
||
h5Recognition.continuous = false
|
||
h5Recognition.maxAlternatives = 1
|
||
sttSupported.value = true
|
||
|
||
h5Recognition.onresult = (event) => {
|
||
let interim = ''
|
||
let finalText = ''
|
||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||
const r = event.results[i]
|
||
const txt = r?.[0]?.transcript || ''
|
||
if (r.isFinal) finalText += txt
|
||
else interim += txt
|
||
}
|
||
if (interim && typeof onPartial === 'function') onPartial(interim)
|
||
if (finalText) {
|
||
h5GotResult = true
|
||
emitResult(finalText)
|
||
}
|
||
}
|
||
h5Recognition.onend = () => {
|
||
sttListening.value = false
|
||
startRequested = false
|
||
sttHolding.value = false
|
||
// 无识别结果也要兜底触发一次 settle,便于一问一答流程重试
|
||
if (!h5GotResult) {
|
||
emitResult('')
|
||
}
|
||
h5GotResult = false
|
||
}
|
||
h5Recognition.onerror = (event) => {
|
||
sttListening.value = false
|
||
startRequested = false
|
||
sttHolding.value = false
|
||
const err = event?.error || ''
|
||
if (err === 'aborted') return
|
||
if (typeof onError === 'function') {
|
||
const handled = onError(err)
|
||
if (handled === true) return
|
||
}
|
||
if (err === 'not-allowed') {
|
||
notify('请允许浏览器使用麦克风')
|
||
} else {
|
||
notify('语音识别失败')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('H5 SpeechRecognition not available', e)
|
||
}
|
||
// #endif
|
||
|
||
onUnmounted(() => {
|
||
sttHolding.value = false
|
||
holdSessionId += 1
|
||
pendingStartSession = 0
|
||
stopListening()
|
||
// #ifdef MP-WEIXIN
|
||
// 释放全局回调指向,避免事件继续派发给已销毁的实例
|
||
if (activeCtl === controller) {
|
||
activeCtl = null
|
||
}
|
||
// #endif
|
||
})
|
||
|
||
return {
|
||
sttListening,
|
||
sttHolding,
|
||
sttSupported,
|
||
beginHoldSpeech,
|
||
endHoldSpeech,
|
||
stopSpeechToText: endHoldSpeech
|
||
}
|
||
}
|
||
|
||
export { isBenignSttError, friendlySttError }
|