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

453 lines
27 KiB
JavaScript

import { fakeProtectionFactory } from './helpers/protection-fake.mjs';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { AUDIO_MODULATABLE } from '../src/runtime/audio-contract.js';
import { applyAutomationMode, automationValueAt, sampleAutomationTrack } from '../src/runtime/audio-automation.js';
import { audioPropertyAt, scheduleNativeAutomation } from '../src/runtime/audio-controls.js';
import { expandSoundGraph } from '../src/runtime/audio-graph.js';
import { AudioSubsystem, instantiateSoundGraph, realizeSoundGraph } from '../src/runtime/audio-engine.js';
import { ResolutionEngine } from '../src/runtime/resolution.js';
import { SeededRNG } from '../src/runtime/rng.js';
import { validateExhibit } from '../src/runtime/validator.js';
import { ExhibitValidator } from '../tools/validate-exhibit.mjs';
const close = (actual, expected, epsilon = 1e-9) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
const track = (target = 'tone.frequency', values = [100, 400], options = {}) => ({ target, points: values.map((value, i) => ({ at: `${i}s`, value })), ...options });
const fixture = (automation = [], nodes = {}, routes = []) => ({
xzbt: '0.1', meta: { id: 'automation-study', name: 'Automation Study' },
audio: { buses: { main: { gain: 1 } } },
sounds: { probe: { name: 'Probe', bus: 'main', recipe: { mode: 'continuous', nodes: { tone: { type: 'oscillator', frequency: 200 }, ...nodes }, routes: [{ from: 'tone', to: 'output' }, ...routes], automation } } }
});
const recipe = (doc) => doc.sounds.probe.recipe;
const planFor = (doc, options = {}) => instantiateSoundGraph(doc, 'probe', { rng: new SeededRNG(42), ...options });
const results = (doc) => [validateExhibit(doc), new ExhibitValidator(doc).validate()];
const valid = (doc) => { for (const result of results(doc)) assert.deepEqual(result.errors, []); };
const invalid = (doc, code) => { for (const result of results(doc)) assert.ok(result.errors.some((error) => error.code === code), `${code}: ${JSON.stringify(result.errors)}`); };
test('trace 1: all three modes and four curves have correct starts, midpoints, ends, and smooth slopes', () => {
for (const mode of ['absolute', 'offset', 'scale']) for (const interpolation of ['step', 'linear', 'exponential', 'smooth']) {
const sampled = sampleAutomationTrack(track('tone.frequency', [2, 8], { mode, interpolation }), (value) => value);
const expectedMid = interpolation === 'step' ? 2 : interpolation === 'exponential' ? 4 : 5;
for (const [at, value] of [[0, 2], [500, expectedMid], [1000, 8]]) close(applyAutomationMode(3, automationValueAt(sampled, at), mode), mode === 'absolute' ? value : mode === 'offset' ? 3 + value : 3 * value);
}
const smooth = sampleAutomationTrack(track('tone.frequency', [0, 1], { interpolation: 'smooth' }), (value) => value);
close(automationValueAt(smooth, 250), 0.15625);
close(automationValueAt(smooth, 0.001) / 0.000001, 0, 0.000004);
close((1 - automationValueAt(smooth, 999.999)) / 0.000001, 0, 0.000004);
});
test('trace 2: delayed first point holds before start and final value holds without looping', () => {
const sampled = sampleAutomationTrack({ target: 'tone.frequency', points: [{ at: '2s', value: 30 }, { at: '4s', value: 50 }] }, (value) => value);
for (const at of [-100, 0, 500, 2000]) assert.equal(automationValueAt(sampled, at), 30);
assert.equal(automationValueAt(sampled, 3000), 40);
for (const at of [4000, 8000, 1e12]) assert.equal(automationValueAt(sampled, at), 50);
});
test('trace 3: duplicate expanded tracks conflict while duplicate modulation routes sum', () => {
invalid(fixture([track(), track()]), 'ERR_AUTOMATION_CONFLICT');
const doc = fixture([track('level.gain', [1, 2])], { level: { type: 'gain' }, bias: { type: 'constant', value: 1 } }, [
{ from: 'tone', to: 'level' }, { from: 'level', to: 'output' },
{ from: 'bias', to: 'level.gain', depth: 0.2 }, { from: 'bias', to: 'level.gain', depth: 0.2 }
]);
valid(doc);
close(audioPropertyAt(planFor(doc), 'level', 'gain', 500, { bias: 1 }), 1.9);
});
test('trace 4: nonpositive exponential segments fall back once per sampled track, not per evaluation', () => {
for (const values of [[0, 4, 0], [-2, 2, -4], [-2, -8, -16]]) {
const warnings = [];
const sampled = sampleAutomationTrack(track('tone.detune', values, { interpolation: 'exponential' }), (value) => value, (warning) => warnings.push(warning));
for (let i = 0; i < 100; i++) close(automationValueAt(sampled, 500), (values[0] + values[1]) / 2);
assert.deepEqual(warnings.map((warning) => warning.code), ['WARN_AUTOMATION_FALLBACK']);
}
});
test('trace 5: target registry, unknown nodes, encapsulation, and node-level rejection match both validators', () => {
for (const [type, properties] of Object.entries(AUDIO_MODULATABLE)) for (const property of Object.keys(properties)) {
const node = type === 'resonator' ? { type, modes: [{ ratio: 1 }] } : { type };
valid(fixture([track(`subject.${property}`)], { subject: node }));
}
invalid(fixture([track('missing.frequency')]), 'ERR_INVALID_REFERENCE');
invalid(fixture([track('tone.waveform')]), 'ERR_UNSUPPORTED_TARGET');
invalid(fixture([track('tone')]), 'ERR_UNSUPPORTED_TARGET');
const doc = componentFixture();
recipe(doc).automation = [track('voice.tone.frequency')];
invalid(doc, 'ERR_INVALID_REFERENCE');
recipe(doc).automation = [track('voice.private')];
invalid(doc, 'ERR_UNSUPPORTED_TARGET');
recipe(doc).automation = [];
recipe(doc).nodes.voice.automation = [];
invalid(doc, 'ERR_UNKNOWN_FIELD');
const external = fixture();
external.parameters = { level: { type: 'number', default: 1 } };
external.bindings = [{ source: 'parameters.level', target: 'sounds.probe.recipe.nodes.tone.frequency' }];
invalid(external, 'ERR_UNSUPPORTED_TARGET');
});
test('point shapes, literal times, strict order, numeric ValueSpecs, and unknown fields are checked at import', () => {
const cases = [
[{ ...track(), points: [] }, 'ERR_SCHEMA_VALIDATION'],
[{ ...track(), points: [{ at: '0s', value: 1 }] }, 'ERR_SCHEMA_VALIDATION'],
[track(undefined, [1, 2], { mode: 'multiply' }), 'ERR_TYPE_MISMATCH'],
[track(undefined, [1, 2], { interpolation: 'bezier' }), 'ERR_TYPE_MISMATCH'],
[track(undefined, [1, 2], { extra: true }), 'ERR_UNKNOWN_FIELD'],
[track(undefined, ['100', 200]), 'ERR_TYPE_MISMATCH'],
[track(undefined, [{ choose: [{ weight: 1, value: false }] }, 200]), 'ERR_TYPE_MISMATCH'],
[track(undefined, [{ op: 'add', args: [true, 2] }, 200]), 'ERR_TYPE_MISMATCH'],
[track(undefined, [1, 2], { points: [{ at: { random: { min: '0s', max: '1s' } }, value: 1 }, { at: '2s', value: 2 }] }), 'ERR_TYPE_MISMATCH'],
[track(undefined, [1, 2], { points: [{ at: '1s', value: 1 }, { at: '1000ms', value: 2 }] }), 'ERR_INVALID_RANGE_ORDER'],
[track(undefined, [1, 2], { points: [{ at: '1s', value: 1 }, { at: '0s', value: 2 }] }), 'ERR_INVALID_RANGE_ORDER'],
[track(undefined, [1, 2], { points: [{ at: '-1s', value: 1 }, { at: '1s', value: 2 }] }), 'ERR_INVALID_DURATION']
];
for (const [definition, code] of cases) invalid(fixture([definition]), code);
const doc = fixture([track(undefined, [{ ref: 'parameters.flag' }, 200])]);
doc.parameters = { flag: { type: 'boolean', default: true } };
invalid(doc, 'ERR_TYPE_MISMATCH');
recipe(doc).automation = {};
invalid(doc, 'ERR_SCHEMA_VALIDATION');
});
function componentFixture() {
const doc = fixture([track('voice.pitch', [100, 400])], { voice: { type: 'component', use: 'voice' } }, [{ from: 'voice', to: 'output' }]);
doc.components = { audio: { voice: {
parameters: { pitch: { type: 'number', min: 10, max: 1000, default: 100 } },
nodes: { tone: { type: 'oscillator', frequency: { op: 'multiply', args: [{ ref: 'inputs.pitch' }, 2] } } },
routes: [{ from: 'tone', to: 'output' }]
} } };
return doc;
}
test('trace 6: expanded limits admit exactly 64 tracks / 256 points and reject 65 / 257', () => {
const doc = fixture();
doc.components = { audio: { unit: { nodes: { level: { type: 'gain' } }, automation: [track('level.gain', [0, 1, 2, 3])] } } };
for (let i = 0; i < 64; i++) recipe(doc).nodes[`unit${i}`] = { type: 'component', use: 'unit' };
valid(doc);
const expanded = expandSoundGraph(doc, 'probe');
assert.equal(expanded.automation.length, 64);
assert.equal(new Set(expanded.automation.map((item) => item.target)).size, 64);
recipe(doc).automation.push(track('tone.frequency'));
invalid(doc, 'ERR_NODE_LIMIT_EXCEEDED');
const points = fixture([track('tone.frequency', Array(256).fill(200))]);
valid(points);
recipe(points).automation[0].points.push({ at: '256s', value: 100 });
invalid(points, 'ERR_NODE_LIMIT_EXCEEDED');
});
test('track sampling is depth-first after each graph nodes/routes and does not redraw when evaluated', () => {
const random = { random: { min: 0, max: 1 } };
const doc = componentFixture();
recipe(doc).nodes = { voice: recipe(doc).nodes.voice, tone: { type: 'oscillator', detune: random, frequency: random }, bias: { type: 'constant' } };
doc.components.audio.voice.nodes.tone.frequency = random;
doc.components.audio.voice.automation = [track('tone.detune', [random, random])];
recipe(doc).routes.push({ from: 'bias', to: 'tone.detune', depth: random });
recipe(doc).automation = [track('voice.pitch', [random, random])];
let draws = 0;
const rng = { stream: () => ({ nextFloat: () => ++draws / 20 }) };
const plan = planFor(doc, { rng });
assert.equal(draws, 8);
close(plan.nodes.find((node) => node.path === 'voice.tone').expressions.frequency, 0.05);
assert.deepEqual(plan.automation[0].points.map((point) => point.value), [0.1, 0.15]);
const tone = plan.nodes.find((node) => node.path === 'tone');
close(tone.values.detune, 0.2);
close(tone.values.frequency, 0.25);
close(plan.routes.find((route) => route.kind === 'modulation').depth, 0.3);
assert.deepEqual(plan.automation[1].points.map((point) => point.value), [0.35, 0.4]);
for (let i = 0; i < 100; i++) audioPropertyAt(plan, 'voice.tone', 'detune', i * 100);
assert.equal(draws, 8);
const seeded = fixture([track('tone.frequency', [random, random])]);
assert.deepEqual(planFor(seeded).automation, planFor(seeded).automation);
assert.notDeepEqual(planFor(seeded).automation, planFor(seeded, { ordinal: 1 }).automation);
});
test('component expressions retain only inputs; procedural choices are frozen, nested automation composes', () => {
const doc = componentFixture();
doc.components.audio.voice.nodes.tone.frequency.args[1] = { choose: [{ weight: 1, value: { random: { min: 2, max: 2 } } }] };
doc.components.audio.voice.automation = [track('tone.frequency', [10, 30], { mode: 'offset' })];
valid(doc);
const plan = planFor(doc);
close(audioPropertyAt(plan, 'voice.tone', 'frequency', 500), 520);
assert.deepEqual(plan.nodes.find((node) => node.path === 'voice.tone').expressions.frequency, { op: 'multiply', args: [{ ref: 'inputs.pitch' }, 2] });
});
test('trace 8: bus binding -> automation -> override -> modulation -> clamp, including live release', async () => {
const doc = fixture();
doc.parameters = { amount: { type: 'number', default: 1, min: 0, max: 10 } };
doc.bindings = [{ source: 'parameters.amount', target: 'audio.buses.main.gain', scale: 2 }];
const engine = new ResolutionEngine(doc, new SeededRNG(42));
const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), resolutionEngine: engine, protectionFactory: fakeProtectionFactory, contextFactory: recordingContext });
await audio.unlock();
const path = 'audio.buses.main.gain';
const remove = engine.addAutomation(path, track(path, [0.5, 1.5], { mode: 'scale' }));
engine.setModulation(path, 'one', 0.1);
engine.setModulation(path, 'two', 0.2);
close(engine.get(path), 1.3);
const override = engine.overrides.add({ target: path, scope: 'scenario', transition: { out: '1s' } }, 3, { owner: 'test' });
engine.advance(500);
close(engine.get(path), 3.3);
engine.setParameter('amount', 0.5);
engine.overrides.beginRelease(override);
engine.advance(500); // lower is now 1 * 1.5; midpoint release = (3 + 1.5) / 2
close(engine.get(path), 2.55);
close(audio.buses.get('main').gain.value, 2.55);
engine.advance(500);
close(engine.get(path), 1.8);
engine.setParameter('amount', 10);
close(engine.get(path), 4);
engine.setModulation(path, 'one', -30);
close(engine.get(path), 0.2); // binding 20 wasn't prematurely clamped to 4
remove();
await audio.dispose();
assert.equal(engine.listeners.size, 0);
engine.dispose();
assert.equal(engine.automation.size + engine.modulation.size, 0);
});
test('only bus targets gain shared automation/modulation stages; duplicate registrations fail', () => {
const doc = fixture();
doc.parameters = { amount: { type: 'number', default: 1 } };
const engine = new ResolutionEngine(doc, new SeededRNG(42));
for (const path of ['parameters.amount', 'state.missing', 'sounds.probe.recipe.nodes.tone.frequency']) {
assert.throws(() => engine.addAutomation(path, track()), { code: 'ERR_UNSUPPORTED_TARGET' });
assert.throws(() => engine.setModulation(path, 'one', 1), { code: 'ERR_UNSUPPORTED_TARGET' });
}
const path = 'audio.buses.main.gain';
const remove = engine.addAutomation(path, track(path, [0, 1]));
assert.throws(() => engine.addAutomation(path, track()), { code: 'ERR_AUTOMATION_CONFLICT' });
remove();
engine.addAutomation(path, track(path, [1, 2]));
remove(); // a stale disposer cannot remove the replacement registration
assert.equal(engine.automation.get(path).points[0].value, 1);
assert.throws(() => engine.overrides.add({ target: 'sounds.probe.recipe.nodes.tone.frequency', scope: 'scenario' }, 1), { code: 'ERR_UNSUPPORTED_TARGET' });
});
// Recording Web Audio stand-in with a scalar DSP evaluator. This verifies the
// connected native graph and scheduled values, not a disconnected logical model.
function recordingContext() {
const nodes = [];
const parameter = (value = 0) => ({
value, inputs: [], events: [],
setValueAtTime(value, at) { this.events.push({ kind: 'set', value, at }); },
linearRampToValueAtTime(value, at) { this.events.push({ kind: 'linear', value, at }); },
exponentialRampToValueAtTime(value, at) { this.events.push({ kind: 'exponential', value, at }); },
setValueCurveAtTime(values, at, duration) { this.events.push({ kind: 'curve', values, at, duration }); },
cancelScheduledValues() { this.events.length = 0; }
});
const make = (kind, properties = {}) => {
const node = { kind, inputs: [], connections: [], disconnected: false, ...properties,
connect(target) { this.connections.push(target); target.inputs.push(this); return target; },
disconnect() { this.disconnected = true; for (const target of this.connections) target.inputs = target.inputs.filter((input) => input !== this); this.connections.length = 0; }
};
nodes.push(node);
return node;
};
const source = { start() { this.started = true; }, stop() { this.stopped = true; } };
const context = {
nodes, sampleRate: 48000, currentTime: 7, state: 'running',
destination: make('destination'),
createGain: () => make('gain', { gain: parameter(1) }),
createConstantSource: () => make('constant', { offset: parameter(1), ...source }),
createOscillator: () => make('oscillator', { frequency: parameter(440), detune: parameter(0), ...source }),
createWaveShaper: () => make('shaper', { curve: null }),
createBiquadFilter: () => make('filter', { frequency: parameter(350), Q: parameter(1), gain: parameter(0), detune: parameter(0) }),
createStereoPanner: () => make('panner', { pan: parameter(0) }),
createDelay: () => make('delay', { delayTime: parameter(0) }),
createDynamicsCompressor: () => make('compressor', Object.fromEntries(['threshold', 'knee', 'ratio', 'attack', 'release'].map((name) => [name, parameter()]))),
close() { this.state = 'closed'; }
};
const curveAt = (values, t) => { const at = Math.max(0, Math.min(1, t)) * (values.length - 1), i = Math.floor(at); return values[i] + ((values[i + 1] ?? values[i]) - values[i]) * (at - i); };
const paramAt = (param, time) => {
let value = param.value, at = 0;
for (const event of param.events) {
if (event.kind === 'curve') {
if (time < event.at) break;
value = curveAt(event.values, (time - event.at) / event.duration);
at = event.at + event.duration;
if (time < at) break;
} else if (time < event.at) {
if (event.kind === 'linear') value += (event.value - value) * (time - at) / (event.at - at);
else if (event.kind === 'exponential') value *= (event.value / value) ** ((time - at) / (event.at - at));
break;
} else { value = event.value; at = event.at; }
}
return value + param.inputs.reduce((sum, node) => sum + signalAt(node, time), 0);
};
const signalAt = (node, time) => {
if (node.kind === 'constant') return paramAt(node.offset, time);
if (node.kind === 'oscillator') return Math.sin(2 * Math.PI * time * paramAt(node.frequency, time));
const input = node.inputs.reduce((sum, child) => sum + signalAt(child, time), 0);
if (node.kind === 'gain') return input * paramAt(node.gain, time);
if (node.kind === 'shaper') return curveAt(node.curve, (input + 1) / 2);
return input;
};
context.paramAt = paramAt;
return context;
}
test('all modes/curves reach the scheduled AudioParam path on the owning clock and retain end values', () => {
for (const mode of ['absolute', 'offset', 'scale']) for (const interpolation of ['step', 'linear', 'exponential', 'smooth']) {
const definition = track('tone.frequency', mode === 'scale' ? [0.5, 2] : [100, 400], { mode, interpolation });
const plan = planFor(fixture([definition]));
const context = recordingContext();
const voice = realizeSoundGraph(context, plan, context.destination);
const param = context.nodes.find((node) => node.kind === 'oscillator').frequency;
for (const elapsed of [0, 0.25, 0.5, 0.75, 1, 4]) close(context.paramAt(param, 7 + elapsed), audioPropertyAt(plan, 'tone', 'frequency', elapsed * 1000), 0.0001);
voice.dispose();
assert.ok(context.nodes.filter((node) => node.started).every((node) => node.stopped));
assert.ok(context.nodes.filter((node) => node.kind !== 'destination').every((node) => node.disconnected));
assert.ok(context.nodes.filter((node) => node.offset).every((node) => node.offset.events.length === 0));
}
const definition = track('tone.frequency', [100, 400, 50], { interpolation: 'smooth' });
definition.points.forEach((point, i) => { point.at = `${i + 1}s`; });
const plan = planFor(fixture([definition])), context = recordingContext();
const voice = realizeSoundGraph(context, plan, context.destination);
const param = context.nodes.find((node) => node.kind === 'oscillator').frequency;
for (const elapsed of [0, 1, 1.001, 1.234567, 1.999, 2, 2.001, 2.345678, 3, 30]) close(context.paramAt(param, 7 + elapsed), audioPropertyAt(plan, 'tone', 'frequency', elapsed * 1000), 1e-8);
close((context.paramAt(param, 8.000001) - 100) / 0.000001, 0, 0.001);
close((50 - context.paramAt(param, 9.999999)) / 0.000001, 0, 0.002);
voice.dispose();
});
test('native modulation sums after automation and before safety clamp, including signed/offset values', () => {
const doc = fixture([track('level.gain', [5, -2])], { level: { type: 'gain' }, bias: { type: 'constant', value: 1 } }, [
{ from: 'tone', to: 'level' }, { from: 'level', to: 'output' },
{ from: 'bias', to: 'level.gain', depth: 1 }, { from: 'bias', to: 'level.gain', depth: -2 }
]);
valid(doc);
const context = recordingContext();
const voice = realizeSoundGraph(context, planFor(doc), context.destination);
const level = context.nodes.filter((node) => node.kind === 'gain')[2];
for (const [seconds, expected] of [[0, 4], [0.25, 2.25], [0.5, 0.5], [1, 0]]) close(context.paramAt(level.gain, 7 + seconds), expected, 1e-5);
voice.dispose();
});
test('native component parameter automation and modulation propagate into internal expressions', () => {
const doc = componentFixture();
recipe(doc).nodes.bias = { type: 'constant', value: 1 };
recipe(doc).routes.push({ from: 'bias', to: 'voice.pitch', depth: 50 });
doc.components.audio.voice.automation = [track('tone.frequency', [10, 30], { mode: 'offset' })];
valid(doc);
const plan = planFor(doc), context = recordingContext();
const voice = realizeSoundGraph(context, plan, context.destination);
const internal = context.nodes.filter((node) => node.kind === 'oscillator')[1];
close(context.paramAt(internal.frequency, 7.5), 620);
close(audioPropertyAt(plan, 'voice.tone', 'frequency', 500, { bias: 1 }), 620);
voice.dispose();
});
test('native delay uses milliseconds, live frequency ceiling clamps, and resonator ratios follow fundamental', () => {
const doc = fixture([track('echo.time', [100, 400]), track('body.fundamental', [100, 400]), track('tone.frequency', [100, 10000])], {
echo: { type: 'delay' }, body: { type: 'resonator', modes: [{ ratio: 2 }, { frequency: 700 }] }
}, [{ from: 'tone', to: 'body' }, { from: 'body', to: 'echo' }, { from: 'echo', to: 'output' }]);
valid(doc);
const context = recordingContext();
context.sampleRate = 8000;
const voice = realizeSoundGraph(context, planFor(doc, { sampleRate: 8000 }), context.destination);
close(context.paramAt(context.nodes.find((node) => node.kind === 'delay').delayTime, 7.5), 0.25);
close(context.paramAt(context.nodes.find((node) => node.kind === 'oscillator').frequency, 8), 3600, 0.001);
const bands = context.nodes.filter((node) => node.kind === 'filter');
close(context.paramAt(bands[0].frequency, 7.5), 500);
close(context.paramAt(bands[1].frequency, 7.5), 700);
voice.dispose();
});
test('automation extends determinable delay tails without mutating sampled base values', () => {
const doc = fixture([track('echo.time', [100, 800])], { hit: { type: 'impulse', duration: '10ms' }, echo: { type: 'delay', time: '100ms', feedback: 0 } });
recipe(doc).mode = 'oneshot';
recipe(doc).routes = [{ from: 'hit', to: 'echo' }, { from: 'echo', to: 'output' }];
valid(doc);
const plan = planFor(doc);
assert.equal(plan.endingBoundMs, 860);
assert.equal(plan.nodes.find((node) => node.path === 'echo').values.time, 100);
});
test('trace 11: disposal and scheduler failure cancel automation and release all voice resources', async () => {
const doc = fixture([track(undefined, [100, 400], { interpolation: 'smooth' })]);
const context = recordingContext();
const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), protectionFactory: fakeProtectionFactory, contextFactory: () => context });
await audio.unlock();
const voice = audio.play('probe');
voice.dispose();
voice.dispose();
assert.equal(voice.state, 'DISPOSED');
assert.equal(voice.plan, null);
assert.equal(voice.realized, null);
assert.equal(audio.continuousVoices.size, 0);
assert.ok(context.nodes.filter((node) => node.started).every((node) => node.stopped));
const makeSource = context.createConstantSource;
context.createConstantSource = () => {
const source = makeSource();
source.offset.linearRampToValueAtTime = () => { throw new Error('injected scheduler failure'); };
return source;
};
assert.equal(audio.play('probe'), null);
assert.equal(audio.voices.size + audio.continuousVoices.size, 0);
assert.ok(context.nodes.filter((node) => ['constant', 'oscillator'].includes(node.kind)).every((node) => node.disconnected));
await audio.dispose();
});
test('native exponential fallback schedules linear ramps and schema exposes only graph-local automation', () => {
const context = recordingContext(), param = context.createGain().gain;
const sampled = sampleAutomationTrack(track('tone.detune', [-1, 0, 2, 8], { interpolation: 'exponential' }), (value) => value);
scheduleNativeAutomation(param, sampled, 7);
assert.deepEqual(param.events.slice(2).map((event) => event.kind), ['linear', 'linear', 'exponential']);
const definitions = JSON.parse(readFileSync(new URL('../schema/xzbt-0.1.schema.json', import.meta.url))).definitions;
assert.equal(definitions.AudioRecipe.oneOf[0].properties.automation.$ref, '#/definitions/AudioAutomationList');
assert.equal(definitions.AudioComponent.properties.automation.$ref, '#/definitions/AudioAutomationList');
assert.equal(definitions.AudioBus.properties.automation, undefined);
});
test('the minimal audio exhibit exercises all modes/curves and compiles with both validators', () => {
const doc = JSON.parse(readFileSync(new URL('../exhibits/minimal-audio.xzbt', import.meta.url)));
valid(doc);
const tracks = [];
for (const soundId of Object.keys(doc.sounds)) {
const plan = instantiateSoundGraph(doc, soundId, { rng: new SeededRNG(42), resolveReference: () => 0.6 });
assert.deepEqual(plan.errors, []);
tracks.push(...plan.automation);
}
assert.deepEqual([...new Set(tracks.map((item) => item.mode))].sort(), ['absolute', 'offset', 'scale']);
assert.deepEqual([...new Set(tracks.map((item) => item.interpolation))].sort(), ['exponential', 'linear', 'smooth', 'step']);
});
test('bus base ValueSpecs sample once and parameter overrides feed downstream buses in the same tick', () => {
const doc = fixture();
doc.audio.buses.main.gain = { random: { min: 0.5, max: 0.5 } };
doc.parameters = { amount: { type: 'number', default: 1 } };
doc.bindings = [{ source: 'parameters.amount', target: 'audio.buses.main.gain' }];
valid(doc);
let draws = 0;
const rng = { stream: () => ({ nextFloat: () => { draws++; return 0.5; } }) };
const engine = new ResolutionEngine(doc, rng);
const path = 'audio.buses.main.gain';
assert.equal(engine.base(path), 0.5);
const id = engine.overrides.add({ target: 'parameters.amount', scope: 'scenario' }, 2);
assert.equal(engine.get(path), 2);
engine.setParameter('amount', 0.75);
assert.equal(engine.get(path), 2);
engine.overrides.beginRelease(id);
assert.equal(engine.get(path), 0.75);
engine.advance(1000);
assert.equal(draws, 1);
});
test('partial and resonator ValueSpecs preserve nested document order before automation draws', () => {
const random = { random: { min: 0, max: 1 } };
const doc = fixture([track('tone.detune', [random, random])]);
recipe(doc).nodes = {
tone: { type: 'oscillator', waveform: 'custom', harmonics: [{ phase: random, gain: random, ratio: random }], frequency: random },
body: { type: 'resonator', modes: [{ gain: random, ratio: random }], fundamental: random }
};
let draws = 0;
const plan = planFor(doc, { rng: { stream: () => ({ nextFloat: () => ++draws / 10 }) } });
const tone = plan.nodes.find((node) => node.path === 'tone');
assert.deepEqual(tone.values.harmonics, [{ phase: 0.1, gain: 0.2, ratio: 0.3 }]);
assert.equal(tone.values.frequency, 0.4);
const body = plan.nodes.find((node) => node.path === 'body');
assert.equal(body.values.modes[0].gain, 0.5);
assert.equal(body.values.modes[0].ratio, 0.6);
assert.equal(body.values.fundamental, 0.7);
assert.deepEqual(plan.automation[0].points.map((point) => point.value), [0.8, 0.9]);
assert.equal(draws, 9);
});