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 @@