This commit is contained in:
Your Name
2026-06-10 09:49:46 +08:00
parent 072c0c3663
commit abc78f4291
2 changed files with 833 additions and 44 deletions
@@ -2,25 +2,84 @@ import { ref, onUnmounted } from 'vue'
const MIN_HOLD_MS = 280
// #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
/**
* 语音转文字(长按说话):微信小程序 WechatSIH5 Web Speech API。
*
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options
* @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, showToast } = {}) {
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
// WechatSI 录音管理器是全局单例:跨页面/快速点按可能残留会话,
// 用以下两个标记把 start/stop 串行化,避免 "please stop after start"
let managerRecording = false
// 快速点按可能残留会话:用排队标记把 start/stop 串行化
// 避免 "please stop after start"
let pendingStartSession = 0
function notify(msg) {
@@ -35,6 +94,11 @@ export function useSpeechToText({ onResult, showToast } = {}) {
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('没听清,请再试一次')
@@ -47,7 +111,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
function stopListening() {
// #ifdef MP-WEIXIN
if (wxRecordManager && (sttListening.value || startRequested || managerRecording)) {
const ownsRecording = sharedRecording && activeCtl === controller
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) {
try {
wxRecordManager.stop()
} catch (e) {}
@@ -106,14 +171,14 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
try {
startRequested = true
managerRecording = true
sharedRecording = true
wxRecordManager.start({
duration: 60000,
lang: 'zh_CN'
})
} catch (e) {
startRequested = false
managerRecording = false
sharedRecording = false
sttHolding.value = false
notify('无法开始录音')
}
@@ -127,7 +192,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
// 上一段会话还没结束(含全局单例残留):先停止并排队,
// 待 onStop 回调后再真正开始,避免 "please stop after start"
if (managerRecording || startRequested) {
if (sharedRecording || startRequested) {
pendingStartSession = session
try {
wxRecordManager.stop()
@@ -147,6 +212,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
try {
startRequested = true
h5GotResult = false
h5Recognition.start()
sttListening.value = true
} catch (e) {
@@ -170,6 +236,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
sttHolding.value = true
// #ifdef MP-WEIXIN
// 把全局录音回调切到当前实例
activeCtl = controller
ensureRecordAuth(() => startWechatRecord(session))
// #endif
// #ifdef H5
@@ -185,13 +253,17 @@ export function useSpeechToText({ onResult, showToast } = {}) {
/** 松手结束:手指抬起 */
function endHoldSpeech() {
if (!sttHolding.value && !sttListening.value && !startRequested && !managerRecording) {
let recording = false
// #ifdef MP-WEIXIN
recording = sharedRecording && activeCtl === controller
// #endif
if (!sttHolding.value && !sttListening.value && !startRequested && !recording) {
return
}
sttHolding.value = false
// 松手后不再补开排队的录音
pendingStartSession = 0
if (sttListening.value || startRequested || managerRecording) {
if (sttListening.value || startRequested || recording) {
stopListening()
return
}
@@ -199,14 +271,10 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
// #ifdef MP-WEIXIN
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
wxRecordManager = plugin.getRecordRecognitionManager()
sttSupported.value = !!wxRecordManager
wxRecordManager.onStart = () => {
managerRecording = true
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
const controller = {
handleStart() {
if (!sttHolding.value) {
try {
wxRecordManager.stop()
@@ -215,9 +283,13 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
startRequested = false
sttListening.value = true
}
wxRecordManager.onStop = (res) => {
managerRecording = false
},
handleRecognize(res) {
if (typeof onPartial !== 'function') return
const t = String(res?.result || '').trim()
if (t) onPartial(t)
},
handleStop(res) {
sttListening.value = false
startRequested = false
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
@@ -229,9 +301,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
pendingStartSession = 0
emitResult(res?.result)
}
wxRecordManager.onError = (res) => {
managerRecording = false
},
handleError(res) {
sttListening.value = false
startRequested = false
const msg = (res && res.msg) || ''
@@ -245,11 +316,17 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
pendingStartSession = 0
sttHolding.value = false
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
if (typeof onError === 'function') {
const handled = onError(msg || '')
if (handled === true) return
}
notify(msg || '语音识别失败')
}
} catch (e) {
console.warn('WechatSI record recognition not available', e)
}
wxRecordManager = getSharedRecordManager()
sttSupported.value = !!wxRecordManager
// #endif
// #ifdef H5
@@ -259,27 +336,49 @@ export function useSpeechToText({ onResult, showToast } = {}) {
if (SR) {
h5Recognition = new SR()
h5Recognition.lang = 'zh-CN'
h5Recognition.interimResults = false
h5Recognition.interimResults = true
h5Recognition.continuous = false
h5Recognition.maxAlternatives = 1
sttSupported.value = true
h5Recognition.onresult = (event) => {
const item = event.results?.[0]?.[0]
emitResult(item?.transcript || '')
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
if (event?.error === 'not-allowed') {
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 if (event?.error !== 'aborted') {
} else {
notify('语音识别失败')
}
}
@@ -295,6 +394,12 @@ export function useSpeechToText({ onResult, showToast } = {}) {
holdSessionId += 1
pendingStartSession = 0
stopListening()
// #ifdef MP-WEIXIN
// 释放全局回调指向,避免事件继续派发给已销毁的实例
if (activeCtl === controller) {
activeCtl = null
}
// #endif
})
return {