453 lines
27 KiB
JavaScript
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);
|
|
});
|