From 10e6402ad679664e6fb70e6da4f3476d09ca3822 Mon Sep 17 00:00:00 2001 From: jing <1683105490@qq.com> Date: Tue, 29 Sep 2026 16:46:59 +0800 Subject: [PATCH] =?UTF-8?q?chore=EF=BC=9A=E8=AF=AD=E9=9F=B3=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=9C=89=E8=BF=94=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .local.config.js | 2 +- common/js/voice-recorder.js | 107 ++++++++++++++---- pages_tool/ai-chat/ai-chat-message.vue | 16 ++- .../components/ai-chat-voice-panel.vue | 3 +- 4 files changed, 106 insertions(+), 22 deletions(-) diff --git a/.local.config.js b/.local.config.js index 960387f..d877da5 100644 --- a/.local.config.js +++ b/.local.config.js @@ -244,7 +244,7 @@ const localDevConfig = ({ // })['2811']; // 本地 // })['5g-317']; // 电磁兼容 // })['5g-351']; // - })['1']; // 本地 + })['2881']; // 本地 // })['local-2']; // 本地 // })['local-11']; // 本地 // })['1-test']; // test diff --git a/common/js/voice-recorder.js b/common/js/voice-recorder.js index 629de65..1cf063a 100644 --- a/common/js/voice-recorder.js +++ b/common/js/voice-recorder.js @@ -181,19 +181,27 @@ export function createVoiceRecorder(opts = {}) { class VoiceRecorder { constructor(opts) { this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS - this._format = opts.format || 'mp3' + // PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信小程序在部分 Android 机型上对 mp3 兼容性差, + // 表现为一启动就 onStop(duration=0,fileSize=0)。aac 是微信录音默认/最稳格式。 + this._format = opts.format || 'aac' this._sampleRate = opts.sampleRate || 16000 this._channels = opts.numberOfChannels || 1 this._bitRate = opts.encodeBitRate || 48000 this._listeners = { progress: [], error: [] } this._isRecording = false + this._isStarting = false // start() 已调用, 但 onStart 尚未回调 + this._stopResolve = null // 当前 stop() 等待 onStop 解析的 resolve + this._pendingStop = false // onStart 之前就调用了 stop() → 等开始后立即停 this._startTs = 0 + this._id = Math.random().toString(36).slice(2, 8) this._autoStopTimer = null + this._earlyStopResult = null // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发时缓存结果 // 平台相关句柄 this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId } this._uniRec = null // uni.getRecorderManager() 返回值 + console.log(`[voice-recorder:${this._id}] created`) } on(event, cb) { @@ -226,6 +234,9 @@ class VoiceRecorder { if (this._isRecording) { return Promise.reject(_err('ALREADY_STARTED', 'recorder already started')) } + if (this._isStarting) { + return Promise.reject(_err('ALREADY_STARTED', 'recorder is starting')) + } if (isH5()) { return this._startH5() @@ -241,7 +252,29 @@ class VoiceRecorder { * resolve(null) 表示已取消 / 无产物,调用方应忽略。 */ stop() { - if (!this._isRecording) return Promise.resolve(null) + console.log(`[voice-recorder:${this._id}] stop() called`, { isRecording: this._isRecording, isStarting: this._isStarting, hasUniRec: !!this._uniRec, hasStopResolve: !!this._stopResolve, earlyResult: !!this._earlyStopResult }) + if (!this._isRecording) { + // PR-FRONTEND-VOICE-RACE (2026-09-29): 真机常见"按住即松手"—— + // onStart 尚未回调 (录音未真正开始) 就调用 stop(), 原逻辑直接返回 null + // 导致上层判定"录音失败,请重试"。这里改为挂起, 等 onStart 触发后立即停, + // 产物仍走 onStop 正常返回 (太短的音轨后端可自行拒绝)。 + if (this._isStarting && this._uniRec) { + console.log(`[voice-recorder:${this._id}] stop() deferred: waiting for onStart`) + return new Promise((resolve) => { + this._pendingStop = true + this._stopResolve = resolve + }) + } + // onStop 在 stop() 之前触发, 且有有效产物则返回缓存结果 + if (this._earlyStopResult) { + const r = this._earlyStopResult + this._earlyStopResult = null + console.log(`[voice-recorder:${this._id}] stop() returning early onStop result`) + return Promise.resolve(r) + } + console.log(`[voice-recorder:${this._id}] stop() early return null (not recording)`) + return Promise.resolve(null) + } if (isH5()) return this._stopH5() return this._stopUni() } @@ -444,46 +477,68 @@ class VoiceRecorder { } this._uniRec = rec + this._isStarting = true + console.log(`[voice-recorder:${this._id}] _startUni: start() invoked on singleton`) rec.onStart(() => { + console.log(`[voice-recorder:${this._id}] onStart fired`) this._isRecording = true + this._isStarting = false this._startTs = Date.now() this._setAutoStopTimer() + if (this._pendingStop) { + // 用户在录音真正开始前就松手: 立即结束, 产物经 onStop 返回 + console.log(`[voice-recorder:${this._id}] onStart: pendingStop detected, calling rec.stop()`) + this._pendingStop = false + try { rec.stop() } catch (e) { /* onStop 自会兜底 */ } + } resolve() }) rec.onStop((res) => { // res = { tempFilePath, duration, fileSize } + console.log(`[voice-recorder:${this._id}] onStop fired`, res, 'hasStopResolve=', !!this._stopResolve) this._uniRec = null this._isRecording = false + this._isStarting = false this._clearAutoStopTimer() const mime = this._format === 'wav' ? 'audio/wav' : this._format === 'aac' ? 'audio/aac' : 'audio/mpeg' - if (this._uniResolve) { - const r = this._uniResolve - this._uniResolve = null - r({ - filePath: res.tempFilePath, - blob: null, - fileName: '', - duration: typeof res.duration === 'number' ? res.duration : (Date.now() - this._startTs), - mime, - size: res.fileSize || 0, - cancelled: false - }) + // PR-FRONTEND-VOICE-DURATION-FIX (2026-09-29): 微信 onStop 的 duration + // 偶尔为 0, 用 start→stop 真实时长兜底, 避免下游按 duration=0 丢弃。 + const realDuration = this._startTs ? Date.now() - this._startTs : 0 + const reportedDuration = (res && typeof res.duration === 'number') ? res.duration : 0 + const result = { + filePath: res.tempFilePath, + blob: null, + fileName: '', + duration: reportedDuration > 0 ? reportedDuration : realDuration, + mime, + size: res.fileSize || 0, + cancelled: false + } + if (this._stopResolve) { + const r = this._stopResolve + this._stopResolve = null + r(result) + } else if (result.filePath && result.duration > 0) { + // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发且产物有效, 缓存起来 + this._earlyStopResult = result } }) rec.onError((err) => { const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed') + console.error(`[voice-recorder:${this._id}] UNI_RECORD_ERROR raw:`, err, 'errMsg=', err && err.errMsg, 'message=', err && err.message) this._uniRec = null this._isRecording = false + this._isStarting = false this._clearAutoStopTimer() this._emit('error', e) - if (this._uniResolve) { - const r = this._uniResolve - this._uniResolve = null + if (this._stopResolve) { + const r = this._stopResolve + this._stopResolve = null r(null) } else { // 启动阶段的 reject @@ -530,25 +585,39 @@ class VoiceRecorder { _stopUni() { return new Promise((resolve) => { + console.log(`[voice-recorder:${this._id}] _stopUni called`) const rec = this._uniRec if (!rec) { resolve(null); return } - this._uniResolve = resolve + this._earlyStopResult = null + this._stopResolve = resolve try { rec.stop() } catch (e) { resolve(null) } }) } _cancelUni() { return new Promise((resolve) => { + console.log(`[voice-recorder:${this._id}] _cancelUni called`) + this._earlyStopResult = null const rec = this._uniRec if (!rec) { + // 挂起的 stop 需要一并了结, 避免松手后卡在 pending + if (this._stopResolve && this._pendingStop) { + const r = this._stopResolve + this._stopResolve = null + this._pendingStop = false + r(null) + } this._isRecording = false + this._isStarting = false this._clearAutoStopTimer() resolve() return } - this._uniResolve = () => resolve() + this._stopResolve = () => resolve() + this._pendingStop = false try { rec.stop() } catch (e) { this._isRecording = false + this._isStarting = false this._clearAutoStopTimer() resolve() } diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index d35e2f7..eb7a9d4 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -4073,6 +4073,7 @@ _handleStreamError(msg) { // - touchend 提交产物 (取消则丢弃) // - H5 鼠标不会触发 touch, click 也走 onVoiceHoldEnd 兜底 (无录音产物就当没按住) onVoiceHoldStart(e) { + console.log('[ai-chat] onVoiceHoldStart fired', this._voiceRecorder ? 'recorder already exists' : 'creating recorder') if (this._voiceRecorder) return this.voiceHolding = true this.voiceHoldCancelling = false @@ -4090,6 +4091,7 @@ _handleStreamError(msg) { } this._onHoldError = (err) => { console.warn('[ai-chat] voice hold error:', err) + console.error('[ai-chat] voice hold error raw:', JSON.stringify(err), 'errMsg=', err && err.errMsg, 'message=', err && err.message) uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' }) this._discardVoiceRecorder() } @@ -4119,6 +4121,7 @@ _handleStreamError(msg) { }, async onVoiceHoldEnd() { + console.log('[ai-chat] onVoiceHoldEnd fired, voiceHolding=', this.voiceHolding, 'recorder=', !!this._voiceRecorder) if (!this.voiceHolding) { // 非按住触发的 click (H5 鼠标等), 不做任何事, 避免双触发 return @@ -4129,6 +4132,7 @@ _handleStreamError(msg) { if (!this._voiceRecorder) { // 启动录音器失败 (无 touch 触发的 click) → 退回弹窗路径, 保留功能不丢 + console.log('[ai-chat] onVoiceHoldEnd: no recorder, fallback to voice panel') this.showVoicePanel() return } @@ -4139,13 +4143,19 @@ _handleStreamError(msg) { } try { const result = await this._voiceRecorder.stop() + console.log('[ai-chat] voice hold stop result:', JSON.stringify(result)) this._teardownVoiceRecorder() if (!result || !result.filePath) { uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' }) return } await this._handleRecordedAudio(result) + // PR-FRONTEND-VOICE-AUTO-SEND (2026-09-29): 按住说话像微信一样, 松手后自动发送, + // 不需要用户再点一次发送按钮。 + console.log('[ai-chat] voice hold auto-send triggered') + this.sendMessage() } catch (e) { + console.error('[ai-chat] voice hold end error:', e && e.errMsg, e && e.message, JSON.stringify(e)) this._teardownVoiceRecorder() uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' }) } @@ -4153,6 +4163,7 @@ _handleStreamError(msg) { // 按住说话: 手势被系统打断 (来电/滑动取消) onVoiceHoldCancel() { + console.log('[ai-chat] onVoiceHoldCancel fired') if (!this.voiceHolding) return this.voiceHolding = false this.voiceHoldCancelling = false @@ -4267,12 +4278,14 @@ _handleStreamError(msg) { }, _discardVoiceRecorder() { + console.log('[ai-chat] _discardVoiceRecorder called') if (!this._voiceRecorder) return try { this._voiceRecorder.cancel() } catch (e) { /* noop */ } this._teardownVoiceRecorder() }, _teardownVoiceRecorder() { + console.log('[ai-chat] _teardownVoiceRecorder called') if (this._voiceRecorder) { if (this._onHoldProgress) this._voiceRecorder.off('progress', this._onHoldProgress) if (this._onHoldError) this._voiceRecorder.off('error', this._onHoldError) @@ -4297,7 +4310,8 @@ _handleStreamError(msg) { case 'ALREADY_STARTED': return this.$lang('ai-chat.index.voiceStartFailed') case 'UNI_RECORD_ERROR': - return this.$lang('ai-chat.index.voiceStopFailed') + // 诊断期: 直接透出微信原始错误 (errMsg/message), 便于截图定位根因 + return (err && (err.errMsg || err.message)) || this.$lang('ai-chat.index.voiceStopFailed') default: return err.message || '' } diff --git a/pages_tool/ai-chat/components/ai-chat-voice-panel.vue b/pages_tool/ai-chat/components/ai-chat-voice-panel.vue index 41bcc0e..1724a50 100644 --- a/pages_tool/ai-chat/components/ai-chat-voice-panel.vue +++ b/pages_tool/ai-chat/components/ai-chat-voice-panel.vue @@ -147,6 +147,7 @@ export default { } this._onError = (err) => { console.warn('[voice-panel] recorder error:', err) + console.error('[voice-panel] recorder error raw:', JSON.stringify(err), 'errMsg=', err && err.errMsg, 'message=', err && err.message) uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' }) this._discardRecorderIfAny() } @@ -227,7 +228,7 @@ export default { case 'ALREADY_STARTED': return this.$lang('ai-chat.index.voiceStartFailed') case 'UNI_RECORD_ERROR': - return this.$lang('ai-chat.index.voiceStopFailed') + return (err && (err.errMsg || err.message)) || this.$lang('ai-chat.index.voiceStopFailed') default: return err.message || '' }