feat(audio): complete phase 3c slice 3 automation
This commit is contained in:
+3
-1
@@ -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);
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user