Files
SoundGame/web/harness/engine-voice.test.mjs
T
xsl 6f4f167efd 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
2026-09-07 23:18:11 +08:00

171 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* engine-voice.test.mjs — 引擎 voice 句尾保真回归(2026-09 真机人耳反馈三修固化)。
*
* 固化的教训(三轮排查递进):
* 一修(废止):源消耗位置 + 固定 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 源(含尾部垫)耗尽后须等 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 path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const WEB = path.resolve(HERE, '..');
let passed = 0; const failures = [];
const assert = (cond, msg) => { if (cond) { passed++; console.log(' ✓ ' + msg); } else { failures.push(msg); console.log(' ✗ ' + msg); } };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---- 全局 mock(在 import 引擎前就位)----
const storageMap = new Map();
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 */ }
globalThis.performance = globalThis.performance || { now: () => Date.now() };
const SR = 44100;
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 帧交付音频图 */
pump() {
if (!this.onaudioprocess) return 0;
const out = { outputBuffer: { getChannelData: () => new Float32Array(4096 * 2) } };
this.onaudioprocess(out);
return 1;
}
}
class MockAudioContext {
constructor() { this.state = 'running'; this.destination = {}; this.sampleRate = SR; }
get currentTime() { return performance.now() / 1000; }
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, 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 = [];
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: () => 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();
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 源(含垫)耗尽:onEnd(管线真排空)才结算 ===');
let guard = 0;
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');
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: () => completed.push('C') });
await sleep(30);
const t3 = engine._voice;
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);
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;
console.log(`\n========================================`);
console.log(`断言通过 ${passed} 条,失败 ${failures.length} 条`);
if (failures.length) { failures.forEach((m) => console.log('FAILED: ' + m)); process.exit(1); }
console.log('全部通过。');