feat(audio): complete phase 3a/3b audio subsystem contract and runtime
Review Phase 3a before building on it, then implement Phase 3b. The Phase 3a draft had four blocking defects: nodes were described as a keyed map while every documented example carried an inline `id` field, so under the strict unknown-field policy each minimal example would have failed its own acceptance trace; no section said where a node lives; the `audioMaxFrequency` ceiling was declared a semantic-stage error while depending on a live AudioContext sample rate; and the sample-hold PRNG child key that section 9.3 requires was undocumented. Close all four, plus nine further gaps in noise seeding, spectral definitions, impulse decay math, Nyquist handling, missing-field codes, LFO phase origin, the units table, node-type staging, and a duplicated diagnostics table. Add Format Specification section 15 for Phase 3b: nine processing and routing node contracts, the component instance node, audio routing and modulation with an explicit modulatable-property registry, twelve graph legality rules, authoring limits, components with a component-scoped `inputs.*` namespace, sound definitions and recipes, and buses. Implement the subsystem in three modules. audio-contract.js holds the declarative node, limit, and modulation tables every consumer reads. audio-graph.js validates, expands components, and checks legality without ever opening an AudioContext. audio-engine.js resolves node fields once from the seeded stream, clamps frequencies to the live device ceiling, realizes the graph through Web Audio, and owns the runtime AudioSubsystem. Extend the schema, delegate the standalone validator's audio checks to the shared module rather than carrying a second implementation, and add a generic audio fixture. Phase 3 is not accepted. Automation precedence, the lifecycle state machine, unlock behavior, voice ceilings, and master protection are Phase 3c. No sound has been heard from any build, so the audio acceptance challenge, peak and finite-sample capture, and listening observations remain open. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011FWPdCqKaaDnP9NC3JAwh6
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { ActivationController } from './activation.js';
|
||||
import { AudioSubsystem } from './audio-engine.js';
|
||||
import { Diagnostics } from './diagnostics.js';
|
||||
import { LibraryManager } from './library.js';
|
||||
import { PersistenceManager } from './persistence.js';
|
||||
@@ -33,6 +34,7 @@ export class XZBTApplication {
|
||||
})
|
||||
});
|
||||
this.busy = false;
|
||||
this.audio = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
@@ -63,6 +65,14 @@ export class XZBTApplication {
|
||||
});
|
||||
element('import-button').addEventListener('click', () => element('import-files').click());
|
||||
element('deactivate-button').addEventListener('click', () => this.deactivate());
|
||||
element('audio-unlock').addEventListener('click', () => this.unlockAudio());
|
||||
element('audio-stop').addEventListener('click', () => {
|
||||
this.audio?.stopAll();
|
||||
this.renderAudio();
|
||||
});
|
||||
element('master-volume').addEventListener('input', (event) => {
|
||||
this.audio?.setMasterVolume(Number(event.target.value));
|
||||
});
|
||||
element('clear-diagnostics').addEventListener('click', () => this.diagnostics.clear());
|
||||
const dropZone = element('drop-zone');
|
||||
for (const type of ['dragenter', 'dragover']) dropZone.addEventListener(type, (event) => {
|
||||
@@ -126,6 +136,7 @@ export class XZBTApplication {
|
||||
if (this.busy || !this.activation.current) return;
|
||||
this.setBusy(true, 'Deactivating exhibit…');
|
||||
try {
|
||||
await this.disposeAudio();
|
||||
await this.activation.deactivate();
|
||||
this.renderLibrary();
|
||||
this.renderStage();
|
||||
@@ -175,6 +186,98 @@ export class XZBTApplication {
|
||||
element('active-sequence').textContent = [preview.nextUint32(), preview.nextUint32(), preview.nextUint32()].join(' · ');
|
||||
this.renderConfiguration(current.performance);
|
||||
this.renderValues(current.performance.engine);
|
||||
this.renderAudio();
|
||||
}
|
||||
|
||||
async unlockAudio() {
|
||||
const current = this.activation.current;
|
||||
if (!current) return;
|
||||
if (!this.audio) {
|
||||
this.audio = new AudioSubsystem({
|
||||
document: current.record.document,
|
||||
rng: current.performance.rng,
|
||||
diagnostics: this.diagnostics
|
||||
});
|
||||
this.audio.setMasterVolume(Number(element('master-volume').value));
|
||||
}
|
||||
await this.audio.unlock();
|
||||
this.renderAudio();
|
||||
}
|
||||
|
||||
async disposeAudio() {
|
||||
if (!this.audio) return;
|
||||
await this.audio.dispose();
|
||||
this.audio = null;
|
||||
this.renderAudio();
|
||||
}
|
||||
|
||||
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.renderAudio();
|
||||
}
|
||||
|
||||
renderAudio() {
|
||||
const current = this.activation.current;
|
||||
const state = element('audio-state');
|
||||
const stopButton = element('audio-stop');
|
||||
const busContainer = element('audio-buses');
|
||||
const soundContainer = element('sound-list');
|
||||
busContainer.replaceChildren();
|
||||
soundContainer.replaceChildren();
|
||||
if (!current) {
|
||||
state.textContent = 'Audio is locked until you start it.';
|
||||
stopButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
const document_ = current.record.document;
|
||||
const sounds = document_.sounds ?? {};
|
||||
const unlocked = Boolean(this.audio?.unlocked);
|
||||
element('audio-unlock').disabled = unlocked;
|
||||
stopButton.disabled = !unlocked || (this.audio?.voices.size ?? 0) === 0;
|
||||
state.textContent = unlocked
|
||||
? `Audio running at ${this.audio.context.sampleRate} Hz · ${this.audio.voices.size} live voice${this.audio.voices.size === 1 ? '' : 's'}`
|
||||
: 'Audio is locked until you start it. Browsers require a user gesture.';
|
||||
|
||||
for (const [id, bus] of Object.entries(document_.audio?.buses ?? {})) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'parameter-control';
|
||||
const label = text('label', `Bus ${id}`);
|
||||
label.htmlFor = `bus-${id}`;
|
||||
const input = document.createElement('input');
|
||||
input.id = `bus-${id}`;
|
||||
input.type = 'range';
|
||||
input.min = '0';
|
||||
input.max = '4';
|
||||
input.step = '0.01';
|
||||
input.value = String(typeof bus.gain === 'number' ? bus.gain : 1);
|
||||
input.disabled = !unlocked;
|
||||
input.addEventListener('input', (event) => this.audio?.setBusGain(id, Number(event.target.value)));
|
||||
row.append(label, input);
|
||||
busContainer.append(row);
|
||||
}
|
||||
|
||||
if (Object.keys(sounds).length === 0) {
|
||||
soundContainer.append(text('p', 'This exhibit declares no sounds.', 'empty'));
|
||||
return;
|
||||
}
|
||||
for (const [id, sound] of Object.entries(sounds)) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'sound-row';
|
||||
row.append(text('span', sound.name ?? id));
|
||||
const button = text('button', 'Play');
|
||||
button.type = 'button';
|
||||
button.disabled = !unlocked;
|
||||
button.addEventListener('click', () => this.playSound(id));
|
||||
row.append(button);
|
||||
soundContainer.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
renderConfiguration(performance) {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// Audio Subsystem Contract 0.1 - declarative tables shared by validation, expansion,
|
||||
// instantiation, and realization. Format Specification 0.1 revision 0.4, sections 14-15.
|
||||
|
||||
export const AUDIO_STATIC_MAX_FREQUENCY = 24000;
|
||||
export const AUDIO_NYQUIST_FACTOR = 0.45;
|
||||
|
||||
export const AUDIO_LIMITS = Object.freeze({
|
||||
nodesPerSound: 128,
|
||||
routesPerSound: 256,
|
||||
componentDepth: 8,
|
||||
resonatorModes: 16,
|
||||
oscillatorPartials: 64
|
||||
});
|
||||
|
||||
export const AUDIO_NOISE_COLORS = Object.freeze(['white', 'pink', 'brown']);
|
||||
export const AUDIO_RESERVED_NODE_KEYS = Object.freeze(['output', 'input']);
|
||||
|
||||
function num(min, max, fallback, extra = {}) {
|
||||
return { kind: 'number', min, max, default: fallback, valuespec: true, ...extra };
|
||||
}
|
||||
function enumeration(values, fallback) {
|
||||
return { kind: 'enum', values: Object.freeze(values), default: fallback, valuespec: false };
|
||||
}
|
||||
function duration(minMs, maxMs, fallback) {
|
||||
return { kind: 'duration', min: minMs, max: maxMs, default: fallback, valuespec: false };
|
||||
}
|
||||
|
||||
// `ceiling: 'audio'` marks a field validated against AUDIO_STATIC_MAX_FREQUENCY at the
|
||||
// semantic stage and clamped to the live audioMaxFrequency at instantiation (section 14.5).
|
||||
export const AUDIO_NODE_TYPES = Object.freeze({
|
||||
oscillator: {
|
||||
class: 'source', acceptsAudio: false,
|
||||
fields: {
|
||||
waveform: enumeration(['sine', 'triangle', 'square', 'sawtooth', 'custom'], 'sine'),
|
||||
frequency: num(0.1, AUDIO_STATIC_MAX_FREQUENCY, 440, { ceiling: 'audio' }),
|
||||
detune: num(-4800, 4800, 0),
|
||||
harmonics: { kind: 'partials', valuespec: false, requiredWhen: { waveform: 'custom' } }
|
||||
}
|
||||
},
|
||||
noise: {
|
||||
class: 'source', acceptsAudio: false,
|
||||
fields: { color: enumeration(AUDIO_NOISE_COLORS, 'white') }
|
||||
},
|
||||
impulse: {
|
||||
class: 'source', acceptsAudio: false,
|
||||
fields: {
|
||||
color: enumeration(AUDIO_NOISE_COLORS, 'white'),
|
||||
duration: duration(1, 500, '10ms'),
|
||||
amplitude: num(0, 1, 1),
|
||||
decay: enumeration(['flat', 'linear', 'exponential'], 'exponential')
|
||||
}
|
||||
},
|
||||
constant: {
|
||||
class: 'control', acceptsAudio: false,
|
||||
fields: { value: num(-1000, 1000, 1) }
|
||||
},
|
||||
lfo: {
|
||||
class: 'control', acceptsAudio: false,
|
||||
fields: {
|
||||
waveform: enumeration(['sine', 'triangle', 'square', 'sawtooth'], 'sine'),
|
||||
frequency: num(0.001, 40, 1),
|
||||
amplitude: num(0, 1000, 1),
|
||||
polarity: enumeration(['bipolar', 'unipolar'], 'bipolar'),
|
||||
phase: num(0, 360, 0, { exclusiveMax: true })
|
||||
}
|
||||
},
|
||||
'sample-hold': {
|
||||
class: 'control', acceptsAudio: false,
|
||||
fields: {
|
||||
rate: num(0.01, 100, 2),
|
||||
min: num(-1000, 1000, -1),
|
||||
max: num(-1000, 1000, 1),
|
||||
slew: duration(0, 1000, '0ms')
|
||||
},
|
||||
rangeOrder: ['min', 'max']
|
||||
},
|
||||
gain: {
|
||||
class: 'processing', acceptsAudio: true,
|
||||
fields: { gain: num(0, 4, 1) }
|
||||
},
|
||||
filter: {
|
||||
class: 'processing', acceptsAudio: true,
|
||||
fields: {
|
||||
mode: enumeration(['lowpass', 'highpass', 'bandpass', 'notch', 'peaking', 'lowshelf', 'highshelf', 'allpass'], 'lowpass'),
|
||||
frequency: num(10, AUDIO_STATIC_MAX_FREQUENCY, 1000, { ceiling: 'audio' }),
|
||||
q: num(0.0001, 100, 1),
|
||||
gain: num(-40, 40, 0),
|
||||
detune: num(-4800, 4800, 0)
|
||||
}
|
||||
},
|
||||
compressor: {
|
||||
class: 'processing', acceptsAudio: true,
|
||||
fields: {
|
||||
threshold: num(-100, 0, -24),
|
||||
knee: num(0, 40, 30),
|
||||
ratio: num(1, 20, 12),
|
||||
attack: duration(0, 1000, '3ms'),
|
||||
release: duration(10, 1000, '250ms')
|
||||
}
|
||||
},
|
||||
waveshaper: {
|
||||
class: 'processing', acceptsAudio: true,
|
||||
fields: {
|
||||
shape: enumeration(['soft-clip', 'hard-clip', 'saturation'], 'soft-clip'),
|
||||
amount: num(0, 1, 0.5),
|
||||
oversample: enumeration(['none', '2x', '4x'], 'none')
|
||||
}
|
||||
},
|
||||
delay: {
|
||||
class: 'processing', acceptsAudio: true,
|
||||
fields: {
|
||||
time: duration(0, 10000, '250ms'),
|
||||
feedback: num(0, 0.95, 0.2),
|
||||
mix: num(0, 1, 0.5)
|
||||
}
|
||||
},
|
||||
reverb: {
|
||||
class: 'processing', acceptsAudio: true,
|
||||
fields: {
|
||||
size: num(0, 1, 0.5),
|
||||
decay: duration(50, 30000, '2s'),
|
||||
damping: num(0, 1, 0.5),
|
||||
predelay: duration(0, 500, '0ms'),
|
||||
mix: num(0, 1, 0.25)
|
||||
}
|
||||
},
|
||||
'stereo-pan': {
|
||||
class: 'processing', acceptsAudio: true,
|
||||
fields: { pan: num(-1, 1, 0) }
|
||||
},
|
||||
mixer: {
|
||||
class: 'routing', acceptsAudio: true,
|
||||
fields: {}
|
||||
},
|
||||
resonator: {
|
||||
class: 'processing', acceptsAudio: true,
|
||||
fields: {
|
||||
fundamental: num(0.1, AUDIO_STATIC_MAX_FREQUENCY, 120, { ceiling: 'audio' }),
|
||||
modes: { kind: 'modes', valuespec: false, required: true },
|
||||
mix: num(0, 1, 1)
|
||||
}
|
||||
},
|
||||
component: {
|
||||
class: 'composite', acceptsAudio: 'declared',
|
||||
fields: {
|
||||
use: { kind: 'component-ref', valuespec: false, required: true },
|
||||
values: { kind: 'component-values', valuespec: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const AUDIO_NODE_TYPE_NAMES = Object.freeze(Object.keys(AUDIO_NODE_TYPES));
|
||||
|
||||
export const AUDIO_PARTIAL_FIELDS = Object.freeze({
|
||||
ratio: { min: 0.001, max: 256, required: true },
|
||||
gain: { min: 0, max: 1, required: true },
|
||||
phase: { min: 0, max: 360, default: 0, exclusiveMax: true }
|
||||
});
|
||||
|
||||
export const AUDIO_MODE_FIELDS = Object.freeze({
|
||||
ratio: { min: 0.001, max: 256 },
|
||||
frequency: { min: 0.1, max: AUDIO_STATIC_MAX_FREQUENCY, ceiling: 'audio' },
|
||||
gain: { min: 0, max: 1, default: 1 },
|
||||
decay: { kind: 'duration', min: 10, max: 20000, default: '1s' }
|
||||
});
|
||||
|
||||
// Section 15.13. Depth is expressed in the listed unit; anything absent is ERR_UNSUPPORTED_TARGET.
|
||||
export const AUDIO_MODULATABLE = Object.freeze({
|
||||
oscillator: Object.freeze({ frequency: 'hz', detune: 'cents' }),
|
||||
gain: Object.freeze({ gain: 'linear' }),
|
||||
filter: Object.freeze({ frequency: 'hz', q: 'unitless', gain: 'db', detune: 'cents' }),
|
||||
delay: Object.freeze({ time: 'ms' }),
|
||||
'stereo-pan': Object.freeze({ pan: 'pan' }),
|
||||
resonator: Object.freeze({ fundamental: 'hz' })
|
||||
});
|
||||
|
||||
export const AUDIO_MODULATION_SOURCE_TYPES = Object.freeze(['constant', 'lfo', 'sample-hold', 'oscillator']);
|
||||
|
||||
export const AUDIO_SOUND_FIELDS = Object.freeze(['name', 'tags', 'usage', 'cadence', 'bus', 'recipe']);
|
||||
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_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 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);
|
||||
}
|
||||
|
||||
export function isControlSourceType(type) {
|
||||
return AUDIO_NODE_TYPES[type]?.class === 'control';
|
||||
}
|
||||
|
||||
export function isSourceType(type) {
|
||||
const entry = AUDIO_NODE_TYPES[type];
|
||||
return entry?.class === 'source' || entry?.class === 'control';
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// Deterministic instantiation of an expanded audio graph, plus its Web Audio realization.
|
||||
// Instantiation is pure and testable without an AudioContext (Format Specification 14.4-14.6).
|
||||
|
||||
import {
|
||||
AUDIO_MODE_FIELDS,
|
||||
AUDIO_NODE_TYPES,
|
||||
AUDIO_PARTIAL_FIELDS,
|
||||
audioMaxFrequency
|
||||
} from './audio-contract.js';
|
||||
import { durationMilliseconds, expandSoundGraph } from './audio-graph.js';
|
||||
import { ValueResolver } from './values.js';
|
||||
import { RuntimeFault, clamp } from './types.js';
|
||||
|
||||
export function soundInstanceKey(soundId, ordinal) {
|
||||
return `${soundId}#${ordinal}`;
|
||||
}
|
||||
|
||||
export function sampleHoldStreamKey(instanceKey, nodePath) {
|
||||
return `${instanceKey}|node|${nodePath}`;
|
||||
}
|
||||
|
||||
export function instantiateSoundGraph(document, soundId, options = {}) {
|
||||
const {
|
||||
sampleRate = 48000,
|
||||
rng = null,
|
||||
ordinal = 0,
|
||||
resolveReference = () => { throw new RuntimeFault('ERR_INVALID_REFERENCE', 'No reference resolver supplied.'); }
|
||||
} = options;
|
||||
|
||||
const expansion = options.expansion ?? expandSoundGraph(document, soundId);
|
||||
if (expansion.errors.length > 0) {
|
||||
return { nodes: [], routes: [], warnings: [], errors: expansion.errors, mode: expansion.mode };
|
||||
}
|
||||
|
||||
const ceiling = audioMaxFrequency(sampleRate);
|
||||
const instanceKey = soundInstanceKey(soundId, ordinal);
|
||||
const stream = rng ? rng.stream('sound', instanceKey) : null;
|
||||
const warnings = [];
|
||||
const componentValues = new Map();
|
||||
|
||||
const resolver = new ValueResolver((reference) => {
|
||||
if (typeof reference === 'string' && reference.startsWith('inputs.')) {
|
||||
const scope = resolver.currentScope;
|
||||
const values = componentValues.get(scope);
|
||||
const name = reference.slice('inputs.'.length);
|
||||
if (!values || !Object.hasOwn(values, name)) {
|
||||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component input '${name}' is not available here.`);
|
||||
}
|
||||
return values[name];
|
||||
}
|
||||
return resolveReference(reference);
|
||||
});
|
||||
|
||||
const evaluate = (spec, scope, path) => {
|
||||
resolver.currentScope = scope;
|
||||
return resolver.evaluate(spec, stream, path);
|
||||
};
|
||||
|
||||
const clampField = (value, spec, nodePath, field) => {
|
||||
let limitMax = spec.max;
|
||||
if (spec.ceiling === 'audio' && ceiling < spec.max) limitMax = ceiling;
|
||||
const bounded = clamp(value, spec.min, limitMax);
|
||||
if (spec.ceiling === 'audio' && value > limitMax) {
|
||||
warnings.push({ code: 'WARN_AUDIO_RATE_CLAMP', path: `${nodePath}.${field}`, message: `Clamped ${value} Hz to the device ceiling ${limitMax} Hz.` });
|
||||
}
|
||||
return bounded;
|
||||
};
|
||||
|
||||
const nodes = [];
|
||||
for (const node of expansion.nodes) {
|
||||
if (node.implicit) {
|
||||
nodes.push({ path: node.path, type: 'gain', implicit: true, values: { gain: 1 } });
|
||||
continue;
|
||||
}
|
||||
const contract = AUDIO_NODE_TYPES[node.type];
|
||||
if (!contract) continue;
|
||||
const scope = node.scope;
|
||||
const values = {};
|
||||
|
||||
if (node.type === 'component') {
|
||||
const declared = node.exposes ?? {};
|
||||
const supplied = node.spec.values ?? {};
|
||||
const resolved = {};
|
||||
for (const [name, rule] of Object.entries(declared)) {
|
||||
const raw = Object.hasOwn(supplied, name) ? supplied[name] : rule.default;
|
||||
const value = evaluate(raw, scope, `${node.path}.values.${name}`);
|
||||
resolved[name] = clamp(value, rule.min ?? -Infinity, rule.max ?? Infinity);
|
||||
}
|
||||
componentValues.set(node.path, resolved);
|
||||
nodes.push({ path: node.path, type: 'component', component: node.component, values: resolved, passthrough: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [field, spec] of Object.entries(contract.fields)) {
|
||||
if (spec.kind === 'partials' || spec.kind === 'modes') continue;
|
||||
const authored = Object.hasOwn(node.spec, field) ? node.spec[field] : spec.default;
|
||||
if (authored === undefined) continue;
|
||||
if (spec.kind === 'enum') { values[field] = authored; continue; }
|
||||
if (spec.kind === 'duration') { values[field] = durationMilliseconds(authored); continue; }
|
||||
const raw = evaluate(authored, scope, `${node.path}.${field}`);
|
||||
values[field] = clampField(raw, spec, node.path, field);
|
||||
}
|
||||
|
||||
if (node.type === 'oscillator' && values.waveform === 'custom') {
|
||||
const partials = [];
|
||||
for (const [index, partial] of (node.spec.harmonics ?? []).entries()) {
|
||||
const ratio = evaluate(partial.ratio, scope, `${node.path}.harmonics[${index}].ratio`);
|
||||
const gain = evaluate(partial.gain, scope, `${node.path}.harmonics[${index}].gain`);
|
||||
const phase = Object.hasOwn(partial, 'phase')
|
||||
? evaluate(partial.phase, scope, `${node.path}.harmonics[${index}].phase`)
|
||||
: AUDIO_PARTIAL_FIELDS.phase.default;
|
||||
if (ratio * values.frequency > ceiling) continue;
|
||||
partials.push({ ratio, gain, phase });
|
||||
}
|
||||
values.harmonics = partials;
|
||||
}
|
||||
|
||||
if (node.type === 'resonator') {
|
||||
const modes = [];
|
||||
for (const [index, mode] of (node.spec.modes ?? []).entries()) {
|
||||
const entry = {
|
||||
gain: Object.hasOwn(mode, 'gain') ? evaluate(mode.gain, scope, `${node.path}.modes[${index}].gain`) : AUDIO_MODE_FIELDS.gain.default,
|
||||
decay: durationMilliseconds(Object.hasOwn(mode, 'decay') ? mode.decay : AUDIO_MODE_FIELDS.decay.default)
|
||||
};
|
||||
entry.frequency = Object.hasOwn(mode, 'frequency')
|
||||
? evaluate(mode.frequency, scope, `${node.path}.modes[${index}].frequency`)
|
||||
: evaluate(mode.ratio, scope, `${node.path}.modes[${index}].ratio`) * values.fundamental;
|
||||
if (entry.frequency > ceiling) continue;
|
||||
modes.push(entry);
|
||||
}
|
||||
values.modes = modes;
|
||||
}
|
||||
|
||||
if (node.type === 'sample-hold') {
|
||||
const slewLimit = 1000 / values.rate;
|
||||
values.slew = Math.min(values.slew, slewLimit);
|
||||
values.streamKey = sampleHoldStreamKey(instanceKey, node.path);
|
||||
values.stream = rng ? rng.stream('sound', values.streamKey) : null;
|
||||
}
|
||||
|
||||
nodes.push({ path: node.path, type: node.type, values, scope });
|
||||
}
|
||||
|
||||
const routes = expansion.routes.map((route) => {
|
||||
if (route.kind !== 'modulation') return { ...route };
|
||||
const owner = expansion.nodes.find((node) => node.path === route.to);
|
||||
return { ...route, depth: evaluate(route.depth, owner?.scope ?? null, `${route.path}.depth`) };
|
||||
});
|
||||
|
||||
return { nodes, routes, warnings, errors: [], mode: expansion.mode, instanceKey, ceiling };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Web Audio realization
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const NOISE_SECONDS = 2;
|
||||
|
||||
function noiseBuffer(context, color) {
|
||||
const length = Math.floor(context.sampleRate * NOISE_SECONDS);
|
||||
const buffer = context.createBuffer(1, length, context.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
if (color === 'white') {
|
||||
for (let index = 0; index < length; index += 1) data[index] = (Math.random() * 2) - 1;
|
||||
} else if (color === 'pink') {
|
||||
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const white = (Math.random() * 2) - 1;
|
||||
b0 = 0.99886 * b0 + white * 0.0555179;
|
||||
b1 = 0.99332 * b1 + white * 0.0750759;
|
||||
b2 = 0.969 * b2 + white * 0.153852;
|
||||
b3 = 0.8665 * b3 + white * 0.3104856;
|
||||
b4 = 0.55 * b4 + white * 0.5329522;
|
||||
b5 = -0.7616 * b5 - white * 0.016898;
|
||||
data[index] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
||||
b6 = white * 0.115926;
|
||||
}
|
||||
} else {
|
||||
let last = 0;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const white = (Math.random() * 2) - 1;
|
||||
last = (last + 0.02 * white) / 1.02;
|
||||
data[index] = last;
|
||||
}
|
||||
}
|
||||
let sum = 0;
|
||||
for (let index = 0; index < length; index += 1) sum += data[index] * data[index];
|
||||
const rms = Math.sqrt(sum / length) || 1;
|
||||
for (let index = 0; index < length; index += 1) data[index] = clamp(data[index] / rms * 0.2, -1, 1);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function shaperCurve(shape, amount) {
|
||||
const points = 1024;
|
||||
const curve = new Float32Array(points);
|
||||
const drive = 1 + (amount * 24);
|
||||
for (let index = 0; index < points; index += 1) {
|
||||
const x = (index * 2 / (points - 1)) - 1;
|
||||
if (amount === 0) curve[index] = x;
|
||||
else if (shape === 'hard-clip') curve[index] = clamp(x * drive, -1, 1);
|
||||
else if (shape === 'saturation') curve[index] = Math.tanh(x * drive);
|
||||
else curve[index] = Math.sign(x) * (1 - Math.exp(-Math.abs(x * drive))) / (1 - Math.exp(-drive));
|
||||
}
|
||||
return curve;
|
||||
}
|
||||
|
||||
function reverbBuffer(context, { size, decay, damping }) {
|
||||
const seconds = Math.max(0.05, (decay / 1000) * (0.4 + (size * 0.6)));
|
||||
const length = Math.max(1, Math.floor(context.sampleRate * seconds));
|
||||
const buffer = context.createBuffer(2, length, context.sampleRate);
|
||||
for (let channel = 0; channel < 2; channel += 1) {
|
||||
const data = buffer.getChannelData(channel);
|
||||
let smoothed = 0;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const envelope = (1 - (index / length)) ** (2 + (damping * 4));
|
||||
const impulse = ((Math.random() * 2) - 1) * envelope;
|
||||
smoothed += (impulse - smoothed) * (1 - (damping * 0.7));
|
||||
data[index] = smoothed;
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function periodicWave(context, partials) {
|
||||
const count = Math.max(2, partials.reduce((highest, partial) => Math.max(highest, Math.round(partial.ratio)), 1) + 1);
|
||||
const real = new Float32Array(count);
|
||||
const imaginary = new Float32Array(count);
|
||||
for (const partial of partials) {
|
||||
const index = Math.round(partial.ratio);
|
||||
if (index < 1 || index >= count) continue;
|
||||
const radians = (partial.phase ?? 0) * Math.PI / 180;
|
||||
real[index] += partial.gain * Math.cos(radians);
|
||||
imaginary[index] += partial.gain * Math.sin(radians);
|
||||
}
|
||||
return context.createPeriodicWave(real, imaginary, { disableNormalization: false });
|
||||
}
|
||||
|
||||
// Builds one realized voice. Lifecycle states (PRD 57) arrive in Phase 3c; this returns the
|
||||
// created endpoints plus a disposer so the caller can release everything it made.
|
||||
export function realizeSoundGraph(context, plan, destination) {
|
||||
const created = new Map();
|
||||
const disposers = [];
|
||||
const starters = [];
|
||||
const sink = context.createGain();
|
||||
sink.gain.value = 1;
|
||||
sink.connect(destination);
|
||||
created.set('output', { input: sink, output: sink });
|
||||
|
||||
const now = () => context.currentTime;
|
||||
|
||||
for (const node of plan.nodes) {
|
||||
const { path, type, values } = node;
|
||||
if (type === 'component') continue;
|
||||
let entry = null;
|
||||
|
||||
if (type === 'oscillator') {
|
||||
const oscillator = context.createOscillator();
|
||||
if (values.waveform === 'custom') oscillator.setPeriodicWave(periodicWave(context, values.harmonics ?? []));
|
||||
else oscillator.type = values.waveform;
|
||||
oscillator.frequency.value = values.frequency;
|
||||
oscillator.detune.value = values.detune;
|
||||
entry = { input: null, output: oscillator, params: { frequency: oscillator.frequency, detune: oscillator.detune } };
|
||||
starters.push(() => oscillator.start());
|
||||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); });
|
||||
} else if (type === 'noise') {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = noiseBuffer(context, values.color);
|
||||
source.loop = true;
|
||||
entry = { input: null, output: source, params: {} };
|
||||
starters.push(() => source.start());
|
||||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||||
} else if (type === 'impulse') {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = noiseBuffer(context, values.color);
|
||||
const envelope = context.createGain();
|
||||
const seconds = values.duration / 1000;
|
||||
const fade = Math.min(0.001, seconds * 0.1);
|
||||
const start = now();
|
||||
envelope.gain.setValueAtTime(values.amplitude, start);
|
||||
if (values.decay === 'linear') envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||||
else if (values.decay === 'exponential') {
|
||||
envelope.gain.exponentialRampToValueAtTime(Math.max(1e-4, values.amplitude * 0.001), start + seconds - fade);
|
||||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||||
} else {
|
||||
envelope.gain.setValueAtTime(values.amplitude, start + seconds - fade);
|
||||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||||
}
|
||||
source.connect(envelope);
|
||||
entry = { input: null, output: envelope, params: {} };
|
||||
starters.push(() => source.start(undefined, 0, seconds));
|
||||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); envelope.disconnect(); });
|
||||
} else if (type === 'constant') {
|
||||
const source = context.createConstantSource();
|
||||
source.offset.value = values.value;
|
||||
entry = { input: null, output: source, params: { value: source.offset } };
|
||||
starters.push(() => source.start());
|
||||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||||
} else if (type === 'lfo') {
|
||||
const oscillator = context.createOscillator();
|
||||
oscillator.type = values.waveform;
|
||||
oscillator.frequency.value = values.frequency;
|
||||
const depth = context.createGain();
|
||||
depth.gain.value = values.polarity === 'unipolar' ? values.amplitude / 2 : values.amplitude;
|
||||
oscillator.connect(depth);
|
||||
let output = depth;
|
||||
if (values.polarity === 'unipolar') {
|
||||
const offset = context.createConstantSource();
|
||||
offset.offset.value = values.amplitude / 2;
|
||||
const sum = context.createGain();
|
||||
depth.connect(sum);
|
||||
offset.connect(sum);
|
||||
output = sum;
|
||||
starters.push(() => offset.start());
|
||||
disposers.push(() => { try { offset.stop(); } catch { /* already stopped */ } offset.disconnect(); sum.disconnect(); });
|
||||
}
|
||||
entry = { input: null, output, params: {} };
|
||||
starters.push(() => oscillator.start(now() + ((values.phase / 360) / Math.max(values.frequency, 1e-6))));
|
||||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); depth.disconnect(); });
|
||||
} else if (type === 'sample-hold') {
|
||||
const source = context.createConstantSource();
|
||||
const period = 1 / values.rate;
|
||||
const slew = values.slew / 1000;
|
||||
let held = 0;
|
||||
source.offset.value = 0;
|
||||
const schedule = (index) => {
|
||||
const target = values.stream
|
||||
? values.min + (values.stream.nextFloat() * (values.max - values.min))
|
||||
: values.min;
|
||||
const at = now() + (index * period);
|
||||
if (slew > 0) source.offset.linearRampToValueAtTime(target, at + slew);
|
||||
else source.offset.setValueAtTime(target, at);
|
||||
held = target;
|
||||
return held;
|
||||
};
|
||||
let tick = 0;
|
||||
const timer = setInterval(() => { schedule(0); tick += 1; }, Math.max(10, period * 1000));
|
||||
schedule(0);
|
||||
entry = { input: null, output: source, params: {} };
|
||||
starters.push(() => source.start());
|
||||
disposers.push(() => { clearInterval(timer); try { source.stop(); } catch { /* already stopped */ } source.disconnect(); void tick; });
|
||||
} else if (type === 'gain') {
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = values.gain ?? 1;
|
||||
entry = { input: gain, output: gain, params: { gain: gain.gain } };
|
||||
disposers.push(() => gain.disconnect());
|
||||
} else if (type === 'filter') {
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = values.mode;
|
||||
filter.frequency.value = values.frequency;
|
||||
filter.Q.value = values.q;
|
||||
filter.gain.value = values.gain;
|
||||
filter.detune.value = values.detune;
|
||||
entry = { input: filter, output: filter, params: { frequency: filter.frequency, q: filter.Q, gain: filter.gain, detune: filter.detune } };
|
||||
disposers.push(() => filter.disconnect());
|
||||
} else if (type === 'compressor') {
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
compressor.threshold.value = values.threshold;
|
||||
compressor.knee.value = values.knee;
|
||||
compressor.ratio.value = values.ratio;
|
||||
compressor.attack.value = values.attack / 1000;
|
||||
compressor.release.value = values.release / 1000;
|
||||
entry = { input: compressor, output: compressor, params: {} };
|
||||
disposers.push(() => compressor.disconnect());
|
||||
} else if (type === 'waveshaper') {
|
||||
const shaper = context.createWaveShaper();
|
||||
shaper.curve = shaperCurve(values.shape, values.amount);
|
||||
shaper.oversample = values.oversample;
|
||||
entry = { input: shaper, output: shaper, params: {} };
|
||||
disposers.push(() => shaper.disconnect());
|
||||
} else if (type === 'delay') {
|
||||
const input = context.createGain();
|
||||
const delay = context.createDelay(10);
|
||||
delay.delayTime.value = values.time / 1000;
|
||||
const feedback = context.createGain();
|
||||
feedback.gain.value = values.feedback;
|
||||
const dry = context.createGain();
|
||||
const wet = context.createGain();
|
||||
const output = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
wet.gain.value = values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
input.connect(delay);
|
||||
delay.connect(feedback).connect(delay);
|
||||
delay.connect(wet).connect(output);
|
||||
entry = { input, output, params: { time: delay.delayTime } };
|
||||
disposers.push(() => [input, delay, feedback, dry, wet, output].forEach((item) => item.disconnect()));
|
||||
} else if (type === 'reverb') {
|
||||
const input = context.createGain();
|
||||
const convolver = context.createConvolver();
|
||||
convolver.buffer = reverbBuffer(context, values);
|
||||
const predelay = context.createDelay(1);
|
||||
predelay.delayTime.value = values.predelay / 1000;
|
||||
const dry = context.createGain();
|
||||
const wet = context.createGain();
|
||||
const output = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
wet.gain.value = values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
input.connect(predelay).connect(convolver).connect(wet).connect(output);
|
||||
entry = { input, output, params: {} };
|
||||
disposers.push(() => [input, convolver, predelay, dry, wet, output].forEach((item) => item.disconnect()));
|
||||
} else if (type === 'stereo-pan') {
|
||||
const panner = context.createStereoPanner();
|
||||
panner.pan.value = values.pan;
|
||||
entry = { input: panner, output: panner, params: { pan: panner.pan } };
|
||||
disposers.push(() => panner.disconnect());
|
||||
} else if (type === 'mixer') {
|
||||
const mixer = context.createGain();
|
||||
mixer.gain.value = 1;
|
||||
entry = { input: mixer, output: mixer, params: {} };
|
||||
disposers.push(() => mixer.disconnect());
|
||||
} else if (type === 'resonator') {
|
||||
const input = context.createGain();
|
||||
const output = context.createGain();
|
||||
const dry = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
const wet = context.createGain();
|
||||
wet.gain.value = values.mix;
|
||||
wet.connect(output);
|
||||
const bands = [];
|
||||
for (const mode of values.modes ?? []) {
|
||||
const band = context.createBiquadFilter();
|
||||
band.type = 'bandpass';
|
||||
band.frequency.value = mode.frequency;
|
||||
band.Q.value = Math.max(1, (mode.frequency * (mode.decay / 1000)) / 3);
|
||||
const level = context.createGain();
|
||||
level.gain.value = mode.gain;
|
||||
input.connect(band).connect(level).connect(wet);
|
||||
bands.push(band, level);
|
||||
}
|
||||
entry = { input, output, params: {} };
|
||||
disposers.push(() => [input, output, dry, wet, ...bands].forEach((item) => item.disconnect()));
|
||||
}
|
||||
|
||||
if (entry) created.set(path, entry);
|
||||
}
|
||||
|
||||
for (const route of plan.routes) {
|
||||
const source = created.get(route.from);
|
||||
if (!source?.output) continue;
|
||||
if (route.kind === 'audio') {
|
||||
const target = created.get(route.to);
|
||||
if (target?.input) source.output.connect(target.input);
|
||||
continue;
|
||||
}
|
||||
const target = created.get(route.to);
|
||||
const param = target?.params?.[route.property];
|
||||
if (!param) continue;
|
||||
const depth = context.createGain();
|
||||
depth.gain.value = route.property === 'time' ? route.depth / 1000 : route.depth;
|
||||
source.output.connect(depth).connect(param);
|
||||
disposers.push(() => depth.disconnect());
|
||||
}
|
||||
|
||||
for (const start of starters) start();
|
||||
|
||||
return {
|
||||
sink,
|
||||
dispose() {
|
||||
for (const release of disposers.reverse()) {
|
||||
try { release(); } catch { /* disposal is best effort */ }
|
||||
}
|
||||
sink.disconnect();
|
||||
created.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 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.
|
||||
export class AudioSubsystem {
|
||||
constructor({ document, rng, diagnostics = null, contextFactory = null } = {}) {
|
||||
this.document = document;
|
||||
this.rng = rng;
|
||||
this.diagnostics = diagnostics;
|
||||
this.hasCustomContext = Boolean(contextFactory);
|
||||
this.contextFactory = contextFactory ?? (() => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)());
|
||||
this.context = null;
|
||||
this.master = null;
|
||||
this.protection = null;
|
||||
this.buses = new Map();
|
||||
this.voices = new Set();
|
||||
this.ordinals = new Map();
|
||||
this.masterVolume = 0.8;
|
||||
}
|
||||
|
||||
get available() {
|
||||
return this.hasCustomContext || typeof (globalThis.AudioContext ?? globalThis.webkitAudioContext) === 'function';
|
||||
}
|
||||
|
||||
get unlocked() {
|
||||
return this.context !== null && this.context.state === 'running';
|
||||
}
|
||||
|
||||
// Must be called from a user gesture; browsers refuse to start audio otherwise.
|
||||
async unlock() {
|
||||
if (!this.available) {
|
||||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', 'This browser exposes no AudioContext; audio is disabled.', { section: 'audio' });
|
||||
return false;
|
||||
}
|
||||
if (!this.context) {
|
||||
this.context = this.contextFactory();
|
||||
this.buildMaster();
|
||||
this.buildBuses();
|
||||
}
|
||||
if (this.context.state === 'suspended') await this.context.resume();
|
||||
return this.unlocked;
|
||||
}
|
||||
|
||||
buildMaster() {
|
||||
const context = this.context;
|
||||
this.protection = context.createDynamicsCompressor();
|
||||
this.protection.threshold.value = -3;
|
||||
this.protection.knee.value = 0;
|
||||
this.protection.ratio.value = 20;
|
||||
this.protection.attack.value = 0.003;
|
||||
this.protection.release.value = 0.25;
|
||||
this.master = context.createGain();
|
||||
this.master.gain.value = this.masterVolume;
|
||||
this.protection.connect(this.master).connect(context.destination);
|
||||
}
|
||||
|
||||
buildBuses() {
|
||||
const declared = this.document?.audio?.buses ?? {};
|
||||
for (const [id, bus] of Object.entries(declared)) {
|
||||
const gain = this.context.createGain();
|
||||
gain.gain.value = typeof bus.gain === 'number' ? bus.gain : 1;
|
||||
gain.connect(this.protection);
|
||||
this.buses.set(id, gain);
|
||||
}
|
||||
}
|
||||
|
||||
busFor(soundId) {
|
||||
const name = this.document?.sounds?.[soundId]?.bus;
|
||||
return (name && this.buses.get(name)) || this.protection;
|
||||
}
|
||||
|
||||
setBusGain(id, value) {
|
||||
const bus = this.buses.get(id);
|
||||
if (bus) bus.gain.value = Math.min(4, Math.max(0, value));
|
||||
}
|
||||
|
||||
setMasterVolume(value) {
|
||||
this.masterVolume = Math.min(1, Math.max(0, value));
|
||||
if (this.master) this.master.gain.value = this.masterVolume;
|
||||
}
|
||||
|
||||
nextOrdinal(soundId) {
|
||||
const next = (this.ordinals.get(soundId) ?? -1) + 1;
|
||||
this.ordinals.set(soundId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
play(soundId, { resolveReference } = {}) {
|
||||
if (!this.unlocked) return null;
|
||||
const plan = instantiateSoundGraph(this.document, soundId, {
|
||||
sampleRate: this.context.sampleRate,
|
||||
rng: this.rng,
|
||||
ordinal: this.nextOrdinal(soundId),
|
||||
resolveReference
|
||||
});
|
||||
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 = {
|
||||
soundId,
|
||||
mode: plan.mode,
|
||||
stop: () => {
|
||||
if (!this.voices.has(handle)) return;
|
||||
this.voices.delete(handle);
|
||||
voice.dispose();
|
||||
}
|
||||
};
|
||||
this.voices.add(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
stopAll() {
|
||||
for (const handle of [...this.voices]) handle.stop();
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.stopAll();
|
||||
this.buses.clear();
|
||||
if (this.context) {
|
||||
try { await this.context.close(); } catch { /* already closed */ }
|
||||
}
|
||||
this.context = null;
|
||||
this.master = null;
|
||||
this.protection = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
// Audio graph validation, component expansion, legality checking, and deterministic
|
||||
// instantiation. Pure: never touches an AudioContext (Format Specification 14.5).
|
||||
|
||||
import {
|
||||
AUDIO_BUS_FIELDS,
|
||||
AUDIO_BUS_GAIN_RANGE,
|
||||
AUDIO_COMPONENT_FIELDS,
|
||||
AUDIO_COMPONENT_PARAMETER_FIELDS,
|
||||
AUDIO_GRAPH_FIELDS,
|
||||
AUDIO_LIMITS,
|
||||
AUDIO_MODE_FIELDS,
|
||||
AUDIO_MODULATABLE,
|
||||
AUDIO_MODULATION_SOURCE_TYPES,
|
||||
AUDIO_NODE_TYPES,
|
||||
AUDIO_PARTIAL_FIELDS,
|
||||
AUDIO_RECIPE_FIELDS,
|
||||
AUDIO_RECIPE_MODES,
|
||||
AUDIO_ROUTE_FIELDS,
|
||||
AUDIO_SOUND_FIELDS,
|
||||
AUDIO_SOUND_USAGE,
|
||||
AUDIO_STATIC_MAX_FREQUENCY,
|
||||
audioMaxFrequency
|
||||
} from './audio-contract.js';
|
||||
import { ID_PATTERN } from './constants.js';
|
||||
import { DURATION_PATTERN } from './types.js';
|
||||
|
||||
const DURATION_UNITS = Object.freeze({ ms: 1, s: 1000, m: 60_000, h: 3_600_000 });
|
||||
|
||||
function isObject(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function durationMilliseconds(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const match = DURATION_PATTERN.exec(value);
|
||||
if (!match) return null;
|
||||
const milliseconds = Number(match[1]) * DURATION_UNITS[match[2]];
|
||||
return Number.isFinite(milliseconds) ? milliseconds : null;
|
||||
}
|
||||
|
||||
function fail(errors, code, path, message) {
|
||||
errors.push({ code, path, message });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Structural validation of a single graph object (section 14.3)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function validateNumericField(document, value, spec, path, errors, helpers, scope) {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return fail(errors, 'ERR_TYPE_MISMATCH', path, 'Numeric field must be finite.');
|
||||
const overMax = spec.exclusiveMax ? value >= spec.max : value > spec.max;
|
||||
if (value < spec.min || overMax) {
|
||||
fail(errors, 'ERR_OUT_OF_BOUNDS', path, `Value ${value} is outside [${spec.min}, ${spec.max}${spec.exclusiveMax ? ')' : ']'}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!spec.valuespec) return fail(errors, 'ERR_TYPE_MISMATCH', path, 'Field requires a literal number.');
|
||||
helpers.validateValueSpec(document, value, path, errors, scope);
|
||||
}
|
||||
|
||||
function validateDurationField(value, spec, path, errors) {
|
||||
const milliseconds = durationMilliseconds(value);
|
||||
if (milliseconds === null) return fail(errors, 'ERR_INVALID_DURATION', path, `Invalid duration ${JSON.stringify(value)}.`);
|
||||
if (milliseconds < spec.min || milliseconds > spec.max) {
|
||||
fail(errors, 'ERR_OUT_OF_BOUNDS', path, `Duration ${value} is outside [${spec.min}ms, ${spec.max}ms].`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePartials(document, node, path, errors, helpers, scope) {
|
||||
const waveform = node.waveform ?? 'sine';
|
||||
const present = Object.hasOwn(node, 'harmonics');
|
||||
if (waveform !== 'custom') {
|
||||
if (present) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.harmonics`, "harmonics is only declared when waveform is 'custom'.");
|
||||
return;
|
||||
}
|
||||
if (!present) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.harmonics`, "waveform 'custom' requires harmonics.");
|
||||
const partials = node.harmonics;
|
||||
if (!Array.isArray(partials) || partials.length === 0) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.harmonics`, 'harmonics must be a non-empty array.');
|
||||
if (partials.length > AUDIO_LIMITS.oscillatorPartials) {
|
||||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', `${path}.harmonics`, `At most ${AUDIO_LIMITS.oscillatorPartials} partials are permitted.`);
|
||||
}
|
||||
partials.forEach((partial, index) => {
|
||||
const entryPath = `${path}.harmonics[${index}]`;
|
||||
if (!isObject(partial)) return fail(errors, 'ERR_SCHEMA_VALIDATION', entryPath, 'Partial must be an object.');
|
||||
for (const field of Object.keys(partial)) {
|
||||
if (!Object.hasOwn(AUDIO_PARTIAL_FIELDS, field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${entryPath}.${field}`, `Unrecognized partial field '${field}'.`);
|
||||
}
|
||||
for (const [field, rule] of Object.entries(AUDIO_PARTIAL_FIELDS)) {
|
||||
if (!Object.hasOwn(partial, field)) {
|
||||
if (rule.required) fail(errors, 'ERR_SCHEMA_VALIDATION', `${entryPath}.${field}`, `Partial requires '${field}'.`);
|
||||
continue;
|
||||
}
|
||||
validateNumericField(document, partial[field], { ...rule, valuespec: true }, `${entryPath}.${field}`, errors, helpers, scope);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateModes(document, node, path, errors, helpers, scope) {
|
||||
const modes = node.modes;
|
||||
if (!Array.isArray(modes) || modes.length === 0) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.modes`, 'resonator requires a non-empty modes array.');
|
||||
if (modes.length > AUDIO_LIMITS.resonatorModes) {
|
||||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', `${path}.modes`, `At most ${AUDIO_LIMITS.resonatorModes} resonator modes are permitted.`);
|
||||
}
|
||||
modes.forEach((mode, index) => {
|
||||
const entryPath = `${path}.modes[${index}]`;
|
||||
if (!isObject(mode)) return fail(errors, 'ERR_SCHEMA_VALIDATION', entryPath, 'Mode must be an object.');
|
||||
for (const field of Object.keys(mode)) {
|
||||
if (!Object.hasOwn(AUDIO_MODE_FIELDS, field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${entryPath}.${field}`, `Unrecognized mode field '${field}'.`);
|
||||
}
|
||||
const hasRatio = Object.hasOwn(mode, 'ratio');
|
||||
const hasFrequency = Object.hasOwn(mode, 'frequency');
|
||||
if (hasRatio === hasFrequency) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', entryPath, 'Each mode declares exactly one of ratio or frequency.');
|
||||
}
|
||||
if (hasRatio) validateNumericField(document, mode.ratio, { ...AUDIO_MODE_FIELDS.ratio, valuespec: true }, `${entryPath}.ratio`, errors, helpers, scope);
|
||||
if (hasFrequency) validateNumericField(document, mode.frequency, { ...AUDIO_MODE_FIELDS.frequency, valuespec: true }, `${entryPath}.frequency`, errors, helpers, scope);
|
||||
if (Object.hasOwn(mode, 'gain')) validateNumericField(document, mode.gain, { ...AUDIO_MODE_FIELDS.gain, valuespec: true }, `${entryPath}.gain`, errors, helpers, scope);
|
||||
if (Object.hasOwn(mode, 'decay')) validateDurationField(mode.decay, AUDIO_MODE_FIELDS.decay, `${entryPath}.decay`, errors);
|
||||
});
|
||||
}
|
||||
|
||||
function validateComponentInstance(document, node, path, errors, helpers, scope) {
|
||||
const components = document.components?.audio;
|
||||
if (typeof node.use !== 'string' || !isObject(components?.[node.use])) {
|
||||
return fail(errors, 'ERR_INVALID_REFERENCE', `${path}.use`, `Component '${node.use}' is not declared.`);
|
||||
}
|
||||
const declared = components[node.use].parameters ?? {};
|
||||
const values = node.values;
|
||||
if (values !== undefined && !isObject(values)) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values`, 'values must be an object.');
|
||||
for (const [key, value] of Object.entries(values ?? {})) {
|
||||
const rule = declared[key];
|
||||
if (!isObject(rule)) {
|
||||
fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.values.${key}`, `Component '${node.use}' does not expose '${key}'.`);
|
||||
continue;
|
||||
}
|
||||
validateNumericField(document, value, {
|
||||
min: rule.min ?? -Infinity, max: rule.max ?? Infinity, valuespec: true
|
||||
}, `${path}.values.${key}`, errors, helpers, scope);
|
||||
}
|
||||
for (const [key, rule] of Object.entries(declared)) {
|
||||
if (!Object.hasOwn(values ?? {}, key) && !Object.hasOwn(rule, 'default')) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values.${key}`, `Exposed parameter '${key}' has no default and no supplied value.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateNode(document, key, node, path, errors, helpers, scope) {
|
||||
if (!ID_PATTERN.test(key)) fail(errors, 'ERR_INVALID_ID', path, `Node key '${key}' is not a valid identifier.`);
|
||||
if (key === 'output') fail(errors, 'ERR_INVALID_ID', path, "'output' is reserved and cannot be declared as a node.");
|
||||
if (key === 'input') fail(errors, 'ERR_INVALID_ID', path, "'input' is reserved and cannot be declared as a node.");
|
||||
if (!isObject(node)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Node must be an object.');
|
||||
const contract = AUDIO_NODE_TYPES[node.type];
|
||||
if (!contract) return fail(errors, 'ERR_INVALID_NODE_TYPE', `${path}.type`, `Unrecognized audio node type '${node.type}'.`);
|
||||
|
||||
for (const field of Object.keys(node)) {
|
||||
if (field === 'type') continue;
|
||||
if (!Object.hasOwn(contract.fields, field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Node type '${node.type}' does not declare '${field}'.`);
|
||||
}
|
||||
|
||||
for (const [field, spec] of Object.entries(contract.fields)) {
|
||||
if (spec.kind === 'partials' || spec.kind === 'modes' || spec.kind === 'component-ref' || spec.kind === 'component-values') continue;
|
||||
if (!Object.hasOwn(node, field)) continue;
|
||||
const value = node[field];
|
||||
const fieldPath = `${path}.${field}`;
|
||||
if (spec.kind === 'enum') {
|
||||
if (typeof value !== 'string' || !spec.values.includes(value)) fail(errors, 'ERR_TYPE_MISMATCH', fieldPath, `'${field}' must be one of: ${spec.values.join(', ')}.`);
|
||||
} else if (spec.kind === 'duration') {
|
||||
validateDurationField(value, spec, fieldPath, errors);
|
||||
} else {
|
||||
validateNumericField(document, value, spec, fieldPath, errors, helpers, scope);
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'oscillator') validatePartials(document, node, path, errors, helpers, scope);
|
||||
if (node.type === 'resonator') validateModes(document, node, path, errors, helpers, scope);
|
||||
if (node.type === 'component') validateComponentInstance(document, node, path, errors, helpers, scope);
|
||||
|
||||
if (contract.rangeOrder) {
|
||||
const [lowField, highField] = contract.rangeOrder;
|
||||
const low = node[lowField] ?? contract.fields[lowField].default;
|
||||
const high = node[highField] ?? contract.fields[highField].default;
|
||||
if (typeof low === 'number' && typeof high === 'number' && low >= high) {
|
||||
fail(errors, 'ERR_INVALID_RANGE_ORDER', `${path}.${lowField}`, `${lowField} must be strictly less than ${highField}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoute(document, route, path, errors, helpers, scope) {
|
||||
if (!isObject(route)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Route must be an object.');
|
||||
for (const field of Object.keys(route)) {
|
||||
if (!AUDIO_ROUTE_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized route field '${field}'.`);
|
||||
}
|
||||
for (const field of ['from', 'to']) {
|
||||
if (typeof route[field] !== 'string' || route[field].length === 0) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${field}`, `Route '${field}' must be a non-empty string.`);
|
||||
}
|
||||
const isModulation = typeof route.to === 'string' && route.to.includes('.');
|
||||
if (isModulation && !Object.hasOwn(route, 'depth')) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.depth`, 'A modulation route requires depth.');
|
||||
}
|
||||
if (!isModulation && Object.hasOwn(route, 'depth')) {
|
||||
fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.depth`, 'depth is only declared on a modulation route.');
|
||||
}
|
||||
if (Object.hasOwn(route, 'depth')) {
|
||||
validateNumericField(document, route.depth, { min: -Infinity, max: Infinity, valuespec: true }, `${path}.depth`, errors, helpers, scope);
|
||||
}
|
||||
}
|
||||
|
||||
function validateGraphObject(document, graph, path, errors, helpers, { allowedFields, scope }) {
|
||||
if (!isObject(graph)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Audio graph must be an object.');
|
||||
for (const field of Object.keys(graph)) {
|
||||
if (!allowedFields.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized audio graph field '${field}'.`);
|
||||
}
|
||||
if (!isObject(graph.nodes) || Object.keys(graph.nodes).length === 0) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.nodes`, 'An audio graph requires a non-empty nodes object.');
|
||||
} else {
|
||||
for (const [key, node] of Object.entries(graph.nodes)) validateNode(document, key, node, `${path}.nodes.${key}`, errors, helpers, scope);
|
||||
}
|
||||
if (graph.routes !== undefined) {
|
||||
if (!Array.isArray(graph.routes)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.routes`, 'routes must be an array.');
|
||||
else graph.routes.forEach((route, index) => validateRoute(document, route, `${path}.routes[${index}]`, errors, helpers, scope));
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Expansion (section 15.11 / 15.15) and legality (section 15.14)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function recipeGraphFor(document, sound, path, errors) {
|
||||
const recipe = sound?.recipe;
|
||||
if (!isObject(recipe)) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.recipe`, 'A sound requires a recipe object.');
|
||||
return null;
|
||||
}
|
||||
if (Object.hasOwn(recipe, 'use')) {
|
||||
if (Object.keys(recipe).some((field) => field !== 'use')) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.recipe`, 'A recipe reference declares only use.');
|
||||
return null;
|
||||
}
|
||||
const shared = document.audio?.recipes?.[recipe.use];
|
||||
if (!isObject(shared)) {
|
||||
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.recipe.use`, `Recipe '${recipe.use}' is not declared.`);
|
||||
return null;
|
||||
}
|
||||
return { graph: shared, path: `$.audio.recipes.${recipe.use}` };
|
||||
}
|
||||
return { graph: recipe, path: `${path}.recipe` };
|
||||
}
|
||||
|
||||
export function expandSoundGraph(document, soundId) {
|
||||
const errors = [];
|
||||
const sound = document.sounds?.[soundId];
|
||||
const soundPath = `$.sounds.${soundId}`;
|
||||
const located = recipeGraphFor(document, sound, soundPath, errors);
|
||||
if (!located) return { nodes: [], routes: [], errors, mode: 'oneshot' };
|
||||
|
||||
const nodes = new Map();
|
||||
const routes = [];
|
||||
const components = document.components?.audio ?? {};
|
||||
|
||||
const join = (prefix, key) => (prefix ? `${prefix}.${key}` : key);
|
||||
|
||||
function expand(graph, prefix, depth, trail, graphPath) {
|
||||
if (depth > AUDIO_LIMITS.componentDepth) {
|
||||
fail(errors, 'ERR_COMPONENT_RECURSION', graphPath, `Component nesting exceeds ${AUDIO_LIMITS.componentDepth} levels.`);
|
||||
return;
|
||||
}
|
||||
const declared = isObject(graph.nodes) ? graph.nodes : {};
|
||||
for (const [key, node] of Object.entries(declared)) {
|
||||
if (!isObject(node)) continue;
|
||||
const path = join(prefix, key);
|
||||
if (node.type === 'component') {
|
||||
const component = components[node.use];
|
||||
if (!isObject(component)) continue;
|
||||
if (trail.includes(node.use)) {
|
||||
fail(errors, 'ERR_COMPONENT_RECURSION', `${graphPath}.nodes.${key}`, `Component '${node.use}' instantiates itself.`);
|
||||
continue;
|
||||
}
|
||||
nodes.set(`${path}.output`, { path: `${path}.output`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
|
||||
if (component.input === true) {
|
||||
nodes.set(`${path}.input`, { path: `${path}.input`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
|
||||
}
|
||||
nodes.set(path, {
|
||||
path, type: 'component', spec: node, implicit: false, scope: prefix || null,
|
||||
component: node.use, acceptsAudio: component.input === true, exposes: component.parameters ?? {}
|
||||
});
|
||||
expand(component, path, depth + 1, [...trail, node.use], `$.components.audio.${node.use}`);
|
||||
} else {
|
||||
nodes.set(path, { path, type: node.type, spec: node, implicit: false, scope: prefix || null });
|
||||
}
|
||||
}
|
||||
|
||||
const declaresInput = prefix ? graph.input === true : false;
|
||||
const resolve = (name, side, routePath) => {
|
||||
if (typeof name !== 'string') return null;
|
||||
if (name === 'output') return prefix ? `${prefix}.output` : 'output';
|
||||
if (name === 'input') {
|
||||
if (!declaresInput) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'input' is only available inside a component declaring input: true.");
|
||||
return null;
|
||||
}
|
||||
return `${prefix}.input`;
|
||||
}
|
||||
const head = name.split('.')[0];
|
||||
const local = declared[head];
|
||||
if (!isObject(local)) {
|
||||
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Route endpoint '${name}' does not resolve in this graph.`);
|
||||
return null;
|
||||
}
|
||||
const localPath = join(prefix, head);
|
||||
const property = name.includes('.') ? name.slice(head.length + 1) : null;
|
||||
if (local.type !== 'component') {
|
||||
if (side === 'from' && property !== null) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Route source '${name}' names a property; only nodes emit signal.`);
|
||||
return null;
|
||||
}
|
||||
return property === null ? localPath : `${localPath}::${property}`;
|
||||
}
|
||||
if (side === 'from') {
|
||||
if (property !== null) {
|
||||
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Component internals are encapsulated; '${name}' is not reachable.`);
|
||||
return null;
|
||||
}
|
||||
return `${localPath}.output`;
|
||||
}
|
||||
if (property !== null) return `${localPath}::${property}`;
|
||||
const component = components[local.use];
|
||||
if (component?.input !== true) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Component '${local.use}' declares no audio input.`);
|
||||
return null;
|
||||
}
|
||||
return `${localPath}.input`;
|
||||
};
|
||||
|
||||
const declaredRoutes = Array.isArray(graph.routes) ? graph.routes : [];
|
||||
declaredRoutes.forEach((route, index) => {
|
||||
if (!isObject(route)) return;
|
||||
const routePath = `${graphPath}.routes[${index}]`;
|
||||
const from = resolve(route.from, 'from', routePath);
|
||||
if (from === null) return;
|
||||
if (from === 'output' || from.endsWith('.output') && route.from === 'output') {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'output' is sink-only and cannot be a route source.");
|
||||
return;
|
||||
}
|
||||
const isModulation = typeof route.to === 'string' && route.to.includes('.');
|
||||
const to = resolve(route.to, 'to', routePath);
|
||||
if (to === null) return;
|
||||
if (isModulation) {
|
||||
const [target, property] = to.includes('::') ? to.split('::') : [to, null];
|
||||
if (property === null) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Modulation target '${route.to}' does not name a node property.`);
|
||||
return;
|
||||
}
|
||||
routes.push({ kind: 'modulation', from, to: target, property, depth: route.depth, path: routePath });
|
||||
} else {
|
||||
if (to.includes('::')) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Audio route target '${route.to}' names a property.`);
|
||||
return;
|
||||
}
|
||||
routes.push({ kind: 'audio', from, to, path: routePath });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function detectCycle(adjacency) {
|
||||
const visiting = new Set();
|
||||
const done = new Set();
|
||||
let cycle = null;
|
||||
const visit = (node, trail) => {
|
||||
if (cycle) return;
|
||||
if (visiting.has(node)) { cycle = [...trail, node]; return; }
|
||||
if (done.has(node)) return;
|
||||
visiting.add(node);
|
||||
for (const next of adjacency.get(node) ?? []) visit(next, [...trail, node]);
|
||||
visiting.delete(node);
|
||||
done.add(node);
|
||||
};
|
||||
for (const node of adjacency.keys()) visit(node, []);
|
||||
return cycle;
|
||||
}
|
||||
|
||||
export function checkGraphLegality(document, soundId, expansion, errors) {
|
||||
const { nodes, routes, graphPath } = expansion;
|
||||
const byPath = new Map(nodes.map((node) => [node.path, node]));
|
||||
const audible = new Map();
|
||||
const combined = new Map();
|
||||
const link = (map, from, to) => {
|
||||
if (!map.has(from)) map.set(from, []);
|
||||
map.get(from).push(to);
|
||||
};
|
||||
|
||||
const authored = nodes.filter((node) => !node.implicit && node.type !== 'component');
|
||||
if (authored.length > AUDIO_LIMITS.nodesPerSound) {
|
||||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${authored.length} nodes; the limit is ${AUDIO_LIMITS.nodesPerSound}.`);
|
||||
}
|
||||
if (routes.length > AUDIO_LIMITS.routesPerSound) {
|
||||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${routes.length} routes; the limit is ${AUDIO_LIMITS.routesPerSound}.`);
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.from === route.to) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', route.path, 'A route cannot connect a node to itself.');
|
||||
continue;
|
||||
}
|
||||
if (route.kind === 'audio') {
|
||||
const target = byPath.get(route.to);
|
||||
if (route.to !== 'output') {
|
||||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Route target '${route.to}' does not resolve.`); continue; }
|
||||
const contract = AUDIO_NODE_TYPES[target.type];
|
||||
const accepts = target.implicit || (target.type === 'component' ? target.acceptsAudio : contract?.acceptsAudio === true);
|
||||
if (!accepts) { fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node '${route.to}' does not accept audio input.`); continue; }
|
||||
}
|
||||
link(audible, route.from, route.to);
|
||||
link(combined, route.from, route.to);
|
||||
continue;
|
||||
}
|
||||
const target = byPath.get(route.to);
|
||||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation target '${route.to}' does not resolve.`); continue; }
|
||||
const source = byPath.get(route.from);
|
||||
if (!source) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation source '${route.from}' does not resolve.`); continue; }
|
||||
const sourceType = source.implicit ? 'gain' : source.type;
|
||||
if (!AUDIO_MODULATION_SOURCE_TYPES.includes(sourceType)) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node type '${sourceType}' cannot drive a modulation route.`);
|
||||
continue;
|
||||
}
|
||||
const permitted = target.type === 'component'
|
||||
? Object.hasOwn(target.exposes ?? {}, route.property)
|
||||
: Object.hasOwn(AUDIO_MODULATABLE[target.type] ?? {}, route.property);
|
||||
if (!permitted) {
|
||||
fail(errors, 'ERR_UNSUPPORTED_TARGET', route.path, `'${target.type}.${route.property}' is not a modulatable property.`);
|
||||
continue;
|
||||
}
|
||||
link(combined, route.from, route.to);
|
||||
}
|
||||
|
||||
const audioCycle = detectCycle(audible);
|
||||
if (audioCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Audio route cycle: ${audioCycle.join(' -> ')}.`);
|
||||
const combinedCycle = detectCycle(combined);
|
||||
if (!audioCycle && combinedCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Modulation dependency cycle: ${combinedCycle.join(' -> ')}.`);
|
||||
|
||||
if (audioCycle) return;
|
||||
const reaches = (start) => {
|
||||
const seen = new Set();
|
||||
const queue = [start];
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
if (current === 'output') return true;
|
||||
if (seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
for (const next of audible.get(current) ?? []) queue.push(next);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let audiblePath = false;
|
||||
for (const node of nodes) {
|
||||
if (node.implicit || node.type === 'component') continue;
|
||||
const contract = AUDIO_NODE_TYPES[node.type];
|
||||
if (!contract) continue;
|
||||
if (contract.acceptsAudio === false && reaches(node.path)) {
|
||||
if (contract.class === 'control') {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', node.path, `Control source '${node.path}' reaches audible output.`);
|
||||
} else {
|
||||
audiblePath = true;
|
||||
}
|
||||
}
|
||||
if (contract.acceptsAudio === false) {
|
||||
for (const route of routes) {
|
||||
if (route.kind === 'audio' && route.to === node.path) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Source node '${node.path}' cannot receive audio input.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!audiblePath) fail(errors, 'ERR_NO_AUDIBLE_PATH', graphPath, `Sound '${soundId}' has no audio path from a source to output.`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Document-level entry point
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function validateAudioSubsystem(document, errors, helpers) {
|
||||
const audio = document.audio;
|
||||
if (audio !== undefined) {
|
||||
if (!isObject(audio)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio', 'audio must be an object.');
|
||||
else {
|
||||
for (const field of Object.keys(audio)) {
|
||||
if (field === 'master') fail(errors, 'ERR_UNKNOWN_FIELD', '$.audio.master', 'audio.master is engine-provided and is reserved for the Phase 3c master-protection contract.');
|
||||
else if (!['buses', 'recipes'].includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `$.audio.${field}`, `Unrecognized audio field '${field}'.`);
|
||||
}
|
||||
if (audio.buses !== undefined) {
|
||||
if (!isObject(audio.buses)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.buses', 'buses must be an object.');
|
||||
else for (const [id, bus] of Object.entries(audio.buses)) {
|
||||
const path = `$.audio.buses.${id}`;
|
||||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Bus ID '${id}' is not a valid identifier.`);
|
||||
if (id === 'master') fail(errors, 'ERR_INVALID_ID', path, "'master' is engine-provided and cannot be declared.");
|
||||
if (!isObject(bus)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Bus must be an object.'); continue; }
|
||||
for (const field of Object.keys(bus)) if (!AUDIO_BUS_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized bus field '${field}'.`);
|
||||
if (Object.hasOwn(bus, 'gain')) {
|
||||
validateNumericField(document, bus.gain, { min: AUDIO_BUS_GAIN_RANGE.min, max: AUDIO_BUS_GAIN_RANGE.max, valuespec: true }, `${path}.gain`, errors, helpers, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (audio.recipes !== undefined) {
|
||||
if (!isObject(audio.recipes)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.recipes', 'recipes must be an object.');
|
||||
else for (const [id, recipe] of Object.entries(audio.recipes)) {
|
||||
const path = `$.audio.recipes.${id}`;
|
||||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Recipe ID '${id}' is not a valid identifier.`);
|
||||
validateGraphObject(document, recipe, path, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||||
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(', ')}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentRoot = document.components?.audio;
|
||||
if (componentRoot !== undefined) {
|
||||
if (!isObject(componentRoot)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.components.audio', 'components.audio must be an object.');
|
||||
else for (const [id, component] of Object.entries(componentRoot)) {
|
||||
const path = `$.components.audio.${id}`;
|
||||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Component ID '${id}' is not a valid identifier.`);
|
||||
if (!isObject(component)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Component must be an object.'); continue; }
|
||||
const exposed = new Set();
|
||||
if (component.parameters !== undefined) {
|
||||
if (!isObject(component.parameters)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.parameters`, 'parameters must be an object.');
|
||||
else for (const [name, rule] of Object.entries(component.parameters)) {
|
||||
const rulePath = `${path}.parameters.${name}`;
|
||||
if (!ID_PATTERN.test(name)) fail(errors, 'ERR_INVALID_ID', rulePath, `Exposed parameter '${name}' is not a valid identifier.`);
|
||||
if (!isObject(rule)) { fail(errors, 'ERR_SCHEMA_VALIDATION', rulePath, 'Exposed parameter must be an object.'); continue; }
|
||||
for (const field of Object.keys(rule)) if (!AUDIO_COMPONENT_PARAMETER_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${rulePath}.${field}`, `Unrecognized parameter field '${field}'.`);
|
||||
if (rule.type !== 'number') fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.type`, "Exposed audio parameters must declare type 'number' in 0.1.");
|
||||
for (const field of ['default', 'min', 'max']) {
|
||||
if (Object.hasOwn(rule, field) && !Number.isFinite(rule[field])) fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.${field}`, `${field} must be a finite number.`);
|
||||
}
|
||||
if (Number.isFinite(rule.min) && Number.isFinite(rule.max) && rule.min > rule.max) fail(errors, 'ERR_OUT_OF_BOUNDS', `${rulePath}.min`, 'min cannot exceed max.');
|
||||
exposed.add(name);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(component, 'input') && typeof component.input !== 'boolean') {
|
||||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.input`, 'input must be a boolean.');
|
||||
}
|
||||
validateGraphObject(document, component, path, errors, helpers, {
|
||||
allowedFields: AUDIO_COMPONENT_FIELDS,
|
||||
scope: { componentParameters: exposed }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sounds = document.sounds;
|
||||
if (sounds === undefined) return;
|
||||
if (!isObject(sounds)) return fail(errors, 'ERR_SCHEMA_VALIDATION', '$.sounds', 'sounds must be an object.');
|
||||
for (const [id, sound] of Object.entries(sounds)) {
|
||||
const path = `$.sounds.${id}`;
|
||||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Sound ID '${id}' is not a valid identifier.`);
|
||||
if (!isObject(sound)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Sound must be an object.'); continue; }
|
||||
for (const field of Object.keys(sound)) if (!AUDIO_SOUND_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized sound field '${field}'.`);
|
||||
if (typeof sound.name !== 'string' || sound.name.trim().length === 0 || sound.name.length > 128) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.name`, 'Sound name must be a non-empty string of at most 128 characters.');
|
||||
}
|
||||
if (sound.tags !== undefined && (!Array.isArray(sound.tags) || sound.tags.length > 16 || sound.tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) {
|
||||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.tags`, 'Tags must contain at most 16 strings of at most 32 characters.');
|
||||
}
|
||||
if (sound.usage !== undefined && (!Array.isArray(sound.usage) || sound.usage.length === 0 || sound.usage.some((entry) => !AUDIO_SOUND_USAGE.includes(entry)))) {
|
||||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.usage`, `usage entries must be among: ${AUDIO_SOUND_USAGE.join(', ')}.`);
|
||||
}
|
||||
if (sound.cadence !== undefined && !isObject(sound.cadence)) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.cadence`, 'cadence must be an object.');
|
||||
}
|
||||
if (sound.bus !== undefined && !isObject(document.audio?.buses?.[sound.bus])) {
|
||||
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.bus`, `Bus '${sound.bus}' is not declared.`);
|
||||
}
|
||||
if (isObject(sound.recipe) && !Object.hasOwn(sound.recipe, 'use')) {
|
||||
validateGraphObject(document, sound.recipe, `${path}.recipe`, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||||
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(', ')}.`);
|
||||
}
|
||||
}
|
||||
const expansion = expandSoundGraph(document, id);
|
||||
errors.push(...expansion.errors);
|
||||
if (expansion.nodes.length > 0) checkGraphLegality(document, id, expansion, errors);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const XZBT_FORMAT_VERSION = '0.1';
|
||||
export const XZBT_RUNTIME_VERSION = '0.1.0-phase2';
|
||||
export const XZBT_RUNTIME_VERSION = '0.1.0-phase3b';
|
||||
export const UINT32_RANGE = 0x1_0000_0000;
|
||||
export const RNG_DOMAINS = Object.freeze([
|
||||
'cadence',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateAudioSubsystem } from './audio-graph.js';
|
||||
import {
|
||||
ALLOWED_META_FIELDS,
|
||||
ALLOWED_TOP_LEVEL_FIELDS,
|
||||
@@ -87,6 +88,7 @@ export function validateExhibit(document, filename = 'document.xzbt') {
|
||||
|
||||
validateDefinitions(document, errors);
|
||||
validateBindings(document, errors);
|
||||
validateAudioSubsystem(document, errors, { validateValueSpec, pushError });
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings: [], filename };
|
||||
}
|
||||
@@ -136,9 +138,10 @@ function validateDefinitionMap(definitions, namespace, allowedTypes, valueField,
|
||||
}
|
||||
}
|
||||
|
||||
function referenceType(document, path) {
|
||||
function referenceType(document, path, scope = null) {
|
||||
if (typeof path !== 'string') return null;
|
||||
const parts = path.split('.');
|
||||
if (parts[0] === 'inputs') return parts.length === 2 && scope?.componentParameters?.has(parts[1]) ? 'number' : null;
|
||||
if (parts.length === 2 && parts[0] === 'parameters') return document.parameters?.[parts[1]]?.type ?? null;
|
||||
if (parts.length === 2 && parts[0] === 'state') return document.state?.[parts[1]]?.type ?? null;
|
||||
if (parts[0] === 'signals') return RUNTIME_SIGNAL_TYPES[path] ?? null;
|
||||
@@ -147,11 +150,11 @@ function referenceType(document, path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateReference(document, path, location, errors) {
|
||||
if (typeof path !== 'string' || !/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/.test(path) || !referenceType(document, path)) pushError(errors, 'ERR_INVALID_REFERENCE', location, `Reference '${path}' does not resolve.`);
|
||||
function validateReference(document, path, location, errors, scope = null) {
|
||||
if (typeof path !== 'string' || !/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/.test(path) || !referenceType(document, path, scope)) pushError(errors, 'ERR_INVALID_REFERENCE', location, `Reference '${path}' does not resolve.`);
|
||||
}
|
||||
|
||||
function validateValueSpec(document, spec, path, errors) {
|
||||
function validateValueSpec(document, spec, path, errors, scope = null) {
|
||||
if (typeof spec === 'number') {
|
||||
if (!Number.isFinite(spec)) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Number must be finite.');
|
||||
return;
|
||||
@@ -168,7 +171,7 @@ function validateValueSpec(document, spec, path, errors) {
|
||||
}
|
||||
const form = forms[0];
|
||||
for (const field of Object.keys(spec)) if (field !== form && !(form === 'op' && field === 'args')) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized ValueSpec field '${field}'.`);
|
||||
if (form === 'ref') return validateReference(document, spec.ref, `${path}.ref`, errors);
|
||||
if (form === 'ref') return validateReference(document, spec.ref, `${path}.ref`, errors, scope);
|
||||
if (form === 'random') {
|
||||
const random = spec.random;
|
||||
if (!isPlainObject(random)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.random`, 'random must be an object.');
|
||||
@@ -183,7 +186,7 @@ function validateValueSpec(document, spec, path, errors) {
|
||||
if (!Array.isArray(spec.choose) || spec.choose.length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose`, 'choose must be a non-empty array.');
|
||||
spec.choose.forEach((option, index) => {
|
||||
if (!isPlainObject(option) || !Object.hasOwn(option, 'value') || !Number.isFinite(option.weight) || option.weight <= 0) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose[${index}]`, 'Choice requires value and a finite positive weight.');
|
||||
else validateValueSpec(document, option.value, `${path}.choose[${index}].value`, errors);
|
||||
else validateValueSpec(document, option.value, `${path}.choose[${index}].value`, errors, scope);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -191,7 +194,7 @@ function validateValueSpec(document, spec, path, errors) {
|
||||
if (!Array.isArray(spec.args)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.args`, 'Operator args must be an array.');
|
||||
else {
|
||||
if (spec.args.length !== VALUE_OPERATORS[spec.op]) pushError(errors, 'ERR_INVALID_ARITY', `${path}.args`, `Operator '${spec.op}' has invalid arity.`);
|
||||
spec.args.forEach((argument, index) => validateValueSpec(document, argument, `${path}.args[${index}]`, errors));
|
||||
spec.args.forEach((argument, index) => validateValueSpec(document, argument, `${path}.args[${index}]`, errors, scope));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user