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, * 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 if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) { try { wxRecordManager.stop() } catch (e) {} } // #endif // #ifdef H5 if (h5Recognition && sttListening.value) { try { h5Recognition.stop() } catch (e) {} } // #endif startRequested = 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 sharedRecording = 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 } /** 松手结束:手指抬起 */ function endHoldSpeech() { 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 || recording) { stopListening() return } holdSessionId += 1 } // #ifdef MP-WEIXIN // 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器 // 转发给 activeCtl(最后一次 beginHoldSpeech 的实例) const controller = { handleStart() { if (!sttHolding.value) { try { wxRecordManager.stop() } catch (e) {} 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) || '' // 上一段未停止就再次 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 // 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示 if (typeof onError === 'function') { const handled = onError(msg || '') if (handled === true) return } notify(msg || '语音识别失败') } } 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 } }