992 lines
41 KiB
JavaScript
992 lines
41 KiB
JavaScript
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();
|
|
});
|