Files
XZBT/test/phase3-audio.test.mjs
T
LabyricornandClaude Opus 5 f07fc777f3 feat(audio): complete phase 3a/3b audio subsystem contract and runtime
Review Phase 3a before building on it, then implement Phase 3b.

The Phase 3a draft had four blocking defects: nodes were described as a
keyed map while every documented example carried an inline `id` field,
so under the strict unknown-field policy each minimal example would have
failed its own acceptance trace; no section said where a node lives; the
`audioMaxFrequency` ceiling was declared a semantic-stage error while
depending on a live AudioContext sample rate; and the sample-hold PRNG
child key that section 9.3 requires was undocumented. Close all four,
plus nine further gaps in noise seeding, spectral definitions, impulse
decay math, Nyquist handling, missing-field codes, LFO phase origin, the
units table, node-type staging, and a duplicated diagnostics table.

Add Format Specification section 15 for Phase 3b: nine processing and
routing node contracts, the component instance node, audio routing and
modulation with an explicit modulatable-property registry, twelve graph
legality rules, authoring limits, components with a component-scoped
`inputs.*` namespace, sound definitions and recipes, and buses.

Implement the subsystem in three modules. audio-contract.js holds the
declarative node, limit, and modulation tables every consumer reads.
audio-graph.js validates, expands components, and checks legality
without ever opening an AudioContext. audio-engine.js resolves node
fields once from the seeded stream, clamps frequencies to the live
device ceiling, realizes the graph through Web Audio, and owns the
runtime AudioSubsystem. Extend the schema, delegate the standalone
validator's audio checks to the shared module rather than carrying a
second implementation, and add a generic audio fixture.

Phase 3 is not accepted. Automation precedence, the lifecycle state
machine, unlock behavior, voice ceilings, and master protection are
Phase 3c. No sound has been heard from any build, so the audio
acceptance challenge, peak and finite-sample capture, and listening
observations remain open.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011FWPdCqKaaDnP9NC3JAwh6
2026-09-05 22:03:18 +00:00

488 lines
23 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
AUDIO_LIMITS,
AUDIO_NODE_TYPE_NAMES,
AUDIO_STATIC_MAX_FREQUENCY,
audioMaxFrequency
} from '../src/runtime/audio-contract.js';
import { expandSoundGraph } from '../src/runtime/audio-graph.js';
import { instantiateSoundGraph, 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: {
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: { 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: {
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), 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);
});