feat(audio): implement phase 3c slice 2 lifecycle and voices
This commit is contained in:
+506
-3
@@ -1,13 +1,24 @@
|
||||
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 { expandSoundGraph } from '../src/runtime/audio-graph.js';
|
||||
import { instantiateSoundGraph, sampleHoldStreamKey } from '../src/runtime/audio-engine.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';
|
||||
|
||||
@@ -29,6 +40,7 @@ function soundWith(nodes = {}, routes = [], soundOverrides = {}, documentOverrid
|
||||
name: 'Probe',
|
||||
bus: 'ambient',
|
||||
recipe: {
|
||||
mode: 'continuous',
|
||||
nodes: { tone: { type: 'oscillator' }, ...nodes },
|
||||
routes: [{ from: 'tone', to: 'output' }, ...routes]
|
||||
},
|
||||
@@ -225,7 +237,7 @@ test('a control-only path fails while the same shape with an audible source pass
|
||||
});
|
||||
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' }] } } }
|
||||
sounds: { probe: { name: 'Probe', recipe: { mode: 'continuous', nodes: { tone: { type: 'oscillator' }, level: { type: 'gain' } }, routes: [{ from: 'tone', to: 'level' }, { from: 'level', to: 'output' }] } } }
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -271,6 +283,7 @@ const componentDocument = (overrides = {}) => exhibit({
|
||||
probe: {
|
||||
name: 'Probe',
|
||||
recipe: {
|
||||
mode: 'continuous',
|
||||
nodes: { a: { type: 'component', use: 'voice', values: { pitch: 330 } } },
|
||||
routes: [{ from: 'a', to: 'output' }]
|
||||
}
|
||||
@@ -484,4 +497,494 @@ test('every node type realizes against an AudioContext stand-in and disposes cle
|
||||
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),
|
||||
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),
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user