chore:语音气泡正常

This commit is contained in:
2026-09-30 09:28:22 +08:00
parent a978858254
commit 5f4dc370f8
2 changed files with 219 additions and 99 deletions

View File

@@ -73,12 +73,12 @@ function _setSingletonBusy(instance, maxDurationMs = DEFAULT_MAX_DURATION_MS) {
_singletonIdleResolver = resolve
})
_singletonBusyInstance = instance
// 保险: 即使 onStop/onError 因异常没触发, 最多等 maxDuration + 5s 也要释放,
// 保险: 即使 onStop/onError 因异常没触发, 最多等 10s 也要释放,
// 避免死锁导致后续所有 start() 永远挂起。
_singletonIdleTimer = setTimeout(() => {
console.warn('[voice-recorder:singleton] idle timeout, force release')
_setSingletonIdle(instance)
}, maxDurationMs + 5000)
}, Math.min(maxDurationMs + 5000, 10000))
}
function _setSingletonIdle(instance) {
@@ -254,6 +254,7 @@ class VoiceRecorder {
this._startTs = 0
this._id = Math.random().toString(36).slice(2, 8)
this._autoStopTimer = null
this._stopFallbackTimer = null // PR-FRONTEND-VOICE-FALLBACK: stop()/cancel() 兜底定时器
this._earlyStopResult = null // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发时缓存结果
// 平台相关句柄
@@ -296,13 +297,22 @@ class VoiceRecorder {
return Promise.reject(_err('ALREADY_STARTED', 'recorder is starting'))
}
this._isStarting = true
let p = null
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()) {
return this._startUni()
}
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
// 启动失败时复位状态, 避免后续 stop()/cancel() 一直挂起
p.catch((e) => {
this._isStarting = false
return Promise.reject(e)
})
return p
}
/**
@@ -316,7 +326,7 @@ class VoiceRecorder {
// onStart 尚未回调 (录音未真正开始) 就调用 stop(), 原逻辑直接返回 null
// 导致上层判定"录音失败,请重试"。这里改为挂起, 等 onStart 触发后立即停,
// 产物仍走 onStop 正常返回 (太短的音轨后端可自行拒绝)。
if (this._isStarting && this._uniRec) {
if (this._isStarting) {
console.log(`[voice-recorder:${this._id}] stop() deferred: waiting for onStart`)
return new Promise((resolve) => {
this._pendingStop = true
@@ -341,6 +351,16 @@ class VoiceRecorder {
* 中断录音并丢弃产物。
*/
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) {
this._clearAutoStopTimer()
return Promise.resolve()
@@ -524,7 +544,13 @@ class VoiceRecorder {
// 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()
})
}
@@ -567,6 +593,7 @@ class VoiceRecorder {
rec.onStop((res) => {
// res = { tempFilePath, duration, fileSize }
console.log(`[voice-recorder:${this._id}] onStop fired`, res, 'hasStopResolve=', !!this._stopResolve)
this._clearStopFallbackTimer()
this._uniRec = null
this._isRecording = false
this._isStarting = false
@@ -610,6 +637,7 @@ class VoiceRecorder {
})
rec.onError((err) => {
this._clearStopFallbackTimer()
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
@@ -662,9 +690,20 @@ class VoiceRecorder {
// 可能导致录音被提前中断。
})
} catch (e) {
this._clearStopFallbackTimer()
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)
}
})
@@ -677,7 +716,30 @@ class VoiceRecorder {
if (!rec) { resolve(null); return }
this._earlyStopResult = null
this._stopResolve = resolve
try { rec.stop() } catch (e) { resolve(null) }
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)
}
})
}
@@ -703,10 +765,24 @@ class VoiceRecorder {
}
this._stopResolve = () => resolve()
this._pendingStop = false
try { rec.stop() } catch (e) {
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._isStarting = false
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()
}
@@ -731,6 +807,13 @@ class VoiceRecorder {
this._autoStopTimer = null
}
}
_clearStopFallbackTimer() {
if (this._stopFallbackTimer) {
clearTimeout(this._stopFallbackTimer)
this._stopFallbackTimer = null
}
}
}
export default createVoiceRecorder

View File

@@ -148,9 +148,10 @@
<text class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
<!-- PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡
模仿微信/Telegram 的语音消息样式:
[▶播放] [语音 + 进度条] [时长]
<!-- PR-USER-VOICE-BUBBLE (2026-09-29): 用户语音消息气泡 —— 微信原生风格
- 左侧显示时长 X", 右侧显示扬声器 + 声波动画
- 气泡右侧带小三角尾巴
- 宽度按时长比例伸缩 (短语音窄、长语音宽)
PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
每条 voice 项都有自己的 url/duration/playing 字段,
模板 v-for 渲染多个 .voice-msg 行, 每行点击 → openVoiceOverlay(v)
@@ -158,17 +159,16 @@
<view
v-else-if="message.type === 'audio'"
class="message-bubble user-audio-bubble">
<view v-for="(v, idx) in (message.voiceList || [])" :key="idx" class="voice-msg" @click.stop="openVoiceOverlay(v)">
<view class="voice-play-btn" :class="{ playing: v.playing }">
<text class="icondiy" :class="v.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
</view>
<view class="voice-info">
<text class="voice-label">{{ $lang('ai-chat.index.audioMessage') || '语音' }}</text>
<view class="voice-progress-track">
<view class="voice-progress-bar" :style="{ width: getVoiceProgress(v) + '%' }"></view>
<view v-for="(v, idx) in (message.voiceList || [])" :key="idx" class="voice-msg" :style="{ width: getVoiceBubbleWidth(v.duration) }" @click.stop="openVoiceOverlay(v)">
<text class="voice-duration">{{ getVoiceDurationText(v.duration) }}</text>
<view class="voice-speaker" :class="{ playing: v.playing }">
<view class="voice-speaker-body"></view>
<view class="voice-speaker-cone"></view>
<view class="voice-speaker-waves">
<view class="voice-speaker-wave"></view>
<view class="voice-speaker-wave"></view>
</view>
</view>
<text class="voice-duration">{{ formatDuration((v.duration || 0) / 1000) }}</text>
</view>
<!-- 语音 + 文字混合时, 文字写在语音列表下方 -->
<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))
},
// 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")
formatElapsed(seconds) {
if (seconds === undefined || seconds === null || isNaN(seconds)) return '1s'
@@ -5425,6 +5443,12 @@ _handleStreamError(msg) {
display: none !important;
content: none !important;
}
/* 语音气泡保持与文字气泡一致的颜色 */
&.user-audio-bubble {
background: #f5f5f5 !important;
padding: 14rpx 22rpx !important;
}
}
}
}
@@ -6953,24 +6977,40 @@ _handleStreamError(msg) {
}
}
/* PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡
模仿微信/Telegram 语音消息样式: 主色实心圆角胶囊 + 播放按钮 + 进度条 + 时长
/* PR-USER-VOICE-BUBBLE (2026-09-29): 用户语音消息气泡 —— 微信原生风格
- 左侧显示时长 X", 右侧显示扬声器 + 声波动画
- 气泡右侧带小三角尾巴
- 宽度按时长比例伸缩 (短语音窄、长语音宽)
与 .user-file-bubble 复用基础结构但视觉完全不同, 这里覆盖其样式
PR-USER-MULTI-VOICE (2026-09-28): 改为 flex-column 让多条 voice-msg 垂直堆叠 */
.user-audio-bubble {
/* 覆盖 .user-file-bubble 的 padding/file-item */
padding: 18rpx 24rpx !important;
min-width: 320rpx;
max-width: 480rpx;
background: $color-primary !important;
border-radius: 24rpx !important;
color: #fff !important;
padding: 14rpx 22rpx !important;
min-width: 180rpx;
max-width: 460rpx;
background: #f5f5f5 !important;
border-radius: 16rpx !important;
color: #1f1f1f !important;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex !important;
overflow: visible;
display: inline-flex !important;
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 {
@@ -6978,93 +7018,90 @@ _handleStreamError(msg) {
}
&.is-error {
background: #c0c4cc !important;
&::after {
border-left-color: #c0c4cc !important;
}
}
.voice-msg {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
.voice-play-btn {
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;
}
}
}
width: 100%;
min-height: 44rpx;
.voice-duration {
flex-shrink: 0;
font-size: 26rpx;
color: #fff;
font-size: 30rpx;
color: #1f1f1f;
font-variant-numeric: tabular-nums;
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): 语音 + 文字混合时, 文字写在语音列表下方
与语音行共用主色背景, 顶部分割线划开两块内容 */
.voice-bubble-text {
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 {
font-size: 28rpx;
color: #fff;
color: #1f1f1f;
line-height: 1.5;
word-break: break-word;
}