diff --git a/XZBT.html b/XZBT.html
index 7ed17c9..3646e3e 100644
--- a/XZBT.html
+++ b/XZBT.html
@@ -716,13 +716,41 @@ const AUDIO_SOUND_FIELDS = Object.freeze(['name', 'tags', 'usage', 'cadence', 'b
const AUDIO_SOUND_USAGE = Object.freeze(['automatic', 'manual', 'scenario']);
const AUDIO_RECIPE_MODES = Object.freeze(['oneshot', 'continuous']);
const AUDIO_GRAPH_FIELDS = Object.freeze(['nodes', 'routes']);
-const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'mode']);
+const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'mode', 'release']);
const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'parameters', 'input']);
const AUDIO_COMPONENT_PARAMETER_FIELDS = Object.freeze(['type', 'default', 'min', 'max', 'unit']);
const AUDIO_BUS_FIELDS = Object.freeze(['gain']);
const AUDIO_BUS_GAIN_RANGE = Object.freeze({ min: 0, max: 4, default: 1 });
const AUDIO_ROUTE_FIELDS = Object.freeze(['from', 'to', 'depth']);
+const AUDIO_VOICE_LIMITS = Object.freeze({
+ oneshot: 64,
+ continuous: 16
+});
+
+const AUDIO_DEFAULT_RELEASE_MS = 50;
+const AUDIO_MAX_RELEASE_MS = 10000;
+
+const AUDIO_LIFECYCLE_STATES = Object.freeze([
+ 'CREATED',
+ 'SCHEDULED',
+ 'ACTIVE',
+ 'RELEASING',
+ 'FINISHED',
+ 'DISPOSED',
+ 'FAILED'
+]);
+
+const AUDIO_LIFECYCLE_TRANSITIONS = Object.freeze({
+ CREATED: Object.freeze(['SCHEDULED', 'FINISHED', 'FAILED']),
+ SCHEDULED: Object.freeze(['ACTIVE', 'RELEASING', 'FAILED']),
+ ACTIVE: Object.freeze(['RELEASING', 'FINISHED', 'FAILED']),
+ RELEASING: Object.freeze(['FINISHED', 'FAILED']),
+ FINISHED: Object.freeze(['DISPOSED']),
+ DISPOSED: Object.freeze([]),
+ FAILED: Object.freeze([])
+});
+
function audioMaxFrequency(sampleRate) {
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return AUDIO_STATIC_MAX_FREQUENCY;
return Math.min(AUDIO_STATIC_MAX_FREQUENCY, sampleRate * AUDIO_NYQUIST_FACTOR);
@@ -972,7 +1000,7 @@ function expandSoundGraph(document, soundId) {
const sound = document.sounds?.[soundId];
const soundPath = `$.sounds.${soundId}`;
const located = recipeGraphFor(document, sound, soundPath, errors);
- if (!located) return { nodes: [], routes: [], errors, mode: 'oneshot' };
+ if (!located) return { nodes: [], routes: [], errors, mode: 'oneshot', release: AUDIO_DEFAULT_RELEASE_MS };
const nodes = new Map();
const routes = [];
@@ -1084,7 +1112,10 @@ function expandSoundGraph(document, soundId) {
expand(located.graph, '', 1, [], located.path);
const mode = AUDIO_RECIPE_MODES.includes(located.graph.mode) ? located.graph.mode : 'oneshot';
- return { nodes: [...nodes.values()], routes, errors, mode, graphPath: located.path };
+ const release = Object.hasOwn(located.graph, 'release')
+ ? (durationMilliseconds(located.graph.release) ?? AUDIO_DEFAULT_RELEASE_MS)
+ : AUDIO_DEFAULT_RELEASE_MS;
+ return { nodes: [...nodes.values()], routes, errors, mode, release, graphPath: located.path };
}
function detectCycle(adjacency) {
@@ -1187,6 +1218,9 @@ function checkGraphLegality(document, soundId, expansion, errors) {
fail(errors, 'ERR_INVALID_ROUTE', node.path, `Control source '${node.path}' reaches audible output.`);
} else {
audiblePath = true;
+ if (expansion.mode === 'oneshot' && (node.type === 'oscillator' || node.type === 'noise')) {
+ fail(errors, 'ERR_INDETERMINATE_ONESHOT', graphPath, `Sound '${soundId}' is a oneshot with an unbounded audible path from '${node.path}' (${node.type}).`);
+ }
}
}
if (contract.acceptsAudio === false) {
@@ -1235,6 +1269,9 @@ function validateAudioSubsystem(document, errors, helpers) {
if (Object.hasOwn(recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(recipe.mode)) {
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
}
+ if (Object.hasOwn(recipe, 'release')) {
+ validateDurationField(recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.release`, errors);
+ }
}
}
}
@@ -1301,6 +1338,9 @@ function validateAudioSubsystem(document, errors, helpers) {
if (Object.hasOwn(sound.recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(sound.recipe.mode)) {
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.recipe.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
}
+ if (Object.hasOwn(sound.recipe, 'release')) {
+ validateDurationField(sound.recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.recipe.release`, errors);
+ }
}
const expansion = expandSoundGraph(document, id);
errors.push(...expansion.errors);
@@ -1308,6 +1348,124 @@ function validateAudioSubsystem(document, errors, helpers) {
}
}
+function computeDeterminableEndingBound(expansion, resolvedNodes = null) {
+ const { nodes, routes, release = AUDIO_DEFAULT_RELEASE_MS } = expansion;
+ const resolvedMap = resolvedNodes instanceof Map
+ ? resolvedNodes
+ : Array.isArray(resolvedNodes)
+ ? new Map(resolvedNodes.map((n) => [n.path, n]))
+ : null;
+
+ const nodeMap = new Map(nodes.map((n) => [n.path, n]));
+
+ function nodeContribution(path) {
+ if (path === 'output') return 0;
+ const node = nodeMap.get(path);
+ if (!node || node.implicit || node.type === 'component') return 0;
+ const type = node.type;
+ const resolved = resolvedMap?.get(path)?.values;
+
+ if (type === 'impulse') {
+ if (resolved && typeof resolved.duration === 'number') return resolved.duration;
+ return durationMilliseconds(node.spec?.duration ?? '10ms') ?? 10;
+ }
+ if (type === 'oscillator' || type === 'noise') {
+ return Infinity;
+ }
+ if (type === 'delay') {
+ let time, feedback;
+ if (resolved) {
+ time = resolved.time ?? 250;
+ feedback = resolved.feedback ?? 0.2;
+ } else {
+ time = durationMilliseconds(node.spec?.time ?? '250ms') ?? 250;
+ feedback = typeof node.spec?.feedback === 'number' ? node.spec.feedback : 0.2;
+ }
+ if (feedback > 0) {
+ const clampedFeedback = Math.min(0.9999, Math.max(1e-6, feedback));
+ const multiplier = Math.ceil(Math.log(0.001) / Math.log(clampedFeedback));
+ return time * multiplier;
+ }
+ return time;
+ }
+ if (type === 'reverb') {
+ let predelay, decay;
+ if (resolved) {
+ predelay = resolved.predelay ?? 0;
+ decay = resolved.decay ?? 2000;
+ } else {
+ predelay = durationMilliseconds(node.spec?.predelay ?? '0ms') ?? 0;
+ decay = durationMilliseconds(node.spec?.decay ?? '2s') ?? 2000;
+ }
+ return predelay + decay;
+ }
+ if (type === 'resonator') {
+ if (resolved?.modes && Array.isArray(resolved.modes) && resolved.modes.length > 0) {
+ return Math.max(...resolved.modes.map((m) => m.decay ?? 1000));
+ }
+ const modes = node.spec?.modes ?? [];
+ if (modes.length > 0) {
+ return Math.max(...modes.map((m) => durationMilliseconds(m.decay ?? '1s') ?? 1000));
+ }
+ return 1000;
+ }
+ return 0;
+ }
+
+ const audioAdjacency = new Map();
+ const inDegree = new Map();
+ const allNodes = new Set();
+ for (const n of nodes) allNodes.add(n.path);
+ allNodes.add('output');
+
+ for (const n of allNodes) {
+ audioAdjacency.set(n, []);
+ inDegree.set(n, 0);
+ }
+
+ for (const route of routes) {
+ if (route.kind === 'audio') {
+ if (!audioAdjacency.has(route.from)) audioAdjacency.set(route.from, []);
+ audioAdjacency.get(route.from).push(route.to);
+ inDegree.set(route.to, (inDegree.get(route.to) ?? 0) + 1);
+ }
+ }
+
+ const dist = new Map();
+ for (const n of allNodes) dist.set(n, -Infinity);
+
+ for (const n of nodes) {
+ if (n.implicit || n.type === 'component') continue;
+ if (n.type === 'impulse' || n.type === 'oscillator' || n.type === 'noise') {
+ dist.set(n.path, nodeContribution(n.path));
+ }
+ }
+
+ const queue = [];
+ for (const [node, deg] of inDegree.entries()) {
+ if (deg === 0) queue.push(node);
+ }
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ const currentDist = dist.get(current);
+ for (const next of audioAdjacency.get(current) ?? []) {
+ if (currentDist !== -Infinity) {
+ const nextContrib = nodeContribution(next);
+ const newDist = currentDist === Infinity ? Infinity : currentDist + nextContrib;
+ if (newDist > dist.get(next)) dist.set(next, newDist);
+ }
+ inDegree.set(next, inDegree.get(next) - 1);
+ if (inDegree.get(next) === 0) queue.push(next);
+ }
+ }
+
+ const outDist = dist.get('output');
+ if (outDist === Infinity) return Infinity;
+ if (outDist === -Infinity) return release;
+ return outDist + release;
+}
+
/* src/runtime/validator.js */
function issue(code, path, message, context = {}) {
return { code, path, message, ...context };
@@ -2322,7 +2480,15 @@ function instantiateSoundGraph(document, soundId, options = {}) {
const expansion = options.expansion ?? expandSoundGraph(document, soundId);
if (expansion.errors.length > 0) {
- return { nodes: [], routes: [], warnings: [], errors: expansion.errors, mode: expansion.mode };
+ return {
+ nodes: [],
+ routes: [],
+ warnings: [],
+ errors: expansion.errors,
+ mode: expansion.mode,
+ release: expansion.release,
+ endingBoundMs: null
+ };
}
const ceiling = audioMaxFrequency(sampleRate);
@@ -2440,7 +2606,22 @@ function instantiateSoundGraph(document, soundId, options = {}) {
return { ...route, depth: evaluate(route.depth, owner?.scope ?? null, `${route.path}.depth`) };
});
- return { nodes, routes, warnings, errors: [], mode: expansion.mode, instanceKey, ceiling };
+ let endingBoundMs = null;
+ if (expansion.mode === 'oneshot') {
+ endingBoundMs = computeDeterminableEndingBound(expansion, nodes);
+ }
+
+ return {
+ nodes,
+ routes,
+ warnings,
+ errors: [],
+ mode: expansion.mode,
+ release: expansion.release,
+ endingBoundMs,
+ instanceKey,
+ ceiling
+ };
}
/* ------------------------------------------------------------------ *
@@ -2536,7 +2717,10 @@ function realizeSoundGraph(context, plan, destination) {
const starters = [];
const sink = context.createGain();
sink.gain.value = 1;
- sink.connect(destination);
+ const releaseGain = context.createGain();
+ releaseGain.gain.value = 1;
+ sink.connect(releaseGain).connect(destination);
+ disposers.push(() => releaseGain.disconnect());
created.set('output', { input: sink, output: sink });
const now = () => context.currentTime;
@@ -2627,6 +2811,7 @@ function realizeSoundGraph(context, plan, destination) {
};
let tick = 0;
const timer = setInterval(() => { schedule(0); tick += 1; }, Math.max(10, period * 1000));
+ if (typeof timer.unref === 'function') timer.unref();
schedule(0);
entry = { input: null, output: source, params: {} };
starters.push(() => source.start());
@@ -2750,26 +2935,180 @@ function realizeSoundGraph(context, plan, destination) {
return {
sink,
+ releaseGain,
dispose() {
for (const release of disposers.reverse()) {
try { release(); } catch { /* disposal is best effort */ }
}
sink.disconnect();
+ releaseGain.disconnect();
created.clear();
}
};
}
+class SoundInstance {
+ constructor({
+ soundId,
+ mode = 'oneshot',
+ releaseMs = AUDIO_DEFAULT_RELEASE_MS,
+ endingBoundMs = null,
+ plan = null,
+ subsystem = null,
+ bus = null,
+ context = null,
+ creationOrder = 0
+ } = {}) {
+ this.soundId = soundId;
+ this.mode = mode;
+ this.releaseMs = releaseMs ?? AUDIO_DEFAULT_RELEASE_MS;
+ this.endingBoundMs = endingBoundMs;
+ this.plan = plan;
+ this.subsystem = subsystem;
+ this.bus = bus;
+ this.context = context;
+ this.creationOrder = creationOrder;
+ this.state = 'CREATED';
+ this.realized = null;
+ this.startTime = null;
+ this.releaseTimer = null;
+ this.endingTimer = null;
+ }
+
+ canTransition(nextState) {
+ const allowed = AUDIO_LIFECYCLE_TRANSITIONS[this.state] ?? [];
+ return allowed.includes(nextState);
+ }
+
+ transition(nextState) {
+ if (!this.canTransition(nextState)) {
+ throw new RuntimeFault('ERR_RUNTIME_FAULT', `Invalid lifecycle transition from '${this.state}' to '${nextState}'.`);
+ }
+ this.state = nextState;
+ return true;
+ }
+
+ realize(context, destination) {
+ this.realized = realizeSoundGraph(context, this.plan, destination);
+ return this.realized;
+ }
+
+ stop() {
+ if (this.state === 'RELEASING' || this.state === 'FINISHED' || this.state === 'DISPOSED' || this.state === 'FAILED') {
+ return;
+ }
+ if (this.subsystem) {
+ this.subsystem.voices.delete(this);
+ }
+ if (this.state === 'CREATED') {
+ this.transition('FINISHED');
+ return;
+ }
+ if (this.state === 'SCHEDULED') {
+ this.transition('RELEASING');
+ if (this.releaseMs === 0) {
+ this.transition('FINISHED');
+ } else {
+ this.releaseTimer = setTimeout(() => {
+ if (this.state === 'RELEASING') {
+ this.transition('FINISHED');
+ }
+ }, this.releaseMs);
+ if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
+ }
+ return;
+ }
+ if (this.state === 'ACTIVE') {
+ if (this.endingTimer) {
+ clearTimeout(this.endingTimer);
+ this.endingTimer = null;
+ }
+ this.transition('RELEASING');
+ if (this.releaseMs === 0) {
+ if (this.realized?.releaseGain?.gain) {
+ const now = this.context?.currentTime ?? 0;
+ this.realized.releaseGain.gain.cancelScheduledValues?.(now);
+ this.realized.releaseGain.gain.setValueAtTime?.(0, now);
+ }
+ this.transition('FINISHED');
+ } else {
+ const now = this.context?.currentTime ?? 0;
+ const durationSec = this.releaseMs / 1000;
+ const gainParam = this.realized?.releaseGain?.gain;
+ if (gainParam) {
+ gainParam.cancelScheduledValues?.(now);
+ gainParam.setValueAtTime?.(gainParam.value, now);
+ gainParam.linearRampToValueAtTime?.(0, now + durationSec);
+ }
+ this.releaseTimer = setTimeout(() => {
+ if (this.state === 'RELEASING') {
+ this.transition('FINISHED');
+ }
+ }, this.releaseMs);
+ if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
+ }
+ }
+ }
+
+ forceFinishAndDispose() {
+ if (this.state === 'RELEASING') {
+ if (this.releaseTimer) {
+ clearTimeout(this.releaseTimer);
+ this.releaseTimer = null;
+ }
+ if (this.realized?.releaseGain?.gain) {
+ const now = this.context?.currentTime ?? 0;
+ this.realized.releaseGain.gain.cancelScheduledValues?.(now);
+ this.realized.releaseGain.gain.setValueAtTime?.(0, now);
+ }
+ this.transition('FINISHED');
+ }
+ this.dispose();
+ }
+
+ dispose() {
+ if (this.state === 'DISPOSED') return;
+ if (this.releaseTimer) {
+ clearTimeout(this.releaseTimer);
+ this.releaseTimer = null;
+ }
+ if (this.endingTimer) {
+ clearTimeout(this.endingTimer);
+ this.endingTimer = null;
+ }
+ if (this.subsystem) {
+ this.subsystem.voices.delete(this);
+ this.subsystem.oneshotVoices?.delete(this);
+ this.subsystem.continuousVoices?.delete(this);
+ }
+ if (this.state !== 'FINISHED' && this.state !== 'FAILED') {
+ if (this.state === 'ACTIVE' || this.state === 'SCHEDULED') {
+ this.transition('RELEASING');
+ this.transition('FINISHED');
+ } else if (this.state === 'CREATED') {
+ this.transition('FINISHED');
+ } else if (this.state === 'RELEASING') {
+ this.transition('FINISHED');
+ }
+ }
+ this.transition('DISPOSED');
+ if (this.realized) {
+ try { this.realized.dispose(); } catch { /* best effort */ }
+ this.realized = null;
+ }
+ }
+}
+
/* ------------------------------------------------------------------ *
* Runtime subsystem
* ------------------------------------------------------------------ */
// Owns the AudioContext, the declared buses, and the engine master chain, and turns a
// sound definition into a realized voice. The lifecycle state machine (PRD 57), voice
-// ceilings, and the measured master-protection contract (PRD 58) are Phase 3c; the master
-// chain built here is engine-owned and unbypassable but its ceiling is not yet verified.
+// ceilings (16.6), and the measured master-protection contract (PRD 58) are Phase 3c;
+// the master chain built here is engine-owned and unbypassable.
class AudioSubsystem {
- constructor({ document, rng, diagnostics = null, contextFactory = null } = {}) {
+ constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null } = {}) {
this.document = document;
this.rng = rng;
this.diagnostics = diagnostics;
@@ -2780,7 +3119,11 @@ class AudioSubsystem {
this.protection = null;
this.buses = new Map();
this.voices = new Set();
+ this.oneshotVoices = new Set();
+ this.continuousVoices = new Set();
+ this.voiceLimits = voiceLimits ?? { ...AUDIO_VOICE_LIMITS };
this.ordinals = new Map();
+ this.nextCreationOrder = 0;
this.masterVolume = 0.8;
}
@@ -2853,41 +3196,114 @@ class AudioSubsystem {
play(soundId, { resolveReference } = {}) {
if (!this.unlocked) return null;
+ const sound = this.document?.sounds?.[soundId];
+ if (!sound) return null;
+
+ const expansion = expandSoundGraph(this.document, soundId);
+ if (expansion.errors.length > 0) {
+ for (const error of expansion.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
+ return null;
+ }
+
+ const mode = expansion.mode ?? 'oneshot';
+ const pool = mode === 'oneshot' ? this.oneshotVoices : this.continuousVoices;
+ const ceiling = mode === 'oneshot' ? this.voiceLimits.oneshot : this.voiceLimits.continuous;
+
+ if (pool.size >= ceiling) {
+ // 16.6 Eviction policy applied in strict order:
+ // 1. Dispose the oldest instance already in FINISHED.
+ const finishedInstances = [...pool].filter((i) => i.state === 'FINISHED');
+ if (finishedInstances.length > 0) {
+ finishedInstances.sort((a, b) => a.creationOrder - b.creationOrder);
+ const candidate = finishedInstances[0];
+ candidate.dispose();
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; disposed oldest FINISHED instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
+ } else {
+ // 2. Evict the oldest instance in RELEASING by advancing its release ramp to immediate completion and disposing it.
+ const releasingInstances = [...pool].filter((i) => i.state === 'RELEASING');
+ if (releasingInstances.length > 0) {
+ releasingInstances.sort((a, b) => a.creationOrder - b.creationOrder);
+ const candidate = releasingInstances[0];
+ candidate.forceFinishAndDispose();
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicted oldest RELEASING instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
+ } else if (mode === 'oneshot') {
+ // 3. For a one-shot request only: evict the oldest ACTIVE one-shot by starting its release.
+ const activeInstances = [...pool].filter((i) => i.state === 'ACTIVE' || i.state === 'SCHEDULED');
+ if (activeInstances.length > 0) {
+ activeInstances.sort((a, b) => a.creationOrder - b.creationOrder);
+ const candidate = activeInstances[0];
+ candidate.stop();
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicting oldest ACTIVE oneshot instance of '${candidate.soundId}' via release.`, { section: 'audio', objectId: soundId });
+ } else {
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; request refused.`, { section: 'audio', objectId: soundId });
+ return null;
+ }
+ } else {
+ // 4. Otherwise refuse the new instance.
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; continuous request refused.`, { section: 'audio', objectId: soundId });
+ return null;
+ }
+ }
+ }
+
const plan = instantiateSoundGraph(this.document, soundId, {
sampleRate: this.context.sampleRate,
rng: this.rng,
ordinal: this.nextOrdinal(soundId),
- resolveReference
+ resolveReference,
+ expansion
});
for (const error of plan.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
if (plan.errors.length > 0) return null;
for (const warning of plan.warnings) this.diagnostics?.warn(warning.code, warning.message, { section: 'audio', objectId: soundId, property: warning.path });
- let voice;
- try {
- voice = realizeSoundGraph(this.context, plan, this.busFor(soundId));
- } catch (error) {
- this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
- return null;
- }
- const handle = {
+
+ const instance = new SoundInstance({
soundId,
mode: plan.mode,
- stop: () => {
- if (!this.voices.has(handle)) return;
- this.voices.delete(handle);
- voice.dispose();
- }
- };
- this.voices.add(handle);
- return handle;
+ releaseMs: plan.release,
+ endingBoundMs: plan.endingBoundMs,
+ plan,
+ subsystem: this,
+ bus: this.busFor(soundId),
+ context: this.context,
+ creationOrder: this.nextCreationOrder++
+ });
+ pool.add(instance);
+
+ try {
+ instance.transition('SCHEDULED');
+ instance.realize(this.context, this.busFor(soundId));
+ instance.transition('ACTIVE');
+ instance.startTime = this.context.currentTime;
+ this.voices.add(instance);
+ } catch (error) {
+ this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
+ instance.transition('FAILED');
+ instance.dispose();
+ return null;
+ }
+
+ if (mode === 'oneshot' && instance.endingBoundMs != null && Number.isFinite(instance.endingBoundMs)) {
+ instance.endingTimer = setTimeout(() => {
+ if (instance.state === 'ACTIVE') {
+ this.voices.delete(instance);
+ instance.transition('FINISHED');
+ }
+ }, instance.endingBoundMs);
+ if (typeof instance.endingTimer?.unref === 'function') instance.endingTimer.unref();
+ }
+
+ return instance;
}
stopAll() {
- for (const handle of [...this.voices]) handle.stop();
+ for (const instance of [...this.voices]) instance.stop();
}
async dispose() {
this.stopAll();
+ for (const instance of [...this.oneshotVoices]) instance.dispose();
+ for (const instance of [...this.continuousVoices]) instance.dispose();
this.buses.clear();
if (this.context) {
try { await this.context.close(); } catch { /* already closed */ }
@@ -3107,12 +3523,7 @@ class XZBTApplication {
playSound(id) {
const current = this.activation.current;
if (!this.audio?.unlocked || !current) return;
- const handle = this.audio.play(id, { resolveReference: (path) => current.performance.engine.get(path) });
- if (handle?.mode === 'oneshot') {
- // Phase 3b has no lifecycle contract yet (PRD 57 is Phase 3c), so a one-shot voice is
- // released on a fixed development timer rather than on a determinable ending.
- setTimeout(() => handle.stop(), 4000);
- }
+ this.audio.play(id, { resolveReference: (path) => current.performance.engine.get(path) });
this.renderAudio();
}
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 59f6556..260e7a4 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,10 +1,10 @@
# XZBT implementation status
**Updated:** September 5, 2026
-**State:** The audio subsystem contract is complete through Phase 3c; Phase 3c implementation (slices 2-4), the Phase 3 audible gates, and the Phase 1 direct-file import/restart observation remain pending
+**State:** The audio subsystem contract is complete through Phase 3c; Phase 3c slices 1 and 2 implemented; automation (slice 3) and master protection (slice 4), the Phase 3 audible gates, and the Phase 1 direct-file import/restart observation remain pending
**Planning baseline:** `05fe2b4e021ba86e4a290d05b63c7cae0e386128`
-**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton, the Phase 2 common grammar, and the Phase 3a/3b audio authoring contract and its implementation pass automated checks. Three gates remain open in the completed work: Phase 1's direct-file two-fixture restart observation, Phase 3's audible observation (no sound has been heard from any build), and the Phase 3c audio contracts on which real playback acceptance depends. Visual, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases.
+**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton, the Phase 2 common grammar, and the Phase 3a/3b/3c-2 audio engine pass automated checks. Three gates remain open in the completed work: Phase 1's direct-file two-fixture restart observation, Phase 3's audible observation (no sound has been heard from any build), and the Phase 3c audio contracts on which real playback acceptance depends. Visual, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases.
The user requested sequential implementation with a stop on problems. The [manual version 3 evidence](evidence/phase0/2026-09-04-user-run-v3.md) verifies embedded data-URL worklet loading in direct-file Chrome. The subsequent [user-performed restart test](evidence/phase0/2026-09-04-user-restart.md) restored Blue Study activity 0.37 and master volume 0.19 immediately on reopening. Native tone output and AudioContext suspend/resume are also observed. Ordinary file import, selection of both exhibits, regular Chrome mode, and [directory cancellation/denial fallback](evidence/phase0/2026-09-05-user-directory-fallback.md) have been confirmed.
@@ -13,7 +13,7 @@ The user requested sequential implementation with a stop on problems. The [manua
| 0 — Contracts and feasibility | Complete | GC1 passed; GC2–GC5 shared contracts and traces passed; GC6/GC7 later gates scheduled and mapped |
| 1 — Runtime skeleton | Implemented; acceptance pending | [Automated evidence](evidence/phase1/2026-09-05-runtime-skeleton.md) passes production-module, lifecycle, PRNG-vector, cache/restore, fixture-validation, and deterministic-build tests. Direct-file two-fixture import/restart remains a user-observed gate. |
| 2 — Common grammar | Complete | [Automated evidence](evidence/phase2/2026-09-05-common-grammar.md) covers production GC2 conformance, typed values, signals, actions, same-tick bindings, transitions, override precedence/release, and parameter restoration. |
-| 3 — Audio engine | Phases 3a/3b complete; 3c contract complete, 3c implementation not started | [Automated evidence](evidence/phase3/2026-09-05-audio-authoring-contract.md) covers Format Specification sections 14-15, all sixteen node types, routing and modulation, twelve legality rules, authoring limits, components, sounds/recipes, and buses. The [Phase 3c contract](evidence/phase3/2026-09-05-phase3c-contract.md) adds section 16: automation tracks and precedence, the seven-state lifecycle and release, determinable one-shot endings, voice ceilings and eviction, master protection, and unlock and pause behavior. Phase 3c implementation slices 2-4 and the PRD 129 audio acceptance challenge remain. |
+| 3 — Audio engine | Slices 3c-1 (contract) and 3c-2 (lifecycle/voices) complete; slices 3c-3 and 3c-4 pending | [Automated evidence](evidence/phase3/2026-09-05-audio-authoring-contract.md) covers sections 14-15; [contract evidence](evidence/phase3/2026-09-05-phase3c-contract.md) adds section 16. [Slice 3c-2 evidence](evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md) implements the seven-state lifecycle, engine-owned release gain, determinable one-shot endings, voice ceilings (64/16), 4-step eviction, and disposal. Slices 3c-3 (automation) and 3c-4 (master protection measurement) and the PRD 129 audio acceptance challenge remain. |
| 4 — Visual engine | Not started | Earlier phases and visual contracts |
| 5 — Events and cadence | Not started | Earlier phases and event/cadence contracts |
| 6 — Scenario director | Not started | Earlier phases and scenario contracts |
@@ -63,6 +63,14 @@ Phase 3 is not accepted. Audio automation precedence, the lifecycle state machin
## Phase 3c contract (slice 1)
-Format Specification section 16 completes the audio subsystem contract. Phase 3c is delivered in four slices, recorded in the implementation plan: the contract (done), lifecycle and voices, automation, and measured master protection. Each slice ends green and committable, because slice 2 replaces the working one-shot timer scaffolding and must not be left half-applied.
+Format Specification section 16 completes the audio subsystem contract. Phase 3c is delivered in four slices, recorded in the implementation plan: the contract (done), lifecycle and voices (done), automation, and measured master protection. Each slice ends green and committable, because slice 2 replaces the working one-shot timer scaffolding and must not be left half-applied.
-Section 16 is contract only. The runtime does not implement it: there is no lifecycle state machine, no voice ceiling, and `automation` and `release` are still rejected as unknown fields. Slice 3c-4 and the PRD 129 audio acceptance challenge are user-observed and cannot be closed by automated tests. No sound has been heard from any build.
+Section 16 was contract only at slice 1.
+
+## Phase 3c lifecycle and voices (slice 2)
+
+Slice 3c-2 implements the seven-state lifecycle state machine (`CREATED`, `SCHEDULED`, `ACTIVE`, `RELEASING`, `FINISHED`, `DISPOSED`, `FAILED`) per section 16.3, the engine-owned internal release gain per 16.4, authorable `release` durations (`0ms` to `10s`) on recipes, determinable one-shot ending bounds calculation per 16.5 (and `ERR_INDETERMINATE_ONESHOT` validation for unbounded sources), voice ceilings (64 one-shot, 16 continuous) with 4-step eviction order (`FINISHED` -> `RELEASING` -> `ACTIVE` -> refuse) and `WARN_VOICE_LIMIT` per 16.6, full disposal, and removal of the fixed 4-second dev timer from `src/runtime/app.js`.
+
+`npm test` runs 67 tests with zero failures. Two clean builds produce byte-identical artifacts with digest `d7d16a89f9b1a9e21262232382d7ae53a0a84abafce191a873327e66dc4103a6`.
+
+Phase 3 is not accepted. Slices 3c-3 (automation) and 3c-4 (measured master protection) remain. No sound has been heard from any build.
diff --git a/docs/evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md b/docs/evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md
new file mode 100644
index 0000000..64cfce8
--- /dev/null
+++ b/docs/evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md
@@ -0,0 +1,52 @@
+# Phase 3c slice 2 — audio lifecycle, release, and voice ceilings
+
+**Date:** September 5, 2026
+**Specification baseline:** Format Specification 0.1 revision 0.4, sections 15-16
+**Result:** Implementation slice complete. All 67 tests pass. Two clean builds byte-identical.
+
+## Scope
+
+Phase 3c is delivered in four slices. This record covers **slice 3c-2 only**: the audio lifecycle state machine, internal release gain, determinable one-shot endings, voice ceilings, eviction policy, and disposal contract.
+
+Slice 3c-2 implements:
+1. **The seven-state lifecycle state machine** (16.3): `CREATED`, `SCHEDULED`, `ACTIVE`, `RELEASING`, `FINISHED`, `DISPOSED`, and `FAILED`, with strict enforcement of permitted transitions and rejection of forbidden transitions with `ERR_RUNTIME_FAULT`. Calling `stop()` on `FINISHED`, `DISPOSED`, or `FAILED` is a verified no-op.
+2. **Engine-owned internal release gain** (16.4): Placed between sound graph `output` (`sink`) and destination bus (`[output] -> [release gain] -> [bus]`). Initial gain is 1.0. Entering `RELEASING` linearly ramps gain to 0 over `releaseDuration`. Unbypassable, non-addressable by authors, and omitted from author node limits.
+3. **Recipe `release` field** (15.16, 16.4): Authorable `release` duration on `audio.recipes.` and `sounds..recipe` within `0ms` to `10s` (default `50ms`). Rejected inside component definitions with `ERR_UNKNOWN_FIELD`.
+4. **Determinable one-shot endings** (16.5): Computed via pure longest-path traversal over expanded DAG routes and node contributions (impulse duration, delay decay bound, reverb predelay + decay, resonator longest mode decay, component inlined bound, plus recipe release). An unbounded source (`oscillator` or `noise`) on an audible path in a `oneshot` sound is rejected at validation with `ERR_INDETERMINATE_ONESHOT`.
+5. **Voice ceilings and 4-step eviction** (16.6): Independent ceilings for one-shot (`64`) and continuous (`16`) voices. A voice counts against its ceiling from `CREATED` until `DISPOSED`. Eviction policy runs in strict order:
+ - Step 1: Dispose oldest `FINISHED` instance.
+ - Step 2: Advance oldest `RELEASING` instance to immediate completion and dispose.
+ - Step 3 (one-shot only): Evict oldest `ACTIVE` one-shot by starting its release ramp.
+ - Step 4: Otherwise refuse request (continuous or no candidates), maintaining runtime stability.
+ - `WARN_VOICE_LIMIT` is emitted on every eviction and refusal. One-shot and continuous pools are strictly isolated and never evict across pools.
+6. **Full disposal** (16.4, 16.6): Releases all Web Audio nodes, connections, buffers, timers, and pool memberships. A `FINISHED` instance continues to occupy its ceiling slot until `DISPOSED`.
+7. **Scaffolding removal**: Replaced the fixed 4-second development timer in `src/runtime/app.js` with natural ending management by the engine.
+
+## Specification reconciliation before implementation
+
+A pre-implementation review of Section 16 identified 5 inconsistencies, corrected in commit `e5ed468`:
+1. Section 15.16 allowed recipe fields table omitted `release`. Added `release` (DurationSpec, default 50ms, optional).
+2. Section 16.6 eviction step 2 contradicted the non-hard-stop invariant. Reconciled step 2 to advance release to immediate completion and dispose, rather than immediate drop.
+3. Section 16.8 unlock and 16.9 pause discard batching incoherence. Added `INFO_AUDIO_PAUSE_SKIP` to section 7 and 16.10 diagnostics tables.
+4. Section 16.5 author remedy text suggested gating an oscillator with an impulse, which is illegal under 15.14 rule 2 (audio route into oscillator is forbidden). Corrected prose to declare `mode: "continuous"` and stop explicitly.
+5. Clarified ending bound calculation on expanded graph where component boundaries are already inlined.
+
+## Verification
+
+- **Automated test suite**: All 67 tests pass cleanly with zero failures (`npm test`).
+- **Required traces tested** (16.11):
+ - **Trace 7**: Permitted transitions succeed (`CREATED -> SCHEDULED -> ACTIVE -> RELEASING -> FINISHED -> DISPOSED`, `CREATED -> FINISHED`, `SCHEDULED -> RELEASING`, `ACTIVE -> FINISHED`, `* -> FAILED -> [terminal]`). Forbidden transitions throw `ERR_RUNTIME_FAULT`. Second stop on terminal states is idempotent.
+ - **Trace 9**: Determinable ending bounds match the section 16.5 contribution table across impulse, resonator, delay (with and without feedback), reverb, branching mixers, and component expansion. Unbounded oscillator/noise in a oneshot recipe is rejected with `ERR_INDETERMINATE_ONESHOT`, while continuous sounds with identical shapes validate cleanly.
+ - **Trace 10**: Eviction order 1 -> 2 -> 3 -> 4 is verified at ceiling. `WARN_VOICE_LIMIT` is emitted with sound and ceiling metadata. Continuous sound is never evicted by one-shot request, and refused request leaves runtime stable.
+ - **Trace 11**: Disposal releases every node and connection. A `FINISHED` instance still occupies its ceiling budget until `DISPOSED`.
+ - **Recipe `release` validation**: Validates `0ms` to `10s`, rejects out-of-bounds (`11s`), invalid duration strings (`-10ms`, `slow`), and rejects `release` declared inside components (`ERR_UNKNOWN_FIELD`).
+- **Exhibit validation**: All three exhibits (`minimal-audio.xzbt`, `minimal-fixed.xzbt`, `minimal-random.xzbt`) validate cleanly with 0 errors via `tools/validate-exhibit.mjs`.
+- **Deterministic build**: `node tools/build-xzbt.mjs` runs twice with identical digest:
+ `SHA-256 d7d16a89f9b1a9e21262232382d7ae53a0a84abafce191a873327e66dc4103a6` (172,825 bytes).
+
+## Not established by this record
+
+- **No automation tracks**: Automation tracks and modes (16.1, 16.2) belong to slice 3c-3.
+- **No master protection measurement**: Master output protection (16.7) verification belongs to slice 3c-4 under GC6.
+- **No sound has been heard from any build**: Automated tests verify Web Audio node topologies, connections, and state transitions against headless stand-ins. Real audible playback observations remain open.
+- **Phase 3 is not accepted**: Slices 3c-3 and 3c-4 remain before Phase 3 can be submitted for acceptance.
diff --git a/schema/xzbt-0.1.schema.json b/schema/xzbt-0.1.schema.json
index 02ae3e3..f2e0d48 100644
--- a/schema/xzbt-0.1.schema.json
+++ b/schema/xzbt-0.1.schema.json
@@ -1616,6 +1616,9 @@
"oneshot",
"continuous"
]
+ },
+ "release": {
+ "$ref": "#/definitions/DurationSpec"
}
}
},
diff --git a/src/runtime/app.js b/src/runtime/app.js
index fcffea3..f77a006 100644
--- a/src/runtime/app.js
+++ b/src/runtime/app.js
@@ -214,12 +214,7 @@ export class XZBTApplication {
playSound(id) {
const current = this.activation.current;
if (!this.audio?.unlocked || !current) return;
- const handle = this.audio.play(id, { resolveReference: (path) => current.performance.engine.get(path) });
- if (handle?.mode === 'oneshot') {
- // Phase 3b has no lifecycle contract yet (PRD 57 is Phase 3c), so a one-shot voice is
- // released on a fixed development timer rather than on a determinable ending.
- setTimeout(() => handle.stop(), 4000);
- }
+ this.audio.play(id, { resolveReference: (path) => current.performance.engine.get(path) });
this.renderAudio();
}
diff --git a/src/runtime/audio-contract.js b/src/runtime/audio-contract.js
index b5cfd93..71dfeb1 100644
--- a/src/runtime/audio-contract.js
+++ b/src/runtime/audio-contract.js
@@ -180,13 +180,41 @@ export const AUDIO_SOUND_FIELDS = Object.freeze(['name', 'tags', 'usage', 'caden
export const AUDIO_SOUND_USAGE = Object.freeze(['automatic', 'manual', 'scenario']);
export const AUDIO_RECIPE_MODES = Object.freeze(['oneshot', 'continuous']);
export const AUDIO_GRAPH_FIELDS = Object.freeze(['nodes', 'routes']);
-export const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'mode']);
+export const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'mode', 'release']);
export const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'parameters', 'input']);
export const AUDIO_COMPONENT_PARAMETER_FIELDS = Object.freeze(['type', 'default', 'min', 'max', 'unit']);
export const AUDIO_BUS_FIELDS = Object.freeze(['gain']);
export const AUDIO_BUS_GAIN_RANGE = Object.freeze({ min: 0, max: 4, default: 1 });
export const AUDIO_ROUTE_FIELDS = Object.freeze(['from', 'to', 'depth']);
+export const AUDIO_VOICE_LIMITS = Object.freeze({
+ oneshot: 64,
+ continuous: 16
+});
+
+export const AUDIO_DEFAULT_RELEASE_MS = 50;
+export const AUDIO_MAX_RELEASE_MS = 10000;
+
+export const AUDIO_LIFECYCLE_STATES = Object.freeze([
+ 'CREATED',
+ 'SCHEDULED',
+ 'ACTIVE',
+ 'RELEASING',
+ 'FINISHED',
+ 'DISPOSED',
+ 'FAILED'
+]);
+
+export const AUDIO_LIFECYCLE_TRANSITIONS = Object.freeze({
+ CREATED: Object.freeze(['SCHEDULED', 'FINISHED', 'FAILED']),
+ SCHEDULED: Object.freeze(['ACTIVE', 'RELEASING', 'FAILED']),
+ ACTIVE: Object.freeze(['RELEASING', 'FINISHED', 'FAILED']),
+ RELEASING: Object.freeze(['FINISHED', 'FAILED']),
+ FINISHED: Object.freeze(['DISPOSED']),
+ DISPOSED: Object.freeze([]),
+ FAILED: Object.freeze([])
+});
+
export function audioMaxFrequency(sampleRate) {
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return AUDIO_STATIC_MAX_FREQUENCY;
return Math.min(AUDIO_STATIC_MAX_FREQUENCY, sampleRate * AUDIO_NYQUIST_FACTOR);
diff --git a/src/runtime/audio-engine.js b/src/runtime/audio-engine.js
index d4b3ac9..e8e9d28 100644
--- a/src/runtime/audio-engine.js
+++ b/src/runtime/audio-engine.js
@@ -2,12 +2,20 @@
// Instantiation is pure and testable without an AudioContext (Format Specification 14.4-14.6).
import {
+ AUDIO_DEFAULT_RELEASE_MS,
+ AUDIO_LIFECYCLE_STATES,
+ AUDIO_LIFECYCLE_TRANSITIONS,
AUDIO_MODE_FIELDS,
AUDIO_NODE_TYPES,
AUDIO_PARTIAL_FIELDS,
+ AUDIO_VOICE_LIMITS,
audioMaxFrequency
} from './audio-contract.js';
-import { durationMilliseconds, expandSoundGraph } from './audio-graph.js';
+import {
+ computeDeterminableEndingBound,
+ durationMilliseconds,
+ expandSoundGraph
+} from './audio-graph.js';
import { ValueResolver } from './values.js';
import { RuntimeFault, clamp } from './types.js';
@@ -29,7 +37,15 @@ export function instantiateSoundGraph(document, soundId, options = {}) {
const expansion = options.expansion ?? expandSoundGraph(document, soundId);
if (expansion.errors.length > 0) {
- return { nodes: [], routes: [], warnings: [], errors: expansion.errors, mode: expansion.mode };
+ return {
+ nodes: [],
+ routes: [],
+ warnings: [],
+ errors: expansion.errors,
+ mode: expansion.mode,
+ release: expansion.release,
+ endingBoundMs: null
+ };
}
const ceiling = audioMaxFrequency(sampleRate);
@@ -147,7 +163,22 @@ export function instantiateSoundGraph(document, soundId, options = {}) {
return { ...route, depth: evaluate(route.depth, owner?.scope ?? null, `${route.path}.depth`) };
});
- return { nodes, routes, warnings, errors: [], mode: expansion.mode, instanceKey, ceiling };
+ let endingBoundMs = null;
+ if (expansion.mode === 'oneshot') {
+ endingBoundMs = computeDeterminableEndingBound(expansion, nodes);
+ }
+
+ return {
+ nodes,
+ routes,
+ warnings,
+ errors: [],
+ mode: expansion.mode,
+ release: expansion.release,
+ endingBoundMs,
+ instanceKey,
+ ceiling
+ };
}
/* ------------------------------------------------------------------ *
@@ -243,7 +274,10 @@ export function realizeSoundGraph(context, plan, destination) {
const starters = [];
const sink = context.createGain();
sink.gain.value = 1;
- sink.connect(destination);
+ const releaseGain = context.createGain();
+ releaseGain.gain.value = 1;
+ sink.connect(releaseGain).connect(destination);
+ disposers.push(() => releaseGain.disconnect());
created.set('output', { input: sink, output: sink });
const now = () => context.currentTime;
@@ -334,6 +368,7 @@ export function realizeSoundGraph(context, plan, destination) {
};
let tick = 0;
const timer = setInterval(() => { schedule(0); tick += 1; }, Math.max(10, period * 1000));
+ if (typeof timer.unref === 'function') timer.unref();
schedule(0);
entry = { input: null, output: source, params: {} };
starters.push(() => source.start());
@@ -457,26 +492,180 @@ export function realizeSoundGraph(context, plan, destination) {
return {
sink,
+ releaseGain,
dispose() {
for (const release of disposers.reverse()) {
try { release(); } catch { /* disposal is best effort */ }
}
sink.disconnect();
+ releaseGain.disconnect();
created.clear();
}
};
}
+export class SoundInstance {
+ constructor({
+ soundId,
+ mode = 'oneshot',
+ releaseMs = AUDIO_DEFAULT_RELEASE_MS,
+ endingBoundMs = null,
+ plan = null,
+ subsystem = null,
+ bus = null,
+ context = null,
+ creationOrder = 0
+ } = {}) {
+ this.soundId = soundId;
+ this.mode = mode;
+ this.releaseMs = releaseMs ?? AUDIO_DEFAULT_RELEASE_MS;
+ this.endingBoundMs = endingBoundMs;
+ this.plan = plan;
+ this.subsystem = subsystem;
+ this.bus = bus;
+ this.context = context;
+ this.creationOrder = creationOrder;
+ this.state = 'CREATED';
+ this.realized = null;
+ this.startTime = null;
+ this.releaseTimer = null;
+ this.endingTimer = null;
+ }
+
+ canTransition(nextState) {
+ const allowed = AUDIO_LIFECYCLE_TRANSITIONS[this.state] ?? [];
+ return allowed.includes(nextState);
+ }
+
+ transition(nextState) {
+ if (!this.canTransition(nextState)) {
+ throw new RuntimeFault('ERR_RUNTIME_FAULT', `Invalid lifecycle transition from '${this.state}' to '${nextState}'.`);
+ }
+ this.state = nextState;
+ return true;
+ }
+
+ realize(context, destination) {
+ this.realized = realizeSoundGraph(context, this.plan, destination);
+ return this.realized;
+ }
+
+ stop() {
+ if (this.state === 'RELEASING' || this.state === 'FINISHED' || this.state === 'DISPOSED' || this.state === 'FAILED') {
+ return;
+ }
+ if (this.subsystem) {
+ this.subsystem.voices.delete(this);
+ }
+ if (this.state === 'CREATED') {
+ this.transition('FINISHED');
+ return;
+ }
+ if (this.state === 'SCHEDULED') {
+ this.transition('RELEASING');
+ if (this.releaseMs === 0) {
+ this.transition('FINISHED');
+ } else {
+ this.releaseTimer = setTimeout(() => {
+ if (this.state === 'RELEASING') {
+ this.transition('FINISHED');
+ }
+ }, this.releaseMs);
+ if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
+ }
+ return;
+ }
+ if (this.state === 'ACTIVE') {
+ if (this.endingTimer) {
+ clearTimeout(this.endingTimer);
+ this.endingTimer = null;
+ }
+ this.transition('RELEASING');
+ if (this.releaseMs === 0) {
+ if (this.realized?.releaseGain?.gain) {
+ const now = this.context?.currentTime ?? 0;
+ this.realized.releaseGain.gain.cancelScheduledValues?.(now);
+ this.realized.releaseGain.gain.setValueAtTime?.(0, now);
+ }
+ this.transition('FINISHED');
+ } else {
+ const now = this.context?.currentTime ?? 0;
+ const durationSec = this.releaseMs / 1000;
+ const gainParam = this.realized?.releaseGain?.gain;
+ if (gainParam) {
+ gainParam.cancelScheduledValues?.(now);
+ gainParam.setValueAtTime?.(gainParam.value, now);
+ gainParam.linearRampToValueAtTime?.(0, now + durationSec);
+ }
+ this.releaseTimer = setTimeout(() => {
+ if (this.state === 'RELEASING') {
+ this.transition('FINISHED');
+ }
+ }, this.releaseMs);
+ if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
+ }
+ }
+ }
+
+ forceFinishAndDispose() {
+ if (this.state === 'RELEASING') {
+ if (this.releaseTimer) {
+ clearTimeout(this.releaseTimer);
+ this.releaseTimer = null;
+ }
+ if (this.realized?.releaseGain?.gain) {
+ const now = this.context?.currentTime ?? 0;
+ this.realized.releaseGain.gain.cancelScheduledValues?.(now);
+ this.realized.releaseGain.gain.setValueAtTime?.(0, now);
+ }
+ this.transition('FINISHED');
+ }
+ this.dispose();
+ }
+
+ dispose() {
+ if (this.state === 'DISPOSED') return;
+ if (this.releaseTimer) {
+ clearTimeout(this.releaseTimer);
+ this.releaseTimer = null;
+ }
+ if (this.endingTimer) {
+ clearTimeout(this.endingTimer);
+ this.endingTimer = null;
+ }
+ if (this.subsystem) {
+ this.subsystem.voices.delete(this);
+ this.subsystem.oneshotVoices?.delete(this);
+ this.subsystem.continuousVoices?.delete(this);
+ }
+ if (this.state !== 'FINISHED' && this.state !== 'FAILED') {
+ if (this.state === 'ACTIVE' || this.state === 'SCHEDULED') {
+ this.transition('RELEASING');
+ this.transition('FINISHED');
+ } else if (this.state === 'CREATED') {
+ this.transition('FINISHED');
+ } else if (this.state === 'RELEASING') {
+ this.transition('FINISHED');
+ }
+ }
+ this.transition('DISPOSED');
+ if (this.realized) {
+ try { this.realized.dispose(); } catch { /* best effort */ }
+ this.realized = null;
+ }
+ }
+}
+
/* ------------------------------------------------------------------ *
* Runtime subsystem
* ------------------------------------------------------------------ */
// Owns the AudioContext, the declared buses, and the engine master chain, and turns a
// sound definition into a realized voice. The lifecycle state machine (PRD 57), voice
-// ceilings, and the measured master-protection contract (PRD 58) are Phase 3c; the master
-// chain built here is engine-owned and unbypassable but its ceiling is not yet verified.
+// ceilings (16.6), and the measured master-protection contract (PRD 58) are Phase 3c;
+// the master chain built here is engine-owned and unbypassable.
export class AudioSubsystem {
- constructor({ document, rng, diagnostics = null, contextFactory = null } = {}) {
+ constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null } = {}) {
this.document = document;
this.rng = rng;
this.diagnostics = diagnostics;
@@ -487,7 +676,11 @@ export class AudioSubsystem {
this.protection = null;
this.buses = new Map();
this.voices = new Set();
+ this.oneshotVoices = new Set();
+ this.continuousVoices = new Set();
+ this.voiceLimits = voiceLimits ?? { ...AUDIO_VOICE_LIMITS };
this.ordinals = new Map();
+ this.nextCreationOrder = 0;
this.masterVolume = 0.8;
}
@@ -560,41 +753,114 @@ export class AudioSubsystem {
play(soundId, { resolveReference } = {}) {
if (!this.unlocked) return null;
+ const sound = this.document?.sounds?.[soundId];
+ if (!sound) return null;
+
+ const expansion = expandSoundGraph(this.document, soundId);
+ if (expansion.errors.length > 0) {
+ for (const error of expansion.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
+ return null;
+ }
+
+ const mode = expansion.mode ?? 'oneshot';
+ const pool = mode === 'oneshot' ? this.oneshotVoices : this.continuousVoices;
+ const ceiling = mode === 'oneshot' ? this.voiceLimits.oneshot : this.voiceLimits.continuous;
+
+ if (pool.size >= ceiling) {
+ // 16.6 Eviction policy applied in strict order:
+ // 1. Dispose the oldest instance already in FINISHED.
+ const finishedInstances = [...pool].filter((i) => i.state === 'FINISHED');
+ if (finishedInstances.length > 0) {
+ finishedInstances.sort((a, b) => a.creationOrder - b.creationOrder);
+ const candidate = finishedInstances[0];
+ candidate.dispose();
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; disposed oldest FINISHED instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
+ } else {
+ // 2. Evict the oldest instance in RELEASING by advancing its release ramp to immediate completion and disposing it.
+ const releasingInstances = [...pool].filter((i) => i.state === 'RELEASING');
+ if (releasingInstances.length > 0) {
+ releasingInstances.sort((a, b) => a.creationOrder - b.creationOrder);
+ const candidate = releasingInstances[0];
+ candidate.forceFinishAndDispose();
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicted oldest RELEASING instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
+ } else if (mode === 'oneshot') {
+ // 3. For a one-shot request only: evict the oldest ACTIVE one-shot by starting its release.
+ const activeInstances = [...pool].filter((i) => i.state === 'ACTIVE' || i.state === 'SCHEDULED');
+ if (activeInstances.length > 0) {
+ activeInstances.sort((a, b) => a.creationOrder - b.creationOrder);
+ const candidate = activeInstances[0];
+ candidate.stop();
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicting oldest ACTIVE oneshot instance of '${candidate.soundId}' via release.`, { section: 'audio', objectId: soundId });
+ } else {
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; request refused.`, { section: 'audio', objectId: soundId });
+ return null;
+ }
+ } else {
+ // 4. Otherwise refuse the new instance.
+ this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; continuous request refused.`, { section: 'audio', objectId: soundId });
+ return null;
+ }
+ }
+ }
+
const plan = instantiateSoundGraph(this.document, soundId, {
sampleRate: this.context.sampleRate,
rng: this.rng,
ordinal: this.nextOrdinal(soundId),
- resolveReference
+ resolveReference,
+ expansion
});
for (const error of plan.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
if (plan.errors.length > 0) return null;
for (const warning of plan.warnings) this.diagnostics?.warn(warning.code, warning.message, { section: 'audio', objectId: soundId, property: warning.path });
- let voice;
- try {
- voice = realizeSoundGraph(this.context, plan, this.busFor(soundId));
- } catch (error) {
- this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
- return null;
- }
- const handle = {
+
+ const instance = new SoundInstance({
soundId,
mode: plan.mode,
- stop: () => {
- if (!this.voices.has(handle)) return;
- this.voices.delete(handle);
- voice.dispose();
- }
- };
- this.voices.add(handle);
- return handle;
+ releaseMs: plan.release,
+ endingBoundMs: plan.endingBoundMs,
+ plan,
+ subsystem: this,
+ bus: this.busFor(soundId),
+ context: this.context,
+ creationOrder: this.nextCreationOrder++
+ });
+ pool.add(instance);
+
+ try {
+ instance.transition('SCHEDULED');
+ instance.realize(this.context, this.busFor(soundId));
+ instance.transition('ACTIVE');
+ instance.startTime = this.context.currentTime;
+ this.voices.add(instance);
+ } catch (error) {
+ this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
+ instance.transition('FAILED');
+ instance.dispose();
+ return null;
+ }
+
+ if (mode === 'oneshot' && instance.endingBoundMs != null && Number.isFinite(instance.endingBoundMs)) {
+ instance.endingTimer = setTimeout(() => {
+ if (instance.state === 'ACTIVE') {
+ this.voices.delete(instance);
+ instance.transition('FINISHED');
+ }
+ }, instance.endingBoundMs);
+ if (typeof instance.endingTimer?.unref === 'function') instance.endingTimer.unref();
+ }
+
+ return instance;
}
stopAll() {
- for (const handle of [...this.voices]) handle.stop();
+ for (const instance of [...this.voices]) instance.stop();
}
async dispose() {
this.stopAll();
+ for (const instance of [...this.oneshotVoices]) instance.dispose();
+ for (const instance of [...this.continuousVoices]) instance.dispose();
this.buses.clear();
if (this.context) {
try { await this.context.close(); } catch { /* already closed */ }
diff --git a/src/runtime/audio-graph.js b/src/runtime/audio-graph.js
index 183b077..94463c7 100644
--- a/src/runtime/audio-graph.js
+++ b/src/runtime/audio-graph.js
@@ -12,6 +12,8 @@ import {
AUDIO_MODULATABLE,
AUDIO_MODULATION_SOURCE_TYPES,
AUDIO_NODE_TYPES,
+ AUDIO_DEFAULT_RELEASE_MS,
+ AUDIO_MAX_RELEASE_MS,
AUDIO_PARTIAL_FIELDS,
AUDIO_RECIPE_FIELDS,
AUDIO_RECIPE_MODES,
@@ -252,7 +254,7 @@ export function expandSoundGraph(document, soundId) {
const sound = document.sounds?.[soundId];
const soundPath = `$.sounds.${soundId}`;
const located = recipeGraphFor(document, sound, soundPath, errors);
- if (!located) return { nodes: [], routes: [], errors, mode: 'oneshot' };
+ if (!located) return { nodes: [], routes: [], errors, mode: 'oneshot', release: AUDIO_DEFAULT_RELEASE_MS };
const nodes = new Map();
const routes = [];
@@ -364,7 +366,10 @@ export function expandSoundGraph(document, soundId) {
expand(located.graph, '', 1, [], located.path);
const mode = AUDIO_RECIPE_MODES.includes(located.graph.mode) ? located.graph.mode : 'oneshot';
- return { nodes: [...nodes.values()], routes, errors, mode, graphPath: located.path };
+ const release = Object.hasOwn(located.graph, 'release')
+ ? (durationMilliseconds(located.graph.release) ?? AUDIO_DEFAULT_RELEASE_MS)
+ : AUDIO_DEFAULT_RELEASE_MS;
+ return { nodes: [...nodes.values()], routes, errors, mode, release, graphPath: located.path };
}
function detectCycle(adjacency) {
@@ -467,6 +472,9 @@ export function checkGraphLegality(document, soundId, expansion, errors) {
fail(errors, 'ERR_INVALID_ROUTE', node.path, `Control source '${node.path}' reaches audible output.`);
} else {
audiblePath = true;
+ if (expansion.mode === 'oneshot' && (node.type === 'oscillator' || node.type === 'noise')) {
+ fail(errors, 'ERR_INDETERMINATE_ONESHOT', graphPath, `Sound '${soundId}' is a oneshot with an unbounded audible path from '${node.path}' (${node.type}).`);
+ }
}
}
if (contract.acceptsAudio === false) {
@@ -515,6 +523,9 @@ export function validateAudioSubsystem(document, errors, helpers) {
if (Object.hasOwn(recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(recipe.mode)) {
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
}
+ if (Object.hasOwn(recipe, 'release')) {
+ validateDurationField(recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.release`, errors);
+ }
}
}
}
@@ -581,9 +592,130 @@ export function validateAudioSubsystem(document, errors, helpers) {
if (Object.hasOwn(sound.recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(sound.recipe.mode)) {
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.recipe.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
}
+ if (Object.hasOwn(sound.recipe, 'release')) {
+ validateDurationField(sound.recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.recipe.release`, errors);
+ }
}
const expansion = expandSoundGraph(document, id);
errors.push(...expansion.errors);
if (expansion.nodes.length > 0) checkGraphLegality(document, id, expansion, errors);
}
}
+
+export function computeDeterminableEndingBound(expansion, resolvedNodes = null) {
+ const { nodes, routes, release = AUDIO_DEFAULT_RELEASE_MS } = expansion;
+ const resolvedMap = resolvedNodes instanceof Map
+ ? resolvedNodes
+ : Array.isArray(resolvedNodes)
+ ? new Map(resolvedNodes.map((n) => [n.path, n]))
+ : null;
+
+ const nodeMap = new Map(nodes.map((n) => [n.path, n]));
+
+ function nodeContribution(path) {
+ if (path === 'output') return 0;
+ const node = nodeMap.get(path);
+ if (!node || node.implicit || node.type === 'component') return 0;
+ const type = node.type;
+ const resolved = resolvedMap?.get(path)?.values;
+
+ if (type === 'impulse') {
+ if (resolved && typeof resolved.duration === 'number') return resolved.duration;
+ return durationMilliseconds(node.spec?.duration ?? '10ms') ?? 10;
+ }
+ if (type === 'oscillator' || type === 'noise') {
+ return Infinity;
+ }
+ if (type === 'delay') {
+ let time, feedback;
+ if (resolved) {
+ time = resolved.time ?? 250;
+ feedback = resolved.feedback ?? 0.2;
+ } else {
+ time = durationMilliseconds(node.spec?.time ?? '250ms') ?? 250;
+ feedback = typeof node.spec?.feedback === 'number' ? node.spec.feedback : 0.2;
+ }
+ if (feedback > 0) {
+ const clampedFeedback = Math.min(0.9999, Math.max(1e-6, feedback));
+ const multiplier = Math.ceil(Math.log(0.001) / Math.log(clampedFeedback));
+ return time * multiplier;
+ }
+ return time;
+ }
+ if (type === 'reverb') {
+ let predelay, decay;
+ if (resolved) {
+ predelay = resolved.predelay ?? 0;
+ decay = resolved.decay ?? 2000;
+ } else {
+ predelay = durationMilliseconds(node.spec?.predelay ?? '0ms') ?? 0;
+ decay = durationMilliseconds(node.spec?.decay ?? '2s') ?? 2000;
+ }
+ return predelay + decay;
+ }
+ if (type === 'resonator') {
+ if (resolved?.modes && Array.isArray(resolved.modes) && resolved.modes.length > 0) {
+ return Math.max(...resolved.modes.map((m) => m.decay ?? 1000));
+ }
+ const modes = node.spec?.modes ?? [];
+ if (modes.length > 0) {
+ return Math.max(...modes.map((m) => durationMilliseconds(m.decay ?? '1s') ?? 1000));
+ }
+ return 1000;
+ }
+ return 0;
+ }
+
+ const audioAdjacency = new Map();
+ const inDegree = new Map();
+ const allNodes = new Set();
+ for (const n of nodes) allNodes.add(n.path);
+ allNodes.add('output');
+
+ for (const n of allNodes) {
+ audioAdjacency.set(n, []);
+ inDegree.set(n, 0);
+ }
+
+ for (const route of routes) {
+ if (route.kind === 'audio') {
+ if (!audioAdjacency.has(route.from)) audioAdjacency.set(route.from, []);
+ audioAdjacency.get(route.from).push(route.to);
+ inDegree.set(route.to, (inDegree.get(route.to) ?? 0) + 1);
+ }
+ }
+
+ const dist = new Map();
+ for (const n of allNodes) dist.set(n, -Infinity);
+
+ for (const n of nodes) {
+ if (n.implicit || n.type === 'component') continue;
+ if (n.type === 'impulse' || n.type === 'oscillator' || n.type === 'noise') {
+ dist.set(n.path, nodeContribution(n.path));
+ }
+ }
+
+ const queue = [];
+ for (const [node, deg] of inDegree.entries()) {
+ if (deg === 0) queue.push(node);
+ }
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ const currentDist = dist.get(current);
+ for (const next of audioAdjacency.get(current) ?? []) {
+ if (currentDist !== -Infinity) {
+ const nextContrib = nodeContribution(next);
+ const newDist = currentDist === Infinity ? Infinity : currentDist + nextContrib;
+ if (newDist > dist.get(next)) dist.set(next, newDist);
+ }
+ inDegree.set(next, inDegree.get(next) - 1);
+ if (inDegree.get(next) === 0) queue.push(next);
+ }
+ }
+
+ const outDist = dist.get('output');
+ if (outDist === Infinity) return Infinity;
+ if (outDist === -Infinity) return release;
+ return outDist + release;
+}
diff --git a/test/phase3-audio.test.mjs b/test/phase3-audio.test.mjs
index d6068a2..1e67ceb 100644
--- a/test/phase3-audio.test.mjs
+++ b/test/phase3-audio.test.mjs
@@ -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();
});