feat(audio): implement phase 3c slice 2 lifecycle and voices
This commit is contained in:
+1
-6
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+291
-25
@@ -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 */ }
|
||||
|
||||
+134
-2
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user