Files
XZBT/test/phase3-protection.test.mjs

261 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import vm from 'node:vm';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AUDIO_PROTECTION } from '../src/runtime/audio-contract.js';
import { MasterProtectionDSP, protectionWorkletSource, loadProtectionWorklet, createProtectionNode } from '../src/runtime/audio-protection.js';
import { AudioSubsystem, instantiateSoundGraph } from '../src/runtime/audio-engine.js';
import { validateExhibit } from '../src/runtime/validator.js';
import { buildAudioAcceptance } from '../tools/build-audio-acceptance.mjs';
import { Diagnostics } from '../src/runtime/diagnostics.js';
import { SeededRNG } from '../src/runtime/rng.js';
const block = (value = 0, length = 128) => [new Float32Array(length).fill(value), new Float32Array(length).fill(value)];
const limit = 10 ** ((AUDIO_PROTECTION.ceilingDb + AUDIO_PROTECTION.toleranceDb) / 20);
function processor(rate = 48000, guard = false) {
let Processor;
vm.runInNewContext(protectionWorkletSource(), {
sampleRate: rate,
AudioWorkletProcessor: class { constructor() { this.messages = []; this.port = { postMessage: data => this.messages.push(data) }; } },
registerProcessor(name, implementation) { assert.equal(name, 'xzbt-protection'); Processor = implementation; }
});
return new Processor({ processorOptions: { guard } });
}
test('the shipped worklet bounds overload, DC, alternating peaks, transients and silence at multiple sample rates', () => {
for (const rate of [8000, 44100, 48000, 96000, 192000]) {
const p = processor(rate), output = block();
let observed = 0;
for (let n = 0; n < 300; n++) {
const input = block();
for (let i = 0; i < 128; i++) {
const t = (n * 128 + i) / rate;
input[0][i] = n < 50 ? 0 : n < 100 ? 1024 : n < 150 ? 4096 * Math.sin(2 * Math.PI * 440 * t) : n < 200 ? (i % 2 ? -3.4e38 : 3.4e38) : n < 250 ? (i === 63 ? 10000 : 0.1) : 0;
input[1][i] = input[0][i] * -0.5;
}
assert.equal(p.process([input, []], [output]), true);
for (const channel of output) for (const sample of channel) {
assert.ok(Number.isFinite(sample));
assert.ok(Math.abs(sample) <= limit);
observed = Math.max(observed, Math.abs(sample));
}
}
assert.ok(observed > 0.8, `rate ${rate} must produce audio, not silently pass`);
}
});
test('quiet stereo audio retains its samples and polarity after exactly the lookahead delay', () => {
const dsp = new MasterProtectionDSP(48000, AUDIO_PROTECTION);
const input = block(0, 1024), output = block(0, 1024);
for (let i = 0; i < 1024; i++) { input[0][i] = Math.sin(i) * 0.2; input[1][i] = input[0][i] * -0.5; }
dsp.process(input, output);
for (let c = 0; c < 2; c++) for (let i = 0; i < 1024; i++) assert.equal(output[c][i], i < dsp.delayFrames ? 0 : input[c][i - dsp.delayFrames]);
});
test('gain recovers monotonically with the configured release time constant, shared across stereo', () => {
const dsp = new MasterProtectionDSP(48000, AUDIO_PROTECTION), output = block();
for (let i = 0; i < 200; i++) dsp.process(block(100), output);
let previous = dsp.gain;
for (let i = 0; i < 400; i++) {
dsp.process(block(0.1), output);
assert.ok(dsp.gain >= previous - 1e-12);
assert.ok(dsp.gain <= 1);
previous = dsp.gain;
}
assert.ok(dsp.gain > 0.98);
assert.deepEqual(output[0], output[1]);
});
test('trace 12: nonfinite at the master mutes the entire stereo block, flushes history and warns once', () => {
const p = processor(), output = block();
p.process([block(0.3)], [output]);
for (const invalid of [NaN, Infinity, -Infinity]) {
const input = block(0.3); input[1][127] = invalid;
p.process([input], [output]);
assert.ok(output.every(channel => channel.every(value => value === 0)));
}
assert.equal(p.messages.filter(message => message.type === 'nonfinite').length, 1);
assert.equal(p.dsp.affectedBlocks, 3);
p.process([block()], [output]);
assert.ok(output.every(channel => channel.every(value => value === 0)));
});
test('voice guards attribute once per instance and a fault mutes the mixed output even with healthy voices', () => {
const master = processor(), guards = [processor(48000, true), processor(48000, true)];
master.port.onmessage({ data: { type: 'capture', id: 1, warmupFrames: 0, frames: 384 } });
for (let b = 0; b < 3; b++) {
const fault = block(0, 128), output = block();
for (const guard of guards) {
const input = block(0.1), guarded = block(), lane = [new Float32Array(128)];
input[0][127] = NaN;
guard.process([input], [guarded, lane]);
assert.ok(guarded.every(channel => channel.every(value => value === 0)));
for (let i = 0; i < 128; i++) fault[0][i] += lane[0][i];
}
master.process([block(10), fault], [output]);
assert.ok(output.every(channel => channel.every(value => value === 0)));
}
for (const guard of guards) assert.equal(guard.messages.length, 1);
const capture = master.messages.find(message => message.type === 'capture');
assert.equal(capture.affectedBlocks, 3);
assert.equal(capture.nonfiniteSamples, 6);
assert.equal(master.messages.filter(message => message.type === 'nonfinite').length, 0);
});
test('capture excludes warmup and measures exact output frames including partial boundary blocks', () => {
const p = processor(), output = block();
p.port.onmessage({ data: { type: 'capture', id: 12, warmupFrames: 300, frames: 257 } });
for (let n = 0; n < 4; n++) p.process([block(0.2)], [output]);
assert.equal(p.messages.length, 0);
p.process([block(0.2)], [output]);
const result = p.messages[0];
assert.equal(result.id, 12);
assert.equal(result.frames, 257);
assert.equal(result.peak, Math.fround(0.2));
assert.equal(result.affectedBlocks, 0);
});
function recordingSetup(load = async () => {}) {
const nodes = [];
const node = kind => {
const result = { kind, connections: [], gain: { value: 1 }, port: { messages: [], postMessage(data) { this.messages.push(data); }, close() { this.closed = true; } },
connect(target, output = 0, input = 0) { this.connections.push({ target, output, input }); return target; },
disconnect() { this.connections = []; this.disconnected = true; } };
nodes.push(result); return result;
};
const context = { state: 'suspended', sampleRate: 48000, currentTime: 0, destination: node('destination'),
createGain: () => node('gain'), async resume() { this.state = 'running'; }, async close() { this.state = 'closed'; } };
const diagnostics = new Diagnostics();
const audio = new AudioSubsystem({ document: { audio: { buses: { bed: { gain: 1 } } } }, rng: new SeededRNG(42), diagnostics,
contextFactory: () => context, protectionFactory: { load, create: (ctx, guard) => node(guard ? 'guard' : 'master-protection') } });
return { audio, context, diagnostics, nodes };
}
test('all buses and direct sounds route through volume then final protection; guard fault lane is separate', async () => {
const { audio, context, nodes } = recordingSetup();
assert.equal(await audio.unlock(), true);
assert.equal(audio.busFor('direct'), audio.master);
assert.equal(audio.buses.get('bed').connections[0].target, audio.master);
assert.equal(audio.master.connections[0].target, audio.protection);
const outputEdges = nodes.flatMap(node => node.connections.map(edge => ({ node, ...edge }))).filter(edge => edge.target === context.destination);
assert.equal(outputEdges.length, 1);
assert.equal(outputEdges[0].node, audio.protection);
const guard = audio.createVoiceGuard({ soundId: 'probe', creationOrder: 7 });
assert.deepEqual(guard.connections[0], { target: audio.protection, output: 1, input: 1 });
guard.port.onmessage({ data: { type: 'nonfinite' } });
assert.equal(audio.diagnostics.list()[0].objectId, 'probe#7');
guard.disconnect(); guard.port.close();
audio.setMasterVolume(NaN);
assert.equal(audio.master.gain.value, 0.8);
await audio.dispose();
assert.ok(nodes.filter(node => node.kind !== 'destination').every(node => node.disconnected));
});
test('missing worklets and rejected resume/load fail closed, warn once, and create no voice', async () => {
for (const failure of ['load', 'resume']) {
const { audio, context, diagnostics, nodes } = recordingSetup(async () => { if (failure === 'load') throw new Error('load rejected'); });
if (failure === 'resume') context.resume = async () => { throw new Error('resume rejected'); };
assert.equal(await audio.unlock(), false);
assert.equal(await audio.unlock(), false);
assert.equal(audio.play('probe'), null);
assert.equal(audio.unlocked, false);
assert.equal(diagnostics.list().filter(entry => entry.code === 'WARN_AUDIO_UNAVAILABLE').length, 1);
assert.ok(nodes.every(node => node.connections.length === 0));
await audio.dispose();
}
await assert.rejects(loadProtectionWorklet({}), /unavailable/);
});
test('concurrent unlocks share initialization; disposal during module loading cannot resurrect output', async () => {
let finish, loads = 0;
const pending = new Promise(resolve => { finish = resolve; });
const { audio, nodes } = recordingSetup(() => { loads++; return pending; });
const first = audio.unlock(), second = audio.unlock();
assert.equal(audio.unlocked, false);
assert.equal(loads, 1);
await audio.dispose(); finish();
assert.deepEqual(await Promise.all([first, second]), [false, false]);
assert.equal(nodes.filter(node => node.kind === 'master-protection').length, 0);
});
test('capture resolves only its own result, prevents overlap, and rejects on disposal or processor failure', async () => {
for (const ending of ['complete', 'dispose', 'error']) {
const { audio } = recordingSetup();
await audio.unlock();
const pending = audio.captureOutput({ seconds: 1, warmupSeconds: 0 });
await assert.rejects(audio.captureOutput(), /already running/);
const request = audio.protection.port.messages[0];
assert.equal(request.frames, 48000);
if (ending === 'complete') {
audio.protection.port.onmessage({ data: { type: 'capture', id: request.id + 1 } });
assert.ok(audio.capturePending);
audio.protection.port.onmessage({ data: { type: 'capture', id: request.id, peak: 0.8 } });
assert.equal((await pending).peak, 0.8);
} else {
const rejected = assert.rejects(pending, /disposed|failed/);
if (ending === 'dispose') await audio.dispose();
else audio.protection.onprocessorerror();
await rejected;
assert.equal(audio.unlocked, false);
}
await audio.dispose();
}
});
test('production loader embeds the exact processor and constructs explicit stereo worklets', async () => {
const previous = globalThis.AudioWorkletNode;
const calls = [];
try {
globalThis.AudioWorkletNode = class { constructor(...args) { calls.push(args); } };
const context = { audioWorklet: { async addModule(url) {
assert.equal(decodeURIComponent(url.split(',').slice(1).join(',')), protectionWorkletSource());
} } };
await loadProtectionWorklet(context);
createProtectionNode(context); createProtectionNode(context, true);
assert.deepEqual(calls[0][2].outputChannelCount, [2]);
assert.equal(calls[0][2].numberOfInputs, 2);
assert.deepEqual(calls[1][2].outputChannelCount, [2, 1]);
} finally { globalThis.AudioWorkletNode = previous; }
});
test('trace 14: absent AudioContext stays silent, creates no instance, and warns once', async () => {
const diagnostics = new Diagnostics();
const audio = new AudioSubsystem({ document: {}, rng: new SeededRNG(42), diagnostics });
assert.equal(await audio.unlock(), false);
assert.equal(await audio.unlock(), false);
assert.equal(audio.play('probe'), null);
assert.equal(audio.voices.size, 0);
assert.equal(diagnostics.list().length, 1);
assert.equal(diagnostics.list()[0].code, 'WARN_AUDIO_UNAVAILABLE');
await audio.dispose();
});
test('the twelve challenge recipes and frozen overload workload validate and instantiate deterministically', () => {
for (const file of ['audio-challenge', 'audio-protection-stress']) {
const document = JSON.parse(readFileSync(`exhibits/${file}.xzbt`, 'utf8'));
assert.deepEqual(validateExhibit(document).errors, []);
if (file === 'audio-challenge') assert.equal(Object.keys(document.sounds).length, 12);
for (const [id, sound] of Object.entries(document.sounds)) {
const plan = instantiateSoundGraph(document, id, { rng: new SeededRNG(42) });
assert.deepEqual(plan.errors, [], id);
assert.deepEqual(plan, instantiateSoundGraph(document, id, { rng: new SeededRNG(42) }));
if (sound.recipe.mode === 'oneshot') assert.ok(Number.isFinite(plan.endingBoundMs));
if (file === 'audio-protection-stress' && id === 'hit') assert.ok(plan.endingBoundMs < 2000);
}
}
});
test('the direct-file acceptance build is deterministic, self-contained, and embeds the production processor', () => {
const directory = mkdtempSync(join(tmpdir(), 'xzbt-protection-'));
const first = buildAudioAcceptance(join(directory, 'first.html'));
const second = buildAudioAcceptance(join(directory, 'second.html'));
assert.equal(first.sha256, second.sha256);
const html = readFileSync(first.outputPath, 'utf8');
assert.doesNotMatch(html, /<(script|link)[^>]+(?:src|href)=/i);
assert.ok(html.includes(MasterProtectionDSP.toString()));
const source = html.match(/<script>([\s\S]*)<\/script>/)[1];
assert.doesNotThrow(() => new vm.Script(source));
});