Compare commits
4 Commits
bf5e32df2b
...
ce705d9d61
| Author | SHA1 | Date | |
|---|---|---|---|
| ce705d9d61 | |||
| 5f4dc370f8 | |||
| a978858254 | |||
| 10e6402ad6 |
@@ -244,7 +244,7 @@ const localDevConfig = ({
|
|||||||
// })['2811']; // 本地
|
// })['2811']; // 本地
|
||||||
// })['5g-317']; // 电磁兼容
|
// })['5g-317']; // 电磁兼容
|
||||||
// })['5g-351']; //
|
// })['5g-351']; //
|
||||||
})['1']; // 本地
|
})['2881']; // 本地
|
||||||
// })['local-2']; // 本地
|
// })['local-2']; // 本地
|
||||||
// })['local-11']; // 本地
|
// })['local-11']; // 本地
|
||||||
// })['1-test']; // test
|
// })['1-test']; // test
|
||||||
|
|||||||
@@ -40,6 +40,64 @@
|
|||||||
|
|
||||||
const DEFAULT_MAX_DURATION_MS = 60 * 1000
|
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 因异常没触发, 最多等 10s 也要释放,
|
||||||
|
// 避免死锁导致后续所有 start() 永远挂起。
|
||||||
|
_singletonIdleTimer = setTimeout(() => {
|
||||||
|
console.warn('[voice-recorder:singleton] idle timeout, force release')
|
||||||
|
_setSingletonIdle(instance)
|
||||||
|
}, Math.min(maxDurationMs + 5000, 10000))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// H5 录音参数: 16kHz mono PCM → 16-bit LE WAV
|
||||||
// 16kHz/16-bit 是大多数 STT 服务 (Whisper / 达摩院 / 腾讯 ASR 等) 最常见的输入规格.
|
// 16kHz/16-bit 是大多数 STT 服务 (Whisper / 达摩院 / 腾讯 ASR 等) 最常见的输入规格.
|
||||||
const H5_SAMPLE_RATE = 16000
|
const H5_SAMPLE_RATE = 16000
|
||||||
@@ -181,19 +239,28 @@ export function createVoiceRecorder(opts = {}) {
|
|||||||
class VoiceRecorder {
|
class VoiceRecorder {
|
||||||
constructor(opts) {
|
constructor(opts) {
|
||||||
this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS
|
this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS
|
||||||
this._format = opts.format || 'mp3'
|
// 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._sampleRate = opts.sampleRate || 16000
|
||||||
this._channels = opts.numberOfChannels || 1
|
this._channels = opts.numberOfChannels || 1
|
||||||
this._bitRate = opts.encodeBitRate || 48000
|
this._bitRate = opts.encodeBitRate || 48000
|
||||||
|
|
||||||
this._listeners = { progress: [], error: [] }
|
this._listeners = { progress: [], error: [] }
|
||||||
this._isRecording = false
|
this._isRecording = false
|
||||||
|
this._isStarting = false // start() 已调用, 但 onStart 尚未回调
|
||||||
|
this._stopResolve = null // 当前 stop() 等待 onStop 解析的 resolve
|
||||||
|
this._pendingStop = false // onStart 之前就调用了 stop() → 等开始后立即停
|
||||||
this._startTs = 0
|
this._startTs = 0
|
||||||
|
this._id = Math.random().toString(36).slice(2, 8)
|
||||||
this._autoStopTimer = null
|
this._autoStopTimer = null
|
||||||
|
this._stopFallbackTimer = null // PR-FRONTEND-VOICE-FALLBACK: stop()/cancel() 兜底定时器
|
||||||
|
this._earlyStopResult = null // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发时缓存结果
|
||||||
|
|
||||||
// 平台相关句柄
|
// 平台相关句柄
|
||||||
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
|
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
|
||||||
this._uniRec = null // uni.getRecorderManager() 返回值
|
this._uniRec = null // uni.getRecorderManager() 返回值
|
||||||
|
console.log(`[voice-recorder:${this._id}] created`)
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event, cb) {
|
on(event, cb) {
|
||||||
@@ -226,14 +293,26 @@ class VoiceRecorder {
|
|||||||
if (this._isRecording) {
|
if (this._isRecording) {
|
||||||
return Promise.reject(_err('ALREADY_STARTED', 'recorder already started'))
|
return Promise.reject(_err('ALREADY_STARTED', 'recorder already started'))
|
||||||
}
|
}
|
||||||
|
if (this._isStarting) {
|
||||||
|
return Promise.reject(_err('ALREADY_STARTED', 'recorder is starting'))
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isStarting = true
|
||||||
|
let p = null
|
||||||
if (isH5()) {
|
if (isH5()) {
|
||||||
return this._startH5()
|
p = this._startH5()
|
||||||
|
} else if (isMpOrApp()) {
|
||||||
|
p = this._startUni()
|
||||||
|
} else {
|
||||||
|
this._isStarting = false
|
||||||
|
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
|
||||||
}
|
}
|
||||||
if (isMpOrApp()) {
|
// 启动失败时复位状态, 避免后续 stop()/cancel() 一直挂起
|
||||||
return this._startUni()
|
p.catch((e) => {
|
||||||
}
|
this._isStarting = false
|
||||||
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
|
return Promise.reject(e)
|
||||||
|
})
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,7 +320,29 @@ class VoiceRecorder {
|
|||||||
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
|
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
|
||||||
*/
|
*/
|
||||||
stop() {
|
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) {
|
||||||
|
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()
|
if (isH5()) return this._stopH5()
|
||||||
return this._stopUni()
|
return this._stopUni()
|
||||||
}
|
}
|
||||||
@@ -250,6 +351,16 @@ class VoiceRecorder {
|
|||||||
* 中断录音并丢弃产物。
|
* 中断录音并丢弃产物。
|
||||||
*/
|
*/
|
||||||
cancel() {
|
cancel() {
|
||||||
|
if (this._isStarting) {
|
||||||
|
// PR-FRONTEND-VOICE-CANCEL-RACE (2026-09-29): onStart 尚未触发就取消,
|
||||||
|
// 不能立即返回, 否则录音器启动后会继续录制。挂起 pendingStop,
|
||||||
|
// 等 onStart 触发后立即 stop(), 产物被丢弃。
|
||||||
|
console.log(`[voice-recorder:${this._id}] cancel() deferred: waiting for onStart`)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this._pendingStop = true
|
||||||
|
this._stopResolve = () => resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
if (!this._isRecording) {
|
if (!this._isRecording) {
|
||||||
this._clearAutoStopTimer()
|
this._clearAutoStopTimer()
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
@@ -430,65 +541,119 @@ class VoiceRecorder {
|
|||||||
// ===================== uni-app (MP/APP/鸿蒙) 实现 =====================
|
// ===================== uni-app (MP/APP/鸿蒙) 实现 =====================
|
||||||
|
|
||||||
_startUni() {
|
_startUni() {
|
||||||
|
// PR-FRONTEND-VOICE-SINGLETON: 串行化所有对微信录音单例的访问,
|
||||||
|
// 防止"上一个 onStop 还没触发, 下一个 start() 就报 audio is recording"。
|
||||||
|
return _singletonIdlePromise.then(() => {
|
||||||
|
// 防竞态: 注册 .then() 后到回调执行前, 可能有其他实例先占用了单例, 需要重新排队。
|
||||||
|
if (_singletonBusyInstance && _singletonBusyInstance !== this) {
|
||||||
|
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy by ${_singletonBusyInstance._id}, re-queue`)
|
||||||
|
return this._startUni()
|
||||||
|
}
|
||||||
|
_setSingletonBusy(this, this._maxDuration)
|
||||||
|
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy acquired`)
|
||||||
|
return this._doStartUni()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_doStartUni() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let rec = null
|
let rec = null
|
||||||
try {
|
try {
|
||||||
rec = uni.getRecorderManager()
|
rec = _getSingletonRecorder()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_setSingletonIdle(this)
|
||||||
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
|
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!rec) {
|
if (!rec) {
|
||||||
|
_setSingletonIdle(this)
|
||||||
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
|
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this._uniRec = rec
|
this._uniRec = rec
|
||||||
|
this._isStarting = true
|
||||||
|
console.log(`[voice-recorder:${this._id}] _startUni: start() invoked on singleton`)
|
||||||
|
|
||||||
rec.onStart(() => {
|
rec.onStart(() => {
|
||||||
|
console.log(`[voice-recorder:${this._id}] onStart fired`)
|
||||||
this._isRecording = true
|
this._isRecording = true
|
||||||
|
this._isStarting = false
|
||||||
this._startTs = Date.now()
|
this._startTs = Date.now()
|
||||||
this._setAutoStopTimer()
|
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()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
rec.onStop((res) => {
|
rec.onStop((res) => {
|
||||||
// res = { tempFilePath, duration, fileSize }
|
// res = { tempFilePath, duration, fileSize }
|
||||||
|
console.log(`[voice-recorder:${this._id}] onStop fired`, res, 'hasStopResolve=', !!this._stopResolve)
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
this._uniRec = null
|
this._uniRec = null
|
||||||
this._isRecording = false
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
this._clearAutoStopTimer()
|
this._clearAutoStopTimer()
|
||||||
const mime = this._format === 'wav' ? 'audio/wav'
|
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信临时文件后缀才是真实容器格式:
|
||||||
: this._format === 'aac' ? 'audio/aac'
|
// Android aac → .m4a, iOS aac → .aac, mp3 → .mp3。按实际后缀给 mime/文件名,
|
||||||
: 'audio/mpeg'
|
// 避免播放器/后端因后缀与容器不符而失败。
|
||||||
if (this._uniResolve) {
|
const tempPath = (res && res.tempFilePath) || ''
|
||||||
const r = this._uniResolve
|
const rawExt = (tempPath.split('?')[0].split('/').pop().match(/\.([a-z0-9]+)$/i) || [])[1] || ''
|
||||||
this._uniResolve = null
|
const ext = rawExt.toLowerCase()
|
||||||
r({
|
const mime = ext === 'm4a' ? 'audio/mp4'
|
||||||
filePath: res.tempFilePath,
|
: ext === 'aac' ? 'audio/aac'
|
||||||
blob: null,
|
: ext === 'mp3' ? 'audio/mpeg'
|
||||||
fileName: '',
|
: ext === 'wav' ? 'audio/wav'
|
||||||
duration: typeof res.duration === 'number' ? res.duration : (Date.now() - this._startTs),
|
: (this._format === 'wav' ? 'audio/wav'
|
||||||
mime,
|
: this._format === 'aac' ? 'audio/aac'
|
||||||
size: res.fileSize || 0,
|
: 'audio/mpeg')
|
||||||
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 duration = reportedDuration > 0 ? reportedDuration : realDuration
|
||||||
|
const result = {
|
||||||
|
filePath: tempPath,
|
||||||
|
blob: null,
|
||||||
|
fileName: tempPath ? `voice_${Date.now()}.${ext || 'aac'}` : '',
|
||||||
|
duration,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
_setSingletonIdle(this)
|
||||||
})
|
})
|
||||||
|
|
||||||
rec.onError((err) => {
|
rec.onError((err) => {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed')
|
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._uniRec = null
|
||||||
this._isRecording = false
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
this._clearAutoStopTimer()
|
this._clearAutoStopTimer()
|
||||||
this._emit('error', e)
|
this._emit('error', e)
|
||||||
if (this._uniResolve) {
|
if (this._stopResolve) {
|
||||||
const r = this._uniResolve
|
const r = this._stopResolve
|
||||||
this._uniResolve = null
|
this._stopResolve = null
|
||||||
r(null)
|
r(null)
|
||||||
} else {
|
} else {
|
||||||
// 启动阶段的 reject
|
// 启动阶段的 reject
|
||||||
reject(e)
|
reject(e)
|
||||||
}
|
}
|
||||||
|
_setSingletonIdle(this)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer)
|
// 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer)
|
||||||
@@ -514,15 +679,31 @@ class VoiceRecorder {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
rec.start({
|
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,
|
sampleRate: this._sampleRate,
|
||||||
numberOfChannels: this._channels,
|
numberOfChannels: this._channels,
|
||||||
encodeBitRate: this._bitRate,
|
encodeBitRate: this._bitRate,
|
||||||
format: this._format,
|
format: this._format
|
||||||
frameSize: 4 // KB,触发 onFrameRecorded 频率
|
// PR-FRONTEND-VOICE-FRAMESIZE (2026-09-29): 去掉 frameSize, 部分机型对此参数敏感,
|
||||||
|
// 可能导致录音被提前中断。
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
this._uniRec = null
|
this._uniRec = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
// 启动阶段就被 stop()/cancel() 抢占, 需要 Fulfill 挂起的 Promise, 而不是 reject
|
||||||
|
if (this._stopResolve && this._pendingStop) {
|
||||||
|
const r = this._stopResolve
|
||||||
|
this._stopResolve = null
|
||||||
|
this._pendingStop = false
|
||||||
|
r(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
reject(e)
|
reject(e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -530,26 +711,79 @@ class VoiceRecorder {
|
|||||||
|
|
||||||
_stopUni() {
|
_stopUni() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
console.log(`[voice-recorder:${this._id}] _stopUni called`)
|
||||||
const rec = this._uniRec
|
const rec = this._uniRec
|
||||||
if (!rec) { resolve(null); return }
|
if (!rec) { resolve(null); return }
|
||||||
this._uniResolve = resolve
|
this._earlyStopResult = null
|
||||||
try { rec.stop() } catch (e) { resolve(null) }
|
this._stopResolve = resolve
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
// PR-FRONTEND-VOICE-FALLBACK: 真机偶发 onStop 不回调, 导致 stop() 永远挂起,
|
||||||
|
// UI 无法释放旧实例, 新录音提示"recorder already exists"。最多等 3s 强制 resolve。
|
||||||
|
this._stopFallbackTimer = setTimeout(() => {
|
||||||
|
console.warn(`[voice-recorder:${this._id}] _stopUni fallback timeout, force resolve`)
|
||||||
|
this._stopResolve = null
|
||||||
|
this._pendingStop = false
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve(null)
|
||||||
|
}, 3000)
|
||||||
|
try { rec.stop() } catch (e) {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._stopResolve = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
_cancelUni() {
|
_cancelUni() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
console.log(`[voice-recorder:${this._id}] _cancelUni called`)
|
||||||
|
this._earlyStopResult = null
|
||||||
const rec = this._uniRec
|
const rec = this._uniRec
|
||||||
if (!rec) {
|
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._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
this._clearAutoStopTimer()
|
this._clearAutoStopTimer()
|
||||||
|
_setSingletonIdle(this)
|
||||||
resolve()
|
resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this._uniResolve = () => resolve()
|
this._stopResolve = () => resolve()
|
||||||
try { rec.stop() } catch (e) {
|
this._pendingStop = false
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
// PR-FRONTEND-VOICE-FALLBACK: cancel() 同样可能因 onStop 不回调而挂起。
|
||||||
|
this._stopFallbackTimer = setTimeout(() => {
|
||||||
|
console.warn(`[voice-recorder:${this._id}] _cancelUni fallback timeout, force resolve`)
|
||||||
|
this._stopResolve = null
|
||||||
this._isRecording = false
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
this._clearAutoStopTimer()
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve()
|
||||||
|
}, 3000)
|
||||||
|
try { rec.stop() } catch (e) {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -573,6 +807,13 @@ class VoiceRecorder {
|
|||||||
this._autoStopTimer = null
|
this._autoStopTimer = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_clearStopFallbackTimer() {
|
||||||
|
if (this._stopFallbackTimer) {
|
||||||
|
clearTimeout(this._stopFallbackTimer)
|
||||||
|
this._stopFallbackTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createVoiceRecorder
|
export default createVoiceRecorder
|
||||||
@@ -167,6 +167,7 @@ export const lang = {
|
|||||||
voiceStartFailed: 'Cannot start recording',
|
voiceStartFailed: 'Cannot start recording',
|
||||||
voiceStopFailed: 'Recording failed, please try again',
|
voiceStopFailed: 'Recording failed, please try again',
|
||||||
voiceNotSupported: 'Voice input is not supported on this platform',
|
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)
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): upload error (recorder result -> cloud)
|
||||||
uploadFailed: 'Upload failed',
|
uploadFailed: 'Upload failed',
|
||||||
uploadFailedWithReason: 'Upload failed: {reason}',
|
uploadFailedWithReason: 'Upload failed: {reason}',
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export const lang = {
|
|||||||
voiceStartFailed: '无法开始录音',
|
voiceStartFailed: '无法开始录音',
|
||||||
voiceStopFailed: '录音失败,请重试',
|
voiceStopFailed: '录音失败,请重试',
|
||||||
voiceNotSupported: '当前平台不支持语音输入',
|
voiceNotSupported: '当前平台不支持语音输入',
|
||||||
|
voiceTooShort: '说话时间太短',
|
||||||
|
voiceRecorderBusy: '录音器正在初始化,请稍后再试',
|
||||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
|
||||||
uploadFailed: '上传失败',
|
uploadFailed: '上传失败',
|
||||||
uploadFailedWithReason: '上传失败:{reason}',
|
uploadFailedWithReason: '上传失败:{reason}',
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
<view v-else-if="message.type === 'file'" class="user-media-group">
|
<view v-else-if="message.type === 'file'" class="user-media-group">
|
||||||
<view class="message-bubble user-file-bubble" @click="previewUserFile(message)">
|
<view class="message-bubble user-file-bubble" @click="previewUserFile(message)">
|
||||||
<view class="file-item">
|
<view class="file-item">
|
||||||
<view class="file-icon"><text class="iconfont icon-file"></text></view>
|
<view class="file-icon"><text class="file-icon-emoji">{{ message.type === 'video' ? '🎬' : (message.type === 'audio' ? '🎵' : '📄') }}</text></view>
|
||||||
<view class="file-info">
|
<view class="file-info">
|
||||||
<text class="file-name">{{ message.fileName || '文件' }}</text>
|
<text class="file-name">{{ message.fileName || '文件' }}</text>
|
||||||
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
|
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
|
||||||
@@ -148,9 +148,10 @@
|
|||||||
<text class="message-text" selectable>{{ message.content }}</text>
|
<text class="message-text" selectable>{{ message.content }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡
|
<!-- PR-USER-VOICE-BUBBLE (2026-09-29): 用户语音消息气泡 —— 微信原生风格
|
||||||
模仿微信/Telegram 的语音消息样式:
|
- 左侧显示时长 X", 右侧显示扬声器 + 声波动画
|
||||||
[▶播放] [语音 + 进度条] [时长]
|
- 气泡右侧带小三角尾巴
|
||||||
|
- 宽度按时长比例伸缩 (短语音窄、长语音宽)
|
||||||
PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
|
PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
|
||||||
每条 voice 项都有自己的 url/duration/playing 字段,
|
每条 voice 项都有自己的 url/duration/playing 字段,
|
||||||
模板 v-for 渲染多个 .voice-msg 行, 每行点击 → openVoiceOverlay(v)
|
模板 v-for 渲染多个 .voice-msg 行, 每行点击 → openVoiceOverlay(v)
|
||||||
@@ -158,17 +159,16 @@
|
|||||||
<view
|
<view
|
||||||
v-else-if="message.type === 'audio'"
|
v-else-if="message.type === 'audio'"
|
||||||
class="message-bubble user-audio-bubble">
|
class="message-bubble user-audio-bubble">
|
||||||
<view v-for="(v, idx) in (message.voiceList || [])" :key="idx" class="voice-msg" @click.stop="openVoiceOverlay(v)">
|
<view v-for="(v, idx) in (message.voiceList || [])" :key="idx" class="voice-msg" :style="{ width: getVoiceBubbleWidth(v.duration) }" @click.stop="openVoiceOverlay(v)">
|
||||||
<view class="voice-play-btn" :class="{ playing: v.playing }">
|
<text class="voice-duration">{{ getVoiceDurationText(v.duration) }}</text>
|
||||||
<text class="icondiy" :class="v.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
|
<view class="voice-speaker" :class="{ playing: v.playing }">
|
||||||
</view>
|
<view class="voice-speaker-body"></view>
|
||||||
<view class="voice-info">
|
<view class="voice-speaker-cone"></view>
|
||||||
<text class="voice-label">{{ $lang('ai-chat.index.audioMessage') || '语音' }}</text>
|
<view class="voice-speaker-waves">
|
||||||
<view class="voice-progress-track">
|
<view class="voice-speaker-wave"></view>
|
||||||
<view class="voice-progress-bar" :style="{ width: getVoiceProgress(v) + '%' }"></view>
|
<view class="voice-speaker-wave"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="voice-duration">{{ formatDuration((v.duration || 0) / 1000) }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<!-- 语音 + 文字混合时, 文字写在语音列表下方 -->
|
<!-- 语音 + 文字混合时, 文字写在语音列表下方 -->
|
||||||
<view v-if="message.content" class="voice-bubble-text">
|
<view v-if="message.content" class="voice-bubble-text">
|
||||||
@@ -3273,6 +3273,24 @@ _handleStreamError(msg) {
|
|||||||
return Math.min(100, Math.max(0, (cur / dur) * 100))
|
return Math.min(100, Math.max(0, (cur / dur) * 100))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// PR-USER-VOICE-BUBBLE-WECHAT (2026-09-29): 微信风格语音气泡宽度按时长比例伸缩,
|
||||||
|
// 短语音窄、长语音宽, 更像微信原生体验。
|
||||||
|
getVoiceBubbleWidth(durationMs) {
|
||||||
|
const dur = Number(durationMs) || 0
|
||||||
|
const seconds = dur / 1000
|
||||||
|
const minW = 200
|
||||||
|
const maxW = 420
|
||||||
|
const maxDuration = 60
|
||||||
|
const ratio = Math.min(1, Math.max(0, seconds / maxDuration))
|
||||||
|
return `${Math.round(minW + ratio * (maxW - minW))}rpx`
|
||||||
|
},
|
||||||
|
|
||||||
|
// PR-USER-VOICE-BUBBLE-WECHAT (2026-09-29): 微信语音气泡时长显示为 X" 格式
|
||||||
|
getVoiceDurationText(durationMs) {
|
||||||
|
const seconds = Math.round((Number(durationMs) || 0) / 1000)
|
||||||
|
return seconds + '"'
|
||||||
|
},
|
||||||
|
|
||||||
// 格式化 AI 处理耗时 (秒 -> "XmXXs" 风格, 0 时显示 "<1s")
|
// 格式化 AI 处理耗时 (秒 -> "XmXXs" 风格, 0 时显示 "<1s")
|
||||||
formatElapsed(seconds) {
|
formatElapsed(seconds) {
|
||||||
if (seconds === undefined || seconds === null || isNaN(seconds)) return '1s'
|
if (seconds === undefined || seconds === null || isNaN(seconds)) return '1s'
|
||||||
@@ -4073,9 +4091,11 @@ _handleStreamError(msg) {
|
|||||||
// - touchend 提交产物 (取消则丢弃)
|
// - touchend 提交产物 (取消则丢弃)
|
||||||
// - H5 鼠标不会触发 touch, click 也走 onVoiceHoldEnd 兜底 (无录音产物就当没按住)
|
// - H5 鼠标不会触发 touch, click 也走 onVoiceHoldEnd 兜底 (无录音产物就当没按住)
|
||||||
onVoiceHoldStart(e) {
|
onVoiceHoldStart(e) {
|
||||||
|
console.log('[ai-chat] onVoiceHoldStart fired', this._voiceRecorder ? 'recorder already exists' : 'creating recorder')
|
||||||
if (this._voiceRecorder) return
|
if (this._voiceRecorder) return
|
||||||
this.voiceHolding = true
|
this.voiceHolding = true
|
||||||
this.voiceHoldCancelling = false
|
this.voiceHoldCancelling = false
|
||||||
|
this._voiceHoldStartTime = Date.now()
|
||||||
try {
|
try {
|
||||||
const touch = (e && e.touches && e.touches[0]) || null
|
const touch = (e && e.touches && e.touches[0]) || null
|
||||||
this._voiceHoldStartY = touch ? touch.clientY : 0
|
this._voiceHoldStartY = touch ? touch.clientY : 0
|
||||||
@@ -4090,6 +4110,7 @@ _handleStreamError(msg) {
|
|||||||
}
|
}
|
||||||
this._onHoldError = (err) => {
|
this._onHoldError = (err) => {
|
||||||
console.warn('[ai-chat] voice hold error:', 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' })
|
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||||
this._discardVoiceRecorder()
|
this._discardVoiceRecorder()
|
||||||
}
|
}
|
||||||
@@ -4119,6 +4140,7 @@ _handleStreamError(msg) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async onVoiceHoldEnd() {
|
async onVoiceHoldEnd() {
|
||||||
|
console.log('[ai-chat] onVoiceHoldEnd fired, voiceHolding=', this.voiceHolding, 'recorder=', !!this._voiceRecorder)
|
||||||
if (!this.voiceHolding) {
|
if (!this.voiceHolding) {
|
||||||
// 非按住触发的 click (H5 鼠标等), 不做任何事, 避免双触发
|
// 非按住触发的 click (H5 鼠标等), 不做任何事, 避免双触发
|
||||||
return
|
return
|
||||||
@@ -4129,6 +4151,7 @@ _handleStreamError(msg) {
|
|||||||
|
|
||||||
if (!this._voiceRecorder) {
|
if (!this._voiceRecorder) {
|
||||||
// 启动录音器失败 (无 touch 触发的 click) → 退回弹窗路径, 保留功能不丢
|
// 启动录音器失败 (无 touch 触发的 click) → 退回弹窗路径, 保留功能不丢
|
||||||
|
console.log('[ai-chat] onVoiceHoldEnd: no recorder, fallback to voice panel')
|
||||||
this.showVoicePanel()
|
this.showVoicePanel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -4139,13 +4162,27 @@ _handleStreamError(msg) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this._voiceRecorder.stop()
|
const result = await this._voiceRecorder.stop()
|
||||||
|
const pressDuration = this._voiceHoldStartTime ? Date.now() - this._voiceHoldStartTime : 0
|
||||||
|
console.log('[ai-chat] voice hold stop result:', JSON.stringify(result), 'pressDuration=', pressDuration)
|
||||||
this._teardownVoiceRecorder()
|
this._teardownVoiceRecorder()
|
||||||
if (!result || !result.filePath) {
|
if (!result || !result.filePath) {
|
||||||
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
|
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
|
||||||
return
|
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)
|
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) {
|
} catch (e) {
|
||||||
|
console.error('[ai-chat] voice hold end error:', e && e.errMsg, e && e.message, JSON.stringify(e))
|
||||||
this._teardownVoiceRecorder()
|
this._teardownVoiceRecorder()
|
||||||
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||||
}
|
}
|
||||||
@@ -4153,6 +4190,7 @@ _handleStreamError(msg) {
|
|||||||
|
|
||||||
// 按住说话: 手势被系统打断 (来电/滑动取消)
|
// 按住说话: 手势被系统打断 (来电/滑动取消)
|
||||||
onVoiceHoldCancel() {
|
onVoiceHoldCancel() {
|
||||||
|
console.log('[ai-chat] onVoiceHoldCancel fired')
|
||||||
if (!this.voiceHolding) return
|
if (!this.voiceHolding) return
|
||||||
this.voiceHolding = false
|
this.voiceHolding = false
|
||||||
this.voiceHoldCancelling = false
|
this.voiceHoldCancelling = false
|
||||||
@@ -4267,12 +4305,14 @@ _handleStreamError(msg) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_discardVoiceRecorder() {
|
_discardVoiceRecorder() {
|
||||||
|
console.log('[ai-chat] _discardVoiceRecorder called')
|
||||||
if (!this._voiceRecorder) return
|
if (!this._voiceRecorder) return
|
||||||
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
|
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
|
||||||
this._teardownVoiceRecorder()
|
this._teardownVoiceRecorder()
|
||||||
},
|
},
|
||||||
|
|
||||||
_teardownVoiceRecorder() {
|
_teardownVoiceRecorder() {
|
||||||
|
console.log('[ai-chat] _teardownVoiceRecorder called')
|
||||||
if (this._voiceRecorder) {
|
if (this._voiceRecorder) {
|
||||||
if (this._onHoldProgress) this._voiceRecorder.off('progress', this._onHoldProgress)
|
if (this._onHoldProgress) this._voiceRecorder.off('progress', this._onHoldProgress)
|
||||||
if (this._onHoldError) this._voiceRecorder.off('error', this._onHoldError)
|
if (this._onHoldError) this._voiceRecorder.off('error', this._onHoldError)
|
||||||
@@ -4288,6 +4328,11 @@ _handleStreamError(msg) {
|
|||||||
// voice-recorder.js 自身不带 Vue 上下文, 通过 err.code 映射到 $lang() key
|
// voice-recorder.js 自身不带 Vue 上下文, 通过 err.code 映射到 $lang() key
|
||||||
_voiceErrMsg(err) {
|
_voiceErrMsg(err) {
|
||||||
if (!err) return ''
|
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) {
|
switch (err.code) {
|
||||||
case 'NOT_SUPPORTED':
|
case 'NOT_SUPPORTED':
|
||||||
case 'NO_RECORDER_MANAGER':
|
case 'NO_RECORDER_MANAGER':
|
||||||
@@ -4297,9 +4342,10 @@ _handleStreamError(msg) {
|
|||||||
case 'ALREADY_STARTED':
|
case 'ALREADY_STARTED':
|
||||||
return this.$lang('ai-chat.index.voiceStartFailed')
|
return this.$lang('ai-chat.index.voiceStartFailed')
|
||||||
case 'UNI_RECORD_ERROR':
|
case 'UNI_RECORD_ERROR':
|
||||||
return this.$lang('ai-chat.index.voiceStopFailed')
|
// 诊断期: 直接透出微信原始错误 (errMsg/message), 便于截图定位根因
|
||||||
|
return msg || this.$lang('ai-chat.index.voiceStopFailed')
|
||||||
default:
|
default:
|
||||||
return err.message || ''
|
return msg || ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -5397,6 +5443,12 @@ _handleStreamError(msg) {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
content: none !important;
|
content: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 语音气泡保持与文字气泡一致的颜色 */
|
||||||
|
&.user-audio-bubble {
|
||||||
|
background: #f5f5f5 !important;
|
||||||
|
padding: 14rpx 22rpx !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6890,6 +6942,15 @@ _handleStreamError(msg) {
|
|||||||
height: 56rpx;
|
height: 56rpx;
|
||||||
margin-right: 12rpx;
|
margin-right: 12rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.file-icon-emoji {
|
||||||
|
font-size: 44rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.iconfont,
|
.iconfont,
|
||||||
.icondiy { font-size: 48rpx; color: #6b7c93; }
|
.icondiy { font-size: 48rpx; color: #6b7c93; }
|
||||||
}
|
}
|
||||||
@@ -6925,24 +6986,40 @@ _handleStreamError(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡
|
/* PR-USER-VOICE-BUBBLE (2026-09-29): 用户语音消息气泡 —— 微信原生风格
|
||||||
模仿微信/Telegram 语音消息样式: 主色实心圆角胶囊 + 播放按钮 + 进度条 + 时长
|
- 左侧显示时长 X", 右侧显示扬声器 + 声波动画
|
||||||
|
- 气泡右侧带小三角尾巴
|
||||||
|
- 宽度按时长比例伸缩 (短语音窄、长语音宽)
|
||||||
与 .user-file-bubble 复用基础结构但视觉完全不同, 这里覆盖其样式
|
与 .user-file-bubble 复用基础结构但视觉完全不同, 这里覆盖其样式
|
||||||
PR-USER-MULTI-VOICE (2026-09-28): 改为 flex-column 让多条 voice-msg 垂直堆叠 */
|
PR-USER-MULTI-VOICE (2026-09-28): 改为 flex-column 让多条 voice-msg 垂直堆叠 */
|
||||||
.user-audio-bubble {
|
.user-audio-bubble {
|
||||||
/* 覆盖 .user-file-bubble 的 padding/file-item */
|
/* 覆盖 .user-file-bubble 的 padding/file-item */
|
||||||
padding: 18rpx 24rpx !important;
|
padding: 14rpx 22rpx !important;
|
||||||
min-width: 320rpx;
|
min-width: 180rpx;
|
||||||
max-width: 480rpx;
|
max-width: 460rpx;
|
||||||
background: $color-primary !important;
|
background: #f5f5f5 !important;
|
||||||
border-radius: 24rpx !important;
|
border-radius: 16rpx !important;
|
||||||
color: #fff !important;
|
color: #1f1f1f !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
display: flex !important;
|
display: inline-flex !important;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16rpx;
|
gap: 12rpx;
|
||||||
|
width: auto !important;
|
||||||
|
|
||||||
|
/* 微信气泡右侧小三角尾巴 */
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -12rpx;
|
||||||
|
top: 20rpx;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border: 12rpx solid transparent;
|
||||||
|
border-left-color: $color-primary;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* 上传中半透明 */
|
/* 上传中半透明 */
|
||||||
&.is-uploading {
|
&.is-uploading {
|
||||||
@@ -6950,93 +7027,90 @@ _handleStreamError(msg) {
|
|||||||
}
|
}
|
||||||
&.is-error {
|
&.is-error {
|
||||||
background: #c0c4cc !important;
|
background: #c0c4cc !important;
|
||||||
|
&::after {
|
||||||
|
border-left-color: #c0c4cc !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-msg {
|
.voice-msg {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
|
width: 100%;
|
||||||
.voice-play-btn {
|
min-height: 44rpx;
|
||||||
flex-shrink: 0;
|
|
||||||
width: 64rpx;
|
|
||||||
height: 64rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.22);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background 0.2s ease, transform 0.15s ease;
|
|
||||||
|
|
||||||
.icondiy {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 播放中按钮背景加深 */
|
|
||||||
&.playing {
|
|
||||||
background: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
&.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8rpx;
|
|
||||||
|
|
||||||
.voice-label {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
line-height: 1;
|
|
||||||
/* PR-CROSS-PLATFORM-VOICE-LABEL (2026-09-28): 异常长 i18n 文案或
|
|
||||||
user-agent 自带文本时, 不撑破气泡宽度, 跨平台一致 */
|
|
||||||
max-width: 240rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-progress-track {
|
|
||||||
height: 8rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.28);
|
|
||||||
border-radius: 4rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.voice-progress-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4rpx;
|
|
||||||
/* 进度条用 transition 平滑过渡, 配合 innerAudioCtx.onTimeUpdate 的秒级刷新 */
|
|
||||||
transition: width 0.15s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-duration {
|
.voice-duration {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 26rpx;
|
font-size: 30rpx;
|
||||||
color: #fff;
|
color: #1f1f1f;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 微信风格语音图标: 扬声器 + 右侧声波 */
|
||||||
|
.voice-speaker {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 36rpx;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
/* 扬声器后身 */
|
||||||
|
.voice-speaker-body {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
background: #1f1f1f;
|
||||||
|
border-radius: 2rpx 0 0 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扬声器喇叭口(指向右侧) */
|
||||||
|
.voice-speaker-cone {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 10rpx solid transparent;
|
||||||
|
border-bottom: 10rpx solid transparent;
|
||||||
|
border-right: 14rpx solid #1f1f1f;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧两条声波弧线 */
|
||||||
|
.voice-speaker-waves {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 2rpx;
|
||||||
|
|
||||||
|
.voice-speaker-wave {
|
||||||
|
border-top: 3rpx solid transparent;
|
||||||
|
border-right: 3rpx solid #1f1f1f;
|
||||||
|
border-bottom: 3rpx solid transparent;
|
||||||
|
border-left: 3rpx solid transparent;
|
||||||
|
border-radius: 0 50% 50% 0;
|
||||||
|
|
||||||
|
&:nth-child(1) {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
margin-left: -8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PR-USER-MULTI-VOICE (2026-09-28): 语音 + 文字混合时, 文字写在语音列表下方
|
/* PR-USER-MULTI-VOICE (2026-09-28): 语音 + 文字混合时, 文字写在语音列表下方
|
||||||
与语音行共用主色背景, 顶部分割线划开两块内容 */
|
与语音行共用主色背景, 顶部分割线划开两块内容 */
|
||||||
.voice-bubble-text {
|
.voice-bubble-text {
|
||||||
padding-top: 12rpx;
|
padding-top: 12rpx;
|
||||||
border-top: 1rpx solid rgba(255, 255, 255, 0.2);
|
border-top: 1rpx solid rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
.message-text {
|
.message-text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #fff;
|
color: #1f1f1f;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this._onError = (err) => {
|
this._onError = (err) => {
|
||||||
console.warn('[voice-panel] recorder error:', 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' })
|
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||||
this._discardRecorderIfAny()
|
this._discardRecorderIfAny()
|
||||||
}
|
}
|
||||||
@@ -227,7 +228,7 @@ export default {
|
|||||||
case 'ALREADY_STARTED':
|
case 'ALREADY_STARTED':
|
||||||
return this.$lang('ai-chat.index.voiceStartFailed')
|
return this.$lang('ai-chat.index.voiceStartFailed')
|
||||||
case 'UNI_RECORD_ERROR':
|
case 'UNI_RECORD_ERROR':
|
||||||
return this.$lang('ai-chat.index.voiceStopFailed')
|
return (err && (err.errMsg || err.message)) || this.$lang('ai-chat.index.voiceStopFailed')
|
||||||
default:
|
default:
|
||||||
return err.message || ''
|
return err.message || ''
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user