- web/harness/engine-voice.test.mjs:引擎 voice 句尾保真三不变式(I1 源未耗尽不得结算 / I2 耗尽后排空窗才断开 / I3 零消耗走旧路径),可驱动 SPN mock 模拟真实消耗, 15 断言全绿;防止未来重构回潮 - tools/audit_voice_tails.py:voice 资产尾部静默审计工具入库(含判读口径头注: 尾静默紧=裁剪工艺≠剪字,句尾完整性验收在引擎层) - 文档:HANDOVER(§1.4 回归清单 / §5.3 质量手段 / §6 已解决账)、roadmap §4 新增全游戏「句尾完整性」真机听测项、asset-pipeline QC 口径、methodology §5 度量盲区教训(媒体播完类问题转写层无效,必须真人耳测+引擎断言)、 web-engine-api voice 自然结束语义 - 修正:结束判定改用 filter.sourcePosition(SimpleFilter 单调精确计数器); 10 套件全绿(8 playtest + guide + engine-voice)
141 lines
7.3 KiB
JavaScript
141 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* engine-voice.test.mjs — 引擎 voice 句尾保真回归(2026-09 真机人耳反馈固化)。
|
||
*
|
||
* 固化的教训:voice 自然结束**不能**用墙钟定时器判定(SPN 4096帧@48k≈85ms +
|
||
* SoundTouch 固有延迟 ≈190ms > 任何小余量),必须以「源真实消耗位置」为准,
|
||
* 耗尽后留排空窗再 disconnect。本测试用可手动驱动的 ScriptProcessorNode mock
|
||
* 模拟真实消耗,把该语义钉死,防止未来重构回潮。
|
||
*
|
||
* 三条不变式:
|
||
* I1 源未耗尽时,_finishVoice 提前触发不得结算(必须短重排等待);
|
||
* I2 源耗尽后第一次触发只挂排空旗,排空窗(VOICE_DRAIN_MS)过后才真结算;
|
||
* I3 消耗位置恒为 0(playtest mock / SPN 首回调未到)走旧立即结算路径。
|
||
*
|
||
* 运行: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';
|
||
|
||
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; // 测试语音 2 秒
|
||
const FRAMES = Math.round(SR * 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(); }
|
||
decodeAudioData() {
|
||
return Promise.resolve({
|
||
duration: DUR, sampleRate: SR, numberOfChannels: 1,
|
||
getChannelData: (i) => new Float32Array(FRAMES), // 真实长度:SoundTouch extract 按帧索引
|
||
});
|
||
}
|
||
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 engine = new WebAudioEngine('audio/');
|
||
await engine.unlock();
|
||
const completed = [];
|
||
const makeOnComplete = (tag) => () => completed.push(tag);
|
||
|
||
console.log('\n=== I1 源未耗尽:提前触发不结算 ===');
|
||
engine.play('voice/tail_test', { onComplete: makeOnComplete('A') });
|
||
await sleep(30); // 等 _playVoice 的 await _load 落地
|
||
let 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); // 模拟旧式墙钟定时器提前到点
|
||
assert(engine._voice === token && !token.stopped, 'I1 未耗尽时不结算(token 仍存活,等待重排)');
|
||
assert(completed.length === 0, 'I1 onComplete 未被提前调用');
|
||
|
||
console.log('\n=== I2 源耗尽:排空窗过后才真结算 ===');
|
||
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 清空)');
|
||
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=== 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') });
|
||
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') });
|
||
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); }
|
||
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('全部通过。');
|