import { fakeProtectionFactory } from './helpers/protection-fake.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; import { AUDIO_DEFAULT_RELEASE_MS, AUDIO_LIFECYCLE_STATES, AUDIO_LIFECYCLE_TRANSITIONS, AUDIO_LIMITS, AUDIO_MAX_RELEASE_MS, AUDIO_NODE_TYPE_NAMES, AUDIO_STATIC_MAX_FREQUENCY, AUDIO_VOICE_LIMITS, audioMaxFrequency } from '../src/runtime/audio-contract.js'; import { computeDeterminableEndingBound, expandSoundGraph } from '../src/runtime/audio-graph.js'; import { AudioSubsystem, SoundInstance, instantiateSoundGraph, realizeSoundGraph, sampleHoldStreamKey } from '../src/runtime/audio-engine.js'; import { SeededRNG } from '../src/runtime/rng.js'; import { validateExhibit } from '../src/runtime/validator.js'; function exhibit(overrides = {}) { return { xzbt: '0.1', meta: { id: 'audio-study', name: 'Audio Study' }, runtime: { seed: 42 }, ...overrides }; } // A minimal audible graph: `extra` nodes and routes are merged in around a working tone. function soundWith(nodes = {}, routes = [], soundOverrides = {}, documentOverrides = {}) { return exhibit({ audio: { buses: { ambient: { gain: 1 } } }, sounds: { probe: { name: 'Probe', bus: 'ambient', recipe: { mode: 'continuous', nodes: { tone: { type: 'oscillator' }, ...nodes }, routes: [{ from: 'tone', to: 'output' }, ...routes] }, ...soundOverrides } }, ...documentOverrides }); } const codes = (document) => validateExhibit(document).errors.map((error) => error.code); const clean = (document) => { const result = validateExhibit(document); assert.deepEqual(result.errors, [], JSON.stringify(result.errors, null, 2)); }; /* --- 14.13 trace 1: every node type's minimal example validates --------------- */ test('every documented minimal node example validates inside a complete graph', () => { const minimal = { oscillator: { type: 'oscillator' }, noise: { type: 'noise' }, impulse: { type: 'impulse' }, constant: { type: 'constant' }, lfo: { type: 'lfo' }, 'sample-hold': { type: 'sample-hold' }, gain: { type: 'gain' }, filter: { type: 'filter' }, compressor: { type: 'compressor' }, waveshaper: { type: 'waveshaper' }, delay: { type: 'delay' }, reverb: { type: 'reverb' }, 'stereo-pan': { type: 'stereo-pan' }, mixer: { type: 'mixer' }, resonator: { type: 'resonator', modes: [{ ratio: 1 }] } }; // `component` is exercised separately; every other type in the 0.1 set is covered here. assert.equal(Object.keys(minimal).length + 1, AUDIO_NODE_TYPE_NAMES.length); for (const [type, node] of Object.entries(minimal)) { const routes = ['constant', 'lfo', 'sample-hold'].includes(type) ? [] : [{ from: 'probe', to: 'output' }]; clean(soundWith({ probe: node }, routes)); } }); test('documented invalid cases emit exactly their documented code', () => { const cases = [ [{ type: 'oscillator', waveform: 'sine', harmonics: [] }, 'ERR_UNKNOWN_FIELD'], [{ type: 'oscillator', waveform: 'custom' }, 'ERR_SCHEMA_VALIDATION'], [{ type: 'noise', color: 'grey' }, 'ERR_TYPE_MISMATCH'], [{ type: 'impulse', duration: '1s' }, 'ERR_OUT_OF_BOUNDS'], [{ type: 'constant', value: 5000 }, 'ERR_OUT_OF_BOUNDS'], [{ type: 'lfo', waveform: 'custom' }, 'ERR_TYPE_MISMATCH'], [{ type: 'sample-hold', min: 1, max: -1 }, 'ERR_INVALID_RANGE_ORDER'], [{ type: 'gain', gain: 8 }, 'ERR_OUT_OF_BOUNDS'], [{ type: 'filter', mode: 'comb' }, 'ERR_TYPE_MISMATCH'], [{ type: 'compressor', ratio: 40 }, 'ERR_OUT_OF_BOUNDS'], [{ type: 'waveshaper', oversample: '8x' }, 'ERR_TYPE_MISMATCH'], [{ type: 'delay', feedback: 1 }, 'ERR_OUT_OF_BOUNDS'], [{ type: 'reverb', decay: '60s' }, 'ERR_OUT_OF_BOUNDS'], [{ type: 'stereo-pan', pan: 2 }, 'ERR_OUT_OF_BOUNDS'], [{ type: 'resonator', modes: [{ ratio: 1, frequency: 200 }] }, 'ERR_SCHEMA_VALIDATION'], [{ type: 'chorus' }, 'ERR_INVALID_NODE_TYPE'] ]; for (const [node, code] of cases) { assert.ok(codes(soundWith({ probe: node })).includes(code), `${node.type}: expected ${code}`); } }); /* --- 14.13 traces 2, 5: identity model and external targeting ---------------- */ test('a node carrying an id field is rejected and output is a reserved key', () => { assert.ok(codes(soundWith({ probe: { type: 'gain', id: 'probe' } })).includes('ERR_UNKNOWN_FIELD')); assert.ok(codes(soundWith({ output: { type: 'gain' } })).includes('ERR_INVALID_ID')); }); test('an external binding to a node field is unsupported while bus gain remains supported', () => { const unsupported = soundWith({}, [], {}, { parameters: { level: { type: 'number', default: 0.5, min: 0, max: 1 } }, bindings: [{ source: 'parameters.level', target: 'sounds.probe.recipe.nodes.tone.frequency' }] }); const reported = codes(unsupported); assert.ok(reported.includes('ERR_UNSUPPORTED_TARGET') || reported.includes('ERR_INVALID_REFERENCE')); clean(soundWith({}, [], {}, { parameters: { level: { type: 'number', default: 0.5, min: 0, max: 4 } }, bindings: [{ source: 'parameters.level', target: 'audio.buses.ambient.gain' }] })); }); /* --- 14.13 trace 3 and 15.19 trace 7: frequency staging ---------------------- */ test('semantic validation uses the static ceiling and never needs a sample rate', () => { assert.equal(typeof globalThis.AudioContext, 'undefined'); assert.ok(codes(soundWith({ probe: { type: 'oscillator', frequency: 30000 } })).includes('ERR_OUT_OF_BOUNDS')); clean(soundWith({ probe: { type: 'oscillator', frequency: AUDIO_STATIC_MAX_FREQUENCY } }, [{ from: 'probe', to: 'output' }])); }); test('instantiation clamps to the live device ceiling and warns instead of failing', () => { assert.equal(audioMaxFrequency(44100), 19845); assert.equal(audioMaxFrequency(96000), AUDIO_STATIC_MAX_FREQUENCY); const document = soundWith({}, []); document.sounds.probe.recipe.nodes.tone.frequency = 22000; const plan = instantiateSoundGraph(document, 'probe', { sampleRate: 44100, rng: new SeededRNG(42) }); assert.deepEqual(plan.errors, []); assert.equal(plan.nodes.find((node) => node.path === 'tone').values.frequency, 19845); assert.equal(plan.warnings.length, 1); assert.equal(plan.warnings[0].code, 'WARN_AUDIO_RATE_CLAMP'); const wide = instantiateSoundGraph(document, 'probe', { sampleRate: 96000, rng: new SeededRNG(42) }); assert.equal(wide.warnings.length, 0); assert.equal(wide.nodes.find((node) => node.path === 'tone').values.frequency, 22000); }); test('custom partials above the device ceiling are omitted rather than aliased', () => { const document = soundWith({}, []); document.sounds.probe.recipe.nodes.tone = { type: 'oscillator', waveform: 'custom', frequency: 5000, harmonics: [{ ratio: 1, gain: 1 }, { ratio: 2, gain: 0.5 }, { ratio: 8, gain: 0.2 }] }; clean(document); const plan = instantiateSoundGraph(document, 'probe', { sampleRate: 44100, rng: new SeededRNG(42) }); assert.deepEqual(plan.nodes.find((node) => node.path === 'tone').values.harmonics.map((partial) => partial.ratio), [1, 2]); }); /* --- 14.13 trace 4: resolve-once semantics ----------------------------------- */ test('a node-field ValueSpec samples once and stays fixed for the node instance', () => { const document = soundWith({}, []); document.sounds.probe.recipe.nodes.tone.frequency = { random: { min: 220, max: 440 } }; clean(document); const first = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42), ordinal: 0 }); const again = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42), ordinal: 0 }); const value = first.nodes.find((node) => node.path === 'tone').values.frequency; assert.equal(again.nodes.find((node) => node.path === 'tone').values.frequency, value); assert.ok(value >= 220 && value <= 440); const later = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42), ordinal: 1 }); assert.notEqual(later.nodes.find((node) => node.path === 'tone').values.frequency, value); }); /* --- 14.13 trace 6: sample-hold stream derivation ---------------------------- */ test('sample-hold streams are seeded, keyed by node path, and reproducible', () => { const document = soundWith({ step: { type: 'sample-hold', rate: 4 } }, []); clean(document); const draw = (seed, ordinal) => { const plan = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(seed), ordinal }); const node = plan.nodes.find((entry) => entry.path === 'step'); assert.equal(node.values.streamKey, sampleHoldStreamKey(`probe#${ordinal}`, 'step')); return [node.values.stream.nextFloat(), node.values.stream.nextFloat(), node.values.stream.nextFloat()]; }; assert.deepEqual(draw(42, 0), draw(42, 0)); assert.notDeepEqual(draw(42, 0), draw(42, 1)); assert.notDeepEqual(draw(42, 0), draw(7, 0)); const renamed = soundWith({ tick: { type: 'sample-hold', rate: 4 } }, []); const renamedPlan = instantiateSoundGraph(renamed, 'probe', { rng: new SeededRNG(42), ordinal: 0 }); const renamedNode = renamedPlan.nodes.find((entry) => entry.path === 'tick'); assert.notDeepEqual([renamedNode.values.stream.nextFloat()], [draw(42, 0)[0]]); }); test('sample-hold slew is clamped to the tick period', () => { const document = soundWith({ step: { type: 'sample-hold', rate: 4, slew: '900ms' } }, []); clean(document); const plan = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42) }); assert.equal(plan.nodes.find((node) => node.path === 'step').values.slew, 250); }); /* --- 15.19 trace 2: every legality rule ------------------------------------- */ test('graph legality rules each emit their documented diagnostic', () => { // Rule 4: output is sink-only. assert.ok(codes(soundWith({ level: { type: 'gain' } }, [{ from: 'output', to: 'level' }])).includes('ERR_INVALID_ROUTE')); // Rule 5: a control source cannot reach output. assert.ok(codes(soundWith({ wobble: { type: 'lfo' } }, [{ from: 'wobble', to: 'output' }])).includes('ERR_INVALID_ROUTE')); // Rule 6: a source cannot receive audio input. assert.ok(codes(soundWith({ hiss: { type: 'noise' } }, [{ from: 'tone', to: 'hiss' }])).includes('ERR_INVALID_ROUTE')); // Rule 3: endpoints must resolve. assert.ok(codes(soundWith({}, [{ from: 'tone', to: 'missing' }])).includes('ERR_INVALID_REFERENCE')); // Rule 7: the audio route graph is acyclic. const cyclic = soundWith({ a: { type: 'gain' }, b: { type: 'gain' } }, [ { from: 'a', to: 'b' }, { from: 'b', to: 'a' } ]); assert.ok(codes(cyclic).includes('ERR_CYCLIC_DEPENDENCY')); // Rule 9: something audible must reach output. const silent = exhibit({ sounds: { probe: { name: 'Probe', recipe: { nodes: { wobble: { type: 'lfo' }, level: { type: 'gain' } }, routes: [{ from: 'wobble', to: 'level.gain', depth: 0.5 }] } } } }); assert.ok(codes(silent).includes('ERR_NO_AUDIBLE_PATH')); // A node routed to itself. assert.ok(codes(soundWith({ a: { type: 'gain' } }, [{ from: 'a', to: 'a' }])).includes('ERR_INVALID_ROUTE')); }); test('a control-only path fails while the same shape with an audible source passes', () => { const controlOnly = exhibit({ sounds: { probe: { name: 'Probe', recipe: { nodes: { bias: { type: 'constant' }, level: { type: 'gain' } }, routes: [{ from: 'bias', to: 'level' }, { from: 'level', to: 'output' }] } } } }); assert.ok(codes(controlOnly).includes('ERR_INVALID_ROUTE')); clean(exhibit({ sounds: { probe: { name: 'Probe', recipe: { mode: 'continuous', nodes: { tone: { type: 'oscillator' }, level: { type: 'gain' } }, routes: [{ from: 'tone', to: 'level' }, { from: 'level', to: 'output' }] } } } })); }); /* --- 15.19 traces 3, 5: modulation ------------------------------------------ */ test('modulation targets follow the registry and depth is required exactly there', () => { clean(soundWith({ wobble: { type: 'lfo', frequency: 3 } }, [{ from: 'wobble', to: 'tone.frequency', depth: 18 }])); assert.ok(codes(soundWith({ wobble: { type: 'lfo' }, verb: { type: 'reverb' } }, [ { from: 'tone', to: 'verb' }, { from: 'verb', to: 'output' }, { from: 'wobble', to: 'verb.mix', depth: 0.2 } ])).includes('ERR_UNSUPPORTED_TARGET')); assert.ok(codes(soundWith({ wobble: { type: 'lfo' } }, [{ from: 'wobble', to: 'tone.frequency' }])).includes('ERR_SCHEMA_VALIDATION')); assert.ok(codes(soundWith({ level: { type: 'gain' } }, [{ from: 'tone', to: 'level', depth: 3 }])).includes('ERR_UNKNOWN_FIELD')); assert.ok(codes(soundWith({ shaper: { type: 'waveshaper' }, wobble: { type: 'lfo' } }, [ { from: 'shaper', to: 'tone.detune', depth: 5 } ])).includes('ERR_INVALID_ROUTE')); }); test('two modulation routes onto one property are both retained for summation', () => { const document = soundWith({ slow: { type: 'lfo', frequency: 0.5 }, fast: { type: 'lfo', frequency: 6 } }, [ { from: 'slow', to: 'tone.frequency', depth: 20 }, { from: 'fast', to: 'tone.frequency', depth: 5 } ]); clean(document); const plan = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42) }); const onTone = plan.routes.filter((route) => route.kind === 'modulation' && route.property === 'frequency'); assert.deepEqual(onTone.map((route) => route.depth), [20, 5]); }); /* --- 15.19 traces 4, 6: components and limits -------------------------------- */ const componentDocument = (overrides = {}) => exhibit({ components: { audio: { voice: { parameters: { pitch: { type: 'number', default: 220, min: 20, max: 2000 } }, input: false, nodes: { tone: { type: 'oscillator', frequency: { ref: 'inputs.pitch' } }, level: { type: 'gain', gain: 0.3 } }, routes: [{ from: 'tone', to: 'level' }, { from: 'level', to: 'output' }] } } }, sounds: { probe: { name: 'Probe', recipe: { mode: 'continuous', nodes: { a: { type: 'component', use: 'voice', values: { pitch: 330 } } }, routes: [{ from: 'a', to: 'output' }] } } }, ...overrides }); test('a component expands, resolves its inputs, and encapsulates its internals', () => { const document = componentDocument(); clean(document); const expansion = expandSoundGraph(document, 'probe'); assert.deepEqual(expansion.errors, []); const paths = expansion.nodes.map((node) => node.path).sort(); assert.deepEqual(paths, ['a', 'a.level', 'a.output', 'a.tone'].sort()); const plan = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42) }); assert.equal(plan.nodes.find((node) => node.path === 'a.tone').values.frequency, 330); const defaulted = componentDocument(); delete defaulted.sounds.probe.recipe.nodes.a.values; const defaultPlan = instantiateSoundGraph(defaulted, 'probe', { rng: new SeededRNG(42) }); assert.equal(defaultPlan.nodes.find((node) => node.path === 'a.tone').values.frequency, 220); const reachInside = componentDocument(); reachInside.sounds.probe.recipe.routes = [{ from: 'a.tone', to: 'output' }]; assert.ok(codes(reachInside).length > 0); const unknownValue = componentDocument(); unknownValue.sounds.probe.recipe.nodes.a.values = { volume: 1 }; assert.ok(codes(unknownValue).includes('ERR_UNKNOWN_FIELD')); const badInput = componentDocument(); badInput.sounds.probe.recipe.nodes.b = { type: 'oscillator' }; badInput.sounds.probe.recipe.routes.push({ from: 'b', to: 'a' }); assert.ok(codes(badInput).includes('ERR_INVALID_ROUTE')); }); test('an exposed component parameter is a legal modulation target', () => { const document = componentDocument(); document.sounds.probe.recipe.nodes.wobble = { type: 'lfo', frequency: 2 }; document.sounds.probe.recipe.routes.push({ from: 'wobble', to: 'a.pitch', depth: 12 }); clean(document); document.sounds.probe.recipe.routes.pop(); document.sounds.probe.recipe.routes.push({ from: 'wobble', to: 'a.volume', depth: 12 }); assert.ok(codes(document).includes('ERR_UNSUPPORTED_TARGET')); }); test('component recursion is rejected', () => { const document = componentDocument(); document.components.audio.voice.nodes.inner = { type: 'component', use: 'voice' }; assert.ok(codes(document).includes('ERR_COMPONENT_RECURSION')); }); test('inputs.* resolves only inside a component graph', () => { const document = soundWith({}, []); document.sounds.probe.recipe.nodes.tone.frequency = { ref: 'inputs.pitch' }; assert.ok(codes(document).includes('ERR_INVALID_REFERENCE')); const undeclared = componentDocument(); undeclared.components.audio.voice.nodes.tone.frequency = { ref: 'inputs.missing' }; assert.ok(codes(undeclared).includes('ERR_INVALID_REFERENCE')); }); test('expanded node and route counts are enforced at their documented limits', () => { const build = (count) => { const nodes = {}; const routes = []; for (let index = 0; index < count; index += 1) { nodes[`g-${index}`] = { type: 'gain' }; routes.push({ from: 'tone', to: `g-${index}` }, { from: `g-${index}`, to: 'output' }); } return soundWith(nodes, routes); }; const atLimit = build(AUDIO_LIMITS.nodesPerSound - 1); assert.equal(expandSoundGraph(atLimit, 'probe').nodes.filter((node) => !node.implicit).length, AUDIO_LIMITS.nodesPerSound); clean(atLimit); assert.ok(codes(build(AUDIO_LIMITS.nodesPerSound)).includes('ERR_NODE_LIMIT_EXCEEDED')); }); test('per-node authoring limits are enforced', () => { const partials = Array.from({ length: 65 }, (unused, index) => ({ ratio: index + 1, gain: 0.1 })); assert.ok(codes(soundWith({ probe: { type: 'oscillator', waveform: 'custom', frequency: 40, harmonics: partials } })).includes('ERR_NODE_LIMIT_EXCEEDED')); const modes = Array.from({ length: 17 }, (unused, index) => ({ ratio: index + 1 })); assert.ok(codes(soundWith({ probe: { type: 'resonator', modes } })).includes('ERR_NODE_LIMIT_EXCEEDED')); }); /* --- buses, recipes, and sound definitions ---------------------------------- */ test('buses, shared recipes, and sound metadata validate against their contracts', () => { assert.ok(codes(exhibit({ audio: { buses: { master: { gain: 1 } } } })).includes('ERR_INVALID_ID')); assert.ok(codes(exhibit({ audio: { master: {} } })).includes('ERR_UNKNOWN_FIELD')); assert.ok(codes(exhibit({ audio: { buses: { ambient: { gain: 9 } } } })).includes('ERR_OUT_OF_BOUNDS')); assert.ok(codes(soundWith({}, [], { bus: 'missing' })).includes('ERR_INVALID_REFERENCE')); assert.ok(codes(soundWith({}, [], { usage: ['sideways'] })).includes('ERR_TYPE_MISMATCH')); const shared = exhibit({ audio: { buses: { ambient: { gain: 1 } }, recipes: { drone: { mode: 'continuous', nodes: { tone: { type: 'oscillator' } }, routes: [{ from: 'tone', to: 'output' }] } } }, sounds: { probe: { name: 'Probe', bus: 'ambient', recipe: { use: 'drone' } } } }); clean(shared); assert.equal(expandSoundGraph(shared, 'probe').mode, 'continuous'); const mixedRecipe = structuredClone(shared); mixedRecipe.sounds.probe.recipe = { use: 'drone', mode: 'oneshot' }; assert.ok(codes(mixedRecipe).includes('ERR_SCHEMA_VALIDATION')); const missingRecipe = structuredClone(shared); missingRecipe.sounds.probe.recipe = { use: 'absent' }; assert.ok(codes(missingRecipe).includes('ERR_INVALID_REFERENCE')); }); test('an exhibit with no audio section still validates', () => { clean(exhibit({ parameters: { level: { type: 'number', default: 0.5, min: 0, max: 1 } } })); }); /* --- realization smoke test against a recording stand-in ---------------------- */ function mockContext() { const log = { connections: [], created: [], started: 0, stopped: 0 }; const param = (value = 0) => ({ value, setValueAtTime() { return this; }, linearRampToValueAtTime() { return this; }, exponentialRampToValueAtTime() { return this; } }); const base = (kind, extra = {}) => { log.created.push(kind); const node = { kind, connect(target) { log.connections.push([kind, target?.kind ?? 'param']); return target; }, disconnect() {}, ...extra }; return node; }; return { log, sampleRate: 48000, currentTime: 0, state: 'running', destination: base('destination'), createGain: () => base('gain', { gain: param(1) }), createOscillator: () => base('oscillator', { frequency: param(440), detune: param(0), type: 'sine', setPeriodicWave() {}, start() { log.started += 1; }, stop() { log.stopped += 1; } }), createConstantSource: () => base('constant', { offset: param(0), start() { log.started += 1; }, stop() { log.stopped += 1; } }), createBufferSource: () => base('buffer-source', { buffer: null, loop: false, start() { log.started += 1; }, stop() { log.stopped += 1; } }), createBiquadFilter: () => base('filter', { type: 'lowpass', frequency: param(1000), Q: param(1), gain: param(0), detune: param(0) }), createDynamicsCompressor: () => base('compressor', { threshold: param(-24), knee: param(30), ratio: param(12), attack: param(0.003), release: param(0.25) }), createWaveShaper: () => base('waveshaper', { curve: null, oversample: 'none' }), createDelay: () => base('delay', { delayTime: param(0) }), createConvolver: () => base('convolver', { buffer: null }), createStereoPanner: () => base('panner', { pan: param(0) }), createPeriodicWave: () => ({ kind: 'periodic-wave' }), createBuffer: (channels, length) => ({ length, getChannelData: () => new Float32Array(length) }) }; } test('every node type realizes against an AudioContext stand-in and disposes cleanly', async () => { const { realizeSoundGraph, AudioSubsystem } = await import('../src/runtime/audio-engine.js'); const document = soundWith({ hiss: { type: 'noise', color: 'pink' }, hit: { type: 'impulse' }, bias: { type: 'constant' }, wobble: { type: 'lfo', polarity: 'unipolar' }, step: { type: 'sample-hold', rate: 2, slew: '50ms' }, level: { type: 'gain', gain: 0.5 }, shape: { type: 'filter' }, squeeze: { type: 'compressor' }, bend: { type: 'waveshaper', amount: 0.4 }, echo: { type: 'delay' }, space: { type: 'reverb' }, place: { type: 'stereo-pan' }, blend: { type: 'mixer' }, body: { type: 'resonator', modes: [{ ratio: 1, decay: '200ms' }, { ratio: 3.1, gain: 0.3 }] } }, [ { from: 'hiss', to: 'blend' }, { from: 'hit', to: 'blend' }, { from: 'blend', to: 'body' }, { from: 'body', to: 'shape' }, { from: 'shape', to: 'squeeze' }, { from: 'squeeze', to: 'bend' }, { from: 'bend', to: 'echo' }, { from: 'echo', to: 'space' }, { from: 'space', to: 'place' }, { from: 'place', to: 'level' }, { from: 'level', to: 'output' }, { from: 'bias', to: 'level.gain', depth: 0.1 }, { from: 'wobble', to: 'shape.frequency', depth: 200 }, { from: 'step', to: 'echo.time', depth: 5 } ]); clean(document); const context = mockContext(); const plan = instantiateSoundGraph(document, 'probe', { sampleRate: context.sampleRate, rng: new SeededRNG(42) }); assert.deepEqual(plan.errors, []); const voice = realizeSoundGraph(context, plan, context.destination); assert.ok(context.log.created.length > 20); assert.ok(context.log.started >= 5); assert.ok(context.log.connections.some(([, target]) => target === 'destination')); voice.dispose(); assert.equal(context.log.stopped, context.log.started); const subsystem = new AudioSubsystem({ document, rng: new SeededRNG(42), protectionFactory: fakeProtectionFactory, contextFactory: () => mockContext() }); await subsystem.unlock(); const handle = subsystem.play('probe'); assert.equal(handle.soundId, 'probe'); assert.equal(subsystem.voices.size, 1); assert.equal(subsystem.nextOrdinal('probe'), 1); subsystem.setMasterVolume(0.5); assert.equal(subsystem.master.gain.value, 0.5); subsystem.setBusGain('ambient', 2); assert.equal(subsystem.buses.get('ambient').gain.value, 2); subsystem.stopAll(); assert.equal(subsystem.voices.size, 0); await subsystem.dispose(); }); /* --- 15.16 recipe release field validation ------------------------------------ */ test('recipe release duration validates in 0ms to 10s and rejects out of bounds or invalid', () => { clean(exhibit({ audio: { buses: { ambient: { gain: 1 } }, recipes: { shared: { release: '0ms', mode: 'continuous', nodes: { tone: { type: 'oscillator' } }, routes: [{ from: 'tone', to: 'output' }] } } }, sounds: { probe: { name: 'Probe', bus: 'ambient', recipe: { use: 'shared' } } } })); clean(exhibit({ audio: { buses: { ambient: { gain: 1 } } }, sounds: { probe: { name: 'Probe', bus: 'ambient', recipe: { release: '10s', mode: 'continuous', nodes: { tone: { type: 'oscillator' } }, routes: [{ from: 'tone', to: 'output' }] } } } })); // invalid duration format assert.ok(codes(exhibit({ sounds: { probe: { name: 'Probe', recipe: { release: '-10ms', nodes: { hit: { type: 'impulse' } }, routes: [{ from: 'hit', to: 'output' }] } } } })).includes('ERR_INVALID_DURATION')); // exceeds 10s (fails bounds) assert.ok(codes(exhibit({ sounds: { probe: { name: 'Probe', recipe: { release: '11s', nodes: { hit: { type: 'impulse' } }, routes: [{ from: 'hit', to: 'output' }] } } } })).includes('ERR_OUT_OF_BOUNDS')); // invalid format string assert.ok(codes(exhibit({ sounds: { probe: { name: 'Probe', recipe: { release: 'slow', nodes: { hit: { type: 'impulse' } }, routes: [{ from: 'hit', to: 'output' }] } } } })).includes('ERR_INVALID_DURATION')); // release inside a component graph is rejected (it is a recipe field, not a component field) assert.ok(codes(exhibit({ components: { audio: { piece: { release: '50ms', nodes: { hit: { type: 'impulse' } }, routes: [{ from: 'hit', to: 'output' }] } } }, sounds: { probe: { name: 'Probe', recipe: { nodes: { c: { type: 'component', use: 'piece' } }, routes: [{ from: 'c', to: 'output' }] } } } })).includes('ERR_UNKNOWN_FIELD')); }); /* --- 16.11 trace 7: lifecycle states and permitted/forbidden transitions ----- */ test('trace 7: permitted lifecycle transitions succeed, forbidden transitions are rejected, and stop on terminal states is idempotent', () => { // Test valid linear lifecycle: CREATED -> SCHEDULED -> ACTIVE -> RELEASING -> FINISHED -> DISPOSED const instance = new SoundInstance({ soundId: 'test-sound', mode: 'oneshot', releaseMs: 50 }); assert.equal(instance.state, 'CREATED'); assert.ok(instance.canTransition('SCHEDULED')); assert.ok(instance.canTransition('FINISHED')); assert.ok(instance.canTransition('FAILED')); assert.equal(instance.canTransition('ACTIVE'), false); assert.equal(instance.canTransition('RELEASING'), false); assert.equal(instance.canTransition('DISPOSED'), false); instance.transition('SCHEDULED'); assert.equal(instance.state, 'SCHEDULED'); assert.ok(instance.canTransition('ACTIVE')); assert.ok(instance.canTransition('RELEASING')); assert.ok(instance.canTransition('FAILED')); assert.equal(instance.canTransition('FINISHED'), false); assert.equal(instance.canTransition('DISPOSED'), false); instance.transition('ACTIVE'); assert.equal(instance.state, 'ACTIVE'); assert.ok(instance.canTransition('RELEASING')); assert.ok(instance.canTransition('FINISHED')); assert.ok(instance.canTransition('FAILED')); assert.equal(instance.canTransition('SCHEDULED'), false); assert.equal(instance.canTransition('CREATED'), false); assert.equal(instance.canTransition('DISPOSED'), false); instance.transition('RELEASING'); assert.equal(instance.state, 'RELEASING'); assert.ok(instance.canTransition('FINISHED')); assert.ok(instance.canTransition('FAILED')); assert.equal(instance.canTransition('ACTIVE'), false); assert.equal(instance.canTransition('DISPOSED'), false); instance.transition('FINISHED'); assert.equal(instance.state, 'FINISHED'); assert.ok(instance.canTransition('DISPOSED')); assert.equal(instance.canTransition('ACTIVE'), false); assert.equal(instance.canTransition('RELEASING'), false); instance.transition('DISPOSED'); assert.equal(instance.state, 'DISPOSED'); assert.equal(instance.canTransition('ACTIVE'), false); assert.equal(instance.canTransition('FINISHED'), false); // Forbidden transition raises ERR_RUNTIME_FAULT const fresh = new SoundInstance({ soundId: 'fresh', mode: 'oneshot' }); assert.throws(() => fresh.transition('ACTIVE'), (err) => err.code === 'ERR_RUNTIME_FAULT'); assert.throws(() => fresh.transition('DISPOSED'), (err) => err.code === 'ERR_RUNTIME_FAULT'); // Alternative paths: // CREATED -> FINISHED (stop before scheduling) const createdStop = new SoundInstance({ soundId: 'created-stop', mode: 'oneshot' }); createdStop.stop(); assert.equal(createdStop.state, 'FINISHED'); // SCHEDULED -> RELEASING (stop while scheduled) const schedStop = new SoundInstance({ soundId: 'sched-stop', mode: 'oneshot', releaseMs: 0 }); schedStop.transition('SCHEDULED'); schedStop.stop(); assert.equal(schedStop.state, 'FINISHED'); // ACTIVE -> FINISHED without release (natural determinable ending for one-shot) const endingOneshot = new SoundInstance({ soundId: 'ending', mode: 'oneshot' }); endingOneshot.transition('SCHEDULED'); endingOneshot.transition('ACTIVE'); endingOneshot.transition('FINISHED'); assert.equal(endingOneshot.state, 'FINISHED'); // FAILED is terminal (PRD 57, 16.3) const failedInstance = new SoundInstance({ soundId: 'failed', mode: 'oneshot' }); failedInstance.transition('FAILED'); assert.equal(failedInstance.state, 'FAILED'); assert.equal(failedInstance.canTransition('DISPOSED'), false); assert.equal(failedInstance.canTransition('ACTIVE'), false); assert.throws(() => failedInstance.transition('DISPOSED'), (err) => err.code === 'ERR_RUNTIME_FAULT'); // Idempotence: calling stop() on FINISHED, DISPOSED, FAILED, or RELEASING is a no-op const terminal = new SoundInstance({ soundId: 'terminal', mode: 'oneshot' }); terminal.transition('FINISHED'); terminal.stop(); assert.equal(terminal.state, 'FINISHED'); terminal.transition('DISPOSED'); terminal.stop(); assert.equal(terminal.state, 'DISPOSED'); const failedTerminal = new SoundInstance({ soundId: 'failed-term', mode: 'oneshot' }); failedTerminal.transition('FAILED'); failedTerminal.stop(); assert.equal(failedTerminal.state, 'FAILED'); }); /* --- 16.11 trace 9: determinable one-shot endings ----------------------------- */ test('trace 9: determinable ending bounds match section 16.5 table and reject unbounded sources in oneshots', () => { // 1. Impulse only: duration + release const impulseDoc = exhibit({ sounds: { hit: { name: 'Hit', recipe: { release: '50ms', nodes: { pulse: { type: 'impulse', duration: '20ms' } }, routes: [{ from: 'pulse', to: 'output' }] } } } }); clean(impulseDoc); const impulseExpansion = expandSoundGraph(impulseDoc, 'hit'); const impulseBound = computeDeterminableEndingBound(impulseExpansion); assert.equal(impulseBound, 20 + 50); // 2. Impulse -> Resonator const resonatorDoc = exhibit({ sounds: { bell: { name: 'Bell', recipe: { release: '50ms', nodes: { hit: { type: 'impulse', duration: '6ms' }, body: { type: 'resonator', modes: [ { ratio: 1, decay: '260ms' }, { ratio: 2.7, decay: '140ms' }, { ratio: 5.4, decay: '70ms' } ] } }, routes: [{ from: 'hit', to: 'body' }, { from: 'body', to: 'output' }] } } } }); clean(resonatorDoc); const resonatorBound = computeDeterminableEndingBound(expandSoundGraph(resonatorDoc, 'bell')); assert.equal(resonatorBound, 6 + 260 + 50); // 3. Impulse -> Delay with feedback // feedback = 0.5: Math.ceil(Math.log(0.001) / Math.log(0.5)) = 10 // delay time = 200ms -> delay bound = 2000ms const delayDoc = exhibit({ sounds: { echo: { name: 'Echo', recipe: { release: '50ms', nodes: { hit: { type: 'impulse', duration: '10ms' }, d: { type: 'delay', time: '200ms', feedback: 0.5 } }, routes: [{ from: 'hit', to: 'd' }, { from: 'd', to: 'output' }] } } } }); clean(delayDoc); const delayBound = computeDeterminableEndingBound(expandSoundGraph(delayDoc, 'echo')); assert.equal(delayBound, 10 + (200 * 10) + 50); // 4. Impulse -> Delay with feedback 0 -> multiplier is 1 (time alone) const delayZeroDoc = exhibit({ sounds: { echo: { name: 'Echo', recipe: { release: '50ms', nodes: { hit: { type: 'impulse', duration: '10ms' }, d: { type: 'delay', time: '200ms', feedback: 0 } }, routes: [{ from: 'hit', to: 'd' }, { from: 'd', to: 'output' }] } } } }); clean(delayZeroDoc); const delayZeroBound = computeDeterminableEndingBound(expandSoundGraph(delayZeroDoc, 'echo')); assert.equal(delayZeroBound, 10 + 200 + 50); // 5. Impulse -> Reverb: predelay + decay const reverbDoc = exhibit({ sounds: { hall: { name: 'Hall', recipe: { release: '50ms', nodes: { hit: { type: 'impulse', duration: '10ms' }, verb: { type: 'reverb', predelay: '40ms', decay: '1500ms' } }, routes: [{ from: 'hit', to: 'verb' }, { from: 'verb', to: 'output' }] } } } }); clean(reverbDoc); const reverbBound = computeDeterminableEndingBound(expandSoundGraph(reverbDoc, 'hall')); assert.equal(reverbBound, 10 + (40 + 1500) + 50); // 6. Branching paths to mixer: longest path wins // Path A: hit (10ms) -> delay (time 100ms, feedback 0) = 110ms // Path B: hit (10ms) -> verb (predelay 0ms, decay 800ms) = 810ms const branchDoc = exhibit({ sounds: { dual: { name: 'Dual', recipe: { release: '50ms', nodes: { hit: { type: 'impulse', duration: '10ms' }, d: { type: 'delay', time: '100ms', feedback: 0 }, v: { type: 'reverb', predelay: '0ms', decay: '800ms' }, mix: { type: 'mixer' } }, routes: [ { from: 'hit', to: 'd' }, { from: 'hit', to: 'v' }, { from: 'd', to: 'mix' }, { from: 'v', to: 'mix' }, { from: 'mix', to: 'output' } ] } } } }); clean(branchDoc); const branchBound = computeDeterminableEndingBound(expandSoundGraph(branchDoc, 'dual')); assert.equal(branchBound, 10 + 800 + 50); // 7. Unbounded source in oneshot rejected with ERR_INDETERMINATE_ONESHOT const oscOneshot = exhibit({ sounds: { tone: { name: 'Tone', recipe: { mode: 'oneshot', nodes: { osc: { type: 'oscillator' } }, routes: [{ from: 'osc', to: 'output' }] } } } }); assert.ok(codes(oscOneshot).includes('ERR_INDETERMINATE_ONESHOT')); const noiseOneshot = exhibit({ sounds: { hiss: { name: 'Hiss', recipe: { mode: 'oneshot', nodes: { n: { type: 'noise' } }, routes: [{ from: 'n', to: 'output' }] } } } }); assert.ok(codes(noiseOneshot).includes('ERR_INDETERMINATE_ONESHOT')); // 8. Continuous sound with identical shape validates cleanly const oscContinuous = exhibit({ sounds: { tone: { name: 'Tone', recipe: { mode: 'continuous', nodes: { osc: { type: 'oscillator' } }, routes: [{ from: 'osc', to: 'output' }] } } } }); clean(oscContinuous); const noiseContinuous = exhibit({ sounds: { hiss: { name: 'Hiss', recipe: { mode: 'continuous', nodes: { n: { type: 'noise' } }, routes: [{ from: 'n', to: 'output' }] } } } }); clean(noiseContinuous); }); /* --- 16.11 trace 10: voice ceilings, eviction order, and refusal stability --- */ test('trace 10: eviction order is followed at ceiling, WARN_VOICE_LIMIT raised, no cross-pool eviction, and refusal is stable', async () => { const warnings = []; const diagnostics = { warn(code, message, meta) { warnings.push({ code, message, meta }); }, error(code, message, meta) { /* errors */ } }; const doc = exhibit({ sounds: { short: { name: 'Short', recipe: { mode: 'oneshot', release: '50ms', nodes: { hit: { type: 'impulse', duration: '5ms' } }, routes: [{ from: 'hit', to: 'output' }] } }, drone: { name: 'Drone', recipe: { mode: 'continuous', release: '50ms', nodes: { tone: { type: 'oscillator' } }, routes: [{ from: 'tone', to: 'output' }] } } } }); // Test with compact voice limits: 3 oneshots, 2 continuous const limits = { oneshot: 3, continuous: 2 }; const subsystem = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), protectionFactory: fakeProtectionFactory, contextFactory: () => mockContext(), diagnostics, voiceLimits: limits }); await subsystem.unlock(); // 1. Fill oneshot pool to ceiling (3 instances) const v1 = subsystem.play('short'); const v2 = subsystem.play('short'); const v3 = subsystem.play('short'); assert.equal(subsystem.oneshotVoices.size, 3); assert.equal(warnings.length, 0); // 2. Put v1 into FINISHED state v1.transition('RELEASING'); v1.transition('FINISHED'); assert.equal(subsystem.oneshotVoices.size, 3); // Still counts in FINISHED! // 3. Play a 4th oneshot -> Eviction step 1: should dispose oldest FINISHED (v1) const v4 = subsystem.play('short'); assert.ok(v4); assert.equal(v1.state, 'DISPOSED'); assert.equal(subsystem.oneshotVoices.size, 3); assert.equal(warnings.filter((w) => w.code === 'WARN_VOICE_LIMIT').length, 1); // 4. Put v2 into RELEASING state (no FINISHED instances remain) v2.transition('RELEASING'); // Play a 5th oneshot -> Eviction step 2: should advance oldest RELEASING (v2) to FINISHED & DISPOSED const v5 = subsystem.play('short'); assert.ok(v5); assert.equal(v2.state, 'DISPOSED'); assert.equal(subsystem.oneshotVoices.size, 3); assert.equal(warnings.filter((w) => w.code === 'WARN_VOICE_LIMIT').length, 2); // 5. Now all instances are ACTIVE (v3, v4, v5). // Play a 6th oneshot -> Eviction step 3 (oneshot only): evict oldest ACTIVE (v3) by starting release const v6 = subsystem.play('short'); assert.ok(v6); assert.equal(v3.state, 'RELEASING'); assert.equal(warnings.filter((w) => w.code === 'WARN_VOICE_LIMIT').length, 3); // 6. Test Continuous sound pool ceiling and refusal const c1 = subsystem.play('drone'); const c2 = subsystem.play('drone'); assert.equal(subsystem.continuousVoices.size, 2); // Oneshot requests DO NOT evict continuous sounds (independent pools) assert.equal(subsystem.continuousVoices.size, 2); // Continuous pool is at ceiling (2 active instances). // A 3rd continuous request cannot evict ACTIVE continuous sounds -> step 4 Refusal! const c3 = subsystem.play('drone'); assert.equal(c3, null); assert.equal(subsystem.continuousVoices.size, 2); const refusalWarn = warnings.find((w) => w.code === 'WARN_VOICE_LIMIT' && w.message.includes('continuous')); assert.ok(refusalWarn); // Subsystem and runtime remain stable after refusal assert.equal(subsystem.unlocked, true); subsystem.stopAll(); await subsystem.dispose(); }); /* --- 16.11 trace 11: disposal completeness and ceiling occupancy ------------- */ test('trace 11: disposal releases all nodes/connections/buffers, and a FINISHED instance counts against ceiling until DISPOSED', async () => { const doc = exhibit({ sounds: { hit: { name: 'Hit', recipe: { mode: 'oneshot', release: '50ms', nodes: { pulse: { type: 'impulse', duration: '10ms' }, level: { type: 'gain', gain: 0.5 } }, routes: [{ from: 'pulse', to: 'level' }, { from: 'level', to: 'output' }] } } } }); const context = mockContext(); const subsystem = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), protectionFactory: fakeProtectionFactory, contextFactory: () => context, voiceLimits: { oneshot: 2, continuous: 2 } }); await subsystem.unlock(); const voice1 = subsystem.play('hit'); assert.equal(voice1.state, 'ACTIVE'); assert.equal(subsystem.oneshotVoices.size, 1); // Transition voice1 to FINISHED (e.g. determinable ending bound) voice1.transition('FINISHED'); assert.equal(voice1.state, 'FINISHED'); // CONTRACT: A FINISHED instance still counts against its ceiling until DISPOSED assert.equal(subsystem.oneshotVoices.size, 1); assert.ok(subsystem.oneshotVoices.has(voice1)); // Second instance const voice2 = subsystem.play('hit'); assert.equal(subsystem.oneshotVoices.size, 2); // Ceiling reached (2/2) // Explicitly disposing voice1 releases its slot voice1.dispose(); assert.equal(voice1.state, 'DISPOSED'); assert.equal(subsystem.oneshotVoices.size, 1); assert.equal(subsystem.oneshotVoices.has(voice1), false); // Calling dispose on voice2 releases its resources voice2.dispose(); assert.equal(voice2.state, 'DISPOSED'); assert.equal(subsystem.oneshotVoices.size, 0); await subsystem.dispose(); });