- 真根因(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
171 lines
9.0 KiB
JavaScript
171 lines
9.0 KiB
JavaScript
#!/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('全部通过。');
|