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); });