feat(audio): complete phase 3c slice 3 automation

This commit is contained in:
2026-09-05 20:22:22 -07:00
parent 9ae2c76075
commit 04d4e49e9f
18 changed files with 2224 additions and 590 deletions
+3 -1
View File
@@ -125,6 +125,7 @@ export class XZBTApplication {
this.setBusy(true, `Preparing ${record.document.meta.name}`);
try {
await this.activation.activate(record);
await this.disposeAudio();
this.renderLibrary();
this.renderStage();
} finally {
@@ -196,6 +197,7 @@ export class XZBTApplication {
this.audio = new AudioSubsystem({
document: current.record.document,
rng: current.performance.rng,
resolutionEngine: current.performance.engine,
diagnostics: this.diagnostics
});
this.audio.setMasterVolume(Number(element('master-volume').value));
@@ -251,7 +253,7 @@ export class XZBTApplication {
input.min = '0';
input.max = '4';
input.step = '0.01';
input.value = String(typeof bus.gain === 'number' ? bus.gain : 1);
input.value = String(current.performance.engine.base(`audio.buses.${id}.gain`));
input.disabled = !unlocked;
input.addEventListener('input', (event) => this.audio?.setBusGain(id, Number(event.target.value)));
row.append(label, input);
+65
View File
@@ -0,0 +1,65 @@
// Shared numeric stages and immutable, once-sampled automation (spec 8.1 / 16.1).
import { RuntimeFault, clamp, parseDuration } from './types.js';
import { AUDIO_AUTOMATION_CURVES, AUDIO_AUTOMATION_MODES, AUDIO_LIMITS } from './audio-contract.js';
export function sampleAutomationTrack(definition, evaluate, warn = () => {}) {
const mode = definition.mode ?? 'absolute';
const interpolation = definition.interpolation ?? 'linear';
if (!AUDIO_AUTOMATION_MODES.includes(mode) || !AUDIO_AUTOMATION_CURVES.includes(interpolation)) {
throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Invalid automation mode or interpolation.', definition.path);
}
if (!Array.isArray(definition.points) || definition.points.length < 2) {
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Automation requires at least two points.', definition.path);
}
if (definition.points.length > AUDIO_LIMITS.automationPoints) {
throw new RuntimeFault('ERR_NODE_LIMIT_EXCEEDED', 'Automation exceeds the point limit.', definition.path);
}
let previous = -1;
const points = definition.points.map((point, index) => {
const path = `${definition.path ?? definition.target}.points[${index}]`;
if (typeof point.at !== 'string') throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Automation time must be a duration literal.', path);
const at = parseDuration(point.at, path);
if (at <= previous) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'Automation times must strictly increase.', path);
previous = at;
const value = evaluate(point.value, `${path}.value`);
if (typeof value !== 'number' || !Number.isFinite(value)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Automation values must resolve to finite numbers.', path);
return Object.freeze({ at, value });
});
if (interpolation === 'exponential' && points.some((point) => point.value <= 0)) {
warn({ code: 'WARN_AUTOMATION_FALLBACK', path: definition.path ?? definition.target, message: 'Nonpositive exponential endpoints use linear interpolation (once per track).' });
}
return Object.freeze({ ...definition, mode, interpolation, points: Object.freeze(points) });
}
export function interpolateAutomation(curve, v0, v1, t) {
if (curve === 'step') return v0;
if (curve === 'exponential' && v0 > 0 && v1 > 0) return Math.exp(Math.log(v0) * (1 - t) + Math.log(v1) * t);
const progress = curve === 'smooth' ? t * t * (3 - 2 * t) : t;
return v0 * (1 - progress) + v1 * progress;
}
export function automationValueAt(track, milliseconds) {
const points = track.points;
if (milliseconds <= points[0].at) return points[0].value;
for (let index = 1; index < points.length; index += 1) {
const left = points[index - 1], right = points[index];
if (milliseconds < right.at) return interpolateAutomation(track.interpolation, left.value, right.value, (milliseconds - left.at) / (right.at - left.at));
}
return points[points.length - 1].value;
}
export function applyAutomationMode(base, value, mode) {
return mode === 'offset' ? base + value : mode === 'scale' ? base * value : value;
}
// Callers supply only stages their target exposes; the override sees the current
// lower value on every evaluation, even while it masks automation.
export function resolveNumericStages(base, { binding, automation, override, modulation = 0, min = -Infinity, max = Infinity, round } = {}) {
let value = binding ? binding(base) : base;
if (automation) value = automation(value);
if (override) value = override(value);
value += modulation;
if (!Number.isFinite(value)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Numeric resolution produced a non-finite value.');
if (round) value = round(value);
return clamp(value, min, max);
}
+9 -4
View File
@@ -9,7 +9,9 @@ export const AUDIO_LIMITS = Object.freeze({
routesPerSound: 256,
componentDepth: 8,
resonatorModes: 16,
oscillatorPartials: 64
oscillatorPartials: 64,
automationTracks: 64,
automationPoints: 256
});
export const AUDIO_NOISE_COLORS = Object.freeze(['white', 'pink', 'brown']);
@@ -179,9 +181,12 @@ export const AUDIO_MODULATION_SOURCE_TYPES = Object.freeze(['constant', 'lfo', '
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', 'release']);
export const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'parameters', 'input']);
export const AUDIO_GRAPH_FIELDS = Object.freeze(['nodes', 'routes', 'automation']);
export const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'automation', 'mode', 'release']);
export const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'automation', 'parameters', 'input']);
export const AUDIO_AUTOMATION_FIELDS = Object.freeze(['target', 'mode', 'interpolation', 'points']);
export const AUDIO_AUTOMATION_MODES = Object.freeze(['absolute', 'offset', 'scale']);
export const AUDIO_AUTOMATION_CURVES = Object.freeze(['step', 'linear', 'exponential', 'smooth']);
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 });
+230
View File
@@ -0,0 +1,230 @@
// Native control-signal graph. Automation is scheduled once on the audio clock;
// modulation sums before the final clamp, not directly on an unclamped AudioParam.
import { AUDIO_MODULATABLE, AUDIO_NODE_TYPES } from './audio-contract.js';
import { applyAutomationMode, automationValueAt, resolveNumericStages } from './audio-automation.js';
import { RuntimeFault, clamp } from './types.js';
import { ValueResolver } from './values.js';
export function audioPropertyRange(node, property, ceiling) {
const rule = node.type === 'component' ? node.exposes[property] : AUDIO_NODE_TYPES[node.type].fields[property];
return { min: rule.min ?? -Infinity, max: rule.ceiling === 'audio' ? Math.min(rule.max, ceiling) : rule.max ?? Infinity };
}
// Exact reference evaluator used by traces and by callers inspecting a voice.
// Source samples are supplied in their own units; sampling this function draws no RNG.
export function audioPropertyAt(plan, target, property, milliseconds, sourceValues = {}) {
const visiting = new Set();
const cache = new Map();
const resolve = (path, field) => {
const key = `${path}::${field}`;
if (cache.has(key)) return cache.get(key);
if (visiting.has(key)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Audio control cycle at '${key}'.`);
const node = plan.nodes.find((item) => item.path === path);
if (!node || !Object.hasOwn(node.type === 'component' ? node.exposes : AUDIO_MODULATABLE[node.type] ?? {}, field)) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Unsupported audio property '${key}'.`);
visiting.add(key);
const resolver = new ValueResolver((ref) => resolve(node.scope, ref.slice('inputs.'.length)));
const base = resolver.evaluate(node.expressions?.[field] ?? node.values[field], null);
const track = plan.automation?.find((item) => item.target === path && item.property === field);
const modulation = plan.routes.filter((route) => route.kind === 'modulation' && route.to === path && route.property === field)
.reduce((sum, route) => sum + (sourceValues[route.from] ?? 0) * route.depth, 0);
const value = resolveNumericStages(base, {
automation: track ? (lower) => applyAutomationMode(lower, automationValueAt(track, milliseconds), track.mode) : undefined,
modulation, ...audioPropertyRange(node, field, plan.ceiling)
});
visiting.delete(key);
cache.set(key, value);
return value;
};
return resolve(target, property);
}
export function scheduleNativeAutomation(param, track, startTime) {
if (track.interpolation === 'smooth') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Smooth automation requires the polynomial control graph.');
const points = track.points;
param.setValueAtTime(points[0].value, startTime);
param.setValueAtTime(points[0].value, startTime + points[0].at / 1000);
for (let i = 1; i < points.length; i += 1) {
const left = points[i - 1], right = points[i];
const end = startTime + right.at / 1000;
if (track.interpolation === 'step') param.setValueAtTime(right.value, end);
else if (track.interpolation === 'exponential' && left.value > 0 && right.value > 0) param.exponentialRampToValueAtTime(right.value, end);
else param.linearRampToValueAtTime(right.value, end);
}
}
export function createAudioControls(context, plan, entries, disposeWith, startTime) {
const nodes = new Map(plan.nodes.map((node) => [node.path, node]));
const cache = new Map();
const building = new Set();
const constants = new Map();
const literal = (value) => ({ value, min: value, max: value });
const own = (node) => { disposeWith(() => node.disconnect()); return node; };
const scheduledSource = (value, min, max, schedule) => {
const source = own(context.createConstantSource());
source.offset.value = value;
disposeWith(() => { source.offset.cancelScheduledValues?.(context.currentTime); try { source.stop(); } catch { /* not started */ } });
schedule?.(source.offset);
source.start(startTime);
return { output: source, min, max };
};
const constant = (value, track) => {
if (track?.interpolation === 'smooth') return smoothSignal(track);
if (!track && constants.has(value)) return constants.get(value);
const values = track ? track.points.map((point) => point.value) : [value];
const signal = scheduledSource(value, Math.min(...values), Math.max(...values), track ? (param) => scheduleNativeAutomation(param, track, startTime) : undefined);
if (!track) constants.set(value, signal);
return signal;
};
const materialize = (signal) => signal.output ? signal : constant(signal.value);
const scale = (signal, factor) => {
if (signal.value !== undefined) return literal(signal.value * factor);
if (factor === 0) return literal(0);
if (factor === 1) return signal;
const gain = own(context.createGain());
gain.gain.value = factor;
signal.output.connect(gain);
return { output: gain, min: Math.min(signal.min * factor, signal.max * factor), max: Math.max(signal.min * factor, signal.max * factor) };
};
const add = (a, b) => {
if (a.value !== undefined && b.value !== undefined) return literal(a.value + b.value);
if (a.value === 0) return b;
if (b.value === 0) return a;
const sum = own(context.createGain());
materialize(a).output.connect(sum);
materialize(b).output.connect(sum);
return { output: sum, min: a.min + b.min, max: a.max + b.max };
};
const multiply = (a, b) => {
if (a.value !== undefined) return scale(b, a.value);
if (b.value !== undefined) return scale(a, b.value);
const gain = own(context.createGain());
gain.gain.value = 0;
a.output.connect(gain);
b.output.connect(gain.gain);
const bounds = [a.min * b.min, a.min * b.max, a.max * b.min, a.max * b.max];
return { output: gain, min: Math.min(...bounds), max: Math.max(...bounds) };
};
const smoothSignal = (track) => {
const points = track.points;
const spans = points.slice(1).map((point, i) => point.value - points[i].value);
const values = points.map((point) => point.value);
const low = Math.min(...values), high = Math.max(...values);
const progress = scheduledSource(0, 0, 1, (param) => {
param.setValueAtTime(0, startTime);
for (let i = 1; i < points.length; i++) {
param.setValueAtTime(0, startTime + points[i - 1].at / 1000);
param.linearRampToValueAtTime(1, startTime + points[i].at / 1000);
}
});
const steps = (initial, min, max, valueAt) => scheduledSource(initial, min, max, (param) => {
param.setValueAtTime(initial, startTime);
for (let i = 0; i < points.length - 1; i++) param.setValueAtTime(valueAt(i), startTime + points[i].at / 1000);
});
const origin = steps(points[0].value, low, high, (i) => points[i].value);
const excursion = steps(spans[0], Math.min(...spans), Math.max(...spans), (i) => spans[i]);
// a-rate polynomial 3t² - 2t³, not a sampled approximation of the curve.
const smooth = multiply(multiply(progress, progress), add(literal(3), scale(progress, -2)));
return { ...add(origin, multiply(excursion, smooth)), min: low, max: high };
};
const shape = (signal, fn, lower = signal.min, upper = signal.max, samples = 4097) => {
if (signal.value !== undefined || lower === upper) return literal(fn(signal.value ?? lower));
if (!Number.isFinite(lower) || !Number.isFinite(upper)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Audio control bounds must be finite.');
const half = (upper - lower) / 2;
const normalized = add(scale(signal, 1 / half), literal(-lower / half - 1));
const shaper = own(context.createWaveShaper());
shaper.curve = Float32Array.from({ length: samples }, (_, i) => fn(lower + (upper - lower) * i / (samples - 1)));
const bounds = [...shaper.curve];
materialize(normalized).output.connect(shaper);
disposeWith(() => { shaper.curve = null; });
return { output: shaper, min: Math.min(...bounds), max: Math.max(...bounds) };
};
const bounded = (signal, min, max) => {
const lower = Math.max(signal.min, min), upper = Math.min(signal.max, max);
if (signal.max <= min) return literal(min);
if (signal.min >= max) return literal(max);
if (signal.min >= min && signal.max <= max) return signal;
// WaveShaper saturates outside [-1,1]; these three collinear points make
// the property safety clamp exact, including after an arbitrary route sum.
return shape(signal, (value) => value, lower, upper, 3);
};
const abs = (signal) => signal.min >= 0 ? signal : signal.max <= 0 ? scale(signal, -1) : shape(signal, Math.abs, -Math.max(-signal.min, signal.max), Math.max(-signal.min, signal.max), 3);
const minimum = (a, b) => scale(add(add(a, b), scale(abs(add(a, scale(b, -1))), -1)), 0.5);
const maximum = (a, b) => scale(add(add(a, b), abs(add(a, scale(b, -1)))), 0.5);
const expression = (spec, scope) => {
if (typeof spec === 'number') return literal(spec);
if (spec.ref) return property(scope, spec.ref.slice('inputs.'.length));
const args = spec.args.map((arg) => expression(arg, scope));
if (args.every((arg) => arg.value !== undefined)) return literal(new ValueResolver(() => {}).evaluate({ op: spec.op, args: args.map((arg) => arg.value) }, null));
const [a, b, c] = args;
switch (spec.op) {
case 'add': return add(a, b);
case 'subtract': return add(a, scale(b, -1));
case 'negate': return scale(a, -1);
case 'multiply': return multiply(a, b);
case 'divide': return multiply(a, shape(b, (value) => value === 0 ? 0 : 1 / value));
case 'abs': return abs(a);
case 'min': return minimum(a, b);
case 'max': return maximum(a, b);
case 'clamp': return minimum(maximum(a, b), c);
case 'lerp': return add(a, multiply(add(b, scale(a, -1)), c));
case 'round': case 'floor': case 'ceil': return shape(a, Math[spec.op]);
default: throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported audio expression '${spec.op}'.`);
}
};
const modulationSource = (route) => {
const node = nodes.get(route.from), output = entries.get(route.from)?.output;
if (!output) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Missing modulation source '${route.from}'.`);
const v = node.values;
const range = node.type === 'constant' ? [v.value, v.value]
: node.type === 'sample-hold' ? [Math.min(0, v.min), Math.max(0, v.max)]
: node.type === 'lfo' ? [v.polarity === 'unipolar' ? 0 : -v.amplitude, v.amplitude] : [-1, 1];
return { output, min: range[0], max: range[1] };
};
const property = (path, field) => {
const key = `${path}::${field}`;
if (cache.has(key)) return cache.get(key);
if (building.has(key)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Audio control cycle at '${key}'.`);
building.add(key);
const node = nodes.get(path);
let signal = expression(node.expressions?.[field] ?? node.values[field], node.scope);
const track = plan.automation?.find((item) => item.target === path && item.property === field);
if (track) {
const curve = constant(track.points[0].value, track);
signal = track.mode === 'offset' ? add(signal, curve) : track.mode === 'scale' ? multiply(signal, curve) : curve;
}
for (const route of plan.routes) if (route.kind === 'modulation' && route.to === path && route.property === field) signal = add(signal, scale(modulationSource(route), route.depth));
const { min, max } = audioPropertyRange(node, field, plan.ceiling);
signal = bounded(signal, min, max);
cache.set(key, signal);
building.delete(key);
return signal;
};
const connect = (signal, param, factor = 1) => {
if (signal.value !== undefined) param.value = signal.value * factor;
else {
param.value = 0;
scale(signal, factor).output.connect(param);
}
disposeWith(() => param.cancelScheduledValues?.(context.currentTime));
};
return {
apply() {
for (const node of plan.nodes) {
if (node.implicit || node.type === 'component') continue;
const entry = entries.get(node.path);
for (const field of Object.keys(AUDIO_MODULATABLE[node.type] ?? {})) {
const signal = property(node.path, field);
if (field === 'fundamental') {
for (const band of entry.bands ?? []) if (band.ratio !== undefined) {
const frequency = bounded(scale(signal, band.ratio), 0.1, plan.ceiling);
connect(frequency, band.frequency);
connect(maximum(literal(1), scale(frequency, band.decay / 3000)), band.q);
}
} else connect(signal, entry.params[field], field === 'time' ? 0.001 : 1);
}
}
},
dispose() { cache.clear(); constants.clear(); nodes.clear(); building.clear(); }
};
}
+314 -271
View File
@@ -6,6 +6,7 @@ import {
AUDIO_LIFECYCLE_STATES,
AUDIO_LIFECYCLE_TRANSITIONS,
AUDIO_MODE_FIELDS,
AUDIO_MODULATABLE,
AUDIO_NODE_TYPES,
AUDIO_PARTIAL_FIELDS,
AUDIO_VOICE_LIMITS,
@@ -16,7 +17,11 @@ import {
durationMilliseconds,
expandSoundGraph
} from './audio-graph.js';
import { applyAutomationMode, sampleAutomationTrack } from './audio-automation.js';
import { createAudioControls } from './audio-controls.js';
import { ValueResolver } from './values.js';
import { ResolutionEngine } from './resolution.js';
import { SeededRNG } from './rng.js';
import { RuntimeFault, clamp } from './types.js';
export function soundInstanceKey(soundId, ordinal) {
@@ -71,6 +76,10 @@ export function instantiateSoundGraph(document, soundId, options = {}) {
resolver.currentScope = scope;
return resolver.evaluate(spec, stream, path);
};
const sample = (spec, scope, path) => {
resolver.currentScope = scope;
return resolver.sample(spec, stream, path, (ref) => scope && ref.startsWith('inputs.'));
};
const clampField = (value, spec, nodePath, field) => {
let limitMax = spec.max;
@@ -82,69 +91,78 @@ export function instantiateSoundGraph(document, soundId, options = {}) {
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 } });
const nodes = expansion.nodes.filter((node) => node.implicit).map((node) => ({ path: node.path, type: 'gain', implicit: true, values: { gain: 1 } }));
const routes = [];
const automation = [];
const expandedNodes = new Map(expansion.nodes.map((node) => [node.path, node]));
for (const item of expansion.samplingOrder) {
if (item.kind === 'route') {
const route = expansion.routes[item.index];
routes.push(route.kind === 'audio' ? { ...route } : { ...route, depth: evaluate(route.depth, route.scope, `${route.path}.depth`) });
continue;
}
if (item.kind === 'automation') {
const track = expansion.automation[item.index];
automation.push(sampleAutomationTrack(track, (value, path) => evaluate(value, track.scope, path), (warning) => warnings.push(warning)));
continue;
}
const node = expandedNodes.get(item.path);
if (!node) continue;
const contract = AUDIO_NODE_TYPES[node.type];
if (!contract) continue;
const scope = node.scope;
const values = {};
const expressions = {};
if (node.type === 'component') {
const declared = node.exposes ?? {};
const supplied = node.spec.values ?? {};
const resolved = {};
for (const [name, rule] of Object.entries(declared)) {
const names = [...Object.keys(supplied), ...Object.keys(declared).filter((name) => !Object.hasOwn(supplied, name))];
for (const name of names) {
const rule = declared[name];
const raw = Object.hasOwn(supplied, name) ? supplied[name] : rule.default;
const value = evaluate(raw, scope, `${node.path}.values.${name}`);
expressions[name] = sample(raw, scope, `${node.path}.values.${name}`);
const value = evaluate(expressions[name], 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 });
nodes.push({ path: node.path, type: 'component', component: node.component, values: resolved, expressions, exposes: declared, scope, passthrough: true });
continue;
}
for (const [field, spec] of Object.entries(contract.fields)) {
if (spec.kind === 'partials' || spec.kind === 'modes') continue;
const fields = [...Object.keys(node.spec).filter((field) => Object.hasOwn(contract.fields, field)), ...Object.keys(contract.fields).filter((field) => !Object.hasOwn(node.spec, field))];
for (const field of fields) {
const spec = contract.fields[field];
if (spec.kind === 'partials' || spec.kind === 'modes') {
const table = spec.kind === 'partials' ? AUDIO_PARTIAL_FIELDS : AUDIO_MODE_FIELDS;
values[field] = (node.spec[field] ?? []).map((entry, index) => {
const sampled = {};
const names = [...Object.keys(entry), ...Object.keys(table).filter((name) => !Object.hasOwn(entry, name) && table[name].default !== undefined)];
for (const name of names) {
const value = Object.hasOwn(entry, name) ? entry[name] : table[name].default;
sampled[name] = name === 'decay' ? durationMilliseconds(value) : evaluate(value, scope, `${node.path}.${field}[${index}].${name}`);
}
return sampled;
});
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}`);
const frozen = sample(authored, scope, `${node.path}.${field}`);
const raw = evaluate(frozen, scope, `${node.path}.${field}`);
if (Object.hasOwn(AUDIO_MODULATABLE[node.type] ?? {}, field)) expressions[field] = frozen;
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;
values.harmonics = values.harmonics.filter((partial) => partial.ratio * values.frequency <= ceiling);
}
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;
values.modes = values.modes.map((mode) => ({ ...mode, frequency: mode.frequency ?? mode.ratio * values.fundamental })).filter((mode) => mode.frequency <= ceiling);
}
if (node.type === 'sample-hold') {
@@ -154,23 +172,26 @@ export function instantiateSoundGraph(document, soundId, options = {}) {
values.stream = rng ? rng.stream('sound', values.streamKey) : null;
}
nodes.push({ path: node.path, type: node.type, values, scope });
nodes.push({ path: node.path, type: node.type, values, expressions, 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`) };
});
let endingBoundMs = null;
if (expansion.mode === 'oneshot') {
endingBoundMs = computeDeterminableEndingBound(expansion, nodes);
const boundedNodes = nodes.map((node) => {
if (node.type !== 'delay') return node;
const track = automation.find((item) => item.target === node.path && item.property === 'time');
const modulated = routes.some((route) => route.kind === 'modulation' && route.to === node.path && route.property === 'time');
// A varying delay must not dispose its voice at the shorter base-time tail.
const maximumTime = modulated ? 10000 : track ? clamp(Math.max(...track.points.map((point) => applyAutomationMode(node.values.time, point.value, track.mode))), 0, 10000) : node.values.time;
return { ...node, values: { ...node.values, time: maximumTime } };
});
endingBoundMs = computeDeterminableEndingBound(expansion, boundedNodes);
}
return {
nodes,
routes,
automation,
warnings,
errors: [],
mode: expansion.mode,
@@ -266,8 +287,8 @@ function periodicWave(context, partials) {
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.
// Builds one voice and its audio-clock automation; partial construction is also
// disposable, so a failed scheduler cannot leave connected or running sources.
export function realizeSoundGraph(context, plan, destination) {
const created = new Map();
const disposers = [];
@@ -281,227 +302,236 @@ export function realizeSoundGraph(context, plan, 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));
if (typeof timer.unref === 'function') timer.unref();
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,
releaseGain,
dispose() {
for (const release of disposers.reverse()) {
try { release(); } catch { /* disposal is best effort */ }
}
sink.disconnect();
releaseGain.disconnect();
created.clear();
const startTime = now();
const dispose = () => {
for (const release of disposers.splice(0).reverse()) {
try { release(); } catch { /* disposal is best effort */ }
}
sink.disconnect();
releaseGain.disconnect();
created.clear();
starters.length = 0;
};
try {
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(startTime));
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(startTime));
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 = startTime;
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(startTime, 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(startTime));
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(startTime));
disposers.push(() => { try { offset.stop(); } catch { /* already stopped */ } offset.disconnect(); sum.disconnect(); });
}
entry = { input: null, output, params: {} };
starters.push(() => oscillator.start(startTime + ((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));
if (typeof timer.unref === 'function') timer.unref();
schedule(0);
entry = { input: null, output: source, params: {} };
starters.push(() => source.start(startTime));
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 = [];
const controls = [];
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);
controls.push({ ratio: mode.ratio, decay: mode.decay, frequency: band.frequency, q: band.Q });
}
entry = { input, output, params: {}, bands: controls };
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;
}
// Modulation routes are connected by the final, clamped control pipeline.
}
const controls = createAudioControls(context, plan, created, (release) => disposers.push(release), startTime);
disposers.push(() => controls.dispose());
controls.apply();
for (const start of starters) start();
return {
sink,
releaseGain,
startTime,
dispose
};
} catch (error) {
dispose();
throw error;
}
}
export class SoundInstance {
@@ -648,11 +678,15 @@ export class SoundInstance {
this.transition('FINISHED');
}
}
this.transition('DISPOSED');
// FAILED is terminal, but still owns the same resource cleanup obligation.
if (this.state !== 'FAILED') this.transition('DISPOSED');
if (this.realized) {
try { this.realized.dispose(); } catch { /* best effort */ }
this.realized = null;
}
this.plan = null;
this.bus = null;
this.context = null;
}
}
@@ -665,10 +699,13 @@ export class SoundInstance {
// 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, voiceLimits = null } = {}) {
constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null, resolutionEngine = null } = {}) {
this.document = document;
this.rng = rng;
this.diagnostics = diagnostics;
this.ownsResolution = !resolutionEngine;
this.resolution = resolutionEngine ?? new ResolutionEngine(document ?? {}, rng ?? new SeededRNG(0), { diagnostics });
this.unsubscribeResolution = this.resolution.subscribe(() => this.updateBusGains());
this.hasCustomContext = Boolean(contextFactory);
this.contextFactory = contextFactory ?? (() => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)());
this.context = null;
@@ -722,9 +759,9 @@ export class AudioSubsystem {
buildBuses() {
const declared = this.document?.audio?.buses ?? {};
for (const [id, bus] of Object.entries(declared)) {
for (const id of Object.keys(declared)) {
const gain = this.context.createGain();
gain.gain.value = typeof bus.gain === 'number' ? bus.gain : 1;
gain.gain.value = this.resolution.get(`audio.buses.${id}.gain`);
gain.connect(this.protection);
this.buses.set(id, gain);
}
@@ -736,8 +773,11 @@ export class AudioSubsystem {
}
setBusGain(id, value) {
const bus = this.buses.get(id);
if (bus) bus.gain.value = Math.min(4, Math.max(0, value));
if (this.document?.audio?.buses?.[id]) this.resolution.setBusBase(id, Math.min(4, Math.max(0, value)));
}
updateBusGains() {
for (const [id, bus] of this.buses) bus.gain.value = this.resolution.get(`audio.buses.${id}.gain`);
}
setMasterVolume(value) {
@@ -751,7 +791,7 @@ export class AudioSubsystem {
return next;
}
play(soundId, { resolveReference } = {}) {
play(soundId, { resolveReference = (path) => this.resolution.get(path) } = {}) {
if (!this.unlocked) return null;
const sound = this.document?.sounds?.[soundId];
if (!sound) return null;
@@ -831,7 +871,7 @@ export class AudioSubsystem {
instance.transition('SCHEDULED');
instance.realize(this.context, this.busFor(soundId));
instance.transition('ACTIVE');
instance.startTime = this.context.currentTime;
instance.startTime = instance.realized.startTime;
this.voices.add(instance);
} catch (error) {
this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
@@ -858,6 +898,8 @@ export class AudioSubsystem {
}
async dispose() {
this.unsubscribeResolution?.();
this.unsubscribeResolution = null;
this.stopAll();
for (const instance of [...this.oneshotVoices]) instance.dispose();
for (const instance of [...this.continuousVoices]) instance.dispose();
@@ -868,5 +910,6 @@ export class AudioSubsystem {
this.context = null;
this.master = null;
this.protection = null;
if (this.ownsResolution) this.resolution.dispose();
}
}
+97 -3
View File
@@ -2,6 +2,9 @@
// instantiation. Pure: never touches an AudioContext (Format Specification 14.5).
import {
AUDIO_AUTOMATION_FIELDS,
AUDIO_AUTOMATION_MODES,
AUDIO_AUTOMATION_CURVES,
AUDIO_BUS_FIELDS,
AUDIO_BUS_GAIN_RANGE,
AUDIO_COMPONENT_FIELDS,
@@ -208,6 +211,77 @@ function validateRoute(document, route, path, errors, helpers, scope) {
}
}
function automationTarget(document, graph, name, path, errors) {
if (typeof name !== 'string') { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Automation requires a target string.'); return null; }
const [key, property, extra] = name.split('.');
const node = graph.nodes?.[key];
if (!isObject(node)) { fail(errors, 'ERR_INVALID_REFERENCE', path, `Automation node '${key}' is not declared in this graph.`); return null; }
if (node.type === 'component' && extra !== undefined) {
fail(errors, 'ERR_INVALID_REFERENCE', path, `Component internals are encapsulated; '${name}' is not reachable.`);
return null;
}
const registry = node.type === 'component' ? document.components?.audio?.[node.use]?.parameters : AUDIO_MODULATABLE[node.type];
if (extra !== undefined || !Object.hasOwn(registry ?? {}, property)) {
fail(errors, 'ERR_UNSUPPORTED_TARGET', path, `'${name}' is not an automatable property.`);
return null;
}
return { key, property };
}
// Structural ValueSpec checks are shared with each validator. This additional
// expected-number walk rejects string/boolean leaves and references, including
// branches of a choice which might not be sampled in a particular run.
function validateAutomationNumber(document, value, path, errors) {
if (typeof value === 'string' || typeof value === 'boolean') return fail(errors, 'ERR_TYPE_MISMATCH', path, 'Automation requires a numeric ValueSpec.');
if (!isObject(value)) return;
if (typeof value.ref === 'string') {
const [namespace, id] = value.ref.split('.');
const type = document[namespace]?.[id]?.type;
if (type && !['number', 'integer'].includes(type) || value.ref === 'signals.scenario.active') fail(errors, 'ERR_TYPE_MISMATCH', path, 'Automation reference must be numeric.');
}
if (Array.isArray(value.args)) value.args.forEach((child, i) => validateAutomationNumber(document, child, `${path}.args[${i}]`, errors));
if (Array.isArray(value.choose)) value.choose.forEach((child, i) => validateAutomationNumber(document, child?.value, `${path}.choose[${i}].value`, errors));
}
function validateAutomation(document, graph, path, errors, helpers, scope) {
if (graph.automation === undefined) return;
if (!Array.isArray(graph.automation)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'automation must be an array.');
const targets = new Set();
graph.automation.forEach((track, index) => {
const location = `${path}[${index}]`;
if (!isObject(track)) return fail(errors, 'ERR_SCHEMA_VALIDATION', location, 'Automation track must be an object.');
for (const key of Object.keys(track)) if (!AUDIO_AUTOMATION_FIELDS.includes(key)) fail(errors, 'ERR_UNKNOWN_FIELD', `${location}.${key}`, `Unknown automation field '${key}'.`);
if (automationTarget(document, graph, track.target, `${location}.target`, errors)) {
if (targets.has(track.target)) fail(errors, 'ERR_AUTOMATION_CONFLICT', location, `More than one track controls '${track.target}'.`);
targets.add(track.target);
}
if (track.mode !== undefined && !AUDIO_AUTOMATION_MODES.includes(track.mode)) fail(errors, 'ERR_TYPE_MISMATCH', `${location}.mode`, 'Unsupported automation mode.');
if (track.interpolation !== undefined && !AUDIO_AUTOMATION_CURVES.includes(track.interpolation)) fail(errors, 'ERR_TYPE_MISMATCH', `${location}.interpolation`, 'Unsupported automation interpolation.');
if (!Array.isArray(track.points) || track.points.length < 2) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${location}.points`, 'Automation requires at least two points.');
let previous = -1;
track.points.forEach((point, i) => {
const pointPath = `${location}.points[${i}]`;
if (!isObject(point)) return fail(errors, 'ERR_SCHEMA_VALIDATION', pointPath, 'Automation point must be an object.');
for (const key of Object.keys(point)) if (!['at', 'value'].includes(key)) fail(errors, 'ERR_UNKNOWN_FIELD', `${pointPath}.${key}`, `Unknown point field '${key}'.`);
const at = durationMilliseconds(point.at);
if (typeof point.at !== 'string') fail(errors, 'ERR_TYPE_MISMATCH', `${pointPath}.at`, 'Automation times must be duration literals, not TimeSpecs.');
else if (at === null) fail(errors, 'ERR_INVALID_DURATION', `${pointPath}.at`, 'Invalid automation duration.');
else {
if (at <= previous) fail(errors, 'ERR_INVALID_RANGE_ORDER', `${pointPath}.at`, 'Automation times must strictly increase.');
previous = at;
}
helpers.validateValueSpec(document, point.value, `${pointPath}.value`, errors, scope);
validateAutomationNumber(document, point.value, `${pointPath}.value`, errors);
});
});
checkAutomationLimits(graph.automation, path, errors);
}
function checkAutomationLimits(tracks, path, errors) {
const count = tracks.reduce((sum, track) => sum + (Array.isArray(track?.points) ? track.points.length : 0), 0);
if (tracks.length > AUDIO_LIMITS.automationTracks || count > AUDIO_LIMITS.automationPoints) fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', path, `Automation exceeds ${AUDIO_LIMITS.automationTracks} tracks or ${AUDIO_LIMITS.automationPoints} total points per expanded sound.`);
}
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)) {
@@ -222,6 +296,7 @@ function validateGraphObject(document, graph, path, errors, helpers, { allowedFi
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));
}
validateAutomation(document, graph, `${path}.automation`, errors, helpers, scope);
}
/* ------------------------------------------------------------------ *
@@ -254,10 +329,12 @@ 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', release: AUDIO_DEFAULT_RELEASE_MS };
if (!located) return { nodes: [], routes: [], automation: [], samplingOrder: [], errors, mode: 'oneshot', release: AUDIO_DEFAULT_RELEASE_MS };
const nodes = new Map();
const routes = [];
const automation = [];
const samplingOrder = [];
const components = document.components?.audio ?? {};
const join = (prefix, key) => (prefix ? `${prefix}.${key}` : key);
@@ -271,6 +348,7 @@ export function expandSoundGraph(document, soundId) {
for (const [key, node] of Object.entries(declared)) {
if (!isObject(node)) continue;
const path = join(prefix, key);
samplingOrder.push({ kind: 'node', path });
if (node.type === 'component') {
const component = components[node.use];
if (!isObject(component)) continue;
@@ -353,7 +431,7 @@ export function expandSoundGraph(document, soundId) {
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 });
routes.push({ kind: 'modulation', from, to: target, property, depth: route.depth, scope: prefix || null, path: routePath });
} else {
if (to.includes('::')) {
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Audio route target '${route.to}' names a property.`);
@@ -361,7 +439,16 @@ export function expandSoundGraph(document, soundId) {
}
routes.push({ kind: 'audio', from, to, path: routePath });
}
samplingOrder.push({ kind: 'route', index: routes.length - 1 });
});
for (const [index, track] of (Array.isArray(graph.automation) ? graph.automation : []).entries()) {
if (!isObject(track)) continue;
const trackPath = `${graphPath}.automation[${index}]`;
const target = automationTarget(document, graph, track.target, `${trackPath}.target`, errors);
if (!target) continue;
automation.push({ ...track, target: join(prefix, target.key), property: target.property, scope: prefix || null, path: trackPath });
samplingOrder.push({ kind: 'automation', index: automation.length - 1 });
}
}
expand(located.graph, '', 1, [], located.path);
@@ -369,7 +456,14 @@ export function expandSoundGraph(document, soundId) {
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 };
checkAutomationLimits(automation, located.path, errors);
const seen = new Set();
for (const track of automation) {
const target = `${track.target}::${track.property}`;
if (seen.has(target)) fail(errors, 'ERR_AUTOMATION_CONFLICT', track.path, `More than one track controls '${target}'.`);
seen.add(target);
}
return { nodes: [...nodes.values()], routes, automation, samplingOrder, errors, mode, release, graphPath: located.path };
}
function detectCycle(adjacency) {
+93 -9
View File
@@ -11,6 +11,7 @@ import {
valueMatchesType
} from './types.js';
import { ConditionEvaluator, ValueResolver } from './values.js';
import { applyAutomationMode, automationValueAt, resolveNumericStages, sampleAutomationTrack } from './audio-automation.js';
const STEP_SECONDS = 1 / 60;
@@ -76,7 +77,7 @@ export class OverrideStack {
priority,
activationSequence: sequence,
sampledValue: normalized,
attackOrigin: this.engine.get(action.target),
attackOrigin: this.engine.preModulationValue(action.target),
attackMs,
releaseMs,
easing,
@@ -153,6 +154,11 @@ export class ResolutionEngine {
this.onParameterChange = onParameterChange;
this.parameters = new Map();
this.state = new Map();
this.busValues = new Map();
this.automation = new Map();
this.modulation = new Map();
this.preModulation = new Map();
this.listeners = new Set();
this.signals = new SignalProvider();
this.bindings = (document.bindings ?? []).map((binding, index) => ({ definition: binding, index, smoother: undefined, enabled: false, lastTick: -1 }));
this.transitions = new Map();
@@ -168,10 +174,16 @@ export class ResolutionEngine {
this.parameters.set(id, valueMatchesType(spec.type, candidate, spec) ? normalizeForSpec(spec, candidate, { clampNumeric: true }) : spec.default);
}
for (const [id, spec] of Object.entries(document.state ?? {})) this.state.set(id, spec.initial);
// Bus ValueSpecs are sampled once, lazily, so references may resolve through
// the same dependency graph as bindings (and cycles are diagnosed there).
this.resolveAll();
}
target(path) {
const parts = path.split('.');
if (parts.length === 4 && parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain' && this.document.audio?.buses?.[parts[2]]) {
return { namespace: 'buses', id: parts[2], spec: { type: 'number', min: 0, max: 4 } };
}
const [namespace, id, extra] = path.split('.');
if (extra !== undefined) return null;
if (namespace === 'parameters' && this.document.parameters?.[id]) return { namespace, id, spec: this.document.parameters[id] };
@@ -182,6 +194,10 @@ export class ResolutionEngine {
base(path) {
const target = this.target(path);
if (!target) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown value reference '${path}'.`, path);
if (target.namespace === 'buses') {
if (!this.busValues.has(target.id)) this.busValues.set(target.id, this.valueResolver.evaluate(this.document.audio.buses[target.id].gain ?? 1, this.rng.stream('sound', `bus:${target.id}`), path));
return this.busValues.get(target.id);
}
return target.namespace === 'parameters' ? this.parameters.get(target.id) : this.state.get(target.id);
}
@@ -195,6 +211,8 @@ export class ResolutionEngine {
this.resolved.clear();
for (const id of Object.keys(this.document.parameters ?? {})) this.resolveTarget(`parameters.${id}`);
for (const id of Object.keys(this.document.state ?? {})) this.resolveTarget(`state.${id}`);
for (const id of Object.keys(this.document.audio?.buses ?? {})) this.resolveTarget(`audio.buses.${id}.gain`);
for (const listener of this.listeners) listener(this);
return this.snapshot();
}
@@ -207,12 +225,26 @@ export class ResolutionEngine {
try {
let lower = this.base(path);
const binding = this.bindings.find((item) => item.definition.target === path);
if (binding) lower = this.bindingValue(binding, target, lower);
const winner = this.overrides.select(path);
let value = winner ? this.overrides.value(winner, lower) : lower;
let value;
if (isNumericType(target.spec.type)) {
if (target.spec.type === 'integer') value = roundHalfAwayFromZero(value);
value = clamp(value, target.spec.min ?? -Infinity, target.spec.max ?? Infinity);
const track = target.namespace === 'buses' ? this.automation.get(path) : null;
value = resolveNumericStages(lower, {
binding: binding ? (base) => this.bindingValue(binding, target, base) : undefined,
automation: track ? (base) => applyAutomationMode(base, automationValueAt(track, this.logicalMilliseconds - track.startedAt), track.mode) : undefined,
override: (currentLower) => {
const preModulation = winner ? this.overrides.value(winner, currentLower) : currentLower;
this.preModulation.set(path, preModulation);
return preModulation;
},
modulation: target.namespace === 'buses' ? [...(this.modulation.get(path)?.values() ?? [])].reduce((sum, contribution) => sum + contribution, 0) : 0,
min: target.spec.min, max: target.spec.max,
round: target.spec.type === 'integer' ? roundHalfAwayFromZero : undefined
});
} else {
if (binding) lower = this.bindingValue(binding, target, lower);
value = winner ? this.overrides.value(winner, lower) : lower;
this.preModulation.set(path, value);
}
this.resolved.set(path, value);
return value;
@@ -237,20 +269,69 @@ export class ResolutionEngine {
value = (source * (definition.scale ?? 1)) + (definition.offset ?? 0);
if (definition.clamp) value = clamp(value, definition.clamp[0], definition.clamp[1]);
const tau = parseDuration(definition.smoothing ?? '0ms') / 1000;
if (!binding.enabled || binding.smoother === undefined) {
if (tau === 0 || !binding.enabled || binding.smoother === undefined) {
binding.smoother = value;
binding.lastTick = this.tickIndex;
} else if (binding.lastTick !== this.tickIndex) {
if (tau === 0) binding.smoother = value;
else binding.smoother += (1 - Math.exp(-STEP_SECONDS / tau)) * (value - binding.smoother);
binding.smoother += (1 - Math.exp(-STEP_SECONDS / tau)) * (value - binding.smoother);
binding.lastTick = this.tickIndex;
}
value = binding.smoother;
}
binding.enabled = true;
if (numeric) return value;
return normalizeForSpec(target.spec, value, { clampNumeric: true });
}
preModulationValue(path) { this.get(path); return this.preModulation.get(path); }
requireAudioStage(path) {
if (this.target(path)?.namespace !== 'buses') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Automation/modulation is not exposed for '${path}'.`, path);
}
// Internal engine registration surface. The graph-only authoring syntax does
// not introduce new bus document fields, or external node targets.
addAutomation(path, definition, { startedAt = this.logicalMilliseconds, stream = this.rng.stream('sound', `bus:${path}:automation`) } = {}) {
this.requireAudioStage(path);
if (!Number.isFinite(startedAt)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Automation start time must be finite.', path);
if (this.automation.has(path)) throw new RuntimeFault('ERR_AUTOMATION_CONFLICT', `More than one track controls '${path}'.`, path);
const track = sampleAutomationTrack({ ...definition, target: path }, (value, location) => this.evaluateValue(value, stream, location), (warning) => this.diagnostics?.warn(warning.code, warning.message, { path: warning.path, section: 'audio' }));
const registration = { ...track, startedAt };
this.automation.set(path, registration);
this.invalidate();
this.resolveAll();
return () => {
if (this.automation.get(path) !== registration) return;
this.automation.delete(path); this.invalidate(); this.resolveAll();
};
}
setModulation(path, id, value) {
this.requireAudioStage(path);
if (!Number.isFinite(value)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Modulation contribution must be finite.', path);
if (!this.modulation.has(path)) this.modulation.set(path, new Map());
this.modulation.get(path).set(id, value);
this.invalidate();
return this.resolveAll();
}
removeModulation(path, id) {
this.requireAudioStage(path);
this.modulation.get(path)?.delete(id);
this.invalidate();
return this.resolveAll();
}
setBusBase(id, value) {
const path = `audio.buses.${id}.gain`;
this.requireAudioStage(path);
this.busValues.set(id, normalizeForSpec(this.target(path).spec, value));
this.invalidate();
return this.resolveAll();
}
subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
setParameter(id, value) {
const spec = this.document.parameters?.[id];
if (!spec) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown parameter '${id}'.`, `parameters.${id}`);
@@ -301,5 +382,8 @@ export class ResolutionEngine {
parameterSnapshot() { return Object.fromEntries(this.parameters); }
stateSnapshot() { return Object.fromEntries(this.state); }
snapshot() { return Object.fromEntries(this.resolved); }
dispose() { this.overrides.clear(); this.transitions.clear(); this.resolved.clear(); }
dispose() {
this.listeners.clear(); this.automation.clear(); this.modulation.clear(); this.busValues.clear(); this.preModulation.clear();
this.overrides.clear(); this.transitions.clear(); this.resolved.clear();
}
}
+14
View File
@@ -11,6 +11,20 @@ export class ValueResolver {
this.resolveReference = resolveReference;
}
// Freeze procedural choices once, retaining only explicitly live component
// input references. Re-evaluating this tree never draws from an RNG.
sample(spec, stream, path = '$', retainReference = () => false) {
if (!isRecord(spec)) return this.evaluate(spec, stream, path);
if (Object.hasOwn(spec, 'ref')) return retainReference(spec.ref) ? { ref: spec.ref } : this.evaluate(spec, stream, path);
if (Object.hasOwn(spec, 'random')) return this.evaluate(spec, stream, path);
if (Object.hasOwn(spec, 'choose')) {
const index = this.evaluate({ choose: spec.choose.map((option, i) => ({ weight: option.weight, value: i })) }, stream, path);
return this.sample(spec.choose[index].value, stream, `${path}.choose[${index}].value`, retainReference);
}
if (Object.hasOwn(spec, 'op')) return { op: spec.op, args: spec.args.map((arg, i) => this.sample(arg, stream, `${path}.args[${i}]`, retainReference)) };
return this.evaluate(spec, stream, path);
}
evaluate(spec, stream, path = '$') {
if (typeof spec === 'number') {
if (!Number.isFinite(spec)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Numeric literals must be finite.', path);