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:
xsl
2026-09-06 13:25:20 +08:00
parent 45124a1b9b
commit da01c2d20b
2 changed files with 27 additions and 4 deletions
+22 -2
View File
@@ -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);