diff --git a/common/js/voice-recorder.js b/common/js/voice-recorder.js index 1cf063a..d8570af 100644 --- a/common/js/voice-recorder.js +++ b/common/js/voice-recorder.js @@ -40,6 +40,64 @@ const DEFAULT_MAX_DURATION_MS = 60 * 1000 +// PR-FRONTEND-VOICE-SINGLETON (2026-09-29): uni.getRecorderManager() / wx.getRecorderManager() +// 是全局单例, 且微信内部状态极易因上一次未完全停止而残留, 导致下一次 start() 报 +// "operateRecorder:fail:audio is recording, don't start"。 +// 这里用模块级 Promise 把所有 start/stop 串行化, 确保前一个实例 onStop/onError 完成后 +// 才允许下一个实例 start()。 +let _singletonIdlePromise = Promise.resolve() +let _singletonIdleResolver = null +let _singletonRec = null +let _singletonBusyInstance = null + +function _getSingletonRecorder() { + if (!_singletonRec) { + _singletonRec = uni.getRecorderManager() + } + return _singletonRec +} + +let _singletonIdleTimer = null + +function _setSingletonBusy(instance, maxDurationMs = DEFAULT_MAX_DURATION_MS) { + if (_singletonIdleTimer) { + clearTimeout(_singletonIdleTimer) + _singletonIdleTimer = null + } + if (_singletonIdleResolver) { + // 已经有未释放的 busy promise, 理论上不应发生; 先强制释放避免死锁 + _singletonIdleResolver() + _singletonIdleResolver = null + } + _singletonIdlePromise = new Promise((resolve) => { + _singletonIdleResolver = resolve + }) + _singletonBusyInstance = instance + // 保险: 即使 onStop/onError 因异常没触发, 最多等 maxDuration + 5s 也要释放, + // 避免死锁导致后续所有 start() 永远挂起。 + _singletonIdleTimer = setTimeout(() => { + console.warn('[voice-recorder:singleton] idle timeout, force release') + _setSingletonIdle(instance) + }, maxDurationMs + 5000) +} + +function _setSingletonIdle(instance) { + if (_singletonIdleTimer) { + clearTimeout(_singletonIdleTimer) + _singletonIdleTimer = null + } + if (_singletonBusyInstance && _singletonBusyInstance !== instance) { + // 不是当前占用者释放, 忽略 + return + } + if (_singletonIdleResolver) { + _singletonIdleResolver() + _singletonIdleResolver = null + } + _singletonIdlePromise = Promise.resolve() + _singletonBusyInstance = null +} + // H5 录音参数: 16kHz mono PCM → 16-bit LE WAV // 16kHz/16-bit 是大多数 STT 服务 (Whisper / 达摩院 / 腾讯 ASR 等) 最常见的输入规格. const H5_SAMPLE_RATE = 16000 @@ -181,9 +239,9 @@ export function createVoiceRecorder(opts = {}) { class VoiceRecorder { constructor(opts) { this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS - // PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信小程序在部分 Android 机型上对 mp3 兼容性差, - // 表现为一启动就 onStop(duration=0,fileSize=0)。aac 是微信录音默认/最稳格式。 - this._format = opts.format || 'aac' + // PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信小程序在部分 Android 机型上对 mp3/aac 兼容性差, + // 表现为一启动就 onStop(duration~100ms)。wav 是无压缩格式, 兼容性最好, 优先使用。 + this._format = opts.format || 'wav' this._sampleRate = opts.sampleRate || 16000 this._channels = opts.numberOfChannels || 1 this._bitRate = opts.encodeBitRate || 48000 @@ -463,15 +521,26 @@ class VoiceRecorder { // ===================== uni-app (MP/APP/鸿蒙) 实现 ===================== _startUni() { + // PR-FRONTEND-VOICE-SINGLETON: 串行化所有对微信录音单例的访问, + // 防止"上一个 onStop 还没触发, 下一个 start() 就报 audio is recording"。 + return _singletonIdlePromise.then(() => { + _setSingletonBusy(this, this._maxDuration) + return this._doStartUni() + }) + } + + _doStartUni() { return new Promise((resolve, reject) => { let rec = null try { - rec = uni.getRecorderManager() + rec = _getSingletonRecorder() } catch (e) { + _setSingletonIdle(this) reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable')) return } if (!rec) { + _setSingletonIdle(this) reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable')) return } @@ -502,18 +571,29 @@ class VoiceRecorder { this._isRecording = false this._isStarting = false this._clearAutoStopTimer() - const mime = this._format === 'wav' ? 'audio/wav' - : this._format === 'aac' ? 'audio/aac' - : 'audio/mpeg' + // PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信临时文件后缀才是真实容器格式: + // Android aac → .m4a, iOS aac → .aac, mp3 → .mp3。按实际后缀给 mime/文件名, + // 避免播放器/后端因后缀与容器不符而失败。 + const tempPath = (res && res.tempFilePath) || '' + const rawExt = (tempPath.split('?')[0].split('/').pop().match(/\.([a-z0-9]+)$/i) || [])[1] || '' + const ext = rawExt.toLowerCase() + const mime = ext === 'm4a' ? 'audio/mp4' + : ext === 'aac' ? 'audio/aac' + : ext === 'mp3' ? 'audio/mpeg' + : ext === 'wav' ? 'audio/wav' + : (this._format === 'wav' ? 'audio/wav' + : this._format === 'aac' ? 'audio/aac' + : 'audio/mpeg') // 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 duration = reportedDuration > 0 ? reportedDuration : realDuration const result = { - filePath: res.tempFilePath, + filePath: tempPath, blob: null, - fileName: '', - duration: reportedDuration > 0 ? reportedDuration : realDuration, + fileName: tempPath ? `voice_${Date.now()}.${ext || 'aac'}` : '', + duration, mime, size: res.fileSize || 0, cancelled: false @@ -526,6 +606,7 @@ class VoiceRecorder { // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发且产物有效, 缓存起来 this._earlyStopResult = result } + _setSingletonIdle(this) }) rec.onError((err) => { @@ -544,6 +625,7 @@ class VoiceRecorder { // 启动阶段的 reject reject(e) } + _setSingletonIdle(this) }) // 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer) @@ -569,15 +651,20 @@ class VoiceRecorder { try { rec.start({ - duration: this._maxDuration / 1000, // uni-app 单位:秒 + // PR-FRONTEND-VOICE-DURATION-UNIT (2026-09-29): 微信 RecorderManager.start 的 duration + // 单位是毫秒(ms), 不是秒。之前 /1000 导致只录 60ms 就被自动停止。 + duration: this._maxDuration, sampleRate: this._sampleRate, numberOfChannels: this._channels, encodeBitRate: this._bitRate, - format: this._format, - frameSize: 4 // KB,触发 onFrameRecorded 频率 + format: this._format + // PR-FRONTEND-VOICE-FRAMESIZE (2026-09-29): 去掉 frameSize, 部分机型对此参数敏感, + // 可能导致录音被提前中断。 }) } catch (e) { this._uniRec = null + this._isStarting = false + _setSingletonIdle(this) reject(e) } }) @@ -610,6 +697,7 @@ class VoiceRecorder { this._isRecording = false this._isStarting = false this._clearAutoStopTimer() + _setSingletonIdle(this) resolve() return } @@ -619,6 +707,7 @@ class VoiceRecorder { this._isRecording = false this._isStarting = false this._clearAutoStopTimer() + _setSingletonIdle(this) resolve() } }) diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js index 9ee74a3..c6b0c62 100644 --- a/lang/en-us/ai-chat/index.js +++ b/lang/en-us/ai-chat/index.js @@ -167,6 +167,7 @@ export const lang = { voiceStartFailed: 'Cannot start recording', voiceStopFailed: 'Recording failed, please try again', voiceNotSupported: 'Voice input is not supported on this platform', + voiceTooShort: 'Speaking time too short', // PR-FRONTEND-VOICE-INPUT (2026-09-24): upload error (recorder result -> cloud) uploadFailed: 'Upload failed', uploadFailedWithReason: 'Upload failed: {reason}', @@ -185,8 +186,8 @@ export const lang = { logInitNavbarFailed: 'Failed to initialize navigation bar', logChatComponentNotInit: 'Chat component not initialized', logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...' - - // ====== AI Agent general copy (customer service is the first agent) ====== - aiAgentDefaultTitle: 'AI Agent', - aiAgentOpenFailed: 'Failed to open AI Agent, please try again later', + + // ====== AI Agent general copy (customer service is the first agent) ====== + aiAgentDefaultTitle: 'AI Agent', + aiAgentOpenFailed: 'Failed to open AI Agent, please try again later', aiAgentNotConfigured: 'This AI Agent is not enabled'} diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js index 20a1059..03ac18e 100644 --- a/lang/zh-cn/ai-chat/index.js +++ b/lang/zh-cn/ai-chat/index.js @@ -166,6 +166,8 @@ export const lang = { voiceStartFailed: '无法开始录音', voiceStopFailed: '录音失败,请重试', voiceNotSupported: '当前平台不支持语音输入', + voiceTooShort: '说话时间太短', + voiceRecorderBusy: '录音器正在初始化,请稍后再试', // PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端) uploadFailed: '上传失败', uploadFailedWithReason: '上传失败:{reason}', diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index eb7a9d4..57555a8 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -4077,6 +4077,7 @@ _handleStreamError(msg) { if (this._voiceRecorder) return this.voiceHolding = true this.voiceHoldCancelling = false + this._voiceHoldStartTime = Date.now() try { const touch = (e && e.touches && e.touches[0]) || null this._voiceHoldStartY = touch ? touch.clientY : 0 @@ -4143,12 +4144,20 @@ _handleStreamError(msg) { } try { const result = await this._voiceRecorder.stop() - console.log('[ai-chat] voice hold stop result:', JSON.stringify(result)) + const pressDuration = this._voiceHoldStartTime ? Date.now() - this._voiceHoldStartTime : 0 + console.log('[ai-chat] voice hold stop result:', JSON.stringify(result), 'pressDuration=', pressDuration) this._teardownVoiceRecorder() if (!result || !result.filePath) { uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' }) return } + // PR-FRONTEND-VOICE-MIN-DURATION (2026-09-29): 过滤过短录音, 像微信一样提示"说话时间太短", + // 避免发出去 100ms 的空语音导致播放失败/后端无法识别。 + if (result.duration < 500) { + console.log('[ai-chat] voice too short, discarded:', result.duration, 'ms') + uni.showToast({ title: this.$lang('ai-chat.index.voiceTooShort'), icon: 'none' }) + return + } await this._handleRecordedAudio(result) // PR-FRONTEND-VOICE-AUTO-SEND (2026-09-29): 按住说话像微信一样, 松手后自动发送, // 不需要用户再点一次发送按钮。 @@ -4301,6 +4310,11 @@ _handleStreamError(msg) { // voice-recorder.js 自身不带 Vue 上下文, 通过 err.code 映射到 $lang() key _voiceErrMsg(err) { if (!err) return '' + const msg = (err && (err.errMsg || err.message || err.errMsg)) || '' + // 微信录音单例仍被占用 (通常因上一次 onStop 尚未触发就被下一次 start 抢占) + if (msg && (msg.includes("is recording") || msg.includes("operateRecorder:fail:audio"))) { + return this.$lang('ai-chat.index.voiceRecorderBusy') + } switch (err.code) { case 'NOT_SUPPORTED': case 'NO_RECORDER_MANAGER': @@ -4311,9 +4325,9 @@ _handleStreamError(msg) { return this.$lang('ai-chat.index.voiceStartFailed') case 'UNI_RECORD_ERROR': // 诊断期: 直接透出微信原始错误 (errMsg/message), 便于截图定位根因 - return (err && (err.errMsg || err.message)) || this.$lang('ai-chat.index.voiceStopFailed') + return msg || this.$lang('ai-chat.index.voiceStopFailed') default: - return err.message || '' + return msg || '' } },