Files
SoundGame/web/harness/engine-voice.test.mjs
T
xsl f5eae6d37c test+docs: 「对白没读完」教训固化到框架(引擎回归测试 + 审计工具 + 四处文档)
- 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)
2026-09-06 16:08:18 +08:00

141 lines
7.3 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 真机人耳反馈固化)。
*
* 固化的教训: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('全部通过。');