From abc78f429140488ee4aa8a25b28da39655f913fb Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 10 Jun 2026 09:49:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tongji/composables/useSpeechToText.js | 169 ++++- TUICallKit-Vue3/tongji/pages/more.vue | 708 +++++++++++++++++- 2 files changed, 833 insertions(+), 44 deletions(-) diff --git a/TUICallKit-Vue3/tongji/composables/useSpeechToText.js b/TUICallKit-Vue3/tongji/composables/useSpeechToText.js index ba943edd..09f01693 100644 --- a/TUICallKit-Vue3/tongji/composables/useSpeechToText.js +++ b/TUICallKit-Vue3/tongji/composables/useSpeechToText.js @@ -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 + /** * 语音转文字(长按说话):微信小程序 WechatSI;H5 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 { diff --git a/TUICallKit-Vue3/tongji/pages/more.vue b/TUICallKit-Vue3/tongji/pages/more.vue index 8803e9fa..497120f9 100644 --- a/TUICallKit-Vue3/tongji/pages/more.vue +++ b/TUICallKit-Vue3/tongji/pages/more.vue @@ -378,6 +378,65 @@ 请如实填写,留空表示该项不上传。当天可多次修改。 + + + + + + + + + + 语音问答录入 + {{ voiceDialog.active ? '跟着提问回答数字即可自动填写' : '点击开始,语音一问一答' }} + + + + 结束 + + + 开始问答 + + + + + + {{ voiceDialog.botText }} + + + {{ voiceDialog.heardText }} + + + + + + + + + {{ voiceDialogStatusText }} + + + + 说完了 + + + {{ voiceDialog.phase === 'listening' ? '重说' : '重听' }} + + + 跳过本项 + + + + + - - + + @@ -720,7 +779,7 @@