chore:语音输入有返回

This commit is contained in:
2026-09-29 16:46:59 +08:00
parent bf5e32df2b
commit 10e6402ad6
4 changed files with 106 additions and 22 deletions

View File

@@ -244,7 +244,7 @@ const localDevConfig = ({
// })['2811']; // 本地
// })['5g-317']; // 电磁兼容
// })['5g-351']; //
})['1']; // 本地
})['2881']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

View File

@@ -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()
}

View File

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

View File

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