261 lines
13 KiB
JavaScript
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));
|
|
});
|