fix(engine): voice 句尾剪字根治——自然结束改以源真实消耗位置判定 + 300ms 管线排空窗(用户真机人耳反馈)
- 根因:结束定时器=墙钟估算+120ms,但 SPN(4096帧@48k≈85ms)+SoundTouch 固有延迟≈190ms, disconnect 时句尾音节仍在管线缓冲里从未播出(每句系统性剪尾;ASR 窗口宽、转写评审均不可见, 属研究章程写明的「真机人耳」盲区,为首个真人耳测反馈) - 修法:_finishVoice 三段判定——源未耗尽→80ms 短重排等待;耗尽→300ms 排空;排空毕→真结算。 consumedSec==0(playtest mock/SPN 首回调未到)走旧路径,9 套件+guide 全绿零扰动 - 合成侧排除:2786 个 voice 文件尾部静默审计 + 5 个最可疑文件(尾静默 0-16ms)ASR 逐句验证, 末字/语气词完整(「…是不是我做错了什么呀?」)——资产无剪字,仅静音尾裁剪紧 - 真机 A/B(radio 确定性链,修复前后同 id 对比):节点存活从「文件时长+0.02s」→「+0.33s」, 五句全部一致;web-engine-api.md 同步
This commit is contained in:
+22
-2
@@ -56,6 +56,7 @@ import { SoundTouch, SimpleFilter, WebAudioBufferSource, getWebAudioNode } from
|
||||
|
||||
const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav'];
|
||||
const LONGPRESS_BOOST = 1.7; // 长按快进倍速
|
||||
const VOICE_DRAIN_MS = 300; // voice 源耗尽后的管线排空窗(SPN 4096帧@48k≈85ms + SoundTouch 内部延迟)
|
||||
|
||||
/**
|
||||
* 语音风暴保险丝(dungeon R2 D0-1,全线保护):同一 voiceId 在 3s 窗口内被调度
|
||||
@@ -463,7 +464,7 @@ export class WebAudioEngine {
|
||||
gain.connect(this._voiceBus);
|
||||
|
||||
const token = {
|
||||
node, gain, st, buf,
|
||||
node, gain, st, buf, filter,
|
||||
stopped: false,
|
||||
onComplete,
|
||||
posSec: 0, // 已消耗的源时长(秒,按 tempo 累计)
|
||||
@@ -486,9 +487,28 @@ export class WebAudioEngine {
|
||||
this._armVoiceEnd(token);
|
||||
}
|
||||
|
||||
/** 结算 voice 自然播完(onComplete 链 + 队列推进)。 */
|
||||
/**
|
||||
* 结算 voice 自然播完(onComplete 链 + 队列推进)。
|
||||
* 尾部保真:以「源真实消耗位置」(SoundTouch 源 position,SPN 回调推进)为准,
|
||||
* 不信墙钟估算——管线有 ~200ms 固有延迟且消耗速率受主线程抖动/后台节流影响,
|
||||
* 旧实现纯定时器 +120ms 一到就 disconnect,仍留在管线缓冲里的句尾音节会被
|
||||
* 剪掉(真机人耳实测每句尾字缺失;ASR 窗口宽看不到,转写评审也看不到)。
|
||||
* consumedSec==0(mock 环境/首个 SPN 回调未到)走旧定时器路径,零行为变化。
|
||||
*/
|
||||
_finishVoice(token) {
|
||||
if (this._voice !== token || token.stopped) return; // 被打断:不回调
|
||||
const consumedSec = token.filter.sourceSound.position / token.buf.sampleRate;
|
||||
if (consumedSec > 0 && consumedSec < token.buf.duration - 0.02) {
|
||||
// 源未耗尽:墙钟先到/主线程卡顿/后台节流——等真实消耗追上再结算
|
||||
token.endTimer = setTimeout(() => this._finishVoice(token), 80);
|
||||
return;
|
||||
}
|
||||
if (consumedSec > 0 && !token.drained) {
|
||||
// 源已耗尽:留一个排空窗,让已进入 SPN/SoundTouch 缓冲的尾音真正播出
|
||||
token.drained = true;
|
||||
token.endTimer = setTimeout(() => this._finishVoice(token), VOICE_DRAIN_MS);
|
||||
return;
|
||||
}
|
||||
token.stopped = true;
|
||||
this._voice = null;
|
||||
this._killVoice(token);
|
||||
|
||||
Reference in New Issue
Block a user