fix(engine): voice 句尾剪字真根因根治(三修:源尾垫静音冲刷 SoundTouch 未冲刷小节)+ onEnd 完成信号 + live 探针适配
- 真根因(live span 8.57s < 文件 8.80s 铁证):SoundTouch Stretch 源结束时不冲刷 内部最后一个小节(~1 sequence),句尾 0.1-0.25s 从未交付音频图——任何平台皆然, ASR 因容错转写仍 sim=1(转写评审盲区的又一实证)。一修固定 300ms 排空窗只是 把这段不存在的交付空等成静音;二修 onEnd 保证「已交付必播完」但交付不了这段 - 三修:源尾垫 VOICE_TAIL_PAD_SEC(0.25s) 静音把真实句尾推出管线;完成 = onEnd (平台无关,disconnect 已交付帧仍播);1.5s 兜底;顶掉复核移至新句数据访问前 (垫长拷贝的 getChannelData 先于杀旧句会打乱探针时序) - live 探针适配:尾部垫使 SoundTouch 读未注册 buffer、gcd 抽读信号消失—— 清扫器加「仍有连接中语音 SPN 则不关段」守卫,断开即移出追踪集合 - engine-voice.test:PAD+I1/I2/I2b/I3 五组 19 断言(含垫长存在性、onEnd 结算、 兜底不早于 1.5s);10 套件全绿 - live 复验(男声 p1):span 8.57→9.04s(≥文件 8.80s),ASR sim=1 verdict=OK
This commit is contained in:
+73
-31
@@ -56,7 +56,9 @@ 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 内部延迟)
|
||||
const VOICE_TAIL_PAD_SEC = 0.25; // 源尾静音垫:把句尾推出 SoundTouch 未冲刷的最后小节(三修)
|
||||
const VOICE_STREAM_EPS_MS = 30; // onEnd(管线已全部交付)后的小缓冲再结算
|
||||
const VOICE_DRAIN_FALLBACK_MS = 1500; // onEnd 未触发时的兜底结算上限(宁迟勿剪)
|
||||
|
||||
/**
|
||||
* 语音风暴保险丝(dungeon R2 D0-1,全线保护):同一 voiceId 在 3s 窗口内被调度
|
||||
@@ -454,9 +456,39 @@ export class WebAudioEngine {
|
||||
const buf = await this._load(id);
|
||||
if (!buf) { this._unduckBgm(); return; }
|
||||
|
||||
// 顶掉语义必须在 await 之后、新句任何音频数据被访问之前复核(dungeon R2
|
||||
// D0-1 根因修复):_load 期间可能又有一句 voice 开播(同 tick 双调度)。
|
||||
// 若此处不补杀,早先的 token 会被 this._voice = token 静默顶号——节点
|
||||
// 永远无人 disconnect(僵尸节点空转抽 buffer),endTimer 也因 _finishVoice
|
||||
// 的 token 校验提前返回而永不清理(R1 live:71 分钟僵尸节点抢占风暴)。
|
||||
// 顺序要求(三修补充):必须先杀旧句再读新句 buffer(垫长拷贝会调
|
||||
// getChannelData),否则探针会先记新句 vstart 再被旧句 disconnect 误关。
|
||||
if (this._voice) {
|
||||
const cur = this._voice;
|
||||
this._voice = null;
|
||||
this._killVoice(cur);
|
||||
}
|
||||
|
||||
const st = new SoundTouch();
|
||||
st.tempo = settings.get().speed; // 语速(不变调,下一句生效)
|
||||
const filter = new SimpleFilter(new WebAudioBufferSource(buf), st);
|
||||
// 尾部保真(三修,2026-09 真机人耳反馈):SoundTouch Stretch 在源结束时
|
||||
// 不冲刷内部最后一个小节(~1 个 sequence),句尾 0.1-0.25s 永远停留在
|
||||
// 管线里不会播出(桌面/手机皆然;一修的固定排空窗与二修的 onEnd 都只能
|
||||
// 保证「已交付的播完」,交付不了这段)。根治 = 源尾垫 VOICE_TAIL_PAD_SEC
|
||||
// 静音,把真实句尾完整推出管线;垫的静音走完后 onEnd 才触发,衔接节奏
|
||||
// 与旧实现的排空窗相当,多出的静音不改变听感。
|
||||
// ctx.createBuffer 缺失(playtest mock)时跳过垫长——mock 走 consumed=0
|
||||
// 旧路径,语义不受影响。
|
||||
let srcBuf = buf;
|
||||
if (typeof this.ctx.createBuffer === 'function') {
|
||||
const padFrames = Math.ceil(VOICE_TAIL_PAD_SEC * buf.sampleRate);
|
||||
srcBuf = this.ctx.createBuffer(buf.numberOfChannels, buf.length + padFrames, buf.sampleRate);
|
||||
for (let ch = 0; ch < buf.numberOfChannels; ch++) {
|
||||
if (srcBuf.copyToChannel) srcBuf.copyToChannel(buf.getChannelData(ch), ch);
|
||||
else srcBuf.getChannelData(ch).set(buf.getChannelData(ch));
|
||||
}
|
||||
}
|
||||
const filter = new SimpleFilter(new WebAudioBufferSource(srcBuf), st);
|
||||
const node = getWebAudioNode(this.ctx, filter); // ScriptProcessorNode,接入即开播
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.value = this.voiceVolume * volume;
|
||||
@@ -464,50 +496,60 @@ export class WebAudioEngine {
|
||||
gain.connect(this._voiceBus);
|
||||
|
||||
const token = {
|
||||
node, gain, st, buf, filter,
|
||||
node, gain, st, buf, filter, srcBuf, // srcBuf = 尾部垫静音后的播放源
|
||||
stopped: false,
|
||||
streamEnded: false, // SoundTouch onEnd:管线内帧已全部交付音频图
|
||||
onComplete,
|
||||
posSec: 0, // 已消耗的源时长(秒,按 tempo 累计)
|
||||
lastT: performance.now(),
|
||||
endTimer: null,
|
||||
};
|
||||
// 顶掉语义必须在 await 之后复核(dungeon R2 D0-1 根因修复):_load 期间
|
||||
// 可能又有一句 voice 开播(同 tick 双调度,如"反馈句+下一步指令句")。
|
||||
// 若此处不补杀,早先的 token 会被 this._voice = token 静默顶号——它的
|
||||
// 节点永远没人 disconnect(僵尸节点空转抽 buffer),endTimer 也因
|
||||
// _finishVoice 的 token 校验提前返回而永不清理,语音引擎自此被"幽灵句"
|
||||
// 污染到页面关闭(R1 live:tut_echo_come 僵尸节点致 71 分钟抢占风暴)。
|
||||
if (this._voice) {
|
||||
const cur = this._voice;
|
||||
this._voice = null;
|
||||
this._killVoice(cur);
|
||||
}
|
||||
// 尾部保真(二修):完成信号用管线自带的 onEnd(extract 返回 0 =
|
||||
// SoundTouch 内部已无未交付帧),它在任何平台都不可能早于「最后一个
|
||||
// 样本已交付」——此时 disconnect 已交付帧仍会播完。固定排空窗
|
||||
// (一修 300ms)在手机端不够(未交付量随平台回调相位浮动),弃用。
|
||||
filter.callback = () => this._onVoiceStreamEnd(token);
|
||||
this._voice = token;
|
||||
this._duckBgm();
|
||||
this._armVoiceEnd(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* SoundTouch onEnd:管线内帧已全部交付音频图(ScriptProcessor 抽空)。
|
||||
* 这是「句尾已真正送进硬件」的平台无关信号——再断开不可能剪字。
|
||||
* 注意它在 onaudioprocess 内同步触发,结算延后一个小缓冲执行。
|
||||
*/
|
||||
_onVoiceStreamEnd(token) {
|
||||
if (token.streamEnded || token.stopped) return;
|
||||
token.streamEnded = true;
|
||||
if (this._voice !== token) return; // 已被顶掉:由 _killVoice 收尾
|
||||
if (token.endTimer !== null) { clearTimeout(token.endTimer); token.endTimer = null; }
|
||||
token.endTimer = setTimeout(() => this._finishVoice(token), VOICE_STREAM_EPS_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算 voice 自然播完(onComplete 链 + 队列推进)。
|
||||
* 尾部保真:以「源真实消耗位置」(SoundTouch 源 position,SPN 回调推进)为准,
|
||||
* 不信墙钟估算——管线有 ~200ms 固有延迟且消耗速率受主线程抖动/后台节流影响,
|
||||
* 旧实现纯定时器 +120ms 一到就 disconnect,仍留在管线缓冲里的句尾音节会被
|
||||
* 剪掉(真机人耳实测每句尾字缺失;ASR 窗口宽看不到,转写评审也看不到)。
|
||||
* consumedSec==0(mock 环境/首个 SPN 回调未到)走旧定时器路径,零行为变化。
|
||||
* 完成优先级:streamEnded(管线排空信号)> 定时器兜底。
|
||||
* 定时器路径仅用于:①源未耗尽时等待(80ms 重排);②onEnd 在异常平台
|
||||
* 未触发时的兜底(VOICE_DRAIN_FALLBACK_MS,宁迟勿剪)。
|
||||
* consumedSec==0(mock 环境/SPN 首回调未到)走旧立即结算,零行为变化。
|
||||
*/
|
||||
_finishVoice(token) {
|
||||
if (this._voice !== token || token.stopped) return; // 被打断:不回调
|
||||
const consumedSec = token.filter.sourcePosition / 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;
|
||||
const consumedSec = token.filter.sourcePosition / token.srcBuf.sampleRate;
|
||||
if (consumedSec > 0 && !token.streamEnded) {
|
||||
if (consumedSec < token.srcBuf.duration - 0.02) {
|
||||
// 源未耗尽:墙钟先到/主线程卡顿/后台节流——等真实消耗追上再结算
|
||||
token.endTimer = setTimeout(() => this._finishVoice(token), 80);
|
||||
return;
|
||||
}
|
||||
if (!token.drained) {
|
||||
// 源已耗尽但 onEnd 未至:挂兜底定时器,等管线排空信号
|
||||
token.drained = true;
|
||||
token.endTimer = setTimeout(() => this._finishVoice(token), VOICE_DRAIN_FALLBACK_MS);
|
||||
return;
|
||||
}
|
||||
// 兜底触发(onEnd 异常缺失):宁可迟结算也不剪尾
|
||||
}
|
||||
token.stopped = true;
|
||||
this._voice = null;
|
||||
@@ -520,7 +562,7 @@ export class WebAudioEngine {
|
||||
|
||||
/** 按当前 tempo 排结束定时器;tempo 变化时重排。 */
|
||||
_armVoiceEnd(token) {
|
||||
const remainSec = Math.max(0.05, (token.buf.duration - token.posSec) / token.st.tempo);
|
||||
const remainSec = Math.max(0.05, (token.srcBuf.duration - token.posSec) / token.st.tempo);
|
||||
token.lastT = performance.now();
|
||||
token.endTimer = setTimeout(() => this._finishVoice(token), remainSec * 1000 + 120);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user