From 04d4e49e9f5b2f239303fa6b1de76edd2dbc15f4 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Sat, 5 Sep 2026 20:22:22 -0700 Subject: [PATCH] feat(audio): complete phase 3c slice 3 automation --- README.md | 12 +- XZBT.html | 1110 ++++++++++++----- docs/IMPLEMENTATION_STATUS.md | 20 +- docs/XZBT_0-1_Format_Specification.md | 2 +- .../phase3/2026-09-05-phase3c-automation.md | 38 + exhibits/minimal-audio.xzbt | 22 +- package.json | 3 +- schema/xzbt-0.1.schema.json | 41 + src/runtime/app.js | 4 +- src/runtime/audio-automation.js | 65 + src/runtime/audio-contract.js | 13 +- src/runtime/audio-controls.js | 230 ++++ src/runtime/audio-engine.js | 585 +++++---- src/runtime/audio-graph.js | 100 +- src/runtime/resolution.js | 102 +- src/runtime/values.js | 14 + test/phase3-automation.test.mjs | 451 +++++++ tools/build-xzbt.mjs | 2 + 18 files changed, 2224 insertions(+), 590 deletions(-) create mode 100644 docs/evidence/phase3/2026-09-05-phase3c-automation.md create mode 100644 src/runtime/audio-automation.js create mode 100644 src/runtime/audio-controls.js create mode 100644 test/phase3-automation.test.mjs diff --git a/README.md b/README.md index bc0c4d1..e56a048 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ XZBT is a self-contained browser runtime for declarative procedural audiovisual | [Format Specification 0.1](docs/XZBT_0-1_Format_Specification.md) | Runtime semantics, contract inventory, and required authoring examples | Document revision 0.4; Phase 0 shared contracts and the complete Phase 3a-3c audio subsystem contract, remaining subsystem contracts in dependency order | | [Gap Closure Decisions](docs/XZBT_0-1_Gap_Closure_Decisions.md) | Decisions and rationale for the seven pre-implementation gaps | Record revision 0.3; GC1–GC5 Phase 0 evidence complete | | [Verification Gates](docs/XZBT_0-1_Verification_Gates.md) | Evidence required before architecture commitment, subsystem work, and release | Phase 0 complete; later subsystem, GC6, and GC7 checks scheduled | -| [Implementation status](docs/IMPLEMENTATION_STATUS.md) | Current phase, stop reason, saved work, and resume prerequisites | Audio contract complete through Phase 3c; Phase 3c implementation, Phase 1 direct-file, and Phase 3 audible observations pending | +| [Implementation status](docs/IMPLEMENTATION_STATUS.md) | Current phase, stop reason, saved work, and resume prerequisites | Implemented through Phase 3c slice 3; stopped before slice 4. Phase 1 direct-file and Phase 3 audible observations pending | | [Implementation plan](docs/XZBT_0-1_Implementation_Plan.md) | Sequenced phases, completion/challenge mapping, and GC6/GC7 verification schedule | Phase 1–9 plan recorded | The PRD is authoritative for product requirements. The format specification is authoritative for runtime semantics where a contract is explicitly defined. The decision record explains those choices; the verification gates define how to check them. These documents must be updated together when a decision changes. An unresolved conflict is a specification defect, not permission for an implementation to choose silently. @@ -21,11 +21,11 @@ The earlier ChatGPT discussion, **Discuss Application Vision** (conversation `6a ## Planning entry point -Phase 0 is complete, the production runtime is implemented through Phase 3b, and the audio subsystem contract is written through Phase 3c. Continue with Phase 3c implementation slice 2 (lifecycle and voices) from the implementation plan while preserving the verified launch model, shared semantic contracts, and production GC2/GC3 behavior. Two observations remain open in completed work: the Phase 1 direct-file two-fixture restart, and the Phase 3 audible acceptance — no sound has been heard from any build. Workload measurements and release soak tests remain later explicit gates. +Phase 0 is complete, the production runtime is implemented through Phase 3c slice 3 (automation), and the audio subsystem contract is written through Phase 3c. Work is stopped at the user-requested slice-3 checkpoint. The next slice is 3c-4 (measured master protection), which requires user-observed hardware measurements and listening; it has not been started. The Phase 1 direct-file two-fixture restart and Phase 3 audible acceptance remain open — no sound has been heard from a production build. Workload measurements and release soak tests remain later explicit gates. The full PRD completion criteria remain the 0.1 release target. Early integrated demonstrations are milestones, not completed MVPs. Reference exhibits develop alongside the engine; Phase 9 completes and audits the suite. -All 10 Phase 0 direct-file feasibility checks in GC1 are verified, and the GC2–GC5 contract oracles pass. Phase 1 turns those contracts into a standalone runtime shell with import, caching, activation control, diagnostics, and production seeded RNG. Phase 2 adds parameters, state, signals, values, conditions, actions, bindings, transitions, overrides, and per-exhibit parameter persistence. Phases 3a and 3b add the audio authoring contract — all sixteen graph node types, routing and modulation, graph legality, authoring limits, components, sounds and recipes, and buses — together with pure validation and expansion, deterministic instantiation, and Web Audio realization. Later Phase 3c, visual, cadence, scenario, UI, library-hardening, benchmark, and soak gates remain explicit in the implementation plan. +All 10 Phase 0 direct-file feasibility checks in GC1 are verified, and the GC2–GC5 contract oracles pass. Phase 1 turns those contracts into a standalone runtime shell with import, caching, activation control, diagnostics, and production seeded RNG. Phase 2 adds parameters, state, signals, values, conditions, actions, bindings, transitions, overrides, and per-exhibit parameter persistence. Phases 3a and 3b add the audio graph contract and engine; Phase 3c slices 1–3 add the remaining audio contract, lifecycle/voice management, and automation. Master protection, visual, cadence, scenario, UI, library-hardening, benchmark, and soak gates remain explicit in the implementation plan. ## Repository configuration @@ -65,9 +65,13 @@ npm run test:phase3 Run every suite with `npm test`. +Run just the Phase 3c slice-3 traces with `npm run test:phase3c3`. The [slice-3 evidence](docs/evidence/phase3/2026-09-05-phase3c-automation.md) records coverage and the remaining manual gates. + The active performance supports typed parameters and state, read-only runtime signals, ValueSpec and ConditionSpec evaluation, ordered `set` and `override` actions, same-tick bindings with deterministic smoothing, numeric transitions, and priority-based temporary overrides. The audio subsystem validates and expands declared graphs without touching an `AudioContext`, instantiates them deterministically from the seeded stream, and realizes them through Web Audio; the application exposes gesture unlock, master volume, per-bus gain, and per-sound triggering. `exhibits/minimal-audio.xzbt` exercises components, modulation, buses, and both recipe modes. -The Phase 3c contract (section 16) specifies automation, the lifecycle state machine, determinable one-shot endings, voice ceilings, master protection, and unlock behavior, but the runtime does not implement it yet: `automation` and `release` are still rejected as unknown fields. No sound has yet been heard from a build, and the audio acceptance challenge, peak and finite-sample capture, and listening observations remain open Phase 3 gates. Visuals, cadence, events, scenarios, and the final schema-driven UI remain assigned to later phases. +The runtime implements recipe `release`, the seven-state lifecycle, determinable one-shot endings, voice ceilings, and graph-local `automation`. Tracks support `absolute`, `offset`, and `scale`, with `step`, `linear`, `exponential`, and `smooth` interpolation. Values are sampled once from the owning sound's seeded stream; duplicate targets and expanded limits are validated. Exposed component parameters participate without exposing component internals. Bus gains now use the shared binding → automation → override → modulation → clamp resolver. Bus automation/modulation registration is internal: the contract does not add document fields to buses or permit external node bindings/overrides. + +Master protection is still the placeholder chain. No sound has yet been heard from a production build; measured protection, the audio acceptance challenge, real GC4 synchronization, peak/finite-sample capture, and listening observations remain open Phase 3 gates. Visuals, cadence, events, scenarios, and the final schema-driven UI remain assigned to later phases. ## Local development server diff --git a/XZBT.html b/XZBT.html index 3646e3e..e6d7df1 100644 --- a/XZBT.html +++ b/XZBT.html @@ -439,6 +439,20 @@ 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); @@ -545,7 +559,9 @@ const AUDIO_LIMITS = Object.freeze({ routesPerSound: 256, componentDepth: 8, resonatorModes: 16, - oscillatorPartials: 64 + oscillatorPartials: 64, + automationTracks: 64, + automationPoints: 256 }); const AUDIO_NOISE_COLORS = Object.freeze(['white', 'pink', 'brown']); @@ -715,9 +731,12 @@ const AUDIO_MODULATION_SOURCE_TYPES = Object.freeze(['constant', 'lfo', 'sample- const AUDIO_SOUND_FIELDS = Object.freeze(['name', 'tags', 'usage', 'cadence', 'bus', 'recipe']); const AUDIO_SOUND_USAGE = Object.freeze(['automatic', 'manual', 'scenario']); const AUDIO_RECIPE_MODES = Object.freeze(['oneshot', 'continuous']); -const AUDIO_GRAPH_FIELDS = Object.freeze(['nodes', 'routes']); -const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'mode', 'release']); -const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'parameters', 'input']); +const AUDIO_GRAPH_FIELDS = Object.freeze(['nodes', 'routes', 'automation']); +const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'automation', 'mode', 'release']); +const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'automation', 'parameters', 'input']); +const AUDIO_AUTOMATION_FIELDS = Object.freeze(['target', 'mode', 'interpolation', 'points']); +const AUDIO_AUTOMATION_MODES = Object.freeze(['absolute', 'offset', 'scale']); +const AUDIO_AUTOMATION_CURVES = Object.freeze(['step', 'linear', 'exponential', 'smooth']); const AUDIO_COMPONENT_PARAMETER_FIELDS = Object.freeze(['type', 'default', 'min', 'max', 'unit']); const AUDIO_BUS_FIELDS = Object.freeze(['gain']); const AUDIO_BUS_GAIN_RANGE = Object.freeze({ min: 0, max: 4, default: 1 }); @@ -765,6 +784,72 @@ function isSourceType(type) { return entry?.class === 'source' || entry?.class === 'control'; } +/* src/runtime/audio-automation.js */ +// Shared numeric stages and immutable, once-sampled automation (spec 8.1 / 16.1). + + +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) }); +} + +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; +} + +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; +} + +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. +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); +} + /* src/runtime/audio-graph.js */ // Audio graph validation, component expansion, legality checking, and deterministic // instantiation. Pure: never touches an AudioContext (Format Specification 14.5). @@ -954,6 +1039,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)) { @@ -968,6 +1124,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); } /* ------------------------------------------------------------------ * @@ -1000,10 +1157,12 @@ 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); @@ -1017,6 +1176,7 @@ 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; @@ -1099,7 +1259,7 @@ 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.`); @@ -1107,7 +1267,16 @@ 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); @@ -1115,7 +1284,14 @@ 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) { @@ -1466,6 +1642,237 @@ function computeDeterminableEndingBound(expansion, resolvedNodes = null) { return outDist + release; } +/* src/runtime/audio-controls.js */ +// Native control-signal graph. Automation is scheduled once on the audio clock; +// modulation sums before the final clamp, not directly on an unclamped AudioParam. + + + + +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. +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); +} + +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); + } +} + +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(); } + }; +} + /* src/runtime/validator.js */ function issue(code, path, message, context = {}) { return { code, path, message, ...context }; @@ -1956,7 +2363,7 @@ class OverrideStack { priority, activationSequence: sequence, sampledValue: normalized, - attackOrigin: this.engine.get(action.target), + attackOrigin: this.engine.preModulationValue(action.target), attackMs, releaseMs, easing, @@ -2033,6 +2440,11 @@ 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(); @@ -2048,10 +2460,16 @@ 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] }; @@ -2062,6 +2480,10 @@ 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); } @@ -2075,6 +2497,8 @@ 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(); } @@ -2087,12 +2511,26 @@ 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; @@ -2117,20 +2555,69 @@ 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}`); @@ -2181,7 +2668,10 @@ 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(); + } } /* src/runtime/actions.js */ @@ -2462,6 +2952,10 @@ class ActivationController { + + + + function soundInstanceKey(soundId, ordinal) { return `${soundId}#${ordinal}`; } @@ -2514,6 +3008,10 @@ 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; @@ -2525,69 +3023,78 @@ 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') { @@ -2597,23 +3104,26 @@ 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, @@ -2709,8 +3219,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. function realizeSoundGraph(context, plan, destination) { const created = new Map(); const disposers = []; @@ -2724,227 +3234,236 @@ 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; + } } class SoundInstance { @@ -3091,11 +3610,15 @@ 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; } } @@ -3108,10 +3631,13 @@ 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. 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; @@ -3165,9 +3691,9 @@ 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); } @@ -3179,8 +3705,11 @@ 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) { @@ -3194,7 +3723,7 @@ 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; @@ -3274,7 +3803,7 @@ 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 }); @@ -3301,6 +3830,8 @@ 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(); @@ -3311,6 +3842,7 @@ class AudioSubsystem { this.context = null; this.master = null; this.protection = null; + if (this.ownsResolution) this.resolution.dispose(); } } @@ -3434,6 +3966,7 @@ class XZBTApplication { this.setBusy(true, `Preparing ${record.document.meta.name}…`); try { await this.activation.activate(record); + await this.disposeAudio(); this.renderLibrary(); this.renderStage(); } finally { @@ -3505,6 +4038,7 @@ 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)); @@ -3560,7 +4094,7 @@ 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); diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 260e7a4..516cd67 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,10 +1,10 @@ # XZBT implementation status **Updated:** September 5, 2026 -**State:** The audio subsystem contract is complete through Phase 3c; Phase 3c slices 1 and 2 implemented; automation (slice 3) and master protection (slice 4), the Phase 3 audible gates, and the Phase 1 direct-file import/restart observation remain pending +**State:** Phase 3c slices 1–3 complete; stopped at the user-requested slice-3 checkpoint. Master protection (slice 4), the Phase 3 audible gates, and the Phase 1 direct-file import/restart observation remain pending **Planning baseline:** `05fe2b4e021ba86e4a290d05b63c7cae0e386128` -**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton, the Phase 2 common grammar, and the Phase 3a/3b/3c-2 audio engine pass automated checks. Three gates remain open in the completed work: Phase 1's direct-file two-fixture restart observation, Phase 3's audible observation (no sound has been heard from any build), and the Phase 3c audio contracts on which real playback acceptance depends. Visual, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases. +**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton, Phase 2 common grammar, and audio engine through Phase 3c slice 3 pass automated checks. Phase 1's direct-file two-fixture restart observation, Phase 3's audible acceptance (no sound has been heard from a production build), and measured master protection remain open. Visual, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases. The user requested sequential implementation with a stop on problems. The [manual version 3 evidence](evidence/phase0/2026-09-04-user-run-v3.md) verifies embedded data-URL worklet loading in direct-file Chrome. The subsequent [user-performed restart test](evidence/phase0/2026-09-04-user-restart.md) restored Blue Study activity 0.37 and master volume 0.19 immediately on reopening. Native tone output and AudioContext suspend/resume are also observed. Ordinary file import, selection of both exhibits, regular Chrome mode, and [directory cancellation/denial fallback](evidence/phase0/2026-09-05-user-directory-fallback.md) have been confirmed. @@ -13,7 +13,7 @@ The user requested sequential implementation with a stop on problems. The [manua | 0 — Contracts and feasibility | Complete | GC1 passed; GC2–GC5 shared contracts and traces passed; GC6/GC7 later gates scheduled and mapped | | 1 — Runtime skeleton | Implemented; acceptance pending | [Automated evidence](evidence/phase1/2026-09-05-runtime-skeleton.md) passes production-module, lifecycle, PRNG-vector, cache/restore, fixture-validation, and deterministic-build tests. Direct-file two-fixture import/restart remains a user-observed gate. | | 2 — Common grammar | Complete | [Automated evidence](evidence/phase2/2026-09-05-common-grammar.md) covers production GC2 conformance, typed values, signals, actions, same-tick bindings, transitions, override precedence/release, and parameter restoration. | -| 3 — Audio engine | Slices 3c-1 (contract) and 3c-2 (lifecycle/voices) complete; slices 3c-3 and 3c-4 pending | [Automated evidence](evidence/phase3/2026-09-05-audio-authoring-contract.md) covers sections 14-15; [contract evidence](evidence/phase3/2026-09-05-phase3c-contract.md) adds section 16. [Slice 3c-2 evidence](evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md) implements the seven-state lifecycle, engine-owned release gain, determinable one-shot endings, voice ceilings (64/16), 4-step eviction, and disposal. Slices 3c-3 (automation) and 3c-4 (master protection measurement) and the PRD 129 audio acceptance challenge remain. | +| 3 — Audio engine | Slices 3c-1 (contract), 3c-2 (lifecycle/voices), and 3c-3 (automation) complete; stopped before slice 4 | [Audio authoring evidence](evidence/phase3/2026-09-05-audio-authoring-contract.md), [contract evidence](evidence/phase3/2026-09-05-phase3c-contract.md), [lifecycle/voice evidence](evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md), and [automation evidence](evidence/phase3/2026-09-05-phase3c-automation.md). Slice 4 requires measured master protection and user listening; PRD 129 and real GC4 audio acceptance remain open. | | 4 — Visual engine | Not started | Earlier phases and visual contracts | | 5 — Events and cadence | Not started | Earlier phases and event/cadence contracts | | 6 — Scenario director | Not started | Earlier phases and scenario contracts | @@ -59,11 +59,11 @@ Implementation lives in `src/runtime/audio-contract.js` (declarative tables), `s `npm test` runs 62 tests with zero failures. Two clean builds produce byte-identical artifacts with digest `64d9932ed863dbae66f309504b88e19370ad2c9017e92c32bd61ea1ea39a3f17`. -Phase 3 is not accepted. Audio automation precedence, the lifecycle state machine, unlock behavior, voice ceilings, and the measured master-protection contract are Phase 3c; no sound has been heard from any build, so the PRD 129 audio acceptance challenge, real GC4 synchronization checks, peak and finite-sample capture, and listening observations all remain open. +At the Phase 3a/3b checkpoint, automation, lifecycle/voices, and measured protection were still future work. The subsequent slice records below supersede that implementation status. PRD 129, real GC4 synchronization, peak/finite-sample capture, and listening observations remain open. ## Phase 3c contract (slice 1) -Format Specification section 16 completes the audio subsystem contract. Phase 3c is delivered in four slices, recorded in the implementation plan: the contract (done), lifecycle and voices (done), automation, and measured master protection. Each slice ends green and committable, because slice 2 replaces the working one-shot timer scaffolding and must not be left half-applied. +Format Specification section 16 completes the audio subsystem contract. Phase 3c is delivered in four slices, recorded in the implementation plan: the contract (done), lifecycle and voices (done), automation (done), and measured master protection (pending). Each slice ends green and committable. Section 16 was contract only at slice 1. @@ -73,4 +73,12 @@ Slice 3c-2 implements the seven-state lifecycle state machine (`CREATED`, `SCHED `npm test` runs 67 tests with zero failures. Two clean builds produce byte-identical artifacts with digest `d7d16a89f9b1a9e21262232382d7ae53a0a84abafce191a873327e66dc4103a6`. -Phase 3 is not accepted. Slices 3c-3 (automation) and 3c-4 (measured master protection) remain. No sound has been heard from any build. +At the slice-2 checkpoint, slices 3 and 4 remained. No sound had been heard from a production build. + +## Phase 3c automation (slice 3) + +Slice 3c-3 implements section 16.1 graph-local tracks, all three modes and four curves, strict literal time ordering, numeric ValueSpec validation, component expansion/encapsulation, conflict detection, and the 64-track / 256-total-point expanded limits. Procedural values are sampled once in depth-first document order. Native audio-clock scheduling holds curve endpoints, sums modulation before the safety clamp, and propagates exposed component controls. Section 16.2 is integrated into the shared Phase 2 resolver and the application's live bus gains; masked automation advances and override release follows its current value. + +`npm test` passes **88 tests**, including 21 new slice-3 tests. The self-contained build is deterministic; the artifact digest is `6aa1b659e1f06ca3175ac62a4544cc3342e8731f6d6f186c68fcd667cf683d97`. See the [slice-3 evidence](evidence/phase3/2026-09-05-phase3c-automation.md) for exact coverage, scheduling precision, and the test boundary. + +**Stop:** Slice 3 is complete; wait for the user before starting slice 4. The master chain is unchanged. Measured protection, audible acceptance, and real GC4 synchronization are not claimed by the automated tests. diff --git a/docs/XZBT_0-1_Format_Specification.md b/docs/XZBT_0-1_Format_Specification.md index 6ae7dac..b484cd7 100644 --- a/docs/XZBT_0-1_Format_Specification.md +++ b/docs/XZBT_0-1_Format_Specification.md @@ -1260,7 +1260,7 @@ This section closes the audio subsystem. It covers automation tracks and their p Sections 14 and 15 define what an exhibit may *declare*. This section defines what the runtime *does* with it over time. Where an earlier section deferred a rule to "Phase 3c", this section is the referent. -**Implementation status.** This section is the Phase 3c *contract*; the runtime does not yet implement it. Until slices 3c-2 and 3c-3 land, a document declaring `automation` or `release` is rejected with `ERR_UNKNOWN_FIELD` by the current validator, sound instances have no lifecycle state machine, and no voice ceiling is enforced. The implementation plan records the slice order. +**Implementation status.** Phase 3c slices 1–3 are implemented: the runtime accepts graph-local `automation` and recipe `release`, implements lifecycle/voice management, and resolves automation before override and modulation. Slice 4 (measured master protection) and the user-observed audio acceptance gates remain open. See the implementation status and slice evidence; the current master chain is still a placeholder, not proof of the protection contract. ### 16.1 Automation tracks diff --git a/docs/evidence/phase3/2026-09-05-phase3c-automation.md b/docs/evidence/phase3/2026-09-05-phase3c-automation.md new file mode 100644 index 0000000..5e8f347 --- /dev/null +++ b/docs/evidence/phase3/2026-09-05-phase3c-automation.md @@ -0,0 +1,38 @@ +# Phase 3c slice 3 — Automation + +Date: September 5, 2026. Contract: PRD 54 and Format Specification revision 0.4, sections 8.1, 14.4, 15.13–15.17, and 16.1–16.2. Scope: implementation-plan slice 3c-3 only. The requested stop is preserved; slice 4 has not started. + +## Delivered + +- Graph-local tracks on inline/shared recipes and reusable components. Both validators share structural/semantic checks; JSON Schema exposes the same authoring fields. +- Defaults, three modes, four curves, first/last holds, and one exponential fallback warning per sampled track. Times are duration literals in strictly increasing order; values are numeric ValueSpecs sampled once from the owning sound stream. +- Depth-first, property-document sampling, including nested components, route depths, and automation after the owning graph's nodes/routes. Internal expressions retain only exposed `inputs.*` references; random choices and external references stay sampled. +- Expanded-target conflicts and limits of 64 tracks / 256 total points. Component internals remain encapsulated. Automation does not enable external node bindings or overrides. +- Audio-clock scheduling, exposed component controls, modulation summation before property safety clamps, millisecond-to-second delay conversion, and resonator-ratio control. Automated delay tails conservatively use their maximum resolved time for disposal bounds. +- Shared numeric resolution: base → binding → automation → override → modulation → clamp. Bus gains use this path in the actual application, with current-value override release and preserved masked base edits. Zero-smoothing bindings react to same-tick edits. Bus automation/modulation registration is an internal runtime surface; no unspecified bus document fields were invented. +- Disposal cancels scheduled controls, stops/disconnects sources, clears control caches, releases the voice plan, and removes bus subscriptions. Injected scheduling failures clean partial graphs without violating the terminal `FAILED` lifecycle state. Successful exhibit switching detaches the preceding audio owner. +- The minimal audio exhibit demonstrates all modes and curves; the standalone artifact includes the new modules with no external runtime dependency. + +## Verification + +`npm test`: **88 passed, 0 failed** (67 existing regression tests plus 21 new tests in `test/phase3-automation.test.mjs`). Node's test workers require the existing sandbox escalation on this machine; no audio device was opened. `npm run test:phase3c3` runs the new suite directly. `npm run test:phase3` includes both audio test files. + +| Required section 16.11 trace | Automated evidence | +| --- | --- | +| 1–2: modes, curves, slopes, holds | Every mode/curve combination at start, midpoint, and end; quarter points and endpoint slopes for smooth; delayed first point and long-time final hold | +| 3: conflict versus modulation sum | Duplicate expanded targets rejected; distinct component instances remain distinct; duplicate modulation routes sum | +| 4: exponential fallback | Zero, sign crossing, and negative endpoints; one warning per track despite repeated evaluations; native linear-ramp fallback | +| 5: target capabilities | Every registry property, unknown nodes, unsupported fields, component encapsulation, external binding/override restrictions, and absence of stages for parameters/state | +| 6: limits | 64 expanded component tracks / 256 points accepted; 65 tracks and 257 points rejected | +| 8: masked automation | Production bus resolver and connected bus AudioParam follow a live binding, masked track, mid-release destination, additive modulation, and final clamp | +| 11: resource cleanup | Voice disposal, duplicate disposal, cleared scheduled events, disconnected native-control graph, released plan/subscription, and injected scheduling failure | + +Additional traces cover seeded sampling order/repeatability, component expressions with frozen procedural choices, bus ValueSpec sampling, same-tick upstream parameter overrides, delay units/tails, device frequency ceilings, and resonator ratios. Existing lifecycle/voice and Phase 0–2 tests continue to pass. + +The connected Web Audio stand-in records scheduled events and evaluates the constructed scalar control graph; it does not merely test a separate analytic formula. Step/linear/exponential tracks schedule native AudioParam events. Smooth tracks schedule linear progress and evaluate `3t² - 2t³` with native a-rate gain/summing nodes, including segment resets and zero endpoint slopes; allocation is independent of segment duration. General nonlinear component-expression shapers (reciprocal and rounding) use sampled lookup curves; this is not a claim of bit-identical rendered samples. The final property clamp uses three collinear WaveShaper points with saturation, after summation. + +`npm run build` and the reproducible-build regression produce byte-identical, self-contained HTML. Checked-in `XZBT.html`: **204,205 bytes**, SHA-256 **`6aa1b659e1f06ca3175ac62a4544cc3342e8731f6d6f186c68fcd667cf683d97`**. `git diff --check` passes. + +## Acceptance boundary and stop + +This is automated slice-3 evidence, not a browser listening or hardware measurement record. No production-build audio has been heard. The placeholder master chain is unchanged; measured peak ceiling, tolerance/release, finite-sample protection, PRD 129 listening, and real GC4 synchronization remain open. Phase 1's direct-file two-fixture restart observation also remains open. Do not start slice 4 or later phases until the user resumes work. diff --git a/exhibits/minimal-audio.xzbt b/exhibits/minimal-audio.xzbt index d06a4de..d61e513 100644 --- a/exhibits/minimal-audio.xzbt +++ b/exhibits/minimal-audio.xzbt @@ -3,9 +3,9 @@ "meta": { "id": "minimal-audio", "name": "Minimal Audio", - "version": "1.0.0", + "version": "1.1.0", "author": "XZBT", - "description": "A minimal generic exhibit exercising the Phase 3 audio graph, component, routing, and bus contracts.", + "description": "A minimal generic exhibit exercising audio graphs, components, routing, buses, and the Phase 3c automation modes and curves.", "license": "CC0-1.0", "tags": ["minimal", "audio"] }, @@ -45,6 +45,11 @@ { "from": "tone", "to": "shape" }, { "from": "shape", "to": "level" }, { "from": "level", "to": "output" } + ], + "automation": [ + { "target": "shape.frequency", "interpolation": "exponential", "points": [ + { "at": "0s", "value": 1800 }, { "at": "12s", "value": 800 } + ] } ] } } @@ -74,6 +79,14 @@ { "from": "trim", "to": "output" }, { "from": "wobble", "to": "low.pitch", "depth": 1.5 }, { "from": "wander", "to": "high.pitch", "depth": 1 } + ], + "automation": [ + { "target": "low.pitch", "mode": "offset", "interpolation": "smooth", "points": [ + { "at": "0s", "value": 0 }, { "at": "6s", "value": 12 }, { "at": "18s", "value": -4 } + ] }, + { "target": "high.level", "mode": "scale", "points": [ + { "at": "0s", "value": 0.4 }, { "at": "3s", "value": 1 } + ] } ] } } @@ -113,6 +126,11 @@ { "from": "body", "to": "place" }, { "from": "place", "to": "trim" }, { "from": "trim", "to": "output" } + ], + "automation": [ + { "target": "place.pan", "interpolation": "step", "points": [ + { "at": "0ms", "value": -0.2 }, { "at": "120ms", "value": 0.2 } + ] } ] } } diff --git a/package.json b/package.json index 208ce4c..3c7d023 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build": "node tools/build-xzbt.mjs", "test:phase1": "node test/phase1-runtime.test.mjs", "test:phase2": "node test/phase2-common-grammar.test.mjs", - "test:phase3": "node test/phase3-audio.test.mjs", + "test:phase3": "node --test test/phase3-*.test.mjs", + "test:phase3c3": "node test/phase3-automation.test.mjs", "test": "node --test test/*.test.mjs" } } diff --git a/schema/xzbt-0.1.schema.json b/schema/xzbt-0.1.schema.json index f2e0d48..7533e74 100644 --- a/schema/xzbt-0.1.schema.json +++ b/schema/xzbt-0.1.schema.json @@ -1594,6 +1594,41 @@ "$ref": "#/definitions/AudioRoute" } }, + "AudioAutomationList": { + "type": "array", + "maxItems": 64, + "description": "Graph-local tracks (16.1); expanded totals and target capabilities are semantic checks.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["target", "points"], + "properties": { + "target": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*\\.[a-z][a-z0-9_-]*$" }, + "mode": { "enum": ["absolute", "offset", "scale"], "default": "absolute" }, + "interpolation": { "enum": ["step", "linear", "exponential", "smooth"], "default": "linear" }, + "points": { + "type": "array", + "minItems": 2, + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["at", "value"], + "properties": { + "at": { "$ref": "#/definitions/DurationSpec" }, + "value": { + "allOf": [ + { "$ref": "#/definitions/ValueSpec" }, + { "type": ["number", "object"] } + ], + "description": "ValueSpec; numeric references and expression leaves are checked semantically." + } + } + } + } + } + } + }, "AudioRecipe": { "type": "object", "description": "A recipe graph, or a reference to a shared recipe (15.16).", @@ -1610,6 +1645,9 @@ "routes": { "$ref": "#/definitions/AudioRouteList" }, + "automation": { + "$ref": "#/definitions/AudioAutomationList" + }, "mode": { "type": "string", "enum": [ @@ -1650,6 +1688,9 @@ "routes": { "$ref": "#/definitions/AudioRouteList" }, + "automation": { + "$ref": "#/definitions/AudioAutomationList" + }, "input": { "type": "boolean" }, diff --git a/src/runtime/app.js b/src/runtime/app.js index f77a006..98d1662 100644 --- a/src/runtime/app.js +++ b/src/runtime/app.js @@ -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); diff --git a/src/runtime/audio-automation.js b/src/runtime/audio-automation.js new file mode 100644 index 0000000..e3b8715 --- /dev/null +++ b/src/runtime/audio-automation.js @@ -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); +} diff --git a/src/runtime/audio-contract.js b/src/runtime/audio-contract.js index 71dfeb1..f99a336 100644 --- a/src/runtime/audio-contract.js +++ b/src/runtime/audio-contract.js @@ -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 }); diff --git a/src/runtime/audio-controls.js b/src/runtime/audio-controls.js new file mode 100644 index 0000000..bdc07e7 --- /dev/null +++ b/src/runtime/audio-controls.js @@ -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(); } + }; +} diff --git a/src/runtime/audio-engine.js b/src/runtime/audio-engine.js index e8e9d28..d84d725 100644 --- a/src/runtime/audio-engine.js +++ b/src/runtime/audio-engine.js @@ -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(); } } diff --git a/src/runtime/audio-graph.js b/src/runtime/audio-graph.js index 94463c7..9779bd9 100644 --- a/src/runtime/audio-graph.js +++ b/src/runtime/audio-graph.js @@ -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) { diff --git a/src/runtime/resolution.js b/src/runtime/resolution.js index 932b82c..f045e45 100644 --- a/src/runtime/resolution.js +++ b/src/runtime/resolution.js @@ -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(); + } } diff --git a/src/runtime/values.js b/src/runtime/values.js index 1fc1bf0..e5cf4a7 100644 --- a/src/runtime/values.js +++ b/src/runtime/values.js @@ -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); diff --git a/test/phase3-automation.test.mjs b/test/phase3-automation.test.mjs new file mode 100644 index 0000000..281e49c --- /dev/null +++ b/test/phase3-automation.test.mjs @@ -0,0 +1,451 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { AUDIO_MODULATABLE } from '../src/runtime/audio-contract.js'; +import { applyAutomationMode, automationValueAt, sampleAutomationTrack } from '../src/runtime/audio-automation.js'; +import { audioPropertyAt, scheduleNativeAutomation } from '../src/runtime/audio-controls.js'; +import { expandSoundGraph } from '../src/runtime/audio-graph.js'; +import { AudioSubsystem, instantiateSoundGraph, realizeSoundGraph } from '../src/runtime/audio-engine.js'; +import { ResolutionEngine } from '../src/runtime/resolution.js'; +import { SeededRNG } from '../src/runtime/rng.js'; +import { validateExhibit } from '../src/runtime/validator.js'; +import { ExhibitValidator } from '../tools/validate-exhibit.mjs'; + +const close = (actual, expected, epsilon = 1e-9) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`); +const track = (target = 'tone.frequency', values = [100, 400], options = {}) => ({ target, points: values.map((value, i) => ({ at: `${i}s`, value })), ...options }); +const fixture = (automation = [], nodes = {}, routes = []) => ({ + xzbt: '0.1', meta: { id: 'automation-study', name: 'Automation Study' }, + audio: { buses: { main: { gain: 1 } } }, + sounds: { probe: { name: 'Probe', bus: 'main', recipe: { mode: 'continuous', nodes: { tone: { type: 'oscillator', frequency: 200 }, ...nodes }, routes: [{ from: 'tone', to: 'output' }, ...routes], automation } } } +}); +const recipe = (doc) => doc.sounds.probe.recipe; +const planFor = (doc, options = {}) => instantiateSoundGraph(doc, 'probe', { rng: new SeededRNG(42), ...options }); +const results = (doc) => [validateExhibit(doc), new ExhibitValidator(doc).validate()]; +const valid = (doc) => { for (const result of results(doc)) assert.deepEqual(result.errors, []); }; +const invalid = (doc, code) => { for (const result of results(doc)) assert.ok(result.errors.some((error) => error.code === code), `${code}: ${JSON.stringify(result.errors)}`); }; + +test('trace 1: all three modes and four curves have correct starts, midpoints, ends, and smooth slopes', () => { + for (const mode of ['absolute', 'offset', 'scale']) for (const interpolation of ['step', 'linear', 'exponential', 'smooth']) { + const sampled = sampleAutomationTrack(track('tone.frequency', [2, 8], { mode, interpolation }), (value) => value); + const expectedMid = interpolation === 'step' ? 2 : interpolation === 'exponential' ? 4 : 5; + for (const [at, value] of [[0, 2], [500, expectedMid], [1000, 8]]) close(applyAutomationMode(3, automationValueAt(sampled, at), mode), mode === 'absolute' ? value : mode === 'offset' ? 3 + value : 3 * value); + } + const smooth = sampleAutomationTrack(track('tone.frequency', [0, 1], { interpolation: 'smooth' }), (value) => value); + close(automationValueAt(smooth, 250), 0.15625); + close(automationValueAt(smooth, 0.001) / 0.000001, 0, 0.000004); + close((1 - automationValueAt(smooth, 999.999)) / 0.000001, 0, 0.000004); +}); + +test('trace 2: delayed first point holds before start and final value holds without looping', () => { + const sampled = sampleAutomationTrack({ target: 'tone.frequency', points: [{ at: '2s', value: 30 }, { at: '4s', value: 50 }] }, (value) => value); + for (const at of [-100, 0, 500, 2000]) assert.equal(automationValueAt(sampled, at), 30); + assert.equal(automationValueAt(sampled, 3000), 40); + for (const at of [4000, 8000, 1e12]) assert.equal(automationValueAt(sampled, at), 50); +}); + +test('trace 3: duplicate expanded tracks conflict while duplicate modulation routes sum', () => { + invalid(fixture([track(), track()]), 'ERR_AUTOMATION_CONFLICT'); + const doc = fixture([track('level.gain', [1, 2])], { level: { type: 'gain' }, bias: { type: 'constant', value: 1 } }, [ + { from: 'tone', to: 'level' }, { from: 'level', to: 'output' }, + { from: 'bias', to: 'level.gain', depth: 0.2 }, { from: 'bias', to: 'level.gain', depth: 0.2 } + ]); + valid(doc); + close(audioPropertyAt(planFor(doc), 'level', 'gain', 500, { bias: 1 }), 1.9); +}); + +test('trace 4: nonpositive exponential segments fall back once per sampled track, not per evaluation', () => { + for (const values of [[0, 4, 0], [-2, 2, -4], [-2, -8, -16]]) { + const warnings = []; + const sampled = sampleAutomationTrack(track('tone.detune', values, { interpolation: 'exponential' }), (value) => value, (warning) => warnings.push(warning)); + for (let i = 0; i < 100; i++) close(automationValueAt(sampled, 500), (values[0] + values[1]) / 2); + assert.deepEqual(warnings.map((warning) => warning.code), ['WARN_AUTOMATION_FALLBACK']); + } +}); + +test('trace 5: target registry, unknown nodes, encapsulation, and node-level rejection match both validators', () => { + for (const [type, properties] of Object.entries(AUDIO_MODULATABLE)) for (const property of Object.keys(properties)) { + const node = type === 'resonator' ? { type, modes: [{ ratio: 1 }] } : { type }; + valid(fixture([track(`subject.${property}`)], { subject: node })); + } + invalid(fixture([track('missing.frequency')]), 'ERR_INVALID_REFERENCE'); + invalid(fixture([track('tone.waveform')]), 'ERR_UNSUPPORTED_TARGET'); + invalid(fixture([track('tone')]), 'ERR_UNSUPPORTED_TARGET'); + const doc = componentFixture(); + recipe(doc).automation = [track('voice.tone.frequency')]; + invalid(doc, 'ERR_INVALID_REFERENCE'); + recipe(doc).automation = [track('voice.private')]; + invalid(doc, 'ERR_UNSUPPORTED_TARGET'); + recipe(doc).automation = []; + recipe(doc).nodes.voice.automation = []; + invalid(doc, 'ERR_UNKNOWN_FIELD'); + const external = fixture(); + external.parameters = { level: { type: 'number', default: 1 } }; + external.bindings = [{ source: 'parameters.level', target: 'sounds.probe.recipe.nodes.tone.frequency' }]; + invalid(external, 'ERR_UNSUPPORTED_TARGET'); +}); + +test('point shapes, literal times, strict order, numeric ValueSpecs, and unknown fields are checked at import', () => { + const cases = [ + [{ ...track(), points: [] }, 'ERR_SCHEMA_VALIDATION'], + [{ ...track(), points: [{ at: '0s', value: 1 }] }, 'ERR_SCHEMA_VALIDATION'], + [track(undefined, [1, 2], { mode: 'multiply' }), 'ERR_TYPE_MISMATCH'], + [track(undefined, [1, 2], { interpolation: 'bezier' }), 'ERR_TYPE_MISMATCH'], + [track(undefined, [1, 2], { extra: true }), 'ERR_UNKNOWN_FIELD'], + [track(undefined, ['100', 200]), 'ERR_TYPE_MISMATCH'], + [track(undefined, [{ choose: [{ weight: 1, value: false }] }, 200]), 'ERR_TYPE_MISMATCH'], + [track(undefined, [{ op: 'add', args: [true, 2] }, 200]), 'ERR_TYPE_MISMATCH'], + [track(undefined, [1, 2], { points: [{ at: { random: { min: '0s', max: '1s' } }, value: 1 }, { at: '2s', value: 2 }] }), 'ERR_TYPE_MISMATCH'], + [track(undefined, [1, 2], { points: [{ at: '1s', value: 1 }, { at: '1000ms', value: 2 }] }), 'ERR_INVALID_RANGE_ORDER'], + [track(undefined, [1, 2], { points: [{ at: '1s', value: 1 }, { at: '0s', value: 2 }] }), 'ERR_INVALID_RANGE_ORDER'], + [track(undefined, [1, 2], { points: [{ at: '-1s', value: 1 }, { at: '1s', value: 2 }] }), 'ERR_INVALID_DURATION'] + ]; + for (const [definition, code] of cases) invalid(fixture([definition]), code); + const doc = fixture([track(undefined, [{ ref: 'parameters.flag' }, 200])]); + doc.parameters = { flag: { type: 'boolean', default: true } }; + invalid(doc, 'ERR_TYPE_MISMATCH'); + recipe(doc).automation = {}; + invalid(doc, 'ERR_SCHEMA_VALIDATION'); +}); + +function componentFixture() { + const doc = fixture([track('voice.pitch', [100, 400])], { voice: { type: 'component', use: 'voice' } }, [{ from: 'voice', to: 'output' }]); + doc.components = { audio: { voice: { + parameters: { pitch: { type: 'number', min: 10, max: 1000, default: 100 } }, + nodes: { tone: { type: 'oscillator', frequency: { op: 'multiply', args: [{ ref: 'inputs.pitch' }, 2] } } }, + routes: [{ from: 'tone', to: 'output' }] + } } }; + return doc; +} + +test('trace 6: expanded limits admit exactly 64 tracks / 256 points and reject 65 / 257', () => { + const doc = fixture(); + doc.components = { audio: { unit: { nodes: { level: { type: 'gain' } }, automation: [track('level.gain', [0, 1, 2, 3])] } } }; + for (let i = 0; i < 64; i++) recipe(doc).nodes[`unit${i}`] = { type: 'component', use: 'unit' }; + valid(doc); + const expanded = expandSoundGraph(doc, 'probe'); + assert.equal(expanded.automation.length, 64); + assert.equal(new Set(expanded.automation.map((item) => item.target)).size, 64); + recipe(doc).automation.push(track('tone.frequency')); + invalid(doc, 'ERR_NODE_LIMIT_EXCEEDED'); + const points = fixture([track('tone.frequency', Array(256).fill(200))]); + valid(points); + recipe(points).automation[0].points.push({ at: '256s', value: 100 }); + invalid(points, 'ERR_NODE_LIMIT_EXCEEDED'); +}); + +test('track sampling is depth-first after each graph nodes/routes and does not redraw when evaluated', () => { + const random = { random: { min: 0, max: 1 } }; + const doc = componentFixture(); + recipe(doc).nodes = { voice: recipe(doc).nodes.voice, tone: { type: 'oscillator', detune: random, frequency: random }, bias: { type: 'constant' } }; + doc.components.audio.voice.nodes.tone.frequency = random; + doc.components.audio.voice.automation = [track('tone.detune', [random, random])]; + recipe(doc).routes.push({ from: 'bias', to: 'tone.detune', depth: random }); + recipe(doc).automation = [track('voice.pitch', [random, random])]; + let draws = 0; + const rng = { stream: () => ({ nextFloat: () => ++draws / 20 }) }; + const plan = planFor(doc, { rng }); + assert.equal(draws, 8); + close(plan.nodes.find((node) => node.path === 'voice.tone').expressions.frequency, 0.05); + assert.deepEqual(plan.automation[0].points.map((point) => point.value), [0.1, 0.15]); + const tone = plan.nodes.find((node) => node.path === 'tone'); + close(tone.values.detune, 0.2); + close(tone.values.frequency, 0.25); + close(plan.routes.find((route) => route.kind === 'modulation').depth, 0.3); + assert.deepEqual(plan.automation[1].points.map((point) => point.value), [0.35, 0.4]); + for (let i = 0; i < 100; i++) audioPropertyAt(plan, 'voice.tone', 'detune', i * 100); + assert.equal(draws, 8); + const seeded = fixture([track('tone.frequency', [random, random])]); + assert.deepEqual(planFor(seeded).automation, planFor(seeded).automation); + assert.notDeepEqual(planFor(seeded).automation, planFor(seeded, { ordinal: 1 }).automation); +}); + +test('component expressions retain only inputs; procedural choices are frozen, nested automation composes', () => { + const doc = componentFixture(); + doc.components.audio.voice.nodes.tone.frequency.args[1] = { choose: [{ weight: 1, value: { random: { min: 2, max: 2 } } }] }; + doc.components.audio.voice.automation = [track('tone.frequency', [10, 30], { mode: 'offset' })]; + valid(doc); + const plan = planFor(doc); + close(audioPropertyAt(plan, 'voice.tone', 'frequency', 500), 520); + assert.deepEqual(plan.nodes.find((node) => node.path === 'voice.tone').expressions.frequency, { op: 'multiply', args: [{ ref: 'inputs.pitch' }, 2] }); +}); + +test('trace 8: bus binding -> automation -> override -> modulation -> clamp, including live release', async () => { + const doc = fixture(); + doc.parameters = { amount: { type: 'number', default: 1, min: 0, max: 10 } }; + doc.bindings = [{ source: 'parameters.amount', target: 'audio.buses.main.gain', scale: 2 }]; + const engine = new ResolutionEngine(doc, new SeededRNG(42)); + const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), resolutionEngine: engine, contextFactory: recordingContext }); + await audio.unlock(); + const path = 'audio.buses.main.gain'; + const remove = engine.addAutomation(path, track(path, [0.5, 1.5], { mode: 'scale' })); + engine.setModulation(path, 'one', 0.1); + engine.setModulation(path, 'two', 0.2); + close(engine.get(path), 1.3); + const override = engine.overrides.add({ target: path, scope: 'scenario', transition: { out: '1s' } }, 3, { owner: 'test' }); + engine.advance(500); + close(engine.get(path), 3.3); + engine.setParameter('amount', 0.5); + engine.overrides.beginRelease(override); + engine.advance(500); // lower is now 1 * 1.5; midpoint release = (3 + 1.5) / 2 + close(engine.get(path), 2.55); + close(audio.buses.get('main').gain.value, 2.55); + engine.advance(500); + close(engine.get(path), 1.8); + engine.setParameter('amount', 10); + close(engine.get(path), 4); + engine.setModulation(path, 'one', -30); + close(engine.get(path), 0.2); // binding 20 wasn't prematurely clamped to 4 + remove(); + await audio.dispose(); + assert.equal(engine.listeners.size, 0); + engine.dispose(); + assert.equal(engine.automation.size + engine.modulation.size, 0); +}); + +test('only bus targets gain shared automation/modulation stages; duplicate registrations fail', () => { + const doc = fixture(); + doc.parameters = { amount: { type: 'number', default: 1 } }; + const engine = new ResolutionEngine(doc, new SeededRNG(42)); + for (const path of ['parameters.amount', 'state.missing', 'sounds.probe.recipe.nodes.tone.frequency']) { + assert.throws(() => engine.addAutomation(path, track()), { code: 'ERR_UNSUPPORTED_TARGET' }); + assert.throws(() => engine.setModulation(path, 'one', 1), { code: 'ERR_UNSUPPORTED_TARGET' }); + } + const path = 'audio.buses.main.gain'; + const remove = engine.addAutomation(path, track(path, [0, 1])); + assert.throws(() => engine.addAutomation(path, track()), { code: 'ERR_AUTOMATION_CONFLICT' }); + remove(); + engine.addAutomation(path, track(path, [1, 2])); + remove(); // a stale disposer cannot remove the replacement registration + assert.equal(engine.automation.get(path).points[0].value, 1); + assert.throws(() => engine.overrides.add({ target: 'sounds.probe.recipe.nodes.tone.frequency', scope: 'scenario' }, 1), { code: 'ERR_UNSUPPORTED_TARGET' }); +}); + +// Recording Web Audio stand-in with a scalar DSP evaluator. This verifies the +// connected native graph and scheduled values, not a disconnected logical model. +function recordingContext() { + const nodes = []; + const parameter = (value = 0) => ({ + value, inputs: [], events: [], + setValueAtTime(value, at) { this.events.push({ kind: 'set', value, at }); }, + linearRampToValueAtTime(value, at) { this.events.push({ kind: 'linear', value, at }); }, + exponentialRampToValueAtTime(value, at) { this.events.push({ kind: 'exponential', value, at }); }, + setValueCurveAtTime(values, at, duration) { this.events.push({ kind: 'curve', values, at, duration }); }, + cancelScheduledValues() { this.events.length = 0; } + }); + const make = (kind, properties = {}) => { + const node = { kind, inputs: [], connections: [], disconnected: false, ...properties, + connect(target) { this.connections.push(target); target.inputs.push(this); return target; }, + disconnect() { this.disconnected = true; for (const target of this.connections) target.inputs = target.inputs.filter((input) => input !== this); this.connections.length = 0; } + }; + nodes.push(node); + return node; + }; + const source = { start() { this.started = true; }, stop() { this.stopped = true; } }; + const context = { + nodes, sampleRate: 48000, currentTime: 7, state: 'running', + destination: make('destination'), + createGain: () => make('gain', { gain: parameter(1) }), + createConstantSource: () => make('constant', { offset: parameter(1), ...source }), + createOscillator: () => make('oscillator', { frequency: parameter(440), detune: parameter(0), ...source }), + createWaveShaper: () => make('shaper', { curve: null }), + createBiquadFilter: () => make('filter', { frequency: parameter(350), Q: parameter(1), gain: parameter(0), detune: parameter(0) }), + createStereoPanner: () => make('panner', { pan: parameter(0) }), + createDelay: () => make('delay', { delayTime: parameter(0) }), + createDynamicsCompressor: () => make('compressor', Object.fromEntries(['threshold', 'knee', 'ratio', 'attack', 'release'].map((name) => [name, parameter()]))), + close() { this.state = 'closed'; } + }; + const curveAt = (values, t) => { const at = Math.max(0, Math.min(1, t)) * (values.length - 1), i = Math.floor(at); return values[i] + ((values[i + 1] ?? values[i]) - values[i]) * (at - i); }; + const paramAt = (param, time) => { + let value = param.value, at = 0; + for (const event of param.events) { + if (event.kind === 'curve') { + if (time < event.at) break; + value = curveAt(event.values, (time - event.at) / event.duration); + at = event.at + event.duration; + if (time < at) break; + } else if (time < event.at) { + if (event.kind === 'linear') value += (event.value - value) * (time - at) / (event.at - at); + else if (event.kind === 'exponential') value *= (event.value / value) ** ((time - at) / (event.at - at)); + break; + } else { value = event.value; at = event.at; } + } + return value + param.inputs.reduce((sum, node) => sum + signalAt(node, time), 0); + }; + const signalAt = (node, time) => { + if (node.kind === 'constant') return paramAt(node.offset, time); + if (node.kind === 'oscillator') return Math.sin(2 * Math.PI * time * paramAt(node.frequency, time)); + const input = node.inputs.reduce((sum, child) => sum + signalAt(child, time), 0); + if (node.kind === 'gain') return input * paramAt(node.gain, time); + if (node.kind === 'shaper') return curveAt(node.curve, (input + 1) / 2); + return input; + }; + context.paramAt = paramAt; + return context; +} + +test('all modes/curves reach the scheduled AudioParam path on the owning clock and retain end values', () => { + for (const mode of ['absolute', 'offset', 'scale']) for (const interpolation of ['step', 'linear', 'exponential', 'smooth']) { + const definition = track('tone.frequency', mode === 'scale' ? [0.5, 2] : [100, 400], { mode, interpolation }); + const plan = planFor(fixture([definition])); + const context = recordingContext(); + const voice = realizeSoundGraph(context, plan, context.destination); + const param = context.nodes.find((node) => node.kind === 'oscillator').frequency; + for (const elapsed of [0, 0.25, 0.5, 0.75, 1, 4]) close(context.paramAt(param, 7 + elapsed), audioPropertyAt(plan, 'tone', 'frequency', elapsed * 1000), 0.0001); + voice.dispose(); + assert.ok(context.nodes.filter((node) => node.started).every((node) => node.stopped)); + assert.ok(context.nodes.filter((node) => node.kind !== 'destination').every((node) => node.disconnected)); + assert.ok(context.nodes.filter((node) => node.offset).every((node) => node.offset.events.length === 0)); + } + const definition = track('tone.frequency', [100, 400, 50], { interpolation: 'smooth' }); + definition.points.forEach((point, i) => { point.at = `${i + 1}s`; }); + const plan = planFor(fixture([definition])), context = recordingContext(); + const voice = realizeSoundGraph(context, plan, context.destination); + const param = context.nodes.find((node) => node.kind === 'oscillator').frequency; + for (const elapsed of [0, 1, 1.001, 1.234567, 1.999, 2, 2.001, 2.345678, 3, 30]) close(context.paramAt(param, 7 + elapsed), audioPropertyAt(plan, 'tone', 'frequency', elapsed * 1000), 1e-8); + close((context.paramAt(param, 8.000001) - 100) / 0.000001, 0, 0.001); + close((50 - context.paramAt(param, 9.999999)) / 0.000001, 0, 0.002); + voice.dispose(); +}); + +test('native modulation sums after automation and before safety clamp, including signed/offset values', () => { + const doc = fixture([track('level.gain', [5, -2])], { level: { type: 'gain' }, bias: { type: 'constant', value: 1 } }, [ + { from: 'tone', to: 'level' }, { from: 'level', to: 'output' }, + { from: 'bias', to: 'level.gain', depth: 1 }, { from: 'bias', to: 'level.gain', depth: -2 } + ]); + valid(doc); + const context = recordingContext(); + const voice = realizeSoundGraph(context, planFor(doc), context.destination); + const level = context.nodes.filter((node) => node.kind === 'gain')[2]; + for (const [seconds, expected] of [[0, 4], [0.25, 2.25], [0.5, 0.5], [1, 0]]) close(context.paramAt(level.gain, 7 + seconds), expected, 1e-5); + voice.dispose(); +}); + +test('native component parameter automation and modulation propagate into internal expressions', () => { + const doc = componentFixture(); + recipe(doc).nodes.bias = { type: 'constant', value: 1 }; + recipe(doc).routes.push({ from: 'bias', to: 'voice.pitch', depth: 50 }); + doc.components.audio.voice.automation = [track('tone.frequency', [10, 30], { mode: 'offset' })]; + valid(doc); + const plan = planFor(doc), context = recordingContext(); + const voice = realizeSoundGraph(context, plan, context.destination); + const internal = context.nodes.filter((node) => node.kind === 'oscillator')[1]; + close(context.paramAt(internal.frequency, 7.5), 620); + close(audioPropertyAt(plan, 'voice.tone', 'frequency', 500, { bias: 1 }), 620); + voice.dispose(); +}); + +test('native delay uses milliseconds, live frequency ceiling clamps, and resonator ratios follow fundamental', () => { + const doc = fixture([track('echo.time', [100, 400]), track('body.fundamental', [100, 400]), track('tone.frequency', [100, 10000])], { + echo: { type: 'delay' }, body: { type: 'resonator', modes: [{ ratio: 2 }, { frequency: 700 }] } + }, [{ from: 'tone', to: 'body' }, { from: 'body', to: 'echo' }, { from: 'echo', to: 'output' }]); + valid(doc); + const context = recordingContext(); + context.sampleRate = 8000; + const voice = realizeSoundGraph(context, planFor(doc, { sampleRate: 8000 }), context.destination); + close(context.paramAt(context.nodes.find((node) => node.kind === 'delay').delayTime, 7.5), 0.25); + close(context.paramAt(context.nodes.find((node) => node.kind === 'oscillator').frequency, 8), 3600, 0.001); + const bands = context.nodes.filter((node) => node.kind === 'filter'); + close(context.paramAt(bands[0].frequency, 7.5), 500); + close(context.paramAt(bands[1].frequency, 7.5), 700); + voice.dispose(); +}); + +test('automation extends determinable delay tails without mutating sampled base values', () => { + const doc = fixture([track('echo.time', [100, 800])], { hit: { type: 'impulse', duration: '10ms' }, echo: { type: 'delay', time: '100ms', feedback: 0 } }); + recipe(doc).mode = 'oneshot'; + recipe(doc).routes = [{ from: 'hit', to: 'echo' }, { from: 'echo', to: 'output' }]; + valid(doc); + const plan = planFor(doc); + assert.equal(plan.endingBoundMs, 860); + assert.equal(plan.nodes.find((node) => node.path === 'echo').values.time, 100); +}); + +test('trace 11: disposal and scheduler failure cancel automation and release all voice resources', async () => { + const doc = fixture([track(undefined, [100, 400], { interpolation: 'smooth' })]); + const context = recordingContext(); + const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), contextFactory: () => context }); + await audio.unlock(); + const voice = audio.play('probe'); + voice.dispose(); + voice.dispose(); + assert.equal(voice.state, 'DISPOSED'); + assert.equal(voice.plan, null); + assert.equal(voice.realized, null); + assert.equal(audio.continuousVoices.size, 0); + assert.ok(context.nodes.filter((node) => node.started).every((node) => node.stopped)); + const makeSource = context.createConstantSource; + context.createConstantSource = () => { + const source = makeSource(); + source.offset.linearRampToValueAtTime = () => { throw new Error('injected scheduler failure'); }; + return source; + }; + assert.equal(audio.play('probe'), null); + assert.equal(audio.voices.size + audio.continuousVoices.size, 0); + assert.ok(context.nodes.filter((node) => ['constant', 'oscillator'].includes(node.kind)).every((node) => node.disconnected)); + await audio.dispose(); +}); + +test('native exponential fallback schedules linear ramps and schema exposes only graph-local automation', () => { + const context = recordingContext(), param = context.createGain().gain; + const sampled = sampleAutomationTrack(track('tone.detune', [-1, 0, 2, 8], { interpolation: 'exponential' }), (value) => value); + scheduleNativeAutomation(param, sampled, 7); + assert.deepEqual(param.events.slice(2).map((event) => event.kind), ['linear', 'linear', 'exponential']); + const definitions = JSON.parse(readFileSync(new URL('../schema/xzbt-0.1.schema.json', import.meta.url))).definitions; + assert.equal(definitions.AudioRecipe.oneOf[0].properties.automation.$ref, '#/definitions/AudioAutomationList'); + assert.equal(definitions.AudioComponent.properties.automation.$ref, '#/definitions/AudioAutomationList'); + assert.equal(definitions.AudioBus.properties.automation, undefined); +}); + +test('the minimal audio exhibit exercises all modes/curves and compiles with both validators', () => { + const doc = JSON.parse(readFileSync(new URL('../exhibits/minimal-audio.xzbt', import.meta.url))); + valid(doc); + const tracks = []; + for (const soundId of Object.keys(doc.sounds)) { + const plan = instantiateSoundGraph(doc, soundId, { rng: new SeededRNG(42), resolveReference: () => 0.6 }); + assert.deepEqual(plan.errors, []); + tracks.push(...plan.automation); + } + assert.deepEqual([...new Set(tracks.map((item) => item.mode))].sort(), ['absolute', 'offset', 'scale']); + assert.deepEqual([...new Set(tracks.map((item) => item.interpolation))].sort(), ['exponential', 'linear', 'smooth', 'step']); +}); + +test('bus base ValueSpecs sample once and parameter overrides feed downstream buses in the same tick', () => { + const doc = fixture(); + doc.audio.buses.main.gain = { random: { min: 0.5, max: 0.5 } }; + doc.parameters = { amount: { type: 'number', default: 1 } }; + doc.bindings = [{ source: 'parameters.amount', target: 'audio.buses.main.gain' }]; + valid(doc); + let draws = 0; + const rng = { stream: () => ({ nextFloat: () => { draws++; return 0.5; } }) }; + const engine = new ResolutionEngine(doc, rng); + const path = 'audio.buses.main.gain'; + assert.equal(engine.base(path), 0.5); + const id = engine.overrides.add({ target: 'parameters.amount', scope: 'scenario' }, 2); + assert.equal(engine.get(path), 2); + engine.setParameter('amount', 0.75); + assert.equal(engine.get(path), 2); + engine.overrides.beginRelease(id); + assert.equal(engine.get(path), 0.75); + engine.advance(1000); + assert.equal(draws, 1); +}); + +test('partial and resonator ValueSpecs preserve nested document order before automation draws', () => { + const random = { random: { min: 0, max: 1 } }; + const doc = fixture([track('tone.detune', [random, random])]); + recipe(doc).nodes = { + tone: { type: 'oscillator', waveform: 'custom', harmonics: [{ phase: random, gain: random, ratio: random }], frequency: random }, + body: { type: 'resonator', modes: [{ gain: random, ratio: random }], fundamental: random } + }; + let draws = 0; + const plan = planFor(doc, { rng: { stream: () => ({ nextFloat: () => ++draws / 10 }) } }); + const tone = plan.nodes.find((node) => node.path === 'tone'); + assert.deepEqual(tone.values.harmonics, [{ phase: 0.1, gain: 0.2, ratio: 0.3 }]); + assert.equal(tone.values.frequency, 0.4); + const body = plan.nodes.find((node) => node.path === 'body'); + assert.equal(body.values.modes[0].gain, 0.5); + assert.equal(body.values.modes[0].ratio, 0.6); + assert.equal(body.values.fundamental, 0.7); + assert.deepEqual(plan.automation[0].points.map((point) => point.value), [0.8, 0.9]); + assert.equal(draws, 9); +}); diff --git a/tools/build-xzbt.mjs b/tools/build-xzbt.mjs index 88507f5..301b696 100644 --- a/tools/build-xzbt.mjs +++ b/tools/build-xzbt.mjs @@ -12,7 +12,9 @@ const sourceFiles = Object.freeze([ 'src/runtime/types.js', 'src/runtime/values.js', 'src/runtime/audio-contract.js', + 'src/runtime/audio-automation.js', 'src/runtime/audio-graph.js', + 'src/runtime/audio-controls.js', 'src/runtime/validator.js', 'src/runtime/persistence.js', 'src/runtime/library.js',