From 6f4f167efd12a712c47bdf82210ec52f08cbba68 Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Mon, 7 Sep 2026 23:18:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(engine):=20voice=20=E5=8F=A5=E5=B0=BE?= =?UTF-8?q?=E5=89=AA=E5=AD=97=E7=9C=9F=E6=A0=B9=E5=9B=A0=E6=A0=B9=E6=B2=BB?= =?UTF-8?q?=EF=BC=88=E4=B8=89=E4=BF=AE=EF=BC=9A=E6=BA=90=E5=B0=BE=E5=9E=AB?= =?UTF-8?q?=E9=9D=99=E9=9F=B3=E5=86=B2=E5=88=B7=20SoundTouch=20=E6=9C=AA?= =?UTF-8?q?=E5=86=B2=E5=88=B7=E5=B0=8F=E8=8A=82=EF=BC=89+=20onEnd=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BF=A1=E5=8F=B7=20+=20live=20=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 真根因(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 --- HANDOVER.md | 2 +- docs/web-engine-api.md | 16 ++-- web/engine/audio.js | 104 ++++++++++++++------- web/harness/engine-voice.test.mjs | 144 ++++++++++++++++++------------ web/harness/live/inject.js | 16 +++- 5 files changed, 182 insertions(+), 100 deletions(-) diff --git a/HANDOVER.md b/HANDOVER.md index 6e5d646c..e0a82674 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -277,7 +277,7 @@ source env.d/mureka.sh |---|---|---| | ~~引擎无公开 `isVoicePlaying`~~ | **已解决(R7)**:`audio.isVoicePlaying()` 公开,游戏层全部收编 | — | | ~~长按快进无 per-game 开关~~ | **已解决(R7)**:`audio.setRateBoostEnabled(false)`,rhythm/space 演奏期已豁免 | — | -| ~~voice 句尾剪字(每句末尾音节缺失)~~ | **已根治(2026-09 真机人耳反馈)**:旧结束定时器(墙钟+120ms)< 管线固有延迟 ~190ms,句尾音节尚在 SPN/SoundTouch 缓冲即被 disconnect。现 `_finishVoice` 以源真实消耗位置(`filter.sourcePosition`)判定 + 300ms 排空窗;回归 `web/harness/engine-voice.test.mjs` 三不变式钉死;真机 A/B(同 id 节点存活 +0.02s→+0.33s)验证 | 引擎层已固化,新游戏零改动继承;合成侧经 ASR 抽查排除剪字(尾静默紧是裁剪工艺,判读口径见 `tools/audit_voice_tails.py` 头注) | +| ~~voice 句尾剪字(每句末尾音节缺失)~~ | **已根治(2026-09 真机人耳反馈,三修)**:一修固定排空窗(手机端未交付量浮动,不够);二修 onEnd 完成信号(仍不解决交付缺失);三修定位真根因——**SoundTouch Stretch 源结束不冲刷最后一个小节,句尾 0.1-0.25s 任何平台都不会播出**,根治 = 源尾垫 0.25s 静音推出真实句尾 + onEnd 驱动完成 + 1.5s 兜底。回归 `web/harness/engine-voice.test.mjs`(PAD+I1-I3);live 验证 span 8.57s→9.04s(≥文件时长 8.80s) | 引擎层已固化,新游戏零改动继承;live 探针同步适配(清扫器守卫);合成侧经 ASR 抽查排除剪字(尾静默紧是裁剪工艺,判读口径见 `tools/audit_voice_tails.py` 头注) | | 向导 earcon 为运行时合成音 | 音色是「可用」级不是「精美」级(正弦/三角/方波包络) | 若升级为录制品 earcon:保持 8 音词表与参数边界不变(14 号文档 §2),只换渲染后端 | | escape「需要帮助吗」问而不等 | P1 终评唯一「擦边当白痴」时刻 | v2 手势宪法专项:真问答 + PLAY 期目标重听入口 | | space/simon 无「局」与结算仪式;storyline 链尾无轻量 turn 信号、二周目重播开场教学;rhythm 教学首误先震后教 | round4 终评 P0 全部裁定入 v2(research/04-playtests/round4/synthesis.md §2) | L5 会话层专项 + 微 polish 清单 | diff --git a/docs/web-engine-api.md b/docs/web-engine-api.md index b91aafe6..6eb74a8d 100644 --- a/docs/web-engine-api.md +++ b/docs/web-engine-api.md @@ -164,13 +164,15 @@ new WebAudioEngine(basePath = 'audio/', opts?) `vendor/soundtouch.js`(SoundTouch 0.3.0,LGPL v2.1,ScriptProcessorNode 实现):`WebAudioBufferSource → SimpleFilter(SoundTouch tempo) → ScriptProcessorNode → gain → voiceBus`。`settings.speed` 只改 tempo 不改 -pitch,1.0/1.35/1.7 均不变调;自然结束以「源真实消耗位置」(SoundTouch -source.position)为准——源未耗尽时定时器只做短重排等待,耗尽后再留 -300ms 排空窗让管线缓冲的尾音播出,然后才 disconnect(2026-09 真机人耳 -反馈修复:旧实现纯墙钟定时器 +120ms 一到即断,管线固有延迟 ~190ms 意味着 -每句尾字被剪)。onComplete/打断/排队语义不变。注意 voice 有 -~100ms 处理延迟(对白场景无感),且 ScriptProcessorNode 已废弃但全平台 -(含 iOS Safari)可用。 +pitch,1.0/1.35/1.7 均不变调。**自然结束(2026-09 三修,真机人耳反馈)**: +① 播放源尾部垫 0.25s 静音——SoundTouch Stretch 源结束时不冲刷内部最后 +一个小节,句尾 0.1-0.25s 若不垫将永远停留在管线里不播出(任何平台); +② 完成信号 = 管线 onEnd(extract 返回 0 = 已无未交付帧),平台无关, +此时 disconnect 已交付帧仍会播完;③ onEnd 异常缺失时 1.5s 定时器兜底 +(宁迟勿剪);④ 源未耗尽时定时器只做 80ms 短重排等待。onComplete/打断/ +排队语义不变。回归:web/harness/engine-voice.test.mjs(PAD+I1-I3 四不变式)。 +注意 voice 有 ~100ms 处理延迟(对白场景无感),且 ScriptProcessorNode +已废弃但全平台(含 iOS Safari)可用。 **长按快进(游戏零改动)**:voice 播放中长按屏幕 → 旁白临时 1.7 倍速 (不变调),松开恢复 `settings.speed`。由 gesture.js 派发的 window 事件 diff --git a/web/engine/audio.js b/web/engine/audio.js index e54f1df7..942a5840 100644 --- a/web/engine/audio.js +++ b/web/engine/audio.js @@ -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); } diff --git a/web/harness/engine-voice.test.mjs b/web/harness/engine-voice.test.mjs index 4bec463d..a35d61b2 100644 --- a/web/harness/engine-voice.test.mjs +++ b/web/harness/engine-voice.test.mjs @@ -1,20 +1,25 @@ #!/usr/bin/env node /** - * engine-voice.test.mjs — 引擎 voice 句尾保真回归(2026-09 真机人耳反馈固化)。 + * engine-voice.test.mjs — 引擎 voice 句尾保真回归(2026-09 真机人耳反馈三修固化)。 * - * 固化的教训:voice 自然结束**不能**用墙钟定时器判定(SPN 4096帧@48k≈85ms + - * SoundTouch 固有延迟 ≈190ms > 任何小余量),必须以「源真实消耗位置」为准, - * 耗尽后留排空窗再 disconnect。本测试用可手动驱动的 ScriptProcessorNode mock - * 模拟真实消耗,把该语义钉死,防止未来重构回潮。 + * 固化的教训(三轮排查递进): + * 一修(废止):源消耗位置 + 固定 300ms 排空窗。手机端未交付量随平台回调 + * 相位浮动(~0.5s),末词仍被剪。 + * 二修(保留):完成信号改用 SoundTouch onEnd(extract 返回 0 = 管线内已无 + * 未交付帧),平台无关;定时器仅剩「源未耗尽等待」与「onEnd 缺失兜底」。 + * 三修(根治):live 实测发现 SoundTouch Stretch 源结束时不冲刷内部最后 + * 一个小节——句尾 0.1-0.25s **根本不会被交付**(任何平台,ASR 因容错听不出)。 + * 根治 = 源尾垫 VOICE_TAIL_PAD_SEC(0.25s) 静音把真实句尾推出管线。 * - * 三条不变式: - * I1 源未耗尽时,_finishVoice 提前触发不得结算(必须短重排等待); - * I2 源耗尽后第一次触发只挂排空旗,排空窗(VOICE_DRAIN_MS)过后才真结算; - * I3 消耗位置恒为 0(playtest mock / SPN 首回调未到)走旧立即结算路径。 + * 四条不变式: + * I1 源未耗尽时,_finishVoice 提前触发不得结算(短重排等待); + * I2 源(含尾部垫)耗尽后须等 onEnd(管线真排空)才结算;onEnd 一到立即(≤~120ms)结算; + * I2b onEnd 异常缺失时,兜底定时器在 VOICE_DRAIN_FALLBACK_MS 后结算(宁迟勿剪); + * I3 消耗位置恒 0(playtest mock / SPN 首回调未到)走旧立即结算路径。 + * 附加 PAD:播放源长度 = 原时长 + 0.25s(尾部静音垫存在性)。 * * 运行:tools/node/bin/node web/harness/engine-voice.test.mjs */ -import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -26,29 +31,29 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ---- 全局 mock(在 import 引擎前就位)---- const storageMap = new Map(); -globalThis.window = { - AudioContext: null, // 下面赋值 - addEventListener() {}, -}; +globalThis.window = { AudioContext: null, addEventListener() {} }; globalThis.localStorage = { getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null), setItem: (k, v) => storageMap.set(k, String(v)), removeItem: (k) => storageMap.delete(k), }; globalThis.document = { addEventListener() {}, getElementById: () => null, body: { appendChild() {}, classList: { add() {}, remove() {} } } }; -try { globalThis.navigator = { userAgent: 'test', vibrate: () => true }; } catch (_) { /* Node22 自带只读 navigator */ } +try { globalThis.navigator = { userAgent: 'test', vibrate: () => true }; } catch (_) { /* Node22 只读 navigator */ } globalThis.performance = globalThis.performance || { now: () => Date.now() }; const SR = 44100; -const DUR = 2.0; // 测试语音 2 秒 +const DUR = 2.0; // 解码出的原始时长 +const PAD = 0.25; // 引擎尾部静音垫(VOICE_TAIL_PAD_SEC) +const SRC_DUR = DUR + PAD; // 播放源总时长 const FRAMES = Math.round(SR * DUR); +const SRC_FRAMES = Math.round(SR * SRC_DUR); class FakeParam { constructor(v = 1) { this.value = v; } setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {} } class DrivableSPN { constructor() { this.connect = () => {}; this.disconnectCount = 0; this.onaudioprocess = null; } disconnect() { this.disconnectCount++; } - /** 模拟一次真实音频回调:SoundTouch 从源拉取至多 4096 帧 */ + /** 模拟一次真实音频回调:SoundTouch 从源拉取至多 4096 帧交付音频图 */ pump() { if (!this.onaudioprocess) return 0; const out = { outputBuffer: { getChannelData: () => new Float32Array(4096 * 2) } }; @@ -63,74 +68,99 @@ class MockAudioContext { createGain() { return { gain: new FakeParam(), connect() {}, disconnect() {} }; } createDynamicsCompressor() { const n = this.createGain(); n.threshold = new FakeParam(); n.ratio = new FakeParam(); n.attack = new FakeParam(); n.release = new FakeParam(); return n; } createScriptProcessor() { return new DrivableSPN(); } + /** 引擎三修:源尾垫静音需要 createBuffer + copyToChannel */ + createBuffer(channels, length, sampleRate) { + const chans = Array.from({ length: channels }, () => new Float32Array(length)); + return { + numberOfChannels: channels, length, sampleRate, duration: length / sampleRate, + getChannelData: (i) => chans[i], + copyToChannel(src, ch) { chans[ch].set(src); }, + }; + } decodeAudioData() { return Promise.resolve({ - duration: DUR, sampleRate: SR, numberOfChannels: 1, - getChannelData: (i) => new Float32Array(FRAMES), // 真实长度:SoundTouch extract 按帧索引 + duration: DUR, length: FRAMES, sampleRate: SR, numberOfChannels: 1, + getChannelData: () => new Float32Array(FRAMES), }); } resume() { this.state = 'running'; return Promise.resolve(); } } globalThis.window.AudioContext = MockAudioContext; - globalThis.fetch = async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(8) }); -// ---- 导入真实引擎 ---- const { WebAudioEngine } = await import(path.join(WEB, 'engine', 'audio.js')); +const FALLBACK_MS = 1500; const engine = new WebAudioEngine('audio/'); await engine.unlock(); const completed = []; -const makeOnComplete = (tag) => () => completed.push(tag); + +console.log('\n=== PAD:播放源存在尾部静音垫 ==='); +engine.play('voice/tail_pad', { onComplete: () => completed.push('P') }); +await sleep(30); +let token = engine._voice; +assert(token && token.srcBuf && Math.abs(token.srcBuf.duration - SRC_DUR) < 0.01, + `播放源 = 原时长+0.25s 垫(${token.srcBuf.duration.toFixed(2)}s = ${DUR}+${PAD})`); +engine.stopVoice(); engine._voice = null; completed.length = 0; console.log('\n=== I1 源未耗尽:提前触发不结算 ==='); -engine.play('voice/tail_test', { onComplete: makeOnComplete('A') }); -await sleep(30); // 等 _playVoice 的 await _load 落地 -let token = engine._voice; +engine.play('voice/tail_test', { onComplete: () => completed.push('A') }); +await sleep(30); +token = engine._voice; assert(token && token.buf && token.filter, 'voice token 已建立(含 filter 引用)'); -for (let i = 0; i < 3; i++) token.node.pump(); // 消耗 ~12288 帧 ≈ 0.28s << 2s -const consumedEarly = token.filter.sourcePosition / token.buf.sampleRate; -assert(consumedEarly > 0 && consumedEarly < DUR - 0.02, `消耗位置已推进且未耗尽(${consumedEarly.toFixed(2)}s / ${DUR}s)`); -engine._finishVoice(token); // 模拟旧式墙钟定时器提前到点 +for (let i = 0; i < 3; i++) token.node.pump(); +const consumedEarly = token.filter.sourcePosition / token.srcBuf.sampleRate; +assert(consumedEarly > 0 && consumedEarly < token.srcBuf.duration - 0.02, + `消耗位置已推进且未耗尽(${consumedEarly.toFixed(2)}s / ${SRC_DUR}s)`); +engine._finishVoice(token); assert(engine._voice === token && !token.stopped, 'I1 未耗尽时不结算(token 仍存活,等待重排)'); assert(completed.length === 0, 'I1 onComplete 未被提前调用'); -console.log('\n=== I2 源耗尽:排空窗过后才真结算 ==='); +console.log('\n=== I2 源(含垫)耗尽:onEnd(管线真排空)才结算 ==='); let guard = 0; -while (token.filter.sourcePosition / SR < DUR && guard++ < 200) token.node.pump(); -const consumedFull = token.filter.sourcePosition / token.buf.sampleRate; -assert(consumedFull >= DUR - 0.02, `源已耗尽(${consumedFull.toFixed(2)}s)`); -if (token.endTimer !== null) { clearTimeout(token.endTimer); token.endTimer = null; } // 摘掉墙钟定时器,纯手动驱动 -const t0 = Date.now(); -engine._finishVoice(token); // 第一次:应只挂排空旗 -assert(token.drained === true && engine._voice === token, 'I2 耗尽后第一触发只置排空旗(不结算)'); -await sleep(VOICE_DRAIN_PLUS()); // 排空窗 -engine._finishVoice(token); // 第二次:真结算 -assert(engine._voice === null && token.stopped, 'I2 排空窗后真结算(_voice 清空)'); +while (token.filter.sourcePosition < SRC_FRAMES && guard++ < 400) token.node.pump(); +token.node.pump(); // SPN 源耗尽后继续回调 → vendor extract 返回 0 → onEnd +assert(token.streamEnded === true, 'I2 源耗尽后 SPN 继续回调触发 onEnd(filter.callback 已接线)'); +await sleep(120); +assert(engine._voice === null && token.stopped, 'I2 onEnd 后短时间内真结算(≤120ms)'); assert(completed.includes('A'), 'I2 onComplete 恰好回调一次'); assert(token.node.disconnectCount >= 1, 'I2 结算时节点已 disconnect'); -assert(Date.now() - t0 >= 280, `I2 排空窗确实等待了 ≥280ms(实测 ${Date.now() - t0}ms)`); -function VOICE_DRAIN_PLUS() { return 380; } + +console.log('\n=== I2b 无 onEnd 的异常平台:兜底定时器结算(不剪) ==='); +engine.play('voice/tail_nofb', { onComplete: () => completed.push('B') }); +await sleep(30); +const t2 = engine._voice; +guard = 0; +while (t2.filter.sourcePosition < SRC_FRAMES && guard++ < 400) t2.node.pump(); // 耗尽但不再多 pump(无 onEnd) +if (t2.endTimer !== null) { clearTimeout(t2.endTimer); t2.endTimer = null; } +const fb0 = Date.now(); +engine._finishVoice(t2); // 墙钟路径:drained + 兜底 +assert(t2.drained === true && engine._voice === t2, 'I2b 耗尽后墙钟触发只挂兜底(不结算)'); +await sleep(400); +assert(engine._voice === t2, 'I2b 兜底期内(400ms < 1500ms)仍不结算'); +await sleep(FALLBACK_MS - 400 + 150); +assert(engine._voice === null && t2.stopped, `I2b 兜底触发后结算(实测 ${Date.now() - fb0}ms ≥ ${FALLBACK_MS})`); +assert(completed.includes('B'), 'I2b onComplete 经兜底回调'); console.log('\n=== I3 消耗位置恒 0(mock/SPN 未回调):旧立即结算路径 ==='); -engine.play('voice/tail_zero', { onComplete: makeOnComplete('B') }); -await sleep(30); -const token2 = engine._voice; -assert(token2 && token2.filter.sourceSound.position === 0, '第二句从未被 pump(position=0)'); -engine._finishVoice(token2); -assert(engine._voice === null && token2.stopped, 'I3 position=0 时立即结算(playtest mock 兼容,链推进不受影响)'); -assert(completed.includes('B'), 'I3 onComplete 立即回调'); - -console.log('\n=== 附加:打断语义不受影响(新句顶掉旧句,旧句 onComplete 不回调) ==='); -engine.play('voice/tail_test2', { onComplete: makeOnComplete('C1') }); +engine.play('voice/tail_zero', { onComplete: () => completed.push('C') }); await sleep(30); const t3 = engine._voice; -for (let i = 0; i < 3; i++) t3.node.pump(); -engine.play('voice/tail_test3', { onComplete: makeOnComplete('C2') }); +assert(t3 && t3.filter.sourcePosition === 0, '该句从未被 pump(position=0)'); +engine._finishVoice(t3); +assert(engine._voice === null && t3.stopped, 'I3 position=0 时立即结算(playtest mock 兼容)'); +assert(completed.includes('C'), 'I3 onComplete 立即回调'); + +console.log('\n=== 附加:打断语义不受影响(新句顶掉旧句,旧句 onComplete 不回调) ==='); +engine.play('voice/tail_test2', { onComplete: () => completed.push('D1') }); await sleep(30); -assert(engine._voice && engine._voice !== t3 && t3.stopped, '新 voice 顶掉旧 voice(旧 token 已停)'); -assert(!completed.includes('C1'), '被打断的旧句不回调 onComplete(排队链语义保持)'); -engine._finishVoice(engine._voice); if (engine._voice) { await sleep(380); engine._finishVoice(engine._voice); } +const t4 = engine._voice; +for (let i = 0; i < 3; i++) t4.node.pump(); +engine.play('voice/tail_test3'); +await sleep(30); +assert(engine._voice && engine._voice !== t4 && t4.stopped, '新 voice 顶掉旧 voice(旧 token 已停)'); +assert(!completed.includes('D1'), '被打断的旧句不回调 onComplete(排队链语义保持)'); +assert(t4.streamEnded === false, '被打断句不触发 onEnd 结算路径'); engine.stopVoice(); engine._voice = null; diff --git a/web/harness/live/inject.js b/web/harness/live/inject.js index 9addd149..94be687a 100644 --- a/web/harness/live/inject.js +++ b/web/harness/live/inject.js @@ -141,8 +141,8 @@ }; const AC = window.AudioContext || window.webkitAudioContext; + const voiceNodes = new Set(); // 连接中的 SoundTouch ScriptProcessorNode(断开即移除) if (AC) { - const voiceNodes = new WeakSet(); // SoundTouch 管线创建的 ScriptProcessorNode const origCSP = AC.prototype.createScriptProcessor; AC.prototype.createScriptProcessor = function (...a) { const n = origCSP.apply(this, a); @@ -153,12 +153,20 @@ AudioNode.prototype.disconnect = function (...a) { // voice 节点断开 = 引擎 killVoice(自然结束/被顶掉/stopVoice 都走这里)。 // 玩家实际听到的尾音就到此刻(FIFO 里未播部分被丢弃),用 now() 而非 lastAct。 - if (voiceNodes.has(this) && seg) closeSeg(now(), 'kill'); + if (voiceNodes.has(this)) { + voiceNodes.delete(this); + if (seg) closeSeg(now(), 'kill'); + } return origDisc.apply(this, a); }; } - // 兜底清扫:长静默后关闭未结段 - setInterval(() => { if (seg && now() - seg.lastAct > VOICE_GAP_MS) closeSeg(seg.lastAct, 'end'); }, 200); + // 兜底清扫:长静默后关闭未结段。 + // 守卫(2026-09 三修适配):引擎的尾部静音垫让 SoundTouch 读的是新建 + // buffer(未注册 url),gcd 抽读信号消失、lastAct 停在句首——只要仍有 + // 连接中的语音 SPN 就说明句在播,不许清扫;段关闭只信 disconnect。 + setInterval(() => { + if (seg && now() - seg.lastAct > VOICE_GAP_MS && voiceNodes.size === 0) closeSeg(seg.lastAct, 'end'); + }, 200); } catch (e) { push({ ev: 'probe_error', where: 'voice-detect', msg: String(e) }); } // ---------- AudioBufferSourceNode:bgm/sfx 播放事件 ----------