Files
XZBT/XZBT.html
T
LabyricornandClaude Opus 5 0af58da89d feat(visual): implement the slice 4d renderer core
The first visual code that draws. Sections 17.4 through 17.14 are implemented
against Format Specification revision 0.8, and the standalone artifact now has a
stage canvas that renders a declared scene.

The renderer is split so that the automated traces of 17.16 can run without a
display, which is what those traces require. visual-engine.js resolves an
exhibit's objects once at their instantiation boundary, composes the
scene-to-device chain, sorts by depth, applies fog and the compositing order,
and emits a **frame plan** in device pixels; visual-canvas2d.js turns a plan
into drawing calls. A plan carries the numeric oracles the traces ask for - a
known scene point at a known display point, a perspective factor, a fog
fraction, a sort order - where a canvas carries only pixels.

visual-math.js implements the affine matrices, the normative local transform of
17.11, the directed arc sweep of 17.9 with its bound checked before
normalization, and section 2 color parsing with the fog and ramp arithmetic of
17.6 and 18.2. Colors outside the four documented forms are ERR_TYPE_MISMATCH:
exact components are what make per-object fog renderer-independent.

visual-geometry.js reduces all fourteen primitives to subpaths of lines and
cubic Beziers, so an affine map carries geometry exactly rather than
approximately. It implements the local extents and anchors of 17.9 - a
rectangle anchored top-left, circular primitives centered - the uniform cardinal
form of catmull-rom with its open-duplication and closed-wrap index rules, the
straight closing segment of a closed bezier spline, and endpoint-parameterized
elliptical arcs.

The composition chain of 19.3 is implemented as written: the fit matrix carries
the contain and cover centering offsets, the camera center is mapped into CSS
coordinates before the translation is formed, perspective acts about the
projection center, and the device-pixel multiplier is applied exactly once and
drops below 1 on a display larger than 4096. Scalars with no axis - stroke
widths, radii, dash lengths - take the single uniform factor g, so a nonuniform
stretch never distorts a stroke.

Depth follows 17.6: effective depth accumulates z and translate.z up the tree,
sortable units order farthest-first with the documented tie-break, a point's own
z projects that point without making it a sortable unit, and an object at or
behind the eye is culled with no diagnostic. Compositing follows 17.12: opacity
multiplies down the tree while layer opacity applies once at the layer, and the
sixteen buffer allocations per frame are granted nearest-first so refusals fall
on the far content, each diagnosed under the 19.5 cadence rather than omitted
silently.

visual-validation.js gains the object tree: the fifteen object types and their
declared fields, no `id` and no `layer` on an object, a declared fill on a
stroke-only primitive, a mask only on a group and only naming its own child,
path legality, spline modes and point counts, gradient stop order, and every
authoring limit of 19.5 that applies to a drawn object.

While implementing V10 I corrected the buffer-accounting rule I had written into
17.12: counting buffers as concurrently live made 19.5's farthest-first shedding
nearly unreachable, since group nesting is already capped at 8. The rule is now
16 allocations per frame, which is the per-frame reading "pass budget" has
carried since 17.5 first used it. The arc-sweep prose is likewise corrected to
normalize the signed delta, so 350 -> 10 clockwise is the 20-degree sweep the
text always claimed rather than 340 the long way round.

test/phase4-renderer.test.mjs adds 22 tests covering traces 1 through 13 of
17.16 plus buffer shedding and the backend's call order. npm test goes from 131
to 153 passing. Traces 15 and 16 remain user-observed, and no procedural system,
automation track, or post-effect executes yet - 4e and 4f own those.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0162Jb1J36judZNT8fHabGVt
2026-09-06 16:38:44 +00:00

7292 lines
344 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; media-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>XZBT 0.1</title>
<style>:root {
color-scheme: dark;
--ink: #f3f3ef;
--muted: #9ba09b;
--surface: #151817;
--surface-2: #1d211f;
--line: #323834;
--accent: #b8ff5a;
--accent-dark: #20330d;
--danger: #ff7b72;
--warning: #ffca5c;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: #0b0d0c; }
button, input { font: inherit; }
button { border: 1px solid var(--line); border-radius: 0.55rem; padding: 0.66rem 0.9rem; color: var(--ink); background: var(--surface-2); cursor: pointer; }
button:hover:not(:disabled), button:focus-visible { border-color: var(--accent); outline: none; }
button:disabled { cursor: not-allowed; opacity: 0.45; }
select, input { width: 100%; accent-color: var(--accent); color: var(--ink); background: #0f1210; border: 1px solid var(--line); border-radius: 0.45rem; padding: 0.5rem; }
.primary { color: #10130e; border-color: var(--accent); background: var(--accent); font-weight: 700; }
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 3vw, 2.5rem); border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: baseline; gap: 0.8rem; }
.brand h1 { margin: 0; letter-spacing: 0.22em; font-size: 1.15rem; }
.brand span, .status-cluster { color: var(--muted); font-size: 0.8rem; }
.status-cluster { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem 1rem; }
.storage-warning { color: var(--warning); }
.storage-ready { color: var(--accent); }
main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-height: calc(100vh - 65px); }
.sidebar { padding: 1.25rem; border-right: 1px solid var(--line); background: #101311; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.section-heading h2 { margin: 0; font-size: 0.95rem; letter-spacing: 0.08em; text-transform: uppercase; }
.drop-zone { padding: 1rem; margin-bottom: 1rem; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 0.75rem; }
.drop-zone.is-dragging { color: var(--accent); border-color: var(--accent); background: var(--accent-dark); }
.exhibit-list { display: grid; gap: 0.75rem; }
.exhibit-card { padding: 1rem; border: 1px solid var(--line); border-radius: 0.8rem; background: var(--surface); }
.exhibit-card.is-active { border-color: var(--accent); box-shadow: inset 3px 0 var(--accent); }
.exhibit-card h3, .exhibit-card p { margin: 0 0 0.55rem; }
.exhibit-card .description { color: #c7cbc7; font-size: 0.9rem; line-height: 1.45; }
.exhibit-card .meta { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.72rem; }
.workspace { display: grid; grid-template-rows: minmax(300px, 1fr) minmax(220px, 38vh); min-width: 0; }
.stage { display: grid; place-items: center; padding: clamp(1.5rem, 5vw, 5rem); background: radial-gradient(circle at 50% 45%, #1a211c, #0b0d0c 62%); }
.stage-card { width: min(660px, 100%); padding: clamp(1.5rem, 4vw, 3rem); border: 1px solid var(--line); border-radius: 1rem; background: rgba(16, 19, 17, 0.88); }
.eyebrow { color: var(--accent); letter-spacing: 0.12em; text-transform: uppercase; font-size: 0.75rem; }
.stage-card h2 { margin: 0.35rem 0 0.7rem; font-size: clamp(1.7rem, 4vw, 3rem); }
.stage-card p { color: var(--muted); line-height: 1.55; }
.runtime-readout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; margin: 1.5rem 0; }
.runtime-readout div { padding: 0.8rem; border: 1px solid var(--line); border-radius: 0.55rem; overflow: hidden; }
.runtime-readout dt { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; }
.runtime-readout dd { margin: 0.35rem 0 0; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
.grammar-panel { margin: 1rem 0; padding-top: 0.8rem; border-top: 1px solid var(--line); }
.grammar-panel h3 { margin: 0 0 0.7rem; font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; }
.parameter-control { display: grid; grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 1.5fr) auto; gap: 0.65rem; align-items: center; margin: 0.55rem 0; }
.parameter-control label { color: #d9ddd9; font-size: 0.82rem; }
.compact { padding: 0.45rem 0.6rem; font-size: 0.75rem; }
.override-badge { margin-left: 0.5rem; padding: 0.12rem 0.35rem; color: var(--accent); background: var(--accent-dark); border-radius: 999px; font-size: 0.62rem; text-transform: uppercase; }
.resolved-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.35rem 1rem; padding: 0; list-style: none; }
.resolved-values li { display: flex; justify-content: space-between; gap: 1rem; min-width: 0; font-size: 0.76rem; }
.resolved-values code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
.resolved-values output, #condition-result { color: var(--accent); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.diagnostics-panel { min-height: 0; padding: 1rem 1.25rem; border-top: 1px solid var(--line); background: var(--surface); overflow: auto; }
.count { display: inline-grid; place-items: center; min-width: 1.6rem; min-height: 1.6rem; border-radius: 999px; color: var(--accent); background: var(--accent-dark); }
.diagnostic-list { display: grid; gap: 0.55rem; padding: 0; margin: 0; list-style: none; }
.diagnostic { display: grid; grid-template-columns: 4.5rem minmax(9rem, auto) 1fr; gap: 0.65rem; align-items: baseline; padding: 0.7rem; border-left: 3px solid var(--line); background: #101311; font-size: 0.82rem; }
.diagnostic.error { border-color: var(--danger); }
.diagnostic.warning { border-color: var(--warning); }
.diagnostic.info { border-color: var(--accent); }
.diagnostic .severity, .diagnostic small { color: var(--muted); font-size: 0.68rem; }
.diagnostic small { grid-column: 3; }
.empty { color: var(--muted); text-align: center; }
[hidden] { display: none !important; }
@media (max-width: 760px) {
main { grid-template-columns: 1fr; }
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
.workspace { grid-template-rows: auto auto; }
.diagnostic { grid-template-columns: 1fr; }
.diagnostic small { grid-column: 1; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; } }
.audio-controls {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
margin-bottom: 0.75rem;
}
.audio-controls label {
margin-left: auto;
font-size: 0.85rem;
}
.audio-buses {
display: grid;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.sound-list {
display: grid;
gap: 0.35rem;
}
.sound-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.35rem 0.55rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.4rem;
}
.stage-canvas {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
margin: 0 0 1rem;
border-radius: 6px;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.08);
}</style>
</head>
<body>
<header class="app-header">
<div class="brand"><h1>XZBT</h1><span id="runtime-version"></span></div>
<div class="status-cluster" role="status" aria-live="polite">
<span id="app-status">Starting…</span><span id="storage-status">Checking storage…</span>
</div>
</header>
<main>
<aside class="sidebar" aria-labelledby="library-title">
<div class="section-heading"><h2 id="library-title">Exhibit library</h2><button id="import-button" class="primary" type="button">Import files</button></div>
<input id="import-files" type="file" accept=".xzbt,application/json" multiple hidden>
<div id="drop-zone" class="drop-zone">Drop .xzbt files here</div>
<p id="empty-state" class="empty">No exhibits loaded.</p>
<div id="library-list" class="exhibit-list"></div>
</aside>
<section class="workspace">
<section class="stage" aria-live="polite">
<div id="stage-empty" class="stage-card"><span class="eyebrow">Runtime ready</span><h2>Select an exhibit</h2><p>Import declarative XZBT 0.1 documents, then activate one from the library. Exhibits run without adding subject-specific code to this runtime.</p></div>
<div id="stage-active" class="stage-card" hidden>
<span class="eyebrow">Active exhibit</span><h2 id="active-name"></h2>
<canvas id="stage-canvas" class="stage-canvas" aria-label="Exhibit visual output"></canvas>
<dl class="runtime-readout"><div><dt>Exhibit ID</dt><dd id="active-id"></dd></div><div><dt>Resolved seed</dt><dd id="active-seed"></dd></div><div style="grid-column:1/-1"><dt>Deterministic visual-stream preview</dt><dd id="active-sequence"></dd></div></dl>
<p>The common grammar resolves stored parameters, mutable state, signals, bindings, actions, and temporary overrides. The audio engine realizes declared graphs, and the visual engine draws declared scenes, layers, primitives, and transforms; procedural systems, automation, and post-effects attach in later slices.</p>
<section class="grammar-panel" aria-labelledby="configuration-title"><h3 id="configuration-title">Parameters</h3><div id="configuration"></div></section>
<section class="grammar-panel" aria-labelledby="audio-title">
<h3 id="audio-title">Audio</h3>
<p id="audio-state" class="empty">Audio is locked until you start it.</p>
<div class="audio-controls">
<button id="audio-unlock" type="button">Start audio</button>
<button id="audio-stop" type="button" disabled>Stop all sounds</button>
<label for="master-volume">Master volume</label>
<input id="master-volume" type="range" min="0" max="1" step="0.01" value="0.8">
</div>
<div id="audio-buses" class="audio-buses"></div>
<div id="sound-list" class="sound-list"></div>
</section>
<section class="grammar-panel" aria-labelledby="values-title"><h3 id="values-title">Resolved values</h3><ul id="resolved-values" class="resolved-values"></ul><p>Generated condition: <output id="condition-result">n/a</output></p></section>
<button id="deactivate-button" type="button">Deactivate</button>
</div>
</section>
<section class="diagnostics-panel" aria-labelledby="diagnostics-title">
<div class="section-heading"><h2 id="diagnostics-title">Diagnostics <span id="diagnostic-count" class="count">0</span></h2><button id="clear-diagnostics" type="button">Clear</button></div>
<p id="diagnostics-empty" class="empty">No diagnostics yet.</p><ol id="diagnostic-list" class="diagnostic-list"></ol>
</section>
</section>
</main>
<script>'use strict';
(() => {
/* src/runtime/constants.js */
const XZBT_FORMAT_VERSION = '0.1';
const XZBT_RUNTIME_VERSION = '0.1.0-phase4-stage0';
const UINT32_RANGE = 0x1_0000_0000;
const RNG_DOMAINS = Object.freeze([
'cadence',
'scenario',
'visual',
'sound',
'manual-sample'
]);
const DATABASE = Object.freeze({
name: 'xzbt-runtime-0.1',
version: 1,
exhibits: 'exhibits',
preferences: 'preferences'
});
const ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
const ALLOWED_TOP_LEVEL_FIELDS = Object.freeze([
'xzbt', 'meta', 'runtime', 'parameters', 'ui', 'state', 'signals',
'components', 'visuals', 'audio', 'sounds', 'cadence', 'modulators',
'bindings', 'events', 'scenarios'
]);
const ALLOWED_META_FIELDS = Object.freeze([
'id', 'name', 'version', 'author', 'description', 'license', 'tags'
]);
/* src/runtime/diagnostics.js */
class Diagnostics {
constructor({ limit = 250, onChange = () => {} } = {}) {
this.limit = limit;
this.onChange = onChange;
this.entries = [];
this.sequence = 0;
}
add(severity, code, message, context = {}) {
const entry = Object.freeze({
sequence: ++this.sequence,
timestamp: new Date().toISOString(),
severity,
code,
exhibitId: context.exhibitId ?? null,
section: context.section ?? null,
objectId: context.objectId ?? null,
property: context.property ?? null,
message
});
this.entries.push(entry);
if (this.entries.length > this.limit) this.entries.shift();
this.onChange(this.list());
return entry;
}
info(code, message, context) { return this.add('info', code, message, context); }
warn(code, message, context) { return this.add('warning', code, message, context); }
error(code, message, context) { return this.add('error', code, message, context); }
list() { return [...this.entries]; }
clear() { this.entries.length = 0; this.onChange([]); }
}
/* src/runtime/rng.js */
function rotateLeft(value, count) {
return ((value << count) | (value >>> (32 - count))) >>> 0;
}
function multiply32(left, right) {
return Math.imul(left, right) >>> 0;
}
function fnv1a32(text) {
let hash = 0x811c9dc5;
for (const byte of new TextEncoder().encode(text)) {
hash ^= byte;
hash = multiply32(hash, 0x01000193);
}
return hash >>> 0;
}
function splitMix32(seed) {
let state = seed >>> 0;
return () => {
state = (state + 0x9e3779b9) >>> 0;
let value = state;
value = multiply32(value ^ (value >>> 16), 0x21f0aaad);
value = multiply32(value ^ (value >>> 15), 0x735a2d97);
return (value ^ (value >>> 15)) >>> 0;
};
}
function deriveStreamState(rootSeed, domain, stableInstanceKey) {
validateRootSeed(rootSeed);
if (!RNG_DOMAINS.includes(domain)) throw new RangeError(`Unknown XZBT random domain: ${domain}.`);
if (typeof stableInstanceKey !== 'string' || stableInstanceKey.length === 0) {
throw new TypeError('A non-empty stable instance key is required.');
}
const hash = fnv1a32(`xzbt-0.1\0${rootSeed}\0${domain}\0${stableInstanceKey}`);
const expand = splitMix32(hash);
const state = [expand(), expand(), expand(), expand()];
if (state.every((word) => word === 0)) state[3] = 1;
return state;
}
class RandomStream {
constructor(state) {
if (!Array.isArray(state) || state.length !== 4 || state.some((word) => !Number.isInteger(word))) {
throw new TypeError('xoshiro128** state must contain four integer words.');
}
this.state = state.map((word) => word >>> 0);
if (this.state.every((word) => word === 0)) throw new RangeError('xoshiro128** state cannot be all zero.');
}
nextUint32() {
const state = this.state;
const result = multiply32(rotateLeft(multiply32(state[1], 5), 7), 9);
const temporary = (state[1] << 9) >>> 0;
state[2] ^= state[0];
state[3] ^= state[1];
state[1] ^= state[2];
state[0] ^= state[3];
state[2] ^= temporary;
state[3] = rotateLeft(state[3], 11);
for (let index = 0; index < 4; index++) state[index] >>>= 0;
return result >>> 0;
}
nextFloat() { return this.nextUint32() / UINT32_RANGE; }
nextInteger(minimum, maximum) {
if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || maximum < minimum) {
throw new RangeError('Integer bounds must be safe integers with maximum >= minimum.');
}
const span = maximum - minimum + 1;
if (span > UINT32_RANGE) throw new RangeError('Integer range cannot exceed 2^32 values.');
const limit = UINT32_RANGE - (UINT32_RANGE % span);
let value;
do value = this.nextUint32(); while (value >= limit);
return minimum + (value % span);
}
}
class SeededRNG {
constructor(rootSeed) {
this.rootSeed = validateRootSeed(rootSeed);
}
stream(domain, stableInstanceKey) {
return new RandomStream(deriveStreamState(this.rootSeed, domain, stableInstanceKey));
}
}
function validateRootSeed(seed) {
if (!Number.isInteger(seed) || seed < 0 || seed >= UINT32_RANGE) {
throw new RangeError('Root seed must be an unsigned 32-bit integer.');
}
return seed >>> 0;
}
function resolveRootSeed(authoredSeed = 'random', cryptoProvider = globalThis.crypto) {
if (authoredSeed !== 'random') return validateRootSeed(authoredSeed);
if (!cryptoProvider?.getRandomValues) throw new Error('Cryptographic entropy is unavailable; a random exhibit seed cannot be resolved.');
return cryptoProvider.getRandomValues(new Uint32Array(1))[0] >>> 0;
}
/* src/runtime/types.js */
const PARAMETER_TYPES = Object.freeze(['number', 'integer', 'boolean', 'string', 'color', 'enum']);
const STATE_TYPES = Object.freeze(['number', 'integer', 'boolean', 'string']);
const EASINGS = Object.freeze(['linear', 'ease-in', 'ease-out', 'ease-in-out']);
const DURATION_PATTERN = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)$/;
const RUNTIME_SIGNAL_TYPES = Object.freeze({
'signals.time.elapsed': 'number',
'signals.time.delta': 'number',
'signals.audio.low': 'number',
'signals.audio.mid': 'number',
'signals.audio.high': 'number',
'signals.audio.energy': 'number',
'signals.pointer.x': 'number',
'signals.pointer.y': 'number',
'signals.viewport.width': 'number',
'signals.viewport.height': 'number',
'signals.scenario.active': 'boolean'
});
class RuntimeFault extends Error {
constructor(code, message, path = '$') {
super(message);
this.name = 'RuntimeFault';
this.code = code;
this.path = path;
}
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNumericType(type) { return type === 'number' || type === 'integer'; }
function clamp(value, minimum = -Infinity, maximum = Infinity) {
return Math.min(maximum, Math.max(minimum, value));
}
function roundHalfAwayFromZero(value) {
return Math.sign(value) * Math.floor(Math.abs(value) + 0.5);
}
function easingValue(name, progress) {
const value = clamp(progress, 0, 1);
switch (name) {
case 'linear': return value;
case 'ease-in': return value * value;
case 'ease-out': return 1 - ((1 - value) * (1 - value));
case 'ease-in-out': return value < 0.5 ? 2 * value * value : 1 - (((-2 * value + 2) ** 2) / 2);
default: throw new RuntimeFault('ERR_INVALID_TRANSITION', `Unsupported easing '${name}'.`);
}
}
function lerp(from, to, amount) { return from + ((to - from) * amount); }
function parseDuration(value, path = '$') {
if (typeof value !== 'string') throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration must be a single-unit string.', path);
const match = DURATION_PATTERN.exec(value);
if (!match) throw new RuntimeFault('ERR_INVALID_DURATION', `Invalid duration '${value}'.`, path);
const scalar = Number(match[1]);
const multipliers = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 };
const milliseconds = scalar * multipliers[match[2]];
if (!Number.isFinite(milliseconds)) throw new RuntimeFault('ERR_INVALID_DURATION', `Duration '${value}' is not finite.`, path);
return milliseconds;
}
function valueMatchesType(type, value, spec = {}) {
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
if (type === 'integer') return Number.isInteger(value);
if (type === 'boolean') return typeof value === 'boolean';
if (type === 'string') return typeof value === 'string';
if (type === 'color') return typeof value === 'string' && value.trim().length > 0;
if (type === 'enum') return typeof value === 'string' && Array.isArray(spec.values) && spec.values.includes(value);
return false;
}
function normalizeForSpec(spec, value, { clampNumeric = false } = {}) {
if (!valueMatchesType(spec.type, value, spec)) {
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Value ${JSON.stringify(value)} does not match type '${spec.type}'.`);
}
if (isNumericType(spec.type)) {
let result = value;
if (spec.type === 'integer') result = roundHalfAwayFromZero(result);
const minimum = spec.min ?? -Infinity;
const maximum = spec.max ?? Infinity;
if (!clampNumeric && (result < minimum || result > maximum)) {
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Value ${result} is outside [${minimum}, ${maximum}].`);
}
result = clamp(result, minimum, maximum);
return result;
}
return value;
}
/* src/runtime/values.js */
const OPERATOR_ARITY = Object.freeze({
abs: 1, negate: 1, round: 1, floor: 1, ceil: 1,
add: 2, subtract: 2, multiply: 2, divide: 2, min: 2, max: 2,
clamp: 3, lerp: 3
});
class ValueResolver {
constructor(resolveReference) {
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);
return spec;
}
if (typeof spec === 'string' || typeof spec === 'boolean') return spec;
if (!isRecord(spec)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Invalid ValueSpec.', path);
if (Object.hasOwn(spec, 'ref')) return this.resolveReference(spec.ref);
if (Object.hasOwn(spec, 'random')) {
if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Random ValueSpec requires an owning random stream.', path);
const { min, max, integer = false } = spec.random;
if (!Number.isFinite(min) || !Number.isFinite(max) || max < min || typeof integer !== 'boolean') {
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Random range requires finite min <= max and an optional boolean integer flag.', `${path}.random`);
}
if (integer && Math.ceil(min) > Math.floor(max)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Integer random range contains no integers.', `${path}.random`);
return integer ? stream.nextInteger(Math.ceil(min), Math.floor(max)) : min + (stream.nextFloat() * (max - min));
}
if (Object.hasOwn(spec, 'choose')) {
if (!stream || !Array.isArray(spec.choose) || spec.choose.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Weighted choice requires a non-empty list and an owning stream.', `${path}.choose`);
let total = 0;
for (const option of spec.choose) {
if (!isRecord(option) || !Number.isFinite(option.weight) || option.weight <= 0 || !Object.hasOwn(option, 'value')) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Every choice requires a value and a finite positive weight.', `${path}.choose`);
total += option.weight;
}
const selected = stream.nextFloat() * total;
let cumulative = 0;
for (let index = 0; index < spec.choose.length; index += 1) {
cumulative += spec.choose[index].weight;
if (selected < cumulative || index === spec.choose.length - 1) return this.evaluate(spec.choose[index].value, stream, `${path}.choose[${index}].value`);
}
}
if (Object.hasOwn(spec, 'op')) {
const arity = OPERATOR_ARITY[spec.op];
if (!arity) throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported ValueSpec operator '${spec.op}'.`, `${path}.op`);
if (!Array.isArray(spec.args) || spec.args.length !== arity) throw new RuntimeFault('ERR_INVALID_ARITY', `Operator '${spec.op}' requires ${arity} arguments.`, `${path}.args`);
const values = spec.args.map((argument, index) => this.evaluate(argument, stream, `${path}.args[${index}]`));
if (values.some((value) => typeof value !== 'number' || !Number.isFinite(value))) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Operator '${spec.op}' requires finite numbers.`, path);
let result;
switch (spec.op) {
case 'abs': result = Math.abs(values[0]); break;
case 'negate': result = -values[0]; break;
case 'round': result = Math.round(values[0]); break;
case 'floor': result = Math.floor(values[0]); break;
case 'ceil': result = Math.ceil(values[0]); break;
case 'add': result = values[0] + values[1]; break;
case 'subtract': result = values[0] - values[1]; break;
case 'multiply': result = values[0] * values[1]; break;
case 'divide': result = values[1] === 0 ? 0 : values[0] / values[1]; break;
case 'min': result = Math.min(values[0], values[1]); break;
case 'max': result = Math.max(values[0], values[1]); break;
case 'clamp':
if (values[1] > values[2]) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Clamp minimum cannot exceed maximum.', path);
result = clamp(values[0], values[1], values[2]);
break;
case 'lerp': result = values[0] + ((values[1] - values[0]) * values[2]); break;
}
if (!Number.isFinite(result)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Operator '${spec.op}' produced a non-finite result.`, path);
return result;
}
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Unrecognized ValueSpec form.', path);
}
}
class ConditionEvaluator {
constructor(valueResolver) { this.values = valueResolver; }
evaluate(condition, stream, path = '$') {
if (!isRecord(condition)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'ConditionSpec must be an object.', path);
if (Object.hasOwn(condition, 'and')) {
if (!Array.isArray(condition.and) || condition.and.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'and requires a non-empty array.', `${path}.and`);
return condition.and.every((child, index) => this.evaluate(child, stream, `${path}.and[${index}]`));
}
if (Object.hasOwn(condition, 'or')) {
if (!Array.isArray(condition.or) || condition.or.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'or requires a non-empty array.', `${path}.or`);
return condition.or.some((child, index) => this.evaluate(child, stream, `${path}.or[${index}]`));
}
if (Object.hasOwn(condition, 'not')) return !this.evaluate(condition.not, stream, `${path}.not`);
const comparisons = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'];
if (!comparisons.includes(condition.op) || !Object.hasOwn(condition, 'left') || !Object.hasOwn(condition, 'right')) throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported comparison operator '${condition.op}'.`, `${path}.op`);
const left = this.values.evaluate(condition.left, stream, `${path}.left`);
const right = this.values.evaluate(condition.right, stream, `${path}.right`);
if (typeof left !== typeof right || !['number', 'boolean', 'string'].includes(typeof left)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Condition operands must have compatible primitive types.', path);
switch (condition.op) {
case 'eq': return left === right;
case 'ne': return left !== right;
case 'gt': return left > right;
case 'gte': return left >= right;
case 'lt': return left < right;
case 'lte': return left <= right;
}
}
}
/* src/runtime/audio-contract.js */
// Audio Subsystem Contract 0.1 - declarative tables shared by validation, expansion,
// instantiation, and realization. Format Specification 0.1 revision 0.4, sections 14-15.
const AUDIO_STATIC_MAX_FREQUENCY = 24000;
const AUDIO_NYQUIST_FACTOR = 0.45;
// Engine-owned candidate values; hardware/listening acceptance remains GC6.
const AUDIO_PROTECTION = Object.freeze({
ceilingDb: -1,
toleranceDb: 0.1,
lookaheadMs: 5,
attackMs: 0.5,
releaseMs: 250,
channels: 2
});
const AUDIO_LIMITS = Object.freeze({
nodesPerSound: 128,
routesPerSound: 256,
componentDepth: 8,
resonatorModes: 16,
oscillatorPartials: 64,
automationTracks: 64,
automationPoints: 256
});
const AUDIO_NOISE_COLORS = Object.freeze(['white', 'pink', 'brown']);
const AUDIO_RESERVED_NODE_KEYS = Object.freeze(['output', 'input']);
function num(min, max, fallback, extra = {}) {
return { kind: 'number', min, max, default: fallback, valuespec: true, ...extra };
}
function enumeration(values, fallback) {
return { kind: 'enum', values: Object.freeze(values), default: fallback, valuespec: false };
}
function duration(minMs, maxMs, fallback) {
return { kind: 'duration', min: minMs, max: maxMs, default: fallback, valuespec: false };
}
// `ceiling: 'audio'` marks a field validated against AUDIO_STATIC_MAX_FREQUENCY at the
// semantic stage and clamped to the live audioMaxFrequency at instantiation (section 14.5).
const AUDIO_NODE_TYPES = Object.freeze({
oscillator: {
class: 'source', acceptsAudio: false,
fields: {
waveform: enumeration(['sine', 'triangle', 'square', 'sawtooth', 'custom'], 'sine'),
frequency: num(0.1, AUDIO_STATIC_MAX_FREQUENCY, 440, { ceiling: 'audio' }),
detune: num(-4800, 4800, 0),
harmonics: { kind: 'partials', valuespec: false, requiredWhen: { waveform: 'custom' } }
}
},
noise: {
class: 'source', acceptsAudio: false,
fields: { color: enumeration(AUDIO_NOISE_COLORS, 'white') }
},
impulse: {
class: 'source', acceptsAudio: false,
fields: {
color: enumeration(AUDIO_NOISE_COLORS, 'white'),
duration: duration(1, 500, '10ms'),
amplitude: num(0, 1, 1),
decay: enumeration(['flat', 'linear', 'exponential'], 'exponential')
}
},
constant: {
class: 'control', acceptsAudio: false,
fields: { value: num(-1000, 1000, 1) }
},
lfo: {
class: 'control', acceptsAudio: false,
fields: {
waveform: enumeration(['sine', 'triangle', 'square', 'sawtooth'], 'sine'),
frequency: num(0.001, 40, 1),
amplitude: num(0, 1000, 1),
polarity: enumeration(['bipolar', 'unipolar'], 'bipolar'),
phase: num(0, 360, 0, { exclusiveMax: true })
}
},
'sample-hold': {
class: 'control', acceptsAudio: false,
fields: {
rate: num(0.01, 100, 2),
min: num(-1000, 1000, -1),
max: num(-1000, 1000, 1),
slew: duration(0, 1000, '0ms')
},
rangeOrder: ['min', 'max']
},
gain: {
class: 'processing', acceptsAudio: true,
fields: { gain: num(0, 4, 1) }
},
filter: {
class: 'processing', acceptsAudio: true,
fields: {
mode: enumeration(['lowpass', 'highpass', 'bandpass', 'notch', 'peaking', 'lowshelf', 'highshelf', 'allpass'], 'lowpass'),
frequency: num(10, AUDIO_STATIC_MAX_FREQUENCY, 1000, { ceiling: 'audio' }),
q: num(0.0001, 100, 1),
gain: num(-40, 40, 0),
detune: num(-4800, 4800, 0)
}
},
compressor: {
class: 'processing', acceptsAudio: true,
fields: {
threshold: num(-100, 0, -24),
knee: num(0, 40, 30),
ratio: num(1, 20, 12),
attack: duration(0, 1000, '3ms'),
release: duration(10, 1000, '250ms')
}
},
waveshaper: {
class: 'processing', acceptsAudio: true,
fields: {
shape: enumeration(['soft-clip', 'hard-clip', 'saturation'], 'soft-clip'),
amount: num(0, 1, 0.5),
oversample: enumeration(['none', '2x', '4x'], 'none')
}
},
delay: {
class: 'processing', acceptsAudio: true,
fields: {
time: duration(0, 10000, '250ms'),
feedback: num(0, 0.95, 0.2),
mix: num(0, 1, 0.5)
}
},
reverb: {
class: 'processing', acceptsAudio: true,
fields: {
size: num(0, 1, 0.5),
decay: duration(50, 30000, '2s'),
damping: num(0, 1, 0.5),
predelay: duration(0, 500, '0ms'),
mix: num(0, 1, 0.25)
}
},
'stereo-pan': {
class: 'processing', acceptsAudio: true,
fields: { pan: num(-1, 1, 0) }
},
mixer: {
class: 'routing', acceptsAudio: true,
fields: {}
},
resonator: {
class: 'processing', acceptsAudio: true,
fields: {
fundamental: num(0.1, AUDIO_STATIC_MAX_FREQUENCY, 120, { ceiling: 'audio' }),
modes: { kind: 'modes', valuespec: false, required: true },
mix: num(0, 1, 1)
}
},
component: {
class: 'composite', acceptsAudio: 'declared',
fields: {
use: { kind: 'component-ref', valuespec: false, required: true },
values: { kind: 'component-values', valuespec: false }
}
}
});
const AUDIO_NODE_TYPE_NAMES = Object.freeze(Object.keys(AUDIO_NODE_TYPES));
const AUDIO_PARTIAL_FIELDS = Object.freeze({
ratio: { min: 0.001, max: 256, required: true },
gain: { min: 0, max: 1, required: true },
phase: { min: 0, max: 360, default: 0, exclusiveMax: true }
});
const AUDIO_MODE_FIELDS = Object.freeze({
ratio: { min: 0.001, max: 256 },
frequency: { min: 0.1, max: AUDIO_STATIC_MAX_FREQUENCY, ceiling: 'audio' },
gain: { min: 0, max: 1, default: 1 },
decay: { kind: 'duration', min: 10, max: 20000, default: '1s' }
});
// Section 15.13. Depth is expressed in the listed unit; anything absent is ERR_UNSUPPORTED_TARGET.
const AUDIO_MODULATABLE = Object.freeze({
oscillator: Object.freeze({ frequency: 'hz', detune: 'cents' }),
gain: Object.freeze({ gain: 'linear' }),
filter: Object.freeze({ frequency: 'hz', q: 'unitless', gain: 'db', detune: 'cents' }),
delay: Object.freeze({ time: 'ms' }),
'stereo-pan': Object.freeze({ pan: 'pan' }),
resonator: Object.freeze({ fundamental: 'hz' })
});
const AUDIO_MODULATION_SOURCE_TYPES = Object.freeze(['constant', 'lfo', 'sample-hold', 'oscillator']);
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', '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 });
const AUDIO_ROUTE_FIELDS = Object.freeze(['from', 'to', 'depth']);
const AUDIO_VOICE_LIMITS = Object.freeze({
oneshot: 64,
continuous: 16
});
const AUDIO_DEFAULT_RELEASE_MS = 50;
const AUDIO_MAX_RELEASE_MS = 10000;
const AUDIO_LIFECYCLE_STATES = Object.freeze([
'CREATED',
'SCHEDULED',
'ACTIVE',
'RELEASING',
'FINISHED',
'DISPOSED',
'FAILED'
]);
const AUDIO_LIFECYCLE_TRANSITIONS = Object.freeze({
CREATED: Object.freeze(['SCHEDULED', 'FINISHED', 'FAILED']),
SCHEDULED: Object.freeze(['ACTIVE', 'RELEASING', 'FAILED']),
ACTIVE: Object.freeze(['RELEASING', 'FINISHED', 'FAILED']),
RELEASING: Object.freeze(['FINISHED', 'FAILED']),
FINISHED: Object.freeze(['DISPOSED']),
DISPOSED: Object.freeze([]),
FAILED: Object.freeze([])
});
function audioMaxFrequency(sampleRate) {
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return AUDIO_STATIC_MAX_FREQUENCY;
return Math.min(AUDIO_STATIC_MAX_FREQUENCY, sampleRate * AUDIO_NYQUIST_FACTOR);
}
function isControlSourceType(type) {
return AUDIO_NODE_TYPES[type]?.class === 'control';
}
function isSourceType(type) {
const entry = AUDIO_NODE_TYPES[type];
return entry?.class === 'source' || entry?.class === 'control';
}
/* src/runtime/audio-protection.js */
// This exact class is serialized into the embedded worklet and exercised in tests.
// No browser globals or imports are used by the DSP core.
class MasterProtectionDSP {
constructor(rate, settings) {
this.ceiling = 10 ** (settings.ceilingDb / 20);
this.delayFrames = Math.max(1, Math.ceil(rate * settings.lookaheadMs / 1000));
this.delay = Array.from({ length: settings.channels }, () => new Float32Array(this.delayFrames));
this.attack = Math.exp(-1 / (rate * settings.attackMs / 1000));
this.release = Math.exp(-1 / (rate * settings.releaseMs / 1000));
this.position = 0;
this.gain = 1;
this.heldPeak = 0;
this.hold = 0;
this.affectedBlocks = 0;
}
process(input, output, fault = false) {
let badSamples = 0;
for (const channel of input) for (const sample of channel) if (!Number.isFinite(sample)) badSamples++;
const muted = fault || badSamples > 0;
if (muted) {
// Flush pending audio as well: no corrupted history can reappear later.
for (const channel of this.delay) channel.fill(0);
for (const channel of output) channel.fill(0);
this.heldPeak = 0;
this.hold = 0;
this.affectedBlocks++;
return { muted, badSamples, peak: 0, clampedSamples: 0 };
}
let peak = 0, clampedSamples = 0;
for (let i = 0; i < output[0].length; i++) {
let incomingPeak = 0;
for (const channel of input) incomingPeak = Math.max(incomingPeak, Math.abs(channel[i] ?? 0));
if (incomingPeak >= this.heldPeak) {
this.heldPeak = incomingPeak;
this.hold = this.delayFrames;
} else if (this.hold > 0) this.hold--;
else this.heldPeak = incomingPeak;
const target = this.heldPeak > this.ceiling ? this.ceiling / this.heldPeak : 1;
const coefficient = target < this.gain ? this.attack : this.release;
this.gain = target + coefficient * (this.gain - target);
for (let c = 0; c < output.length; c++) {
const delayed = this.delay[c][this.position];
this.delay[c][this.position] = input[c]?.[i] ?? 0;
const value = delayed * this.gain;
if (Math.abs(value) > this.ceiling) clampedSamples++;
// Final sample clamp is required even during attack and extreme overload.
output[c][i] = Math.max(-this.ceiling, Math.min(this.ceiling, value));
peak = Math.max(peak, Math.abs(output[c][i]));
}
this.position = (this.position + 1) % this.delayFrames;
}
return { muted, badSamples, peak, clampedSamples };
}
}
function protectionWorkletSource() {
return `const SETTINGS = ${JSON.stringify(AUDIO_PROTECTION)};
${MasterProtectionDSP.toString()}
class XZBTProtectionProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
this.guard = options.processorOptions?.guard === true;
this.dsp = this.guard ? null : new MasterProtectionDSP(sampleRate, SETTINGS);
this.warned = false;
this.capture = null;
this.port.onmessage = ({ data }) => {
if (data.type === 'capture' && !this.guard) {
this.capture = { id: data.id, skip: data.warmupFrames, remaining: data.frames,
frames: 0, peak: 0, affectedBlocks: 0, nonfiniteSamples: 0, clampedSamples: 0 };
}
};
}
process(inputs, outputs) {
const input = inputs[0] ?? [], output = outputs[0];
let result;
if (this.guard) {
let badSamples = 0;
for (const channel of input) for (const sample of channel) if (!Number.isFinite(sample)) badSamples++;
for (let c = 0; c < output.length; c++) {
output[c].fill(0);
if (!badSamples && input[c]) output[c].set(input[c]);
}
// Separate finite fault lane lets the final master mute the same whole
// render quantum, while attribution remains attached to this voice.
outputs[1][0].fill(badSamples);
result = { badSamples, muted: badSamples > 0 };
} else {
const fault = (inputs[1] ?? []).some(channel => channel.some(value => value !== 0));
result = this.dsp.process(input, output, fault);
const capture = this.capture;
if (capture) {
const start = Math.min(capture.skip, output[0].length);
capture.skip -= start;
const count = Math.min(capture.remaining, output[0].length - start);
if (count > 0) {
for (const channel of output) for (let i = start; i < start + count; i++) capture.peak = Math.max(capture.peak, Math.abs(channel[i]));
capture.frames += count;
capture.remaining -= count;
capture.affectedBlocks += result.muted ? 1 : 0;
capture.nonfiniteSamples += result.badSamples + (inputs[1]?.[0]?.[0] ?? 0);
capture.clampedSamples += result.clampedSamples;
}
if (capture.remaining === 0) {
this.port.postMessage({ type: 'capture', ...capture, sampleRate });
this.capture = null;
}
}
}
if (result.badSamples > 0 && !this.warned) {
this.warned = true;
this.port.postMessage({ type: 'nonfinite' });
}
return true;
}
}
registerProcessor('xzbt-protection', XZBTProtectionProcessor);`;
}
async function loadProtectionWorklet(context) {
if (!context.audioWorklet || typeof globalThis.AudioWorkletNode !== 'function') {
throw new Error('AudioWorklet master protection is unavailable.');
}
// The Phase 0 direct-file probe verified this embedded data-URL loading path.
await context.audioWorklet.addModule(`data:text/javascript;charset=utf-8,${encodeURIComponent(protectionWorkletSource())}`);
}
function createProtectionNode(context, guard = false) {
return new AudioWorkletNode(context, 'xzbt-protection', {
numberOfInputs: guard ? 1 : 2,
numberOfOutputs: guard ? 2 : 1,
outputChannelCount: guard ? [AUDIO_PROTECTION.channels, 1] : [AUDIO_PROTECTION.channels],
channelCount: AUDIO_PROTECTION.channels,
channelCountMode: 'explicit',
processorOptions: { guard }
});
}
/* 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).
const DURATION_UNITS = Object.freeze({ ms: 1, s: 1000, m: 60_000, h: 3_600_000 });
function isObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function durationMilliseconds(value) {
if (typeof value !== 'string') return null;
const match = DURATION_PATTERN.exec(value);
if (!match) return null;
const milliseconds = Number(match[1]) * DURATION_UNITS[match[2]];
return Number.isFinite(milliseconds) ? milliseconds : null;
}
function fail(errors, code, path, message) {
errors.push({ code, path, message });
}
/* ------------------------------------------------------------------ *
* Structural validation of a single graph object (section 14.3)
* ------------------------------------------------------------------ */
function validateNumericField(document, value, spec, path, errors, helpers, scope) {
if (typeof value === 'number') {
if (!Number.isFinite(value)) return fail(errors, 'ERR_TYPE_MISMATCH', path, 'Numeric field must be finite.');
const overMax = spec.exclusiveMax ? value >= spec.max : value > spec.max;
if (value < spec.min || overMax) {
fail(errors, 'ERR_OUT_OF_BOUNDS', path, `Value ${value} is outside [${spec.min}, ${spec.max}${spec.exclusiveMax ? ')' : ']'}.`);
}
return;
}
if (!spec.valuespec) return fail(errors, 'ERR_TYPE_MISMATCH', path, 'Field requires a literal number.');
helpers.validateValueSpec(document, value, path, errors, scope);
}
function validateDurationField(value, spec, path, errors) {
const milliseconds = durationMilliseconds(value);
if (milliseconds === null) return fail(errors, 'ERR_INVALID_DURATION', path, `Invalid duration ${JSON.stringify(value)}.`);
if (milliseconds < spec.min || milliseconds > spec.max) {
fail(errors, 'ERR_OUT_OF_BOUNDS', path, `Duration ${value} is outside [${spec.min}ms, ${spec.max}ms].`);
}
}
function validatePartials(document, node, path, errors, helpers, scope) {
const waveform = node.waveform ?? 'sine';
const present = Object.hasOwn(node, 'harmonics');
if (waveform !== 'custom') {
if (present) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.harmonics`, "harmonics is only declared when waveform is 'custom'.");
return;
}
if (!present) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.harmonics`, "waveform 'custom' requires harmonics.");
const partials = node.harmonics;
if (!Array.isArray(partials) || partials.length === 0) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.harmonics`, 'harmonics must be a non-empty array.');
if (partials.length > AUDIO_LIMITS.oscillatorPartials) {
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', `${path}.harmonics`, `At most ${AUDIO_LIMITS.oscillatorPartials} partials are permitted.`);
}
partials.forEach((partial, index) => {
const entryPath = `${path}.harmonics[${index}]`;
if (!isObject(partial)) return fail(errors, 'ERR_SCHEMA_VALIDATION', entryPath, 'Partial must be an object.');
for (const field of Object.keys(partial)) {
if (!Object.hasOwn(AUDIO_PARTIAL_FIELDS, field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${entryPath}.${field}`, `Unrecognized partial field '${field}'.`);
}
for (const [field, rule] of Object.entries(AUDIO_PARTIAL_FIELDS)) {
if (!Object.hasOwn(partial, field)) {
if (rule.required) fail(errors, 'ERR_SCHEMA_VALIDATION', `${entryPath}.${field}`, `Partial requires '${field}'.`);
continue;
}
validateNumericField(document, partial[field], { ...rule, valuespec: true }, `${entryPath}.${field}`, errors, helpers, scope);
}
});
}
function validateModes(document, node, path, errors, helpers, scope) {
const modes = node.modes;
if (!Array.isArray(modes) || modes.length === 0) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.modes`, 'resonator requires a non-empty modes array.');
if (modes.length > AUDIO_LIMITS.resonatorModes) {
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', `${path}.modes`, `At most ${AUDIO_LIMITS.resonatorModes} resonator modes are permitted.`);
}
modes.forEach((mode, index) => {
const entryPath = `${path}.modes[${index}]`;
if (!isObject(mode)) return fail(errors, 'ERR_SCHEMA_VALIDATION', entryPath, 'Mode must be an object.');
for (const field of Object.keys(mode)) {
if (!Object.hasOwn(AUDIO_MODE_FIELDS, field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${entryPath}.${field}`, `Unrecognized mode field '${field}'.`);
}
const hasRatio = Object.hasOwn(mode, 'ratio');
const hasFrequency = Object.hasOwn(mode, 'frequency');
if (hasRatio === hasFrequency) {
fail(errors, 'ERR_SCHEMA_VALIDATION', entryPath, 'Each mode declares exactly one of ratio or frequency.');
}
if (hasRatio) validateNumericField(document, mode.ratio, { ...AUDIO_MODE_FIELDS.ratio, valuespec: true }, `${entryPath}.ratio`, errors, helpers, scope);
if (hasFrequency) validateNumericField(document, mode.frequency, { ...AUDIO_MODE_FIELDS.frequency, valuespec: true }, `${entryPath}.frequency`, errors, helpers, scope);
if (Object.hasOwn(mode, 'gain')) validateNumericField(document, mode.gain, { ...AUDIO_MODE_FIELDS.gain, valuespec: true }, `${entryPath}.gain`, errors, helpers, scope);
if (Object.hasOwn(mode, 'decay')) validateDurationField(mode.decay, AUDIO_MODE_FIELDS.decay, `${entryPath}.decay`, errors);
});
}
function validateComponentInstance(document, node, path, errors, helpers, scope) {
const components = document.components?.audio;
if (typeof node.use !== 'string' || !isObject(components?.[node.use])) {
return fail(errors, 'ERR_INVALID_REFERENCE', `${path}.use`, `Component '${node.use}' is not declared.`);
}
const declared = components[node.use].parameters ?? {};
const values = node.values;
if (values !== undefined && !isObject(values)) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values`, 'values must be an object.');
for (const [key, value] of Object.entries(values ?? {})) {
const rule = declared[key];
if (!isObject(rule)) {
fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.values.${key}`, `Component '${node.use}' does not expose '${key}'.`);
continue;
}
validateNumericField(document, value, {
min: rule.min ?? -Infinity, max: rule.max ?? Infinity, valuespec: true
}, `${path}.values.${key}`, errors, helpers, scope);
}
for (const [key, rule] of Object.entries(declared)) {
if (!Object.hasOwn(values ?? {}, key) && !Object.hasOwn(rule, 'default')) {
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values.${key}`, `Exposed parameter '${key}' has no default and no supplied value.`);
}
}
}
function validateNode(document, key, node, path, errors, helpers, scope) {
if (!ID_PATTERN.test(key)) fail(errors, 'ERR_INVALID_ID', path, `Node key '${key}' is not a valid identifier.`);
if (key === 'output') fail(errors, 'ERR_INVALID_ID', path, "'output' is reserved and cannot be declared as a node.");
if (key === 'input') fail(errors, 'ERR_INVALID_ID', path, "'input' is reserved and cannot be declared as a node.");
if (!isObject(node)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Node must be an object.');
const contract = AUDIO_NODE_TYPES[node.type];
if (!contract) return fail(errors, 'ERR_INVALID_NODE_TYPE', `${path}.type`, `Unrecognized audio node type '${node.type}'.`);
for (const field of Object.keys(node)) {
if (field === 'type') continue;
if (!Object.hasOwn(contract.fields, field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Node type '${node.type}' does not declare '${field}'.`);
}
for (const [field, spec] of Object.entries(contract.fields)) {
if (spec.kind === 'partials' || spec.kind === 'modes' || spec.kind === 'component-ref' || spec.kind === 'component-values') continue;
if (!Object.hasOwn(node, field)) continue;
const value = node[field];
const fieldPath = `${path}.${field}`;
if (spec.kind === 'enum') {
if (typeof value !== 'string' || !spec.values.includes(value)) fail(errors, 'ERR_TYPE_MISMATCH', fieldPath, `'${field}' must be one of: ${spec.values.join(', ')}.`);
} else if (spec.kind === 'duration') {
validateDurationField(value, spec, fieldPath, errors);
} else {
validateNumericField(document, value, spec, fieldPath, errors, helpers, scope);
}
}
if (node.type === 'oscillator') validatePartials(document, node, path, errors, helpers, scope);
if (node.type === 'resonator') validateModes(document, node, path, errors, helpers, scope);
if (node.type === 'component') validateComponentInstance(document, node, path, errors, helpers, scope);
if (contract.rangeOrder) {
const [lowField, highField] = contract.rangeOrder;
const low = node[lowField] ?? contract.fields[lowField].default;
const high = node[highField] ?? contract.fields[highField].default;
if (typeof low === 'number' && typeof high === 'number' && low >= high) {
fail(errors, 'ERR_INVALID_RANGE_ORDER', `${path}.${lowField}`, `${lowField} must be strictly less than ${highField}.`);
}
}
}
function validateRoute(document, route, path, errors, helpers, scope) {
if (!isObject(route)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Route must be an object.');
for (const field of Object.keys(route)) {
if (!AUDIO_ROUTE_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized route field '${field}'.`);
}
for (const field of ['from', 'to']) {
if (typeof route[field] !== 'string' || route[field].length === 0) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${field}`, `Route '${field}' must be a non-empty string.`);
}
const isModulation = typeof route.to === 'string' && route.to.includes('.');
if (isModulation && !Object.hasOwn(route, 'depth')) {
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.depth`, 'A modulation route requires depth.');
}
if (!isModulation && Object.hasOwn(route, 'depth')) {
fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.depth`, 'depth is only declared on a modulation route.');
}
if (Object.hasOwn(route, 'depth')) {
validateNumericField(document, route.depth, { min: -Infinity, max: Infinity, valuespec: true }, `${path}.depth`, errors, helpers, scope);
}
}
function 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)) {
if (!allowedFields.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized audio graph field '${field}'.`);
}
if (!isObject(graph.nodes) || Object.keys(graph.nodes).length === 0) {
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.nodes`, 'An audio graph requires a non-empty nodes object.');
} else {
for (const [key, node] of Object.entries(graph.nodes)) validateNode(document, key, node, `${path}.nodes.${key}`, errors, helpers, scope);
}
if (graph.routes !== undefined) {
if (!Array.isArray(graph.routes)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.routes`, 'routes must be an array.');
else graph.routes.forEach((route, index) => validateRoute(document, route, `${path}.routes[${index}]`, errors, helpers, scope));
}
validateAutomation(document, graph, `${path}.automation`, errors, helpers, scope);
}
/* ------------------------------------------------------------------ *
* Expansion (section 15.11 / 15.15) and legality (section 15.14)
* ------------------------------------------------------------------ */
function recipeGraphFor(document, sound, path, errors) {
const recipe = sound?.recipe;
if (!isObject(recipe)) {
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.recipe`, 'A sound requires a recipe object.');
return null;
}
if (Object.hasOwn(recipe, 'use')) {
if (Object.keys(recipe).some((field) => field !== 'use')) {
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.recipe`, 'A recipe reference declares only use.');
return null;
}
const shared = document.audio?.recipes?.[recipe.use];
if (!isObject(shared)) {
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.recipe.use`, `Recipe '${recipe.use}' is not declared.`);
return null;
}
return { graph: shared, path: `$.audio.recipes.${recipe.use}` };
}
return { graph: recipe, path: `${path}.recipe` };
}
function expandSoundGraph(document, soundId) {
const errors = [];
const sound = document.sounds?.[soundId];
const soundPath = `$.sounds.${soundId}`;
const located = recipeGraphFor(document, sound, soundPath, errors);
if (!located) return { nodes: [], routes: [], 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);
function expand(graph, prefix, depth, trail, graphPath) {
if (depth > AUDIO_LIMITS.componentDepth) {
fail(errors, 'ERR_COMPONENT_RECURSION', graphPath, `Component nesting exceeds ${AUDIO_LIMITS.componentDepth} levels.`);
return;
}
const declared = isObject(graph.nodes) ? graph.nodes : {};
for (const [key, node] of Object.entries(declared)) {
if (!isObject(node)) continue;
const path = join(prefix, key);
samplingOrder.push({ kind: 'node', path });
if (node.type === 'component') {
const component = components[node.use];
if (!isObject(component)) continue;
if (trail.includes(node.use)) {
fail(errors, 'ERR_COMPONENT_RECURSION', `${graphPath}.nodes.${key}`, `Component '${node.use}' instantiates itself.`);
continue;
}
nodes.set(`${path}.output`, { path: `${path}.output`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
if (component.input === true) {
nodes.set(`${path}.input`, { path: `${path}.input`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
}
nodes.set(path, {
path, type: 'component', spec: node, implicit: false, scope: prefix || null,
component: node.use, acceptsAudio: component.input === true, exposes: component.parameters ?? {}
});
expand(component, path, depth + 1, [...trail, node.use], `$.components.audio.${node.use}`);
} else {
nodes.set(path, { path, type: node.type, spec: node, implicit: false, scope: prefix || null });
}
}
const declaresInput = prefix ? graph.input === true : false;
const resolve = (name, side, routePath) => {
if (typeof name !== 'string') return null;
if (name === 'output') return prefix ? `${prefix}.output` : 'output';
if (name === 'input') {
if (!declaresInput) {
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'input' is only available inside a component declaring input: true.");
return null;
}
return `${prefix}.input`;
}
const head = name.split('.')[0];
const local = declared[head];
if (!isObject(local)) {
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Route endpoint '${name}' does not resolve in this graph.`);
return null;
}
const localPath = join(prefix, head);
const property = name.includes('.') ? name.slice(head.length + 1) : null;
if (local.type !== 'component') {
if (side === 'from' && property !== null) {
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Route source '${name}' names a property; only nodes emit signal.`);
return null;
}
return property === null ? localPath : `${localPath}::${property}`;
}
if (side === 'from') {
if (property !== null) {
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Component internals are encapsulated; '${name}' is not reachable.`);
return null;
}
return `${localPath}.output`;
}
if (property !== null) return `${localPath}::${property}`;
const component = components[local.use];
if (component?.input !== true) {
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Component '${local.use}' declares no audio input.`);
return null;
}
return `${localPath}.input`;
};
const declaredRoutes = Array.isArray(graph.routes) ? graph.routes : [];
declaredRoutes.forEach((route, index) => {
if (!isObject(route)) return;
const routePath = `${graphPath}.routes[${index}]`;
const from = resolve(route.from, 'from', routePath);
if (from === null) return;
if (from === 'output' || from.endsWith('.output') && route.from === 'output') {
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'output' is sink-only and cannot be a route source.");
return;
}
const isModulation = typeof route.to === 'string' && route.to.includes('.');
const to = resolve(route.to, 'to', routePath);
if (to === null) return;
if (isModulation) {
const [target, property] = to.includes('::') ? to.split('::') : [to, null];
if (property === null) {
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Modulation target '${route.to}' does not name a node property.`);
return;
}
routes.push({ kind: 'modulation', from, to: target, property, depth: route.depth, scope: prefix || null, path: routePath });
} else {
if (to.includes('::')) {
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Audio route target '${route.to}' names a property.`);
return;
}
routes.push({ kind: 'audio', from, to, path: routePath });
}
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);
const mode = AUDIO_RECIPE_MODES.includes(located.graph.mode) ? located.graph.mode : 'oneshot';
const release = Object.hasOwn(located.graph, 'release')
? (durationMilliseconds(located.graph.release) ?? AUDIO_DEFAULT_RELEASE_MS)
: AUDIO_DEFAULT_RELEASE_MS;
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) {
const visiting = new Set();
const done = new Set();
let cycle = null;
const visit = (node, trail) => {
if (cycle) return;
if (visiting.has(node)) { cycle = [...trail, node]; return; }
if (done.has(node)) return;
visiting.add(node);
for (const next of adjacency.get(node) ?? []) visit(next, [...trail, node]);
visiting.delete(node);
done.add(node);
};
for (const node of adjacency.keys()) visit(node, []);
return cycle;
}
function checkGraphLegality(document, soundId, expansion, errors) {
const { nodes, routes, graphPath } = expansion;
const byPath = new Map(nodes.map((node) => [node.path, node]));
const audible = new Map();
const combined = new Map();
const link = (map, from, to) => {
if (!map.has(from)) map.set(from, []);
map.get(from).push(to);
};
const authored = nodes.filter((node) => !node.implicit && node.type !== 'component');
if (authored.length > AUDIO_LIMITS.nodesPerSound) {
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${authored.length} nodes; the limit is ${AUDIO_LIMITS.nodesPerSound}.`);
}
if (routes.length > AUDIO_LIMITS.routesPerSound) {
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${routes.length} routes; the limit is ${AUDIO_LIMITS.routesPerSound}.`);
}
for (const route of routes) {
if (route.from === route.to) {
fail(errors, 'ERR_INVALID_ROUTE', route.path, 'A route cannot connect a node to itself.');
continue;
}
if (route.kind === 'audio') {
const target = byPath.get(route.to);
if (route.to !== 'output') {
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Route target '${route.to}' does not resolve.`); continue; }
const contract = AUDIO_NODE_TYPES[target.type];
const accepts = target.implicit || (target.type === 'component' ? target.acceptsAudio : contract?.acceptsAudio === true);
if (!accepts) { fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node '${route.to}' does not accept audio input.`); continue; }
}
link(audible, route.from, route.to);
link(combined, route.from, route.to);
continue;
}
const target = byPath.get(route.to);
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation target '${route.to}' does not resolve.`); continue; }
const source = byPath.get(route.from);
if (!source) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation source '${route.from}' does not resolve.`); continue; }
const sourceType = source.implicit ? 'gain' : source.type;
if (!AUDIO_MODULATION_SOURCE_TYPES.includes(sourceType)) {
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node type '${sourceType}' cannot drive a modulation route.`);
continue;
}
const permitted = target.type === 'component'
? Object.hasOwn(target.exposes ?? {}, route.property)
: Object.hasOwn(AUDIO_MODULATABLE[target.type] ?? {}, route.property);
if (!permitted) {
fail(errors, 'ERR_UNSUPPORTED_TARGET', route.path, `'${target.type}.${route.property}' is not a modulatable property.`);
continue;
}
link(combined, route.from, route.to);
}
const audioCycle = detectCycle(audible);
if (audioCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Audio route cycle: ${audioCycle.join(' -> ')}.`);
const combinedCycle = detectCycle(combined);
if (!audioCycle && combinedCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Modulation dependency cycle: ${combinedCycle.join(' -> ')}.`);
if (audioCycle) return;
const reaches = (start) => {
const seen = new Set();
const queue = [start];
while (queue.length) {
const current = queue.shift();
if (current === 'output') return true;
if (seen.has(current)) continue;
seen.add(current);
for (const next of audible.get(current) ?? []) queue.push(next);
}
return false;
};
let audiblePath = false;
for (const node of nodes) {
if (node.implicit || node.type === 'component') continue;
const contract = AUDIO_NODE_TYPES[node.type];
if (!contract) continue;
if (contract.acceptsAudio === false && reaches(node.path)) {
if (contract.class === 'control') {
fail(errors, 'ERR_INVALID_ROUTE', node.path, `Control source '${node.path}' reaches audible output.`);
} else {
audiblePath = true;
if (expansion.mode === 'oneshot' && (node.type === 'oscillator' || node.type === 'noise')) {
fail(errors, 'ERR_INDETERMINATE_ONESHOT', graphPath, `Sound '${soundId}' is a oneshot with an unbounded audible path from '${node.path}' (${node.type}).`);
}
}
}
if (contract.acceptsAudio === false) {
for (const route of routes) {
if (route.kind === 'audio' && route.to === node.path) {
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Source node '${node.path}' cannot receive audio input.`);
}
}
}
}
if (!audiblePath) fail(errors, 'ERR_NO_AUDIBLE_PATH', graphPath, `Sound '${soundId}' has no audio path from a source to output.`);
}
/* ------------------------------------------------------------------ *
* Document-level entry point
* ------------------------------------------------------------------ */
function validateAudioSubsystem(document, errors, helpers) {
const audio = document.audio;
if (audio !== undefined) {
if (!isObject(audio)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio', 'audio must be an object.');
else {
for (const field of Object.keys(audio)) {
if (field === 'master') fail(errors, 'ERR_UNKNOWN_FIELD', '$.audio.master', 'audio.master is engine-provided and is reserved for the Phase 3c master-protection contract.');
else if (!['buses', 'recipes'].includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `$.audio.${field}`, `Unrecognized audio field '${field}'.`);
}
if (audio.buses !== undefined) {
if (!isObject(audio.buses)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.buses', 'buses must be an object.');
else for (const [id, bus] of Object.entries(audio.buses)) {
const path = `$.audio.buses.${id}`;
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Bus ID '${id}' is not a valid identifier.`);
if (id === 'master') fail(errors, 'ERR_INVALID_ID', path, "'master' is engine-provided and cannot be declared.");
if (!isObject(bus)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Bus must be an object.'); continue; }
for (const field of Object.keys(bus)) if (!AUDIO_BUS_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized bus field '${field}'.`);
if (Object.hasOwn(bus, 'gain')) {
validateNumericField(document, bus.gain, { min: AUDIO_BUS_GAIN_RANGE.min, max: AUDIO_BUS_GAIN_RANGE.max, valuespec: true }, `${path}.gain`, errors, helpers, null);
}
}
}
if (audio.recipes !== undefined) {
if (!isObject(audio.recipes)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.recipes', 'recipes must be an object.');
else for (const [id, recipe] of Object.entries(audio.recipes)) {
const path = `$.audio.recipes.${id}`;
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Recipe ID '${id}' is not a valid identifier.`);
validateGraphObject(document, recipe, path, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
if (Object.hasOwn(recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(recipe.mode)) {
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
}
if (Object.hasOwn(recipe, 'release')) {
validateDurationField(recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.release`, errors);
}
}
}
}
}
const componentRoot = document.components?.audio;
if (componentRoot !== undefined) {
if (!isObject(componentRoot)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.components.audio', 'components.audio must be an object.');
else for (const [id, component] of Object.entries(componentRoot)) {
const path = `$.components.audio.${id}`;
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Component ID '${id}' is not a valid identifier.`);
if (!isObject(component)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Component must be an object.'); continue; }
const exposed = new Set();
if (component.parameters !== undefined) {
if (!isObject(component.parameters)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.parameters`, 'parameters must be an object.');
else for (const [name, rule] of Object.entries(component.parameters)) {
const rulePath = `${path}.parameters.${name}`;
if (!ID_PATTERN.test(name)) fail(errors, 'ERR_INVALID_ID', rulePath, `Exposed parameter '${name}' is not a valid identifier.`);
if (!isObject(rule)) { fail(errors, 'ERR_SCHEMA_VALIDATION', rulePath, 'Exposed parameter must be an object.'); continue; }
for (const field of Object.keys(rule)) if (!AUDIO_COMPONENT_PARAMETER_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${rulePath}.${field}`, `Unrecognized parameter field '${field}'.`);
if (rule.type !== 'number') fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.type`, "Exposed audio parameters must declare type 'number' in 0.1.");
for (const field of ['default', 'min', 'max']) {
if (Object.hasOwn(rule, field) && !Number.isFinite(rule[field])) fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.${field}`, `${field} must be a finite number.`);
}
if (Number.isFinite(rule.min) && Number.isFinite(rule.max) && rule.min > rule.max) fail(errors, 'ERR_OUT_OF_BOUNDS', `${rulePath}.min`, 'min cannot exceed max.');
exposed.add(name);
}
}
if (Object.hasOwn(component, 'input') && typeof component.input !== 'boolean') {
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.input`, 'input must be a boolean.');
}
validateGraphObject(document, component, path, errors, helpers, {
allowedFields: AUDIO_COMPONENT_FIELDS,
scope: { componentParameters: exposed }
});
}
}
const sounds = document.sounds;
if (sounds === undefined) return;
if (!isObject(sounds)) return fail(errors, 'ERR_SCHEMA_VALIDATION', '$.sounds', 'sounds must be an object.');
for (const [id, sound] of Object.entries(sounds)) {
const path = `$.sounds.${id}`;
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Sound ID '${id}' is not a valid identifier.`);
if (!isObject(sound)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Sound must be an object.'); continue; }
for (const field of Object.keys(sound)) if (!AUDIO_SOUND_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized sound field '${field}'.`);
if (typeof sound.name !== 'string' || sound.name.trim().length === 0 || sound.name.length > 128) {
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.name`, 'Sound name must be a non-empty string of at most 128 characters.');
}
if (sound.tags !== undefined && (!Array.isArray(sound.tags) || sound.tags.length > 16 || sound.tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) {
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.tags`, 'Tags must contain at most 16 strings of at most 32 characters.');
}
if (sound.usage !== undefined && (!Array.isArray(sound.usage) || sound.usage.length === 0 || sound.usage.some((entry) => !AUDIO_SOUND_USAGE.includes(entry)))) {
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.usage`, `usage entries must be among: ${AUDIO_SOUND_USAGE.join(', ')}.`);
}
if (sound.cadence !== undefined && !isObject(sound.cadence)) {
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.cadence`, 'cadence must be an object.');
}
if (sound.bus !== undefined && !isObject(document.audio?.buses?.[sound.bus])) {
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.bus`, `Bus '${sound.bus}' is not declared.`);
}
if (isObject(sound.recipe) && !Object.hasOwn(sound.recipe, 'use')) {
validateGraphObject(document, sound.recipe, `${path}.recipe`, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
if (Object.hasOwn(sound.recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(sound.recipe.mode)) {
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.recipe.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
}
if (Object.hasOwn(sound.recipe, 'release')) {
validateDurationField(sound.recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.recipe.release`, errors);
}
}
const expansion = expandSoundGraph(document, id);
errors.push(...expansion.errors);
if (expansion.nodes.length > 0) checkGraphLegality(document, id, expansion, errors);
}
}
function computeDeterminableEndingBound(expansion, resolvedNodes = null) {
const { nodes, routes, release = AUDIO_DEFAULT_RELEASE_MS } = expansion;
const resolvedMap = resolvedNodes instanceof Map
? resolvedNodes
: Array.isArray(resolvedNodes)
? new Map(resolvedNodes.map((n) => [n.path, n]))
: null;
const nodeMap = new Map(nodes.map((n) => [n.path, n]));
function nodeContribution(path) {
if (path === 'output') return 0;
const node = nodeMap.get(path);
if (!node || node.implicit || node.type === 'component') return 0;
const type = node.type;
const resolved = resolvedMap?.get(path)?.values;
if (type === 'impulse') {
if (resolved && typeof resolved.duration === 'number') return resolved.duration;
return durationMilliseconds(node.spec?.duration ?? '10ms') ?? 10;
}
if (type === 'oscillator' || type === 'noise') {
return Infinity;
}
if (type === 'delay') {
let time, feedback;
if (resolved) {
time = resolved.time ?? 250;
feedback = resolved.feedback ?? 0.2;
} else {
time = durationMilliseconds(node.spec?.time ?? '250ms') ?? 250;
feedback = typeof node.spec?.feedback === 'number' ? node.spec.feedback : 0.2;
}
if (feedback > 0) {
const clampedFeedback = Math.min(0.9999, Math.max(1e-6, feedback));
const multiplier = Math.ceil(Math.log(0.001) / Math.log(clampedFeedback));
return time * multiplier;
}
return time;
}
if (type === 'reverb') {
let predelay, decay;
if (resolved) {
predelay = resolved.predelay ?? 0;
decay = resolved.decay ?? 2000;
} else {
predelay = durationMilliseconds(node.spec?.predelay ?? '0ms') ?? 0;
decay = durationMilliseconds(node.spec?.decay ?? '2s') ?? 2000;
}
return predelay + decay;
}
if (type === 'resonator') {
if (resolved?.modes && Array.isArray(resolved.modes) && resolved.modes.length > 0) {
return Math.max(...resolved.modes.map((m) => m.decay ?? 1000));
}
const modes = node.spec?.modes ?? [];
if (modes.length > 0) {
return Math.max(...modes.map((m) => durationMilliseconds(m.decay ?? '1s') ?? 1000));
}
return 1000;
}
return 0;
}
const audioAdjacency = new Map();
const inDegree = new Map();
const allNodes = new Set();
for (const n of nodes) allNodes.add(n.path);
allNodes.add('output');
for (const n of allNodes) {
audioAdjacency.set(n, []);
inDegree.set(n, 0);
}
for (const route of routes) {
if (route.kind === 'audio') {
if (!audioAdjacency.has(route.from)) audioAdjacency.set(route.from, []);
audioAdjacency.get(route.from).push(route.to);
inDegree.set(route.to, (inDegree.get(route.to) ?? 0) + 1);
}
}
const dist = new Map();
for (const n of allNodes) dist.set(n, -Infinity);
for (const n of nodes) {
if (n.implicit || n.type === 'component') continue;
if (n.type === 'impulse' || n.type === 'oscillator' || n.type === 'noise') {
dist.set(n.path, nodeContribution(n.path));
}
}
const queue = [];
for (const [node, deg] of inDegree.entries()) {
if (deg === 0) queue.push(node);
}
while (queue.length > 0) {
const current = queue.shift();
const currentDist = dist.get(current);
for (const next of audioAdjacency.get(current) ?? []) {
if (currentDist !== -Infinity) {
const nextContrib = nodeContribution(next);
const newDist = currentDist === Infinity ? Infinity : currentDist + nextContrib;
if (newDist > dist.get(next)) dist.set(next, newDist);
}
inDegree.set(next, inDegree.get(next) - 1);
if (inDegree.get(next) === 0) queue.push(next);
}
}
const outDist = dist.get('output');
if (outDist === Infinity) return Infinity;
if (outDist === -Infinity) return release;
return outDist + release;
}
/* 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/visual-contract.js */
// Visual subsystem contract registries, sections 17-19 of the Format
// Specification at revision 0.8. This module is the single place the runtime
// reads the normative vocabularies, ceilings, and target capabilities from, in
// the same role `audio-contract.js` plays for sections 14-16.
//
// Stage 0 scope (pre-slice-4d): vocabularies, the centralized ceiling table,
// the four section 8.1 target families, and the structural surface of the
// `visuals` block. It draws nothing. Slices 4d, 4e, and 4f add the renderer,
// the procedural systems, and the automation/lifecycle/effect execution that
// these registries describe.
// ---------------------------------------------------------------------------
// Vocabularies
// ---------------------------------------------------------------------------
const COORDINATE_SPACES = Object.freeze(['normalized', 'viewport', 'virtual']);
const FIT_MODES = Object.freeze(['contain', 'cover', 'stretch']);
/** Visual System Set 0.1 (17.7, 18.2, 18.4, 18.5). */
const VISUAL_SYSTEM_TYPES = Object.freeze(['graphic', 'particles', 'emitter', 'repeater']);
/** Visual Primitive Set 0.1 (17.9) — closed at fourteen geometry primitives. */
const VISUAL_PRIMITIVE_TYPES = Object.freeze([
'point', 'line', 'polyline', 'polygon', 'rectangle', 'rounded-rectangle',
'ellipse', 'arc', 'ring', 'path', 'bezier', 'spline', 'text', 'group'
]);
/** The fifteenth visual *object* type, which declares no geometry (18.1). */
const VISUAL_OBJECT_TYPES = Object.freeze([...VISUAL_PRIMITIVE_TYPES, 'component']);
/** Visual Behavior Set 0.1 (18.6). */
const VISUAL_BEHAVIOR_TYPES = Object.freeze([
'drift', 'rotate', 'oscillate', 'orbit', 'wander', 'follow-path', 'point-wander',
'pulse', 'twinkle', 'noise-displace', 'face-motion', 'wrap', 'bounce',
'attract', 'repel', 'field-follow', 'morph'
]);
/** Procedural field set (18.7). */
const VISUAL_FIELD_TYPES = Object.freeze([
'directional', 'radial', 'vortex', 'attractor', 'repulsor', 'noise'
]);
/** Placement distribution set (18.3). */
const DISTRIBUTION_TYPES = Object.freeze([
'point', 'uniform', 'line', 'rectangle', 'ellipse', 'ring', 'path', 'grid', 'depth'
]);
/** Safe blend set (17.12). */
const BLEND_MODES = Object.freeze([
'normal', 'add', 'screen', 'multiply', 'overlay', 'lighten', 'darken', 'difference'
]);
/** Object filter vocabulary (17.12). */
const FILTER_TYPES = Object.freeze([
'brightness', 'contrast', 'saturate', 'hue-rotate', 'grayscale', 'sepia', 'invert'
]);
/** Automation curves and loop modes (19.1). */
const AUTOMATION_CURVES = Object.freeze(['step', 'linear', 'exponential', 'smooth']);
const AUTOMATION_MODES = Object.freeze(['absolute', 'offset', 'scale']);
const LOOP_MODES = Object.freeze(['repeat', 'ping-pong']);
const LIFECYCLE_MODES = Object.freeze(['persistent', 'spawned']);
/**
* Fields of the `spawn` container (19.2). Section 17.7 makes each of these
* `ERR_UNKNOWN_FIELD` at the top level of a system, which is what keeps a
* spawned instance's `lifetime` distinct from a particle's.
*/
const SPAWN_FIELDS = Object.freeze(['lifetime', 'release', 'ownership', 'inputs', 'cancelWithScenario']);
// ---------------------------------------------------------------------------
// Camera and post-effects
// ---------------------------------------------------------------------------
/** 19.3. `projection` is authored once: not automatable, not bindable. */
const CAMERA_FIELDS = Object.freeze({
x: { type: 'number' },
y: { type: 'number' },
zoom: { type: 'number', min: 0.01, max: 100, default: 1 },
rotation: { type: 'number', default: 0 },
focalLength: { type: 'number', min: 1, max: 100_000, default: 1000 }
});
const CAMERA_PROJECTIONS = Object.freeze(['orthographic', 'perspective']);
/**
* 19.4, in normative array order. `numeric` carries each parameter's range and
* default; `colors` are authored literals and take no ValueSpec. `passes` is
* the frame budget cost: blur and bloom read back the frame and cost two.
*/
const POST_EFFECTS = Object.freeze({
vignette: {
passes: 1,
numeric: { amount: { min: 0, max: 1, default: 0.5 }, radius: { min: 0, max: 1, default: 0.75 }, softness: { min: 0, max: 1, default: 0.5 } },
colors: { color: '#000000' }
},
scanlines: {
passes: 1,
numeric: { amount: { min: 0, max: 1, default: 0.3 }, spacing: { min: 1, max: 64, default: 3 }, thickness: { min: 0, max: 1, default: 0.5 }, speed: { default: 0 } },
colors: {}
},
grain: {
passes: 1,
numeric: { amount: { min: 0, max: 1, default: 0.15 }, scale: { min: 0.25, max: 16, default: 1 }, speed: { min: 0, max: 60, default: 24 } },
colors: {}
},
'color-adjust': {
passes: 1,
numeric: { brightness: { min: 0, max: 4, default: 1 }, contrast: { min: 0, max: 4, default: 1 }, saturation: { min: 0, max: 4, default: 1 }, hueRotate: { default: 0 } },
colors: {}
},
blur: { passes: 2, numeric: { radius: { min: 0, max: 32, default: 4 } }, colors: {} },
bloom: {
passes: 2,
numeric: { threshold: { min: 0, max: 1, default: 0.7 }, intensity: { min: 0, max: 2, default: 0.6 }, radius: { min: 0, max: 32, default: 8 } },
colors: {}
},
fade: { passes: 1, numeric: { amount: { min: 0, max: 1, default: 0 } }, colors: { color: '#000000' } }
});
const POST_EFFECT_TYPES = Object.freeze(Object.keys(POST_EFFECTS));
/**
* `color-adjust`'s authored parameter names map onto the 17.12 filter
* operations without renaming the authored fields (19.4).
*/
const EFFECT_FILTER_OPERATIONS = Object.freeze({
brightness: 'brightness', contrast: 'contrast', saturation: 'saturate', hueRotate: 'hue-rotate'
});
// ---------------------------------------------------------------------------
// The centralized ceiling table (19.5)
// ---------------------------------------------------------------------------
/**
* Authoring bounds reject an exhibit at import; runtime ceilings shed work by a
* documented deterministic rule and keep the exhibit running. Every aggregate
* value here is provisional until the slice 4h GC6 measurement runs — its
* shape is normative now, its number is not measured.
*/
const VISUAL_LIMITS = Object.freeze({
authoring: Object.freeze({
layers: 16,
declaredSystems: 64,
expandedStaticObjects: 16_384,
groupNesting: 8,
componentNesting: 8,
polygonVertices: 512,
pathCommands: 512,
splinePoints: 256,
gradientStops: 16,
strokeDashEntries: 8,
filtersPerObject: 4,
behaviorsPerObject: 8,
declaredFields: 8,
referencedFieldsPerSystem: 4,
particleCapacity: 4096,
emitterCapacity: 512,
repeaterCount: 1024,
burstEntries: 16,
gridDimension: 256,
trailLength: 128,
maxLinks: 1024,
nearestLinksPerItem: 8,
pairwiseLinkedPopulation: 256,
textCharacters: 256,
postEffectEntries: 4,
effectRadius: 32,
automationTracks: 128,
automationPoints: 2048,
automationPointsPerTrack: 256
}),
runtime: Object.freeze({
liveParticles: 8192,
liveEmittedItems: 2048,
liveSpawnedInstances: 64,
linkSegmentsPerTick: 4096,
trailHistorySamples: 32_768,
fieldEvaluationsPerTick: 32_768,
offscreenBuffersPerFrame: 16,
postEffectPassesPerFrame: 8,
devicePixelRatioCeiling: 2,
backingStoreEdge: 4096,
liveAutomationTracks: 128,
liveAutomationPoints: 2048,
sustainedShedTicks: 120
})
});
/** 19.5: one diagnostic per (code, subject) key per logical second. */
const DIAGNOSTIC_CADENCE_MS = 1000;
// ---------------------------------------------------------------------------
// The four section 8.1 visual target families (19.1)
// ---------------------------------------------------------------------------
const EFFECT_INDEX_PATTERN = /^visuals\.effects\[(\d+)\]\.([A-Za-z][A-Za-z0-9]*)$/;
/**
* Resolve a target path against the four visual target families. Returns a
* capability record, or `null` when the path is not a visual target at all.
*
* A path inside a family that names something undeclared returns
* `{ reason: 'reference' }` so the caller can raise ERR_INVALID_REFERENCE
* rather than ERR_UNSUPPORTED_TARGET; a path that looks visual but is outside
* every family returns `{ reason: 'unsupported' }`. That distinction is what
* trace 4 of 19.7 and trace 14 of 17.16 both check.
*/
function matchVisualTarget(document, path) {
if (typeof path !== 'string' || !path.startsWith('visuals.')) return null;
const visuals = document?.visuals;
const effect = EFFECT_INDEX_PATTERN.exec(path);
if (effect) {
const index = Number(effect[1]);
const entry = Array.isArray(visuals?.effects) ? visuals.effects[index] : undefined;
if (!entry) return { reason: 'reference' };
const definition = POST_EFFECTS[entry.type];
const parameter = definition?.numeric?.[effect[2]];
if (!parameter) return { reason: 'unsupported' };
return {
family: 'visuals.effects', namespace: 'visual-effect', id: `${index}.${effect[2]}`,
index, parameter: effect[2],
spec: { type: 'number', min: parameter.min, max: parameter.max },
default: parameter.default,
stages: { binding: true, automation: true, override: true, modulation: false }
};
}
const parts = path.split('.');
if (parts.length === 3 && parts[1] === 'camera') {
const field = CAMERA_FIELDS[parts[2]];
if (!field) return { reason: 'unsupported' };
return {
family: 'visuals.camera', namespace: 'visual-camera', id: parts[2],
spec: { type: 'number', min: field.min, max: field.max },
default: field.default,
stages: { binding: true, automation: true, override: true, modulation: true }
};
}
if (parts.length === 4 && parts[1] === 'layers') {
if (!ID_PATTERN.test(parts[2]) || !visuals?.layers?.[parts[2]]) return { reason: 'reference' };
if (parts[3] !== 'opacity') return { reason: 'unsupported' };
return {
family: 'visuals.layers', namespace: 'visual-layer', id: parts[2],
spec: { type: 'number', min: 0, max: 1 }, default: 1,
stages: { binding: true, automation: true, override: true, modulation: false }
};
}
if (parts.length === 4 && parts[1] === 'systems') {
if (!ID_PATTERN.test(parts[2]) || !visuals?.systems?.[parts[2]]) return { reason: 'reference' };
if (parts[3] !== 'visible') return { reason: 'unsupported' };
return {
family: 'visuals.systems', namespace: 'visual-system', id: parts[2],
spec: { type: 'boolean' }, default: true,
// Boolean targets take no automation and no modulation stage: those
// stages are absent, not identity hooks (8.1).
stages: { binding: true, automation: false, override: true, modulation: false }
};
}
return { reason: 'unsupported' };
}
/** The authored ValueSpec behind a visual target, or its documented default. */
function visualTargetSource(document, target) {
const visuals = document?.visuals ?? {};
switch (target.namespace) {
case 'visual-camera': return visuals.camera?.[target.id] ?? cameraDefault(visuals, target.id);
case 'visual-layer': return visuals.layers?.[target.id]?.opacity ?? 1;
case 'visual-system': return visuals.systems?.[target.id]?.visible ?? true;
case 'visual-effect': return visuals.effects?.[target.index]?.[target.parameter] ?? target.default;
default: return undefined;
}
}
/**
* 19.3: the camera default is the scene center, so an exhibit that never
* mentions the camera looks identical to one that declares its defaults.
*/
function cameraDefault(visuals, field) {
if (field !== 'x' && field !== 'y') return CAMERA_FIELDS[field].default;
const scene = visuals.scene ?? {};
if (scene.coordinateSpace === 'normalized') return 0.5;
// In `viewport` space the scene rectangle *is* the display rectangle, whose
// size is not known at import. The resolved base is 0 here and slice 4d
// substitutes the live display center for an unauthored camera position.
const extent = field === 'x' ? scene.width : scene.height;
return Number.isFinite(extent) ? extent / 2 : 0;
}
/** Frame pass cost of an authored effect chain (19.4). */
function postEffectPassCount(effects) {
if (!Array.isArray(effects)) return 0;
return effects.reduce((total, entry) => total + (POST_EFFECTS[entry?.type]?.passes ?? 0), 0);
}
/* src/runtime/visual-diagnostics.js */
// The single visual diagnostic cadence of 19.5.
//
// Every resource diagnostic is keyed by (code, subject) and raised at most once
// per logical second. A key counts consecutive shedding ticks; on reaching 120
// the next raise for that key carries a `sustained` detail, which does not
// bypass the rate limit and does not mint a second code. One tick with no shed
// on a key resets both the counter and the rate limit, so a recovery followed
// by a new overload reports promptly instead of being suppressed.
const SUSTAINED_TICKS = VISUAL_LIMITS.runtime.sustainedShedTicks;
class VisualDiagnosticCadence {
constructor(diagnostics, { section = 'visual' } = {}) {
this.diagnostics = diagnostics;
this.section = section;
this.keys = new Map();
this.raised = [];
}
entry(key) {
if (!this.keys.has(key)) this.keys.set(key, { lastRaisedAt: -Infinity, consecutive: 0, seenThisTick: false });
return this.keys.get(key);
}
// Report one shed. Returns the diagnostic raised, or null when the key's
// per-second slot is still occupied.
shed(code, subject, message, logicalMilliseconds, context = {}) {
const key = code + ' ' + subject;
const state = this.entry(key);
state.seenThisTick = true;
state.consecutive += 1;
if (logicalMilliseconds - state.lastRaisedAt < DIAGNOSTIC_CADENCE_MS) return null;
state.lastRaisedAt = logicalMilliseconds;
const sustained = state.consecutive >= SUSTAINED_TICKS;
const text = sustained ? message + ' Sustained for ' + state.consecutive + ' consecutive ticks.' : message;
const record = { code, subject, message: text, sustained, at: logicalMilliseconds, ...context };
this.raised.push(record);
this.diagnostics?.warn(code, text, { section: this.section, objectId: subject, ...context });
return record;
}
// Close a tick: any key that did not shed resets its counter and its slot.
endTick() {
for (const state of this.keys.values()) {
if (state.seenThisTick) state.seenThisTick = false;
else if (state.consecutive !== 0) {
state.consecutive = 0;
state.lastRaisedAt = -Infinity;
}
}
}
// A capability warning, not a resource one: 17.12's conic fallback and
// 19.4's reduced-resolution blur report a renderer fact once per instance for
// the instance's life, and are deliberately outside the per-second cadence.
once(code, subject, message, context = {}) {
const key = 'once ' + code + ' ' + subject;
if (this.keys.has(key)) return null;
this.keys.set(key, { lastRaisedAt: Infinity, consecutive: 0, seenThisTick: false });
const record = { code, subject, message, sustained: false, at: null, ...context };
this.raised.push(record);
this.diagnostics?.warn(code, message, { section: this.section, objectId: subject, ...context });
return record;
}
clear() { this.keys.clear(); this.raised.length = 0; }
}
/* src/runtime/visual-math.js */
// 2D affine matrices, angle conventions, and color arithmetic for the visual
// subsystem (sections 17.2, 17.6, 17.11, 19.3 of the Format Specification at
// revision 0.8).
//
// Matrices are the six-element affine form [a, b, c, d, e, f]:
//
// x' = a * x + c * y + e
// y' = b * x + d * y + f
//
// `multiply(m, n)` composes so that `n` is applied to the point first, which is
// the right-to-left reading the transform model of 17.11 and the camera chain
// of 19.3 are both written in.
const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
const DEGREES_TO_RADIANS = Math.PI / 180;
function multiply(m, n) {
return [
m[0] * n[0] + m[2] * n[1],
m[1] * n[0] + m[3] * n[1],
m[0] * n[2] + m[2] * n[3],
m[1] * n[2] + m[3] * n[3],
m[0] * n[4] + m[2] * n[5] + m[4],
m[1] * n[4] + m[3] * n[5] + m[5]
];
}
function compose(...matrices) {
return matrices.reduce((total, matrix) => multiply(total, matrix), IDENTITY);
}
function translation(tx, ty) { return [1, 0, 0, 1, tx, ty]; }
function scaling(sx, sy) { return [sx, 0, 0, sy, 0, 0]; }
/** 17.2: degrees, `0` along `+x`, positive angles turning toward `+y`. */
function rotation(degrees) {
const radians = degrees * DEGREES_TO_RADIANS;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
return [cos, sin, -sin, cos, 0, 0];
}
/** 17.11: `skew` is authored in degrees on each axis. */
function skewing(xDegrees, yDegrees) {
return [1, Math.tan(yDegrees * DEGREES_TO_RADIANS), Math.tan(xDegrees * DEGREES_TO_RADIANS), 1, 0, 0];
}
function apply(matrix, x, y) {
return [matrix[0] * x + matrix[2] * y + matrix[4], matrix[1] * x + matrix[3] * y + matrix[5]];
}
/** The uniform factor an axis-less appearance dimension takes (19.3). */
function uniformFactor(matrix) {
return Math.sqrt(Math.abs(matrix[0] * matrix[3] - matrix[1] * matrix[2]));
}
/**
* 17.11: `M_local = T(position + translate) x T(origin) x R x K x S x T(-origin)`.
* The order is normative even where a particular object would not notice.
*/
function localMatrix({ position = { x: 0, y: 0 }, translate = { x: 0, y: 0 }, rotation: angle = 0, skew = { x: 0, y: 0 }, scale = { x: 1, y: 1 }, origin = { x: 0, y: 0 } } = {}) {
return compose(
translation(position.x + translate.x, position.y + translate.y),
translation(origin.x, origin.y),
rotation(angle),
skewing(skew.x, skew.y),
scaling(scale.x, scale.y),
translation(-origin.x, -origin.y)
);
}
/**
* 17.9: the directed sweep of an `arc` or `ring`. The `360` bound is checked on
* the authored numbers before any normalization, so `0 -> 720` is rejected
* rather than folded into a full turn.
*/
function directedSweep(startAngle, endAngle, direction = 'clockwise', path = '$') {
const delta = direction === 'counter-clockwise' ? startAngle - endAngle : endAngle - startAngle;
if (!Number.isFinite(delta)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Arc angles must be finite.', path);
if (Math.abs(delta) > 360) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc sweep may not exceed 360 degrees.', path);
const sign = direction === 'counter-clockwise' ? -1 : 1;
if (delta === 0) return { sweep: 0, sign };
// Normalize the *signed* delta, so 350 -> 10 clockwise is a 20-degree sweep
// through zero rather than a 340-degree one the long way round.
const magnitude = Math.abs(delta) === 360 ? 360 : ((delta % 360) + 360) % 360;
return { sweep: magnitude, sign };
}
// ---------------------------------------------------------------------------
// Color (section 2: `#rgb`, `#rrggbb`, `#rrggbbaa`, or a CSS color keyword)
// ---------------------------------------------------------------------------
const KEYWORDS = Object.freeze({
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff',
beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000', blanchedalmond: '#ffebcd', blue: '#0000ff',
blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c',
cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b', darkgray: '#a9a9a9',
darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b', darkmagenta: '#8b008b', darkolivegreen: '#556b2f',
darkorange: '#ff8c00', darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f',
darkslateblue: '#483d8b', darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1',
darkviolet: '#9400d3', deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22', fuchsia: '#ff00ff',
gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', gray: '#808080',
green: '#008000', greenyellow: '#adff2f', grey: '#808080', honeydew: '#f0fff0', hotpink: '#ff69b4',
indianred: '#cd5c5c', indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa',
lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6', lightcoral: '#f08080',
lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3', lightgreen: '#90ee90',
lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa',
lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de', lightyellow: '#ffffe0',
lime: '#00ff00', limegreen: '#32cd32', linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000',
mediumaquamarine: '#66cdaa', mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db',
mediumseagreen: '#3cb371', mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc',
mediumvioletred: '#c71585', midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000', olivedrab: '#6b8e23',
orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6', palegoldenrod: '#eee8aa', palegreen: '#98fb98',
paleturquoise: '#afeeee', palevioletred: '#db7093', papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f',
pink: '#ffc0cb', plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399',
red: '#ff0000', rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513', salmon: '#fa8072',
sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee', sienna: '#a0522d', silver: '#c0c0c0',
skyblue: '#87ceeb', slateblue: '#6a5acd', slategray: '#708090', slategrey: '#708090', snow: '#fffafa',
springgreen: '#00ff7f', steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8',
tomato: '#ff6347', transparent: '#00000000', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3',
white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32'
});
const HEX3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i;
const HEX6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
const HEX8 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
/**
* Parse a section 2 `color` into `{ r, g, b, a }` with components on `0..255`
* and alpha on `0..1`. Anything outside the four documented forms is
* ERR_TYPE_MISMATCH: the strict coercion ban of section 2 leaves no room for
* guessing, and depth fog (17.6) needs exact components to be renderer-independent.
*/
function parseColor(value, path = '$') {
if (typeof value !== 'string' || value.length === 0) {
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Expected a color, received ${typeof value}.`, path);
}
const text = KEYWORDS[value.toLowerCase()] ?? value;
let match = HEX8.exec(text);
if (match) return { r: parseInt(match[1], 16), g: parseInt(match[2], 16), b: parseInt(match[3], 16), a: parseInt(match[4], 16) / 255 };
match = HEX6.exec(text);
if (match) return { r: parseInt(match[1], 16), g: parseInt(match[2], 16), b: parseInt(match[3], 16), a: 1 };
match = HEX3.exec(text);
if (match) return { r: parseInt(match[1] + match[1], 16), g: parseInt(match[2] + match[2], 16), b: parseInt(match[3] + match[3], 16), a: 1 };
throw new RuntimeFault('ERR_TYPE_MISMATCH', `'${value}' is not a #rgb, #rrggbb, #rrggbbaa, or CSS keyword color.`, path);
}
function isColorLiteral(value) {
try { parseColor(value); return true; } catch { return false; }
}
function formatColor({ r, g, b, a }) {
const byte = (component) => Math.max(0, Math.min(255, Math.round(component))).toString(16).padStart(2, '0');
const hex = `#${byte(r)}${byte(g)}${byte(b)}`;
return a >= 1 ? hex : `${hex}${byte(a * 255)}`;
}
/**
* 17.6: depth fog blends per component in non-premultiplied sRGB with no
* linearization step. Alpha is never fogged, and the fog color's own alpha is
* ignored, so an object keeps exactly the transparency it was authored with.
*/
function fogColor(color, fog, fraction) {
if (fraction <= 0) return color;
const f = Math.min(1, fraction);
return {
r: color.r + (fog.r - color.r) * f,
g: color.g + (fog.g - color.g) * f,
b: color.b + (fog.b - color.b) * f,
a: color.a
};
}
/** 18.2: a `color` life ramp interpolates in sRGB component space including alpha. */
function mixColor(from, to, amount) {
return {
r: from.r + (to.r - from.r) * amount,
g: from.g + (to.g - from.g) * amount,
b: from.b + (to.b - from.b) * amount,
a: from.a + (to.a - from.a) * amount
};
}
/* src/runtime/visual-geometry.js */
// Local-space geometry for Visual Primitive Set 0.1 (17.9, 17.13).
//
// Every primitive is reduced to the same shape: a list of subpaths whose
// segments are straight lines and cubic Beziers. Curves are represented rather
// than flattened, because an affine map carries a cubic Bezier to a cubic
// Bezier exactly, which is what lets the engine transform geometry through the
// full composition chain of 19.3 without approximating it.
//
// Local extents and anchors follow the table of 17.9: a rectangle's top-left
// corner sits at the origin, circular primitives are centered on it, and a
// path's commands carry their own local coordinates.
/** The circular Bezier constant: 4/3 * tan(pi/8). */
const KAPPA = 0.5522847498307936;
function subpath(start) {
return { start, segments: [], closed: false };
}
function lineTo(current, to) {
current.segments.push({ type: 'line', to });
}
function cubicTo(current, c1, c2, to) {
current.segments.push({ type: 'cubic', c1, c2, to });
}
/** One elliptical arc segment, center-parameterized, as cubic Beziers. */
function arcSegments(current, cx, cy, rx, ry, startDegrees, sweepDegrees, sign, rotationDegrees = 0) {
if (sweepDegrees === 0) return;
const pieces = Math.max(1, Math.ceil(sweepDegrees / 90));
const step = (sweepDegrees / pieces) * sign * DEGREES_TO_RADIANS;
const phi = rotationDegrees * DEGREES_TO_RADIANS;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const place = (angle, radial) => {
const x = radial ? rx * Math.cos(angle) : -rx * Math.sin(angle);
const y = radial ? ry * Math.sin(angle) : ry * Math.cos(angle);
const px = x * cosPhi - y * sinPhi;
const py = x * sinPhi + y * cosPhi;
return radial ? [cx + px, cy + py] : [px, py];
};
let theta = startDegrees * DEGREES_TO_RADIANS;
for (let piece = 0; piece < pieces; piece += 1) {
const next = theta + step;
const alpha = (4 / 3) * Math.tan((next - theta) / 4);
const p0 = place(theta, true);
const p1 = place(next, true);
const d0 = place(theta, false);
const d1 = place(next, false);
cubicTo(current, [p0[0] + alpha * d0[0], p0[1] + alpha * d0[1]], [p1[0] - alpha * d1[0], p1[1] - alpha * d1[1]], p1);
theta = next;
}
}
function ellipseSubpath(cx, cy, rx, ry) {
const path = subpath([cx + rx, cy]);
arcSegments(path, cx, cy, rx, ry, 0, 360, 1);
path.closed = true;
return path;
}
function radiusPair(radius, path) {
if (typeof radius === 'number') return [radius, radius];
if (radius && typeof radius === 'object') return [radius.x ?? 0, radius.y ?? 0];
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'radius must be a number or {x, y}.', path);
}
// Points carry three components. A point's own `z` is not a sortable depth
// (17.6); it is carried so the engine can project each point with its own
// perspective factor, which is the one effect 17.6 gives it.
function point(value, path) {
if (!value || typeof value !== 'object') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Expected a point.', path);
return [value.x ?? 0, value.y ?? 0, value.z ?? 0];
}
// ---------------------------------------------------------------------------
// Splines (17.13)
// ---------------------------------------------------------------------------
/**
* 17.13: `catmull-rom` is a uniform cardinal spline. For the segment between
* `p1` and `p2` with neighbours `p0` and `p3`, tangents are
* `m1 = tension * (p2 - p0)` and `m2 = tension * (p3 - p1)`, and the cubic
* Hermite form converts to the Bezier control points below.
*/
function catmullRomSegments(points, closed, tension) {
const count = points.length;
const at = (index) => {
if (closed) return points[((index % count) + count) % count];
return points[Math.max(0, Math.min(count - 1, index))];
};
const path = subpath(points[0].slice(0, 3));
const last = closed ? count : count - 1;
for (let index = 0; index < last; index += 1) {
const p0 = at(index - 1);
const p1 = at(index);
const p2 = at(index + 1);
const p3 = at(index + 2);
const m1 = [0, 1, 2].map((axis) => tension * ((p2[axis] ?? 0) - (p0[axis] ?? 0)));
const m2 = [0, 1, 2].map((axis) => tension * ((p3[axis] ?? 0) - (p1[axis] ?? 0)));
cubicTo(path,
[0, 1, 2].map((axis) => (p1[axis] ?? 0) + m1[axis] / 3),
[0, 1, 2].map((axis) => (p2[axis] ?? 0) - m2[axis] / 3),
[(p2[0] ?? 0), (p2[1] ?? 0), (p2[2] ?? 0)]);
}
path.closed = closed;
return path;
}
function bezierSpline(points, closed, path) {
if ((points.length - 1) % 3 !== 0 || points.length < 4) {
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A bezier-mode spline needs 3n + 1 points.', path);
}
const result = subpath(points[0].slice(0, 3));
for (let index = 1; index + 2 < points.length; index += 3) {
cubicTo(result, points[index].slice(0, 3), points[index + 1].slice(0, 3), points[index + 2].slice(0, 3));
}
// 17.13: a closed bezier spline closes with a straight line; the point list
// carries no control points for a closing segment and inventing two would be
// authoring geometry the author did not write.
if (closed) {
lineTo(result, points[0].slice(0, 3));
result.closed = true;
}
return result;
}
function linearSpline(points, closed) {
const result = subpath(points[0].slice(0, 3));
for (let index = 1; index < points.length; index += 1) lineTo(result, points[index].slice(0, 3));
if (closed) result.closed = true;
return result;
}
// ---------------------------------------------------------------------------
// Path commands (17.13)
// ---------------------------------------------------------------------------
/** Endpoint-parameterized elliptical arc, converted to center parameterization. */
function endpointArc(current, from, command, path) {
const [rxRaw, ryRaw] = radiusPair(command.radius, path);
const rx = Math.abs(rxRaw);
const ry = Math.abs(ryRaw);
const to = point(command.to, path);
if (rx <= 0 || ry <= 0) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc radius component must be above zero.', path);
const phi = (command.rotation ?? 0) * DEGREES_TO_RADIANS;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const dx = (from[0] - to[0]) / 2;
const dy = (from[1] - to[1]) / 2;
const x1 = cosPhi * dx + sinPhi * dy;
const y1 = -sinPhi * dx + cosPhi * dy;
let rxs = rx * rx;
let rys = ry * ry;
const lambda = (x1 * x1) / rxs + (y1 * y1) / rys;
let scaledRx = rx;
let scaledRy = ry;
if (lambda > 1) {
const factor = Math.sqrt(lambda);
scaledRx = rx * factor;
scaledRy = ry * factor;
rxs = scaledRx * scaledRx;
rys = scaledRy * scaledRy;
}
const numerator = Math.max(0, rxs * rys - rxs * y1 * y1 - rys * x1 * x1);
const denominator = rxs * y1 * y1 + rys * x1 * x1;
const coefficient = (command.largeArc === !!command.sweep ? -1 : 1) * Math.sqrt(denominator === 0 ? 0 : numerator / denominator);
const cx1 = coefficient * ((scaledRx * y1) / scaledRy);
const cy1 = coefficient * (-(scaledRy * x1) / scaledRx);
const cx = cosPhi * cx1 - sinPhi * cy1 + (from[0] + to[0]) / 2;
const cy = sinPhi * cx1 + cosPhi * cy1 + (from[1] + to[1]) / 2;
const angle = (ux, uy, vx, vy) => {
const dot = ux * vx + uy * vy;
const length = Math.hypot(ux, uy) * Math.hypot(vx, vy);
const sign = ux * vy - uy * vx < 0 ? -1 : 1;
return sign * Math.acos(Math.max(-1, Math.min(1, dot / (length || 1))));
};
const start = angle(1, 0, (x1 - cx1) / scaledRx, (y1 - cy1) / scaledRy);
let delta = angle((x1 - cx1) / scaledRx, (y1 - cy1) / scaledRy, (-x1 - cx1) / scaledRx, (-y1 - cy1) / scaledRy);
if (!command.sweep && delta > 0) delta -= 2 * Math.PI;
if (command.sweep && delta < 0) delta += 2 * Math.PI;
arcSegments(current, cx, cy, scaledRx, scaledRy, start / DEGREES_TO_RADIANS, Math.abs(delta) / DEGREES_TO_RADIANS, Math.sign(delta) || 1, command.rotation ?? 0);
}
function pathSubpaths(commands, path = '$') {
if (!Array.isArray(commands) || commands.length === 0) throw new RuntimeFault('ERR_INVALID_PATH', 'A path needs at least one command.', path);
if (commands[0].op !== 'move') throw new RuntimeFault('ERR_INVALID_PATH', 'A path must begin with a move command.', path);
const subpaths = [];
let current = null;
let cursor = [0, 0];
let previousWasClose = false;
for (const command of commands) {
switch (command.op) {
case 'move': {
cursor = point(command.to, path);
current = subpath(cursor);
subpaths.push(current);
previousWasClose = false;
break;
}
case 'line': {
cursor = point(command.to, path);
lineTo(current, cursor);
previousWasClose = false;
break;
}
case 'quadratic': {
const control = point(command.c, path);
const to = point(command.to, path);
// Degree-elevate the quadratic so every stored curve is a cubic.
cubicTo(current,
[cursor[0] + (2 / 3) * (control[0] - cursor[0]), cursor[1] + (2 / 3) * (control[1] - cursor[1])],
[to[0] + (2 / 3) * (control[0] - to[0]), to[1] + (2 / 3) * (control[1] - to[1])],
to);
cursor = to;
previousWasClose = false;
break;
}
case 'cubic': {
const to = point(command.to, path);
cubicTo(current, point(command.c1, path), point(command.c2, path), to);
cursor = to;
previousWasClose = false;
break;
}
case 'arc': {
endpointArc(current, cursor, command, path);
cursor = point(command.to, path);
previousWasClose = false;
break;
}
case 'close': {
if (current === null || current.closed || previousWasClose) throw new RuntimeFault('ERR_INVALID_PATH', 'close has no open subpath.', path);
current.closed = true;
cursor = current.start.slice();
previousWasClose = true;
break;
}
default:
throw new RuntimeFault('ERR_INVALID_PATH', `Unknown path command '${command.op}'.`, path);
}
}
return subpaths;
}
// ---------------------------------------------------------------------------
// Primitives
// ---------------------------------------------------------------------------
/**
* Build the local-space subpaths of one visual object. `point`, `text`,
* `group`, and `component` have no path geometry and return an empty list;
* the engine draws them by their own rules.
*/
function primitiveSubpaths(object, path = '$') {
switch (object.type) {
case 'point':
case 'text':
case 'group':
case 'component':
return [];
case 'line': {
const result = subpath([0, 0]);
lineTo(result, point(object.to, path));
return [result];
}
case 'bezier': {
const result = subpath([0, 0]);
cubicTo(result, point(object.c1, path), point(object.c2, path), point(object.to, path));
return [result];
}
case 'polyline':
case 'polygon': {
const points = (object.points ?? []).map((entry) => point(entry, path));
if (points.length < (object.type === 'polygon' ? 3 : 2)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `${object.type} needs more points.`, path);
const result = linearSpline(points, object.type === 'polygon');
return [result];
}
case 'rectangle': {
const width = object.size?.width ?? 0;
const height = object.size?.height ?? 0;
const result = subpath([0, 0]);
lineTo(result, [width, 0]);
lineTo(result, [width, height]);
lineTo(result, [0, height]);
result.closed = true;
return [result];
}
case 'rounded-rectangle': {
const width = object.size?.width ?? 0;
const height = object.size?.height ?? 0;
const limit = Math.min(width, height) / 2;
const raw = object.radius;
const corners = typeof raw === 'number'
? { topLeft: raw, topRight: raw, bottomRight: raw, bottomLeft: raw }
: { topLeft: raw?.topLeft ?? 0, topRight: raw?.topRight ?? 0, bottomRight: raw?.bottomRight ?? 0, bottomLeft: raw?.bottomLeft ?? 0 };
const clampRadius = (value) => Math.max(0, Math.min(limit, value));
const tl = clampRadius(corners.topLeft);
const tr = clampRadius(corners.topRight);
const br = clampRadius(corners.bottomRight);
const bl = clampRadius(corners.bottomLeft);
const result = subpath([tl, 0]);
lineTo(result, [width - tr, 0]);
if (tr > 0) cubicTo(result, [width - tr + tr * KAPPA, 0], [width, tr - tr * KAPPA], [width, tr]);
lineTo(result, [width, height - br]);
if (br > 0) cubicTo(result, [width, height - br + br * KAPPA], [width - br + br * KAPPA, height], [width - br, height]);
lineTo(result, [bl, height]);
if (bl > 0) cubicTo(result, [bl - bl * KAPPA, height], [0, height - bl + bl * KAPPA], [0, height - bl]);
lineTo(result, [0, tl]);
if (tl > 0) cubicTo(result, [0, tl - tl * KAPPA], [tl - tl * KAPPA, 0], [tl, 0]);
result.closed = true;
return [result];
}
case 'ellipse': {
const [rx, ry] = radiusPair(object.radius, path);
return [ellipseSubpath(0, 0, rx, ry)];
}
case 'arc': {
const [rx, ry] = radiusPair(object.radius, path);
const { sweep, sign } = directedSweep(object.startAngle ?? 0, object.endAngle ?? 0, object.direction ?? 'clockwise', path);
const startDegrees = object.startAngle ?? 0;
const result = subpath([rx * Math.cos(startDegrees * DEGREES_TO_RADIANS), ry * Math.sin(startDegrees * DEGREES_TO_RADIANS)]);
arcSegments(result, 0, 0, rx, ry, startDegrees, sweep, sign);
return [result];
}
case 'ring': {
const [rx, ry] = radiusPair(object.radius, path);
const inner = object.innerRadius ?? 0;
if (inner >= Math.min(rx, ry)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'innerRadius must be less than radius.', path);
const startDegrees = object.startAngle ?? 0;
const endDegrees = object.endAngle ?? 360;
const { sweep, sign } = directedSweep(startDegrees, endDegrees, object.direction ?? 'clockwise', path);
const scaleInnerX = inner;
const scaleInnerY = inner * (ry / (rx || 1));
if (sweep === 360) {
// A full annulus is two closed rings; the even-odd or nonzero rule of
// the fill leaves the hole, so the inner ring runs the other way.
const outer = ellipseSubpath(0, 0, rx, ry);
const hole = subpath([scaleInnerX, 0]);
arcSegments(hole, 0, 0, scaleInnerX, scaleInnerY, 0, 360, -1);
hole.closed = true;
return inner > 0 ? [outer, hole] : [outer];
}
const endAngle = startDegrees + sweep * sign;
const result = subpath([rx * Math.cos(startDegrees * DEGREES_TO_RADIANS), ry * Math.sin(startDegrees * DEGREES_TO_RADIANS)]);
arcSegments(result, 0, 0, rx, ry, startDegrees, sweep, sign);
lineTo(result, [scaleInnerX * Math.cos(endAngle * DEGREES_TO_RADIANS), scaleInnerY * Math.sin(endAngle * DEGREES_TO_RADIANS)]);
arcSegments(result, 0, 0, scaleInnerX, scaleInnerY, endAngle, sweep, -sign);
result.closed = true;
return [result];
}
case 'path':
return pathSubpaths(object.commands, path);
case 'spline': {
const points = (object.points ?? []).map((entry) => point(entry, path));
if (points.length < 2) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A spline needs at least 2 points.', path);
const mode = object.mode ?? 'catmull-rom';
if (mode === 'linear') return [linearSpline(points, object.closed === true)];
if (mode === 'bezier') return [bezierSpline(points, object.closed === true, path)];
return [catmullRomSegments(points, object.closed === true, object.tension ?? 0.5)];
}
default:
throw new RuntimeFault('ERR_INVALID_PRIMITIVE_TYPE', `Unknown visual primitive '${object.type}'.`, path);
}
}
/** Every point a subpath list touches, for bounding-box work (17.12 fallback axis). */
function subpathPoints(subpaths) {
const points = [];
for (const path of subpaths) {
points.push(path.start);
for (const segment of path.segments) {
if (segment.type === 'cubic') points.push(segment.c1, segment.c2);
points.push(segment.to);
}
}
return points;
}
function boundingBox(subpaths) {
const points = subpathPoints(subpaths);
if (points.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
for (const [x, y] of points) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
/* src/runtime/visual-validation.js */
// Structural and semantic validation of the `visuals` block, sections 17-19 at
// Format Specification revision 0.8.
//
// Stage 0 scope (pre-slice-4d). This module validates the *shared* surface that
// the schema, the target resolver, and every later visual slice all depend on:
// the `visuals` container and its allowed fields, the scene model, layers, the
// common system fields and the `spawn` container, procedural fields, the
// post-effect chain, and visual automation with its two declaration scopes and
// its automatable registry. Type-specific system fields — a particle system's
// emission block, an emitter's bursts, a repeater's distribution — are carried
// through unvalidated and are tightened by slices 4d, 4e, and 4f, which is
// where the runtime that reads them lands.
const AUTHORING = VISUAL_LIMITS.authoring;
const VISUALS_FIELDS = new Set(['scene', 'layers', 'systems', 'fields', 'camera', 'effects', 'automation']);
const SCENE_FIELDS = new Set(['coordinateSpace', 'width', 'height', 'fit', 'background', 'depthFog']);
const DEPTH_FOG_FIELDS = new Set(['color', 'near', 'far', 'density']);
const LAYER_FIELDS = new Set(['opacity', 'blend', 'visible', 'parallax']);
const CAMERA_ALLOWED = new Set([...Object.keys(CAMERA_FIELDS), 'projection']);
const COMMON_SYSTEM_FIELDS = new Set(['type', 'layer', 'visible', 'lifecycle', 'automation', 'spawn']);
const SPAWN_ONLY_AT_TOP_LEVEL = new Set(['release', 'ownership', 'inputs', 'cancelWithScenario']);
/** 17.7: `lifetime` at a system's top level is the per-item duration, and only these two types have one. */
const TYPES_WITH_ITEM_LIFETIME = new Set(['particles', 'emitter']);
const TRACK_FIELDS = new Set(['target', 'mode', 'interpolation', 'loop', 'points']);
const FIELD_COMMON = new Set(['type', 'bounds', 'enabled']);
const FIELD_TYPE_FIELDS = Object.freeze({
directional: ['direction', 'strength'],
radial: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
vortex: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
attractor: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
repulsor: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
noise: ['scale', 'speed', 'octaves', 'persistence', 'amplitude', 'mode', 'direction', 'center', 'size']
});
/** Numeric object properties a `graphic` system's automation may address (19.1). */
const GRAPHIC_NUMERIC_PROPERTIES = new Set([
'position.x', 'position.y', 'z',
'transform.translate.x', 'transform.translate.y', 'transform.translate.z',
'transform.rotation', 'transform.scale.x', 'transform.scale.y',
'transform.skew.x', 'transform.skew.y', 'transform.origin.x', 'transform.origin.y',
'style.opacity', 'style.strokeWidth', 'style.pointSize', 'style.strokeDashOffset', 'style.blur',
'size.width', 'size.height', 'radius', 'innerRadius', 'startAngle', 'endAngle', 'tension'
]);
const POINT_PROPERTY = /^points\[\d+\]\.[xyz]$/;
/** 19.1: the automatable properties of a procedural system. A repeater has none. */
const SYSTEM_AUTOMATABLE = Object.freeze({
particles: new Set(['rate', 'position.x', 'position.y', 'acceleration.x', 'acceleration.y', 'acceleration.z', 'drag']),
emitter: new Set(['rate', 'position.x', 'position.y', 'acceleration.x', 'acceleration.y', 'acceleration.z', 'drag']),
repeater: new Set()
});
const EFFECT_INDEX = /^effects\[(\d+)\]\.([A-Za-z][A-Za-z0-9]*)$/;
function isColor(value) {
return typeof value === 'string' && value.length > 0;
}
function validateVisualSubsystem(document, errors, helpers) {
const { validateValueSpec, pushError } = helpers;
const fail = (code, path, message) => pushError(errors, code, path, message);
const visuals = document.visuals;
if (visuals === undefined) return;
if (!isRecord(visuals)) return fail('ERR_SCHEMA_VALIDATION', '$.visuals', 'visuals must be an object.');
for (const field of Object.keys(visuals)) {
if (!VISUALS_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.${field}`, `Unrecognized visuals field '${field}'.`);
}
const scene = validateScene(document, visuals.scene, errors, helpers);
const layers = validateLayers(document, visuals.layers, errors, helpers);
validateCamera(document, visuals.camera, errors, helpers);
const effects = validateEffects(document, visuals.effects, errors, helpers);
const fields = validateFields(document, visuals.fields, errors, helpers);
const systems = validateSystems(document, visuals.systems, layers, fields, errors, helpers);
const context = { scene, layers, effects, systems };
let tracks = 0;
let points = 0;
const count = (result) => { tracks += result.tracks; points += result.points; };
count(validateAutomationArray(document, visuals.automation, '$.visuals.automation', { scope: 'exhibit', context }, errors, helpers));
for (const [id, system] of Object.entries(isRecord(visuals.systems) ? visuals.systems : {})) {
if (!isRecord(system)) continue;
count(validateAutomationArray(document, system.automation, `$.visuals.systems.${id}.automation`, { scope: 'system', systemId: id, system, context }, errors, helpers));
}
// 19.1: the authoring bound counts declared records once per declaration.
// The live budget of the same numbers is a runtime check at spawn time.
if (tracks > AUTHORING.automationTracks) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals', `Declared automation tracks (${tracks}) exceed the ${AUTHORING.automationTracks} authoring bound.`);
if (points > AUTHORING.automationPoints) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals', `Declared automation points (${points}) exceed the ${AUTHORING.automationPoints} authoring bound.`);
}
// ---------------------------------------------------------------------------
function validateScene(document, scene, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (scene === undefined) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene', 'visuals.scene is required when visuals is present.');
return {};
}
if (!isRecord(scene)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene', 'scene must be an object.');
return {};
}
for (const field of Object.keys(scene)) {
if (!SCENE_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.scene.${field}`, `Unrecognized scene field '${field}'.`);
}
const space = scene.coordinateSpace ?? 'virtual';
if (!COORDINATE_SPACES.includes(space)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.coordinateSpace', `Unsupported coordinate space '${scene.coordinateSpace}'.`);
const virtual = space === 'virtual';
for (const axis of ['width', 'height']) {
const value = scene[axis];
if (!virtual && value !== undefined) fail('ERR_UNKNOWN_FIELD', `$.visuals.scene.${axis}`, `${axis} is only declared in the 'virtual' coordinate space.`);
else if (virtual && value === undefined) fail('ERR_SCHEMA_VALIDATION', `$.visuals.scene.${axis}`, `${axis} is required in the 'virtual' coordinate space.`);
else if (virtual && (!Number.isFinite(value) || value < 1 || value > 16_384)) fail('ERR_OUT_OF_BOUNDS', `$.visuals.scene.${axis}`, `${axis} must be between 1 and 16384.`);
}
// 17.4 (V17): in `viewport` an absent `fit` is accepted and takes no default;
// only an explicitly authored non-`stretch` value is rejected.
if (scene.fit !== undefined) {
if (!FIT_MODES.includes(scene.fit)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.fit', `Unsupported fit '${scene.fit}'.`);
else if (space === 'viewport' && scene.fit !== 'stretch') fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.fit', "An explicit fit other than 'stretch' has no meaning in the 'viewport' coordinate space.");
}
if (scene.background !== undefined && !isColor(scene.background)) fail('ERR_TYPE_MISMATCH', '$.visuals.scene.background', 'background must be a color.');
if (scene.depthFog !== undefined) {
const fog = scene.depthFog;
if (!isRecord(fog)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog', 'depthFog must be an object.');
else {
for (const field of Object.keys(fog)) if (!DEPTH_FOG_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.scene.depthFog.${field}`, `Unrecognized depthFog field '${field}'.`);
if (!isColor(fog.color)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog.color', 'depthFog.color is required.');
if (!Number.isFinite(fog.far)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog.far', 'depthFog.far is required.');
else if (Number.isFinite(fog.near ?? 0) && fog.far <= (fog.near ?? 0)) fail('ERR_INVALID_RANGE_ORDER', '$.visuals.scene.depthFog.far', 'depthFog.far must exceed depthFog.near.');
if (fog.density !== undefined && (!Number.isFinite(fog.density) || fog.density < 0 || fog.density > 1)) fail('ERR_OUT_OF_BOUNDS', '$.visuals.scene.depthFog.density', 'depthFog.density must be between 0 and 1.');
}
}
return scene;
}
function validateLayers(document, layers, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (layers === undefined) return null;
if (!isRecord(layers)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.layers', 'layers must be an object.');
return null;
}
const ids = Object.keys(layers);
// 17.5 (V17): a present-but-empty layer map is rejected; an exhibit that
// wants the implicit layer omits the key entirely.
if (ids.length === 0) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.layers', 'An empty layer map is not a layer set; omit the key for the implicit layer.');
return null;
}
if (ids.length > AUTHORING.layers) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.layers', `At most ${AUTHORING.layers} layers.`);
for (const [id, layer] of Object.entries(layers)) {
const path = `$.visuals.layers.${id}`;
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', path, `Layer ID '${id}' is invalid.`);
if (!isRecord(layer)) { fail('ERR_SCHEMA_VALIDATION', path, 'Layer must be an object.'); continue; }
for (const field of Object.keys(layer)) if (!LAYER_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized layer field '${field}'.`);
if (layer.opacity !== undefined) validateValueSpec(document, layer.opacity, `${path}.opacity`, errors);
if (layer.visible !== undefined) validateValueSpec(document, layer.visible, `${path}.visible`, errors);
if (layer.blend !== undefined && !BLEND_MODES.includes(layer.blend)) fail('ERR_SCHEMA_VALIDATION', `${path}.blend`, `Unsupported blend mode '${layer.blend}'.`);
if (layer.parallax !== undefined && !Number.isFinite(layer.parallax)) fail('ERR_TYPE_MISMATCH', `${path}.parallax`, 'parallax must be a finite number.');
}
return new Set(ids);
}
function validateCamera(document, camera, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (camera === undefined) return;
if (!isRecord(camera)) return fail('ERR_SCHEMA_VALIDATION', '$.visuals.camera', 'camera must be an object.');
for (const field of Object.keys(camera)) {
if (!CAMERA_ALLOWED.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.camera.${field}`, `Unrecognized camera field '${field}'.`);
}
if (camera.projection !== undefined && !CAMERA_PROJECTIONS.includes(camera.projection)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.camera.projection', `Unsupported projection '${camera.projection}'.`);
}
for (const [field, spec] of Object.entries(CAMERA_FIELDS)) {
const value = camera[field];
if (value === undefined) continue;
validateValueSpec(document, value, `$.visuals.camera.${field}`, errors);
// 19.3 (V20): a *literal* outside the closed range is rejected at import;
// a value that resolves outside it is clamped by the 8.1 safety stage.
if (typeof value === 'number' && ((spec.min !== undefined && value < spec.min) || (spec.max !== undefined && value > spec.max))) {
fail('ERR_OUT_OF_BOUNDS', `$.visuals.camera.${field}`, `camera.${field} must be between ${spec.min} and ${spec.max}.`);
}
}
}
function validateEffects(document, effects, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (effects === undefined) return [];
if (!Array.isArray(effects)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.effects', 'effects must be an array.');
return [];
}
if (effects.length > AUTHORING.postEffectEntries) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.effects', `At most ${AUTHORING.postEffectEntries} post-effect entries.`);
effects.forEach((entry, index) => {
const path = `$.visuals.effects[${index}]`;
if (!isRecord(entry)) return fail('ERR_SCHEMA_VALIDATION', path, 'Effect entry must be an object.');
const definition = POST_EFFECTS[entry.type];
if (!definition) return fail('ERR_INVALID_EFFECT_TYPE', `${path}.type`, `Unsupported post-effect type '${entry.type}'.`);
for (const field of Object.keys(entry)) {
if (field === 'type' || field === 'enabled') continue;
if (!Object.hasOwn(definition.numeric, field) && !Object.hasOwn(definition.colors, field)) {
fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Effect '${entry.type}' declares no parameter '${field}'.`);
}
}
if (entry.enabled !== undefined) validateValueSpec(document, entry.enabled, `${path}.enabled`, errors);
for (const [parameter, range] of Object.entries(definition.numeric)) {
const value = entry[parameter];
if (value === undefined) continue;
validateValueSpec(document, value, `${path}.${parameter}`, errors);
if (typeof value === 'number' && ((range.min !== undefined && value < range.min) || (range.max !== undefined && value > range.max))) {
fail('ERR_OUT_OF_BOUNDS', `${path}.${parameter}`, `${parameter} must be between ${range.min} and ${range.max}.`);
}
}
// 19.4: `color` parameters and `type` are authored once, never ValueSpecs.
for (const parameter of Object.keys(definition.colors)) {
if (entry[parameter] !== undefined && !isColor(entry[parameter])) fail('ERR_TYPE_MISMATCH', `${path}.${parameter}`, `${parameter} must be an authored color literal.`);
}
});
return effects;
}
function validateFields(document, fields, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (fields === undefined) return new Set();
if (!isRecord(fields)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.fields', 'fields must be an object.');
return new Set();
}
const ids = Object.keys(fields);
if (ids.length > AUTHORING.declaredFields) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.fields', `At most ${AUTHORING.declaredFields} declared fields.`);
for (const [id, field] of Object.entries(fields)) {
const path = `$.visuals.fields.${id}`;
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', path, `Field ID '${id}' is invalid.`);
if (!isRecord(field)) { fail('ERR_SCHEMA_VALIDATION', path, 'Field must be an object.'); continue; }
if (!VISUAL_FIELD_TYPES.includes(field.type)) { fail('ERR_INVALID_FIELD_TYPE', `${path}.type`, `Unsupported field type '${field.type}'.`); continue; }
const allowed = new Set([...FIELD_COMMON, ...FIELD_TYPE_FIELDS[field.type]]);
for (const key of Object.keys(field)) if (!allowed.has(key)) fail('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Field type '${field.type}' declares no '${key}'.`);
if (field.enabled !== undefined) validateValueSpec(document, field.enabled, `${path}.enabled`, errors);
if (field.type === 'noise') {
const mode = field.mode ?? 'curl';
if (!['curl', 'gradient', 'value'].includes(mode)) fail('ERR_SCHEMA_VALIDATION', `${path}.mode`, `Unsupported noise mode '${field.mode}'.`);
// 18.7 (A2): `direction` is required under `value` and unknown otherwise.
if (mode === 'value' && field.direction === undefined) fail('ERR_SCHEMA_VALIDATION', `${path}.direction`, "A 'value' noise field requires a direction.");
if (mode !== 'value' && field.direction !== undefined) fail('ERR_UNKNOWN_FIELD', `${path}.direction`, `direction is not declared under noise mode '${mode}'.`);
if (field.octaves !== undefined && (!Number.isInteger(field.octaves) || field.octaves < 1 || field.octaves > 4)) fail('ERR_OUT_OF_BOUNDS', `${path}.octaves`, 'octaves must be an integer from 1 to 4.');
if (field.persistence !== undefined && (!Number.isFinite(field.persistence) || field.persistence < 0 || field.persistence > 1)) fail('ERR_OUT_OF_BOUNDS', `${path}.persistence`, 'persistence must be between 0 and 1.');
if (field.scale !== undefined && (!Number.isFinite(field.scale) || field.scale <= 0)) fail('ERR_OUT_OF_BOUNDS', `${path}.scale`, 'scale must be above 0.');
}
}
return new Set(ids);
}
function validateSystems(document, systems, layers, fields, errors, { validateValueSpec, pushError }) {
const fail = (code, path, message) => pushError(errors, code, path, message);
if (systems === undefined) return {};
if (!isRecord(systems)) {
fail('ERR_SCHEMA_VALIDATION', '$.visuals.systems', 'systems must be an object.');
return {};
}
const ids = Object.keys(systems);
if (ids.length > AUTHORING.declaredSystems) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.systems', `At most ${AUTHORING.declaredSystems} declared visual systems.`);
for (const [id, system] of Object.entries(systems)) {
const path = `$.visuals.systems.${id}`;
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', path, `System ID '${id}' is invalid.`);
if (!isRecord(system)) { fail('ERR_SCHEMA_VALIDATION', path, 'System must be an object.'); continue; }
if (!VISUAL_SYSTEM_TYPES.includes(system.type)) fail('ERR_INVALID_SYSTEM_TYPE', `${path}.type`, `Unsupported visual system type '${system.type}'.`);
// 17.7 (V17): the presence of the layer map decides whether `layer` is required.
if (layers) {
if (system.layer === undefined) fail('ERR_INVALID_REFERENCE', `${path}.layer`, 'A system must name a declared layer when visuals.layers is present.');
else if (!layers.has(system.layer)) fail('ERR_INVALID_REFERENCE', `${path}.layer`, `Layer '${system.layer}' is not declared.`);
} else if (system.layer !== undefined) {
fail('ERR_INVALID_REFERENCE', `${path}.layer`, 'No layers are declared, so there is no layer to name.');
}
if (system.visible !== undefined) validateValueSpec(document, system.visible, `${path}.visible`, errors);
const lifecycle = system.lifecycle ?? 'persistent';
if (!LIFECYCLE_MODES.includes(lifecycle)) fail('ERR_SCHEMA_VALIDATION', `${path}.lifecycle`, `Unsupported lifecycle '${system.lifecycle}'.`);
// 17.7 (A1): the four spawn-only names never appear at a system's top
// level; `lifetime` does, but only where the type's own table declares it
// as the per-item duration.
for (const field of Object.keys(system)) {
if (SPAWN_ONLY_AT_TOP_LEVEL.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `'${field}' is a field of the spawn container, not of the system.`);
}
if (system.lifetime !== undefined && !TYPES_WITH_ITEM_LIFETIME.has(system.type)) {
fail('ERR_UNKNOWN_FIELD', `${path}.lifetime`, `A '${system.type}' system declares no lifetime; a spawned instance's duration is spawn.lifetime.`);
}
validateSpawn(document, system, lifecycle, path, errors, { validateValueSpec, pushError });
if (system.automation !== undefined && !Array.isArray(system.automation)) fail('ERR_SCHEMA_VALIDATION', `${path}.automation`, 'automation must be an array.');
// The drawn object tree. 18.2, 18.4, and 18.5 each say the owning system
// owns the item's placement, so those fields are unknown on the template.
if (system.type === 'graphic') validateVisualObjectMap(document, system.content, `${path}.content`, errors, { validateValueSpec, pushError });
if (system.type === 'particles' && system.render !== undefined) validateVisualObject(document, system.render, `${path}.render`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'] });
if (system.type === 'emitter' && system.emit !== undefined) validateVisualObject(document, system.emit, `${path}.emit`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'] });
if (system.type === 'repeater' && system.repeat !== undefined) validateVisualObject(document, system.repeat, `${path}.repeat`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z'] });
if (Array.isArray(system.fields)) {
if (system.fields.length > AUTHORING.referencedFieldsPerSystem) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.fields`, `A system may reference at most ${AUTHORING.referencedFieldsPerSystem} fields.`);
system.fields.forEach((reference, index) => {
if (!fields.has(reference)) fail('ERR_INVALID_REFERENCE', `${path}.fields[${index}]`, `Field '${reference}' is not declared.`);
});
}
}
return systems;
}
function validateSpawn(document, system, lifecycle, path, errors, { validateValueSpec, pushError }) {
const fail = (code, location, message) => pushError(errors, code, location, message);
const spawn = system.spawn;
if (spawn === undefined) return;
if (lifecycle !== 'spawned') return fail('ERR_UNKNOWN_FIELD', `${path}.spawn`, 'A persistent system has no spawn container.');
if (!isRecord(spawn)) return fail('ERR_SCHEMA_VALIDATION', `${path}.spawn`, 'spawn must be an object.');
for (const field of Object.keys(spawn)) {
if (!SPAWN_FIELDS.includes(field)) fail('ERR_UNKNOWN_FIELD', `${path}.spawn.${field}`, `Unrecognized spawn field '${field}'.`);
}
for (const field of ['lifetime', 'release']) {
const value = spawn[field];
if (value !== undefined && (typeof value !== 'string' || !DURATION_PATTERN.test(value))) fail('ERR_INVALID_DURATION', `${path}.spawn.${field}`, `spawn.${field} must be a duration literal.`);
}
if (spawn.ownership !== undefined && spawn.ownership !== 'persistent') fail('ERR_SCHEMA_VALIDATION', `${path}.spawn.ownership`, "spawn.ownership accepts only 'persistent'.");
if (spawn.cancelWithScenario !== undefined) {
if (typeof spawn.cancelWithScenario !== 'boolean') fail('ERR_TYPE_MISMATCH', `${path}.spawn.cancelWithScenario`, 'spawn.cancelWithScenario must be a boolean.');
// 19.2 (V23): severing the originating-scenario relationship requires that
// the instance's resources belong to the performance root.
else if (spawn.cancelWithScenario === false && spawn.ownership !== 'persistent') fail('ERR_UNSUPPORTED_TARGET', `${path}.spawn.cancelWithScenario`, "cancelWithScenario: false requires ownership: 'persistent'.");
}
if (spawn.inputs !== undefined && !isRecord(spawn.inputs)) fail('ERR_SCHEMA_VALIDATION', `${path}.spawn.inputs`, 'spawn.inputs must be an object.');
}
// ---------------------------------------------------------------------------
// Visual automation (19.1)
// ---------------------------------------------------------------------------
function validateAutomationArray(document, tracks, path, scope, errors, helpers) {
const { validateValueSpec, pushError } = helpers;
const fail = (code, location, message) => pushError(errors, code, location, message);
if (tracks === undefined) return { tracks: 0, points: 0 };
if (!Array.isArray(tracks)) {
fail('ERR_SCHEMA_VALIDATION', path, 'automation must be an array.');
return { tracks: 0, points: 0 };
}
let points = 0;
const written = new Set();
tracks.forEach((track, index) => {
const location = `${path}[${index}]`;
if (!isRecord(track)) return fail('ERR_SCHEMA_VALIDATION', location, 'Automation track must be an object.');
for (const field of Object.keys(track)) if (!TRACK_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${location}.${field}`, `Unrecognized automation field '${field}'.`);
if (track.mode !== undefined && !AUTOMATION_MODES.includes(track.mode)) fail('ERR_SCHEMA_VALIDATION', `${location}.mode`, `Unsupported automation mode '${track.mode}'.`);
if (track.interpolation !== undefined && !AUTOMATION_CURVES.includes(track.interpolation)) fail('ERR_SCHEMA_VALIDATION', `${location}.interpolation`, `Unsupported interpolation '${track.interpolation}'.`);
if (track.loop !== undefined) {
const loop = track.loop;
if (!isRecord(loop)) fail('ERR_SCHEMA_VALIDATION', `${location}.loop`, 'loop must be an object.');
else {
for (const field of Object.keys(loop)) if (field !== 'mode' && field !== 'count') fail('ERR_UNKNOWN_FIELD', `${location}.loop.${field}`, `Unrecognized loop field '${field}'.`);
if (!LOOP_MODES.includes(loop.mode)) fail('ERR_SCHEMA_VALIDATION', `${location}.loop.mode`, `Unsupported loop mode '${loop.mode}'.`);
// 19.1 (V12): `infinite` is legal in every scope, bounded by whatever
// owns the track, so no scope check belongs here.
if (loop.count !== undefined && loop.count !== 'infinite' && (!Number.isInteger(loop.count) || loop.count < 1)) {
fail('ERR_OUT_OF_BOUNDS', `${location}.loop.count`, "loop.count must be 'infinite' or an integer of at least 1.");
}
}
}
if (!Array.isArray(track.points) || track.points.length < 2) fail('ERR_SCHEMA_VALIDATION', `${location}.points`, 'A track requires at least 2 points.');
else {
if (track.points.length > AUTHORING.automationPointsPerTrack) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${location}.points`, `At most ${AUTHORING.automationPointsPerTrack} points per track.`);
points += track.points.length;
let previous = -Infinity;
track.points.forEach((point, pointIndex) => {
const pointPath = `${location}.points[${pointIndex}]`;
if (!isRecord(point)) return fail('ERR_SCHEMA_VALIDATION', pointPath, 'Automation point must be an object.');
for (const field of Object.keys(point)) if (field !== 'at' && field !== 'value') fail('ERR_UNKNOWN_FIELD', `${pointPath}.${field}`, `Unrecognized point field '${field}'.`);
// 19.1: `at` is a duration literal only, never a procedural TimeSpec,
// so the strictly-increasing rule stays decidable at import.
if (typeof point.at !== 'string' || !DURATION_PATTERN.test(point.at)) fail('ERR_INVALID_DURATION', `${pointPath}.at`, 'Point at must be a duration literal.');
else {
const milliseconds = durationMilliseconds(point.at);
if (milliseconds <= previous) fail('ERR_INVALID_RANGE_ORDER', `${pointPath}.at`, 'Automation point times must be strictly increasing.');
previous = milliseconds;
}
if (!Object.hasOwn(point, 'value')) fail('ERR_SCHEMA_VALIDATION', `${pointPath}.value`, 'Automation point requires a value.');
else validateValueSpec(document, point.value, `${pointPath}.value`, errors);
});
}
const resolution = resolveAutomationTarget(track.target, scope);
if (resolution.code) fail(resolution.code, `${location}.target`, resolution.message);
else if (written.has(resolution.key)) fail('ERR_AUTOMATION_CONFLICT', `${location}.target`, `More than one track controls '${track.target}'.`);
else written.add(resolution.key);
});
return { tracks: tracks.length, points };
}
function durationMilliseconds(value) {
const [, scalar, unit] = DURATION_PATTERN.exec(value);
return Number(scalar) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[unit];
}
/**
* 19.1: two declaration scopes, and a track in one naming a target in the other
* is ERR_INVALID_REFERENCE. A target that exists but exposes no automation
* stage — a repeater property, a boolean, a color — is ERR_UNSUPPORTED_TARGET.
*/
function resolveAutomationTarget(target, scope) {
if (typeof target !== 'string' || target.length === 0) {
return { code: 'ERR_SCHEMA_VALIDATION', message: 'A track requires a target.' };
}
const context = scope.context;
const head = target.split('.')[0].replace(/\[\d+\]$/, '');
if (scope.scope === 'exhibit') {
if (head === 'systems') return { code: 'ERR_INVALID_REFERENCE', message: 'Exhibit-scope automation never reaches into a system; declare the track on the system.' };
if (head === 'scene') {
if (!['scene.depthFog.near', 'scene.depthFog.far', 'scene.depthFog.density'].includes(target)) {
return { code: 'ERR_UNSUPPORTED_TARGET', message: `Scene property '${target}' is not automatable.` };
}
return { key: target };
}
if (head === 'layers') {
const parts = target.split('.');
if (parts.length !== 3 || !context.layers?.has(parts[1])) return { code: 'ERR_INVALID_REFERENCE', message: `Layer '${parts[1]}' is not declared.` };
if (parts[2] !== 'opacity' && parts[2] !== 'parallax') return { code: 'ERR_UNSUPPORTED_TARGET', message: `Layer property '${parts[2]}' is not automatable.` };
return { key: target };
}
if (head === 'camera') {
const parts = target.split('.');
// 19.3: `projection` is authored once — not automatable, not bindable.
if (parts.length !== 2 || !Object.hasOwn(CAMERA_FIELDS, parts[1])) return { code: 'ERR_UNSUPPORTED_TARGET', message: `Camera property '${target}' is not automatable.` };
return { key: target };
}
if (head === 'effects') {
const match = EFFECT_INDEX.exec(target);
if (!match) return { code: 'ERR_UNSUPPORTED_TARGET', message: `Effect target '${target}' is not automatable.` };
const entry = context.effects?.[Number(match[1])];
if (!entry) return { code: 'ERR_INVALID_REFERENCE', message: `Effect index ${match[1]} is outside the authored chain.` };
if (!Object.hasOwn(POST_EFFECTS[entry.type]?.numeric ?? {}, match[2])) return { code: 'ERR_UNSUPPORTED_TARGET', message: `Effect '${entry.type}' has no numeric parameter '${match[2]}'.` };
return { key: target };
}
return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${target}' is not in the automatable registry.` };
}
// System scope. Targets are relative to the owning system.
if (['scene', 'layers', 'camera', 'effects'].includes(head)) {
return { code: 'ERR_INVALID_REFERENCE', message: 'System-scope automation never reaches exhibit-scope properties.' };
}
if (head === 'systems') return { code: 'ERR_INVALID_REFERENCE', message: 'No track may address another system.' };
const type = scope.system?.type;
if (type === 'graphic') return resolveGraphicTarget(target, scope.system);
const automatable = SYSTEM_AUTOMATABLE[type];
if (!automatable) return { code: 'ERR_UNSUPPORTED_TARGET', message: `A '${type}' system exposes no automatable property.` };
// 19.1 (V11): a repeater has no automatable property at all, `step` included.
if (!automatable.has(target)) return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${target}' is not automatable on a '${type}' system.` };
return { key: `${scope.systemId}.${target}` };
}
function resolveGraphicTarget(target, system) {
const parts = target.split('.');
let container = system?.content;
let index = 0;
while (index < parts.length) {
if (!isRecord(container) || !Object.hasOwn(container, parts[index])) {
return { code: 'ERR_INVALID_REFERENCE', message: `'${target}' does not name an object in this system's content.` };
}
const object = container[parts[index]];
index += 1;
const remainder = parts.slice(index).join('.');
if (remainder.length === 0) return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${target}' names an object, not a numeric property of one.` };
if (GRAPHIC_NUMERIC_PROPERTIES.has(remainder) || POINT_PROPERTY.test(remainder)) return { key: target };
// Not a property tail, so the next segment must be a container key.
if (isRecord(object) && (object.type === 'group' || object.type === 'component') && isRecord(object.children)) {
container = object.children;
continue;
}
return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${remainder}' is not a numeric object property.` };
}
return { code: 'ERR_INVALID_REFERENCE', message: `'${target}' does not name an object in this system's content.` };
}
// ---------------------------------------------------------------------------
// Visual objects (17.9, 17.10, 17.12, 17.13)
// ---------------------------------------------------------------------------
const COMMON_OBJECT_FIELDS = ['type', 'position', 'z', 'transform', 'style', 'behaviors', 'visible', 'lifetime'];
/** Type-specific geometry fields, per the tables of 17.9 and 17.13. */
const OBJECT_TYPE_FIELDS = Object.freeze({
point: [],
line: ['to'],
polyline: ['points'],
polygon: ['points'],
rectangle: ['size'],
'rounded-rectangle': ['size', 'radius'],
ellipse: ['radius'],
arc: ['radius', 'startAngle', 'endAngle', 'direction'],
ring: ['radius', 'innerRadius', 'startAngle', 'endAngle', 'direction'],
path: ['commands', 'fillRule'],
bezier: ['c1', 'c2', 'to'],
spline: ['points', 'mode', 'closed', 'tension', 'fillRule'],
text: ['text', 'font', 'size', 'weight', 'italic', 'align', 'baseline', 'letterSpacing', 'maxWidth'],
group: ['children'],
component: ['component', 'inputs']
});
const REQUIRED_GEOMETRY = Object.freeze({
line: ['to'], polyline: ['points'], polygon: ['points'], rectangle: ['size'],
'rounded-rectangle': ['size', 'radius'], ellipse: ['radius'], arc: ['radius'], ring: ['radius'],
path: ['commands'], bezier: ['c1', 'c2', 'to'], spline: ['points'], text: ['text'],
group: ['children'], component: ['component']
});
/** 17.12: these four primitives have no interior. */
const STROKE_ONLY_TYPES = new Set(['line', 'polyline', 'arc', 'bezier']);
const POINT_BOUNDS = Object.freeze({ polyline: [2, 512], polygon: [3, 512], spline: [2, 256] });
const STYLE_FIELDS = new Set(['fill', 'stroke', 'strokeWidth', 'strokeCap', 'strokeJoin', 'strokeDash',
'strokeDashOffset', 'pointSize', 'opacity', 'blend', 'glow', 'shadow', 'blur', 'filters', 'clip', 'mask']);
const TRANSFORM_FIELDS = new Set(['translate', 'rotation', 'scale', 'skew', 'origin']);
const PATH_OPS = new Set(['move', 'line', 'quadratic', 'cubic', 'arc', 'close']);
function validateVisualObjectMap(document, container, path, errors, helpers, options = {}) {
const { pushError } = helpers;
if (!isRecord(container)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'A visual object container must be an object.');
if (Object.keys(container).length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'An empty object container is not a container.');
for (const [key, object] of Object.entries(container)) {
if (!ID_PATTERN.test(key)) pushError(errors, 'ERR_INVALID_ID', `${path}.${key}`, `Object key '${key}' is invalid.`);
validateVisualObject(document, object, `${path}.${key}`, errors, helpers, { ...options, depth: options.depth ?? 1 });
}
}
function validateVisualObject(document, object, path, errors, helpers, options = {}) {
const { validateValueSpec, pushError } = helpers;
const fail = (code, location, message) => pushError(errors, code, location, message);
if (!isRecord(object)) return fail('ERR_SCHEMA_VALIDATION', path, 'A visual object must be an object.');
const type = object.type;
if (!Object.hasOwn(OBJECT_TYPE_FIELDS, type)) return fail('ERR_INVALID_PRIMITIVE_TYPE', `${path}.type`, `'${type}' is outside Visual Primitive Set 0.1 and the component object type.`);
const depth = options.depth ?? 1;
if (depth > AUTHORING.groupNesting) fail('ERR_VISUAL_LIMIT_EXCEEDED', path, `Group nesting deeper than ${AUTHORING.groupNesting} levels.`);
// 17.8: a visual object carries no `id`; its container key is its identity.
// 17.10: `layer` is a system property, never an object one.
const allowed = new Set([...COMMON_OBJECT_FIELDS, ...OBJECT_TYPE_FIELDS[type]]);
const owned = new Set(options.ownedByOwner ?? []);
for (const field of Object.keys(object)) {
if (!allowed.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `A '${type}' object declares no '${field}'.`);
else if (owned.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `The owning system owns '${field}'; the drawn object does not declare it.`);
}
for (const field of REQUIRED_GEOMETRY[type] ?? []) {
if (object[field] === undefined) fail('ERR_SCHEMA_VALIDATION', `${path}.${field}`, `A '${type}' object requires '${field}'.`);
}
if (object.behaviors !== undefined) {
if (!Array.isArray(object.behaviors)) fail('ERR_SCHEMA_VALIDATION', `${path}.behaviors`, 'behaviors must be an array.');
else if (object.behaviors.length > AUTHORING.behaviorsPerObject) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.behaviors`, `At most ${AUTHORING.behaviorsPerObject} behaviors per object.`);
}
if (object.transform !== undefined) {
if (!isRecord(object.transform)) fail('ERR_SCHEMA_VALIDATION', `${path}.transform`, 'transform must be an object.');
else for (const field of Object.keys(object.transform)) if (!TRANSFORM_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.transform.${field}`, `Unrecognized transform field '${field}'.`);
}
const bounds = POINT_BOUNDS[type];
if (bounds && object.points !== undefined) {
if (!Array.isArray(object.points)) fail('ERR_SCHEMA_VALIDATION', `${path}.points`, 'points must be an array.');
else if (object.points.length < bounds[0]) fail('ERR_SCHEMA_VALIDATION', `${path}.points`, `A '${type}' needs at least ${bounds[0]} points.`);
else if (object.points.length > bounds[1]) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.points`, `At most ${bounds[1]} points.`);
}
if (type === 'spline') {
const mode = object.mode ?? 'catmull-rom';
if (!['catmull-rom', 'bezier', 'linear'].includes(mode)) fail('ERR_SCHEMA_VALIDATION', `${path}.mode`, `Unsupported spline mode '${mode}'.`);
// 17.13: `tension` is catmull-rom only.
if (object.tension !== undefined && mode !== 'catmull-rom') fail('ERR_UNKNOWN_FIELD', `${path}.tension`, "tension is declared only under the 'catmull-rom' mode.");
if (mode === 'bezier' && Array.isArray(object.points) && (object.points.length - 1) % 3 !== 0) {
fail('ERR_SCHEMA_VALIDATION', `${path}.points`, 'A bezier-mode spline needs 3n + 1 points.');
}
}
if (type === 'ring' && Number.isFinite(object.innerRadius) && Number.isFinite(object.radius) && object.innerRadius >= object.radius) {
fail('ERR_INVALID_RANGE_ORDER', `${path}.innerRadius`, 'innerRadius must be less than radius.');
}
if ((type === 'arc' || type === 'ring') && object.direction !== undefined && !['clockwise', 'counter-clockwise'].includes(object.direction)) {
fail('ERR_SCHEMA_VALIDATION', `${path}.direction`, `Unsupported direction '${object.direction}'.`);
}
if ((type === 'arc' || type === 'ring') && Number.isFinite(object.startAngle) && Number.isFinite(object.endAngle)) {
const delta = (object.direction === 'counter-clockwise' ? object.startAngle - object.endAngle : object.endAngle - object.startAngle);
// 17.9: the 360 bound is checked on the authored numbers, before normalization.
if (Math.abs(delta) > 360) fail('ERR_OUT_OF_BOUNDS', `${path}.endAngle`, 'An arc sweep may not exceed 360 degrees.');
}
if (type === 'path') validatePathCommands(object.commands, `${path}.commands`, errors, helpers);
if (type === 'text' && typeof object.font === 'string' && !['sans-serif', 'serif', 'monospace'].includes(object.font)) {
fail('ERR_SCHEMA_VALIDATION', `${path}.font`, "font is limited to 'sans-serif', 'serif', and 'monospace' (13.3).");
}
validateVisualStyle(document, object, type, path, errors, helpers);
if (type === 'group') {
validateVisualObjectMap(document, object.children, `${path}.children`, errors, helpers, { ...options, depth: depth + 1, ownedByOwner: [] });
if (object.style?.mask !== undefined && object.style.mask !== null) {
if (!isRecord(object.children) || !Object.hasOwn(object.children, object.style.mask)) {
fail('ERR_INVALID_REFERENCE', `${path}.style.mask`, `mask '${object.style.mask}' names no child of this group.`);
}
}
}
}
function validateVisualStyle(document, object, type, path, errors, helpers) {
const { validateValueSpec, pushError } = helpers;
const fail = (code, location, message) => pushError(errors, code, location, message);
const style = object.style;
if (style === undefined) return;
if (!isRecord(style)) return fail('ERR_SCHEMA_VALIDATION', `${path}.style`, 'style must be an object.');
for (const field of Object.keys(style)) if (!STYLE_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.style.${field}`, `Unrecognized style field '${field}'.`);
// 17.12: a `fill` *declared* on a stroke-only primitive is an error; one
// *inherited* is ignored for it, which is a runtime rule, not a validation one.
if (STROKE_ONLY_TYPES.has(type) && Object.hasOwn(style, 'fill')) {
fail('ERR_UNKNOWN_FIELD', `${path}.style.fill`, `A '${type}' has no interior, so it declares no fill.`);
}
// 17.12: `mask` is legal only on a group.
if (Object.hasOwn(style, 'mask') && type !== 'group') fail('ERR_UNKNOWN_FIELD', `${path}.style.mask`, 'mask is declared only on a group.');
if (style.blend !== undefined && !BLEND_MODES.includes(style.blend)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.blend`, `Unsupported blend mode '${style.blend}'.`);
if (style.strokeCap !== undefined && !['butt', 'round', 'square'].includes(style.strokeCap)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.strokeCap`, `Unsupported strokeCap '${style.strokeCap}'.`);
if (style.strokeJoin !== undefined && !['miter', 'round', 'bevel'].includes(style.strokeJoin)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.strokeJoin`, `Unsupported strokeJoin '${style.strokeJoin}'.`);
if (style.strokeDash !== undefined) {
if (!Array.isArray(style.strokeDash) || style.strokeDash.length < 1) fail('ERR_SCHEMA_VALIDATION', `${path}.style.strokeDash`, 'strokeDash must be a non-empty array.');
else if (style.strokeDash.length > AUTHORING.strokeDashEntries) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.style.strokeDash`, `At most ${AUTHORING.strokeDashEntries} strokeDash entries.`);
}
if (style.filters !== undefined) {
if (!Array.isArray(style.filters)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.filters`, 'filters must be an array.');
else if (style.filters.length > AUTHORING.filtersPerObject) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.style.filters`, `At most ${AUTHORING.filtersPerObject} filters per object.`);
else style.filters.forEach((entry, index) => {
if (!isRecord(entry) || !FILTER_TYPES.includes(entry.type)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.filters[${index}].type`, 'Unsupported filter type.');
});
}
for (const role of ['fill', 'stroke']) validatePaint(document, style[role], `${path}.style.${role}`, errors, helpers);
for (const role of ['glow', 'shadow']) {
const entry = style[role];
if (entry === undefined || entry === null) continue;
if (!isRecord(entry)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.${role}`, `${role} must be an object or null.`);
else if (entry.color !== undefined) validateValueSpec(document, entry.color, `${path}.style.${role}.color`, errors);
}
if (isRecord(style.clip) && !['rectangle', 'ellipse'].includes(style.clip.shape)) {
fail('ERR_SCHEMA_VALIDATION', `${path}.style.clip.shape`, "A clip shape is 'rectangle' or 'ellipse'.");
}
}
function validatePaint(document, paint, path, errors, helpers) {
const { validateValueSpec, pushError } = helpers;
if (paint === undefined || paint === null) return;
if (!isRecord(paint) || Object.hasOwn(paint, 'ref') || Object.hasOwn(paint, 'random') || Object.hasOwn(paint, 'choose') || Object.hasOwn(paint, 'op')) {
// 17.12 (V5): a color leaf is a ValueSpec<color>; it never returns a paint object.
return validateValueSpec(document, paint, path, errors);
}
const fail = (code, location, message) => pushError(errors, code, location, message);
if (!['linear-gradient', 'radial-gradient', 'conic-gradient'].includes(paint.type)) {
return fail('ERR_SCHEMA_VALIDATION', `${path}.type`, `Unsupported paint type '${paint.type}'.`);
}
if (!Array.isArray(paint.stops) || paint.stops.length < 2) return fail('ERR_SCHEMA_VALIDATION', `${path}.stops`, 'A gradient needs at least 2 stops.');
if (paint.stops.length > AUTHORING.gradientStops) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.stops`, `At most ${AUTHORING.gradientStops} gradient stops.`);
let previous = -Infinity;
paint.stops.forEach((stop, index) => {
if (!isRecord(stop) || !Number.isFinite(stop.offset)) return fail('ERR_SCHEMA_VALIDATION', `${path}.stops[${index}]`, 'A stop needs a numeric offset and a color.');
if (stop.offset < 0 || stop.offset > 1) fail('ERR_OUT_OF_BOUNDS', `${path}.stops[${index}].offset`, 'A stop offset is between 0 and 1.');
if (stop.offset <= previous) fail('ERR_INVALID_RANGE_ORDER', `${path}.stops[${index}].offset`, 'Stop offsets must be strictly increasing.');
previous = stop.offset;
validateValueSpec(document, stop.color, `${path}.stops[${index}].color`, errors);
});
}
function validatePathCommands(commands, path, errors, { pushError }) {
const fail = (code, location, message) => pushError(errors, code, location, message);
if (!Array.isArray(commands) || commands.length === 0) return fail('ERR_SCHEMA_VALIDATION', path, 'A path needs at least one command.');
if (commands.length > AUTHORING.pathCommands) fail('ERR_VISUAL_LIMIT_EXCEEDED', path, `At most ${AUTHORING.pathCommands} path commands.`);
if (!isRecord(commands[0]) || commands[0].op !== 'move') fail('ERR_INVALID_PATH', `${path}[0]`, 'A path must begin with a move command.');
let open = false;
commands.forEach((command, index) => {
if (!isRecord(command) || !PATH_OPS.has(command.op)) return fail('ERR_INVALID_PATH', `${path}[${index}].op`, `Unknown path command '${command?.op}'.`);
if (command.op === 'move') open = true;
else if (command.op === 'close') {
if (!open) fail('ERR_INVALID_PATH', `${path}[${index}]`, 'close has no open subpath.');
open = false;
}
if (command.op === 'arc') {
const radius = command.radius;
const components = typeof radius === 'number' ? [radius] : [radius?.x, radius?.y];
// 17.13: a zero or negative radius component is ERR_OUT_OF_BOUNDS. It is
// not silently degraded to a line.
if (components.some((value) => !Number.isFinite(value) || value <= 0)) {
fail('ERR_OUT_OF_BOUNDS', `${path}[${index}].radius`, 'An arc radius component must be above zero.');
}
}
});
}
/* src/runtime/validator.js */
function issue(code, path, message, context = {}) {
return { code, path, message, ...context };
}
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseExhibit(source, filename = 'document.xzbt') {
if (typeof source !== 'string') {
return { valid: false, document: null, errors: [issue('ERR_SCHEMA_VALIDATION', '$', 'Exhibit source must be UTF-8 JSON text.')], filename };
}
const normalizedSource = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
try {
return { valid: true, document: JSON.parse(normalizedSource), errors: [], filename, source: normalizedSource };
} catch (error) {
return { valid: false, document: null, errors: [issue('ERR_SCHEMA_VALIDATION', '$', `Malformed JSON: ${error.message}`)], filename, source: normalizedSource };
}
}
function validateExhibit(document, filename = 'document.xzbt') {
const errors = [];
const add = (code, path, message, context) => errors.push(issue(code, path, message, context));
if (!isPlainObject(document)) {
add('ERR_SCHEMA_VALIDATION', '$', 'Top-level document must be a JSON object.');
return { valid: false, errors, warnings: [], filename };
}
if (document.xzbt !== XZBT_FORMAT_VERSION) {
add('ERR_UNSUPPORTED_VERSION', '$.xzbt', `Unsupported XZBT format version: '${document.xzbt}'. Expected '${XZBT_FORMAT_VERSION}'.`, { property: 'xzbt' });
}
for (const field of Object.keys(document)) {
if (!ALLOWED_TOP_LEVEL_FIELDS.includes(field)) add('ERR_UNKNOWN_FIELD', `$.${field}`, `Unrecognized top-level field: '${field}'.`, { section: field });
}
if (!isPlainObject(document.meta)) {
add('ERR_SCHEMA_VALIDATION', '$.meta', 'Missing or invalid required object: meta.', { section: 'meta' });
} else {
for (const field of Object.keys(document.meta)) {
if (!ALLOWED_META_FIELDS.includes(field)) add('ERR_UNKNOWN_FIELD', `$.meta.${field}`, `Unrecognized metadata field: '${field}'.`, { section: 'meta', property: field });
}
const { id, name, version, author, description, license, tags } = document.meta;
if (typeof id !== 'string' || !ID_PATTERN.test(id) || id.length > 64) {
add('ERR_INVALID_ID', '$.meta.id', "Exhibit id must match ^[a-z][a-z0-9_-]*$ and contain at most 64 characters.", { section: 'meta', property: 'id' });
}
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 128) {
add('ERR_SCHEMA_VALIDATION', '$.meta.name', 'Exhibit name must be a non-empty string of at most 128 characters.', { section: 'meta', property: 'name' });
}
if (version !== undefined && typeof version !== 'string') add('ERR_TYPE_MISMATCH', '$.meta.version', 'Exhibit version must be a string.', { section: 'meta', property: 'version' });
if (author !== undefined && (typeof author !== 'string' || author.length > 128)) add('ERR_TYPE_MISMATCH', '$.meta.author', 'Author must be a string of at most 128 characters.', { section: 'meta', property: 'author' });
if (description !== undefined && (typeof description !== 'string' || description.length > 1024)) add('ERR_TYPE_MISMATCH', '$.meta.description', 'Description must be a string of at most 1024 characters.', { section: 'meta', property: 'description' });
if (license !== undefined && typeof license !== 'string') add('ERR_TYPE_MISMATCH', '$.meta.license', 'License must be a string.', { section: 'meta', property: 'license' });
if (tags !== undefined && (!Array.isArray(tags) || tags.length > 16 || tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) {
add('ERR_TYPE_MISMATCH', '$.meta.tags', 'Tags must contain at most 16 strings of at most 32 characters.', { section: 'meta', property: 'tags' });
}
}
if (document.runtime !== undefined) {
if (!isPlainObject(document.runtime)) {
add('ERR_SCHEMA_VALIDATION', '$.runtime', 'runtime must be an object.', { section: 'runtime' });
} else {
for (const field of Object.keys(document.runtime)) {
if (field !== 'seed') add('ERR_UNKNOWN_FIELD', `$.runtime.${field}`, `Unrecognized runtime field: '${field}'.`, { section: 'runtime', property: field });
}
const seed = document.runtime.seed;
if (seed !== undefined && seed !== 'random' && (!Number.isInteger(seed) || seed < 0 || seed >= UINT32_RANGE)) {
add('ERR_OUT_OF_BOUNDS', '$.runtime.seed', "Seed must be 'random' or an unsigned 32-bit integer.", { section: 'runtime', property: 'seed' });
}
}
}
validateDefinitions(document, errors);
validateBindings(document, errors);
validateAudioSubsystem(document, errors, { validateValueSpec, pushError });
validateVisualSubsystem(document, errors, { validateValueSpec, pushError });
return { valid: errors.length === 0, errors, warnings: [], filename };
}
const PARAMETER_FIELDS = new Set(['type', 'default', 'min', 'max', 'step', 'values', 'label', 'unit']);
const STATE_FIELDS = new Set(['type', 'initial', 'min', 'max']);
const BINDING_FIELDS = new Set(['source', 'target', 'scale', 'offset', 'clamp', 'smoothing', 'when']);
const VALUE_OPERATORS = Object.freeze({ abs: 1, negate: 1, round: 1, floor: 1, ceil: 1, add: 2, subtract: 2, multiply: 2, divide: 2, min: 2, max: 2, clamp: 3, lerp: 3 });
const COMPARISONS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']);
function pushError(errors, code, path, message) { errors.push(issue(code, path, message)); }
function validateDefinitions(document, errors) {
validateDefinitionMap(document.parameters, 'parameters', PARAMETER_TYPES, 'default', PARAMETER_FIELDS, errors);
validateDefinitionMap(document.state, 'state', STATE_TYPES, 'initial', STATE_FIELDS, errors);
}
function validateDefinitionMap(definitions, namespace, allowedTypes, valueField, allowedFields, errors) {
if (definitions === undefined) return;
if (!isPlainObject(definitions)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.${namespace}`, `${namespace} must be an object.`);
return;
}
for (const [id, spec] of Object.entries(definitions)) {
const path = `$.${namespace}.${id}`;
if (!ID_PATTERN.test(id)) pushError(errors, 'ERR_INVALID_ID', path, `${namespace} ID '${id}' is invalid.`);
if (!isPlainObject(spec)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', path, `${namespace} definition must be an object.`);
continue;
}
for (const field of Object.keys(spec)) if (!allowedFields.has(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized ${namespace} field '${field}'.`);
if (!allowedTypes.includes(spec.type)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.type`, `Unsupported ${namespace} type '${spec.type}'.`);
if (!Object.hasOwn(spec, valueField)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${valueField}`, `Missing required field '${valueField}'.`);
else if (!valueMatchesType(spec.type, spec[valueField], spec)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.${valueField}`, `Value does not match declared type '${spec.type}'.`);
if (spec.min !== undefined && !Number.isFinite(spec.min)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.min`, 'min must be a finite number.');
if (spec.max !== undefined && !Number.isFinite(spec.max)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.max`, 'max must be a finite number.');
if (Number.isFinite(spec.min) && Number.isFinite(spec.max) && spec.min > spec.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.min`, 'min cannot exceed max.');
if (isNumericType(spec.type) && typeof spec[valueField] === 'number') {
if (spec.min !== undefined && spec[valueField] < spec.min) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, `${valueField} is below min.`);
if (spec.max !== undefined && spec[valueField] > spec.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, `${valueField} is above max.`);
}
if (namespace === 'parameters' && spec.step !== undefined && (!Number.isFinite(spec.step) || spec.step <= 0)) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.step`, 'step must be a finite positive number.');
if (spec.type === 'enum') {
if (!Array.isArray(spec.values) || spec.values.length === 0 || spec.values.some((value) => typeof value !== 'string')) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values`, 'Enum values must be a non-empty string array.');
else if (!spec.values.includes(spec[valueField])) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, 'Enum default is not declared in values.');
}
}
}
function referenceType(document, path, scope = null) {
if (typeof path !== 'string') return null;
const parts = path.split('.');
if (parts[0] === 'inputs') return parts.length === 2 && scope?.componentParameters?.has(parts[1]) ? 'number' : null;
if (parts.length === 2 && parts[0] === 'parameters') return document.parameters?.[parts[1]]?.type ?? null;
if (parts.length === 2 && parts[0] === 'state') return document.state?.[parts[1]]?.type ?? null;
if (parts[0] === 'signals') return RUNTIME_SIGNAL_TYPES[path] ?? null;
if (parts.length === 2 && parts[0] === 'modulators') return document.modulators?.[parts[1]] ? 'number' : null;
if (parts.length === 4 && parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain') return document.audio?.buses?.[parts[2]] ? 'number' : null;
return null;
}
function validateReference(document, path, location, errors, scope = null) {
if (typeof path !== 'string' || !/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/.test(path) || !referenceType(document, path, scope)) pushError(errors, 'ERR_INVALID_REFERENCE', location, `Reference '${path}' does not resolve.`);
}
function validateValueSpec(document, spec, path, errors, scope = null) {
if (typeof spec === 'number') {
if (!Number.isFinite(spec)) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Number must be finite.');
return;
}
if (typeof spec === 'string' || typeof spec === 'boolean') return;
if (!isPlainObject(spec)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Invalid ValueSpec.');
return;
}
const forms = ['ref', 'random', 'choose', 'op'].filter((field) => Object.hasOwn(spec, field));
if (forms.length !== 1) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'ValueSpec must use exactly one recognized form.');
return;
}
const form = forms[0];
for (const field of Object.keys(spec)) if (field !== form && !(form === 'op' && field === 'args')) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized ValueSpec field '${field}'.`);
if (form === 'ref') return validateReference(document, spec.ref, `${path}.ref`, errors, scope);
if (form === 'random') {
const random = spec.random;
if (!isPlainObject(random)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.random`, 'random must be an object.');
for (const field of Object.keys(random)) if (!['min', 'max', 'integer', 'distribution'].includes(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.random.${field}`, `Unknown random field '${field}'.`);
if (!Number.isFinite(random.min) || !Number.isFinite(random.max)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.random`, 'random min and max must be finite numbers.');
else if (random.min > random.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.random`, 'random min cannot exceed max.');
if (random.integer !== undefined && typeof random.integer !== 'boolean') pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.random.integer`, 'integer must be boolean.');
if (random.distribution !== undefined && !['uniform', 'gaussian'].includes(random.distribution)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.random.distribution`, 'Unsupported distribution.');
return;
}
if (form === 'choose') {
if (!Array.isArray(spec.choose) || spec.choose.length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose`, 'choose must be a non-empty array.');
spec.choose.forEach((option, index) => {
if (!isPlainObject(option) || !Object.hasOwn(option, 'value') || !Number.isFinite(option.weight) || option.weight <= 0) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose[${index}]`, 'Choice requires value and a finite positive weight.');
else validateValueSpec(document, option.value, `${path}.choose[${index}].value`, errors, scope);
});
return;
}
if (!Object.hasOwn(VALUE_OPERATORS, spec.op)) pushError(errors, 'ERR_INVALID_OPERATOR', `${path}.op`, `Unknown operator '${spec.op}'.`);
if (!Array.isArray(spec.args)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.args`, 'Operator args must be an array.');
else {
if (spec.args.length !== VALUE_OPERATORS[spec.op]) pushError(errors, 'ERR_INVALID_ARITY', `${path}.args`, `Operator '${spec.op}' has invalid arity.`);
spec.args.forEach((argument, index) => validateValueSpec(document, argument, `${path}.args[${index}]`, errors, scope));
}
}
function validateCondition(document, condition, path, errors) {
if (!isPlainObject(condition)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'ConditionSpec must be an object.');
if (Object.hasOwn(condition, 'and') || Object.hasOwn(condition, 'or')) {
const key = Object.hasOwn(condition, 'and') ? 'and' : 'or';
if (Object.keys(condition).length !== 1) pushError(errors, 'ERR_UNKNOWN_FIELD', path, 'Logical condition contains extra fields.');
if (!Array.isArray(condition[key]) || condition[key].length === 0) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${key}`, `${key} requires a non-empty array.`);
else condition[key].forEach((child, index) => validateCondition(document, child, `${path}.${key}[${index}]`, errors));
return;
}
if (Object.hasOwn(condition, 'not')) {
if (Object.keys(condition).length !== 1) pushError(errors, 'ERR_UNKNOWN_FIELD', path, 'not condition contains extra fields.');
return validateCondition(document, condition.not, `${path}.not`, errors);
}
if (!COMPARISONS.has(condition.op)) pushError(errors, 'ERR_INVALID_OPERATOR', `${path}.op`, `Unknown comparison '${condition.op}'.`);
if (!Object.hasOwn(condition, 'left') || !Object.hasOwn(condition, 'right')) pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Comparison requires left and right.');
else {
validateValueSpec(document, condition.left, `${path}.left`, errors);
validateValueSpec(document, condition.right, `${path}.right`, errors);
}
}
/**
* The exposed binding-target surface: `parameters.*`, `state.*`,
* `audio.buses.<id>.gain`, and the four visual target families 19.1 adds. A
* target inside a family that names something undeclared is
* ERR_INVALID_REFERENCE; one outside every family is ERR_UNSUPPORTED_TARGET.
* Per-object visual properties fall in the second case and stay there in 0.1.
*/
function bindingTargetType(document, target) {
const visual = matchVisualTarget(document, target);
if (visual) return visual.reason ? null : visual.spec.type;
if (typeof target !== 'string') return null;
if (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(target)) return referenceType(document, target);
return null;
}
function validateBindingTarget(document, target, location, errors) {
const visual = matchVisualTarget(document, target);
if (visual) {
if (visual.reason === 'reference') pushError(errors, 'ERR_INVALID_REFERENCE', location, `Binding target '${target}' does not resolve.`);
else if (visual.reason === 'unsupported') pushError(errors, 'ERR_UNSUPPORTED_TARGET', location, `Binding target '${target}' is not exposed. Per-object visual properties are not externally addressable in 0.1.`);
return;
}
validateReference(document, target, location, errors);
if (!(typeof target === 'string' && (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(target)))) {
pushError(errors, 'ERR_UNSUPPORTED_TARGET', location, `Binding target '${target}' is not exposed.`);
}
}
function validateBindings(document, errors) {
if (document.bindings === undefined) return;
if (!Array.isArray(document.bindings)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.bindings', 'bindings must be an array.');
const writers = new Map();
const dependencies = new Map();
document.bindings.forEach((binding, index) => {
const path = `$.bindings[${index}]`;
if (!isPlainObject(binding)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Binding must be an object.');
for (const field of Object.keys(binding)) if (!BINDING_FIELDS.has(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unknown binding field '${field}'.`);
validateReference(document, binding.source, `${path}.source`, errors);
validateBindingTarget(document, binding.target, `${path}.target`, errors);
for (const field of ['scale', 'offset']) if (binding[field] !== undefined && !Number.isFinite(binding[field])) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.${field}`, `${field} must be finite.`);
if (binding.clamp !== undefined && (!Array.isArray(binding.clamp) || binding.clamp.length !== 2 || binding.clamp.some((value) => !Number.isFinite(value)))) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.clamp`, 'clamp must contain two finite numbers.');
else if (binding.clamp?.[0] > binding.clamp?.[1]) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.clamp`, 'clamp minimum cannot exceed maximum.');
if (binding.smoothing !== undefined && (typeof binding.smoothing !== 'string' || !DURATION_PATTERN.test(binding.smoothing))) pushError(errors, 'ERR_INVALID_DURATION', `${path}.smoothing`, 'Invalid smoothing duration.');
if (binding.when !== undefined) validateCondition(document, binding.when, `${path}.when`, errors);
const sourceType = referenceType(document, binding.source);
const targetType = bindingTargetType(document, binding.target);
if (sourceType && targetType) {
const transformed = binding.scale !== undefined || binding.offset !== undefined || binding.clamp !== undefined || (binding.smoothing !== undefined && binding.smoothing !== '0ms');
if (transformed && (!isNumericType(sourceType) || !isNumericType(targetType))) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Binding transforms require numeric endpoints.');
else if (!transformed && sourceType !== targetType && !(isNumericType(sourceType) && isNumericType(targetType))) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Binding endpoint types do not match.');
}
if (typeof binding.target === 'string') {
if (writers.has(binding.target)) pushError(errors, 'ERR_CONFLICTING_BINDING', `${path}.target`, `Target already written by binding ${writers.get(binding.target)}.`);
else writers.set(binding.target, index);
if (!dependencies.has(binding.target)) dependencies.set(binding.target, []);
if (typeof binding.source === 'string') dependencies.get(binding.target).push(binding.source);
}
});
const visiting = new Set();
const visited = new Set();
const visit = (node, trail) => {
if (visiting.has(node)) return pushError(errors, 'ERR_CYCLIC_DEPENDENCY', '$.bindings', `Binding cycle: ${[...trail, node].join(' -> ')}.`);
if (visited.has(node)) return;
visiting.add(node);
for (const source of dependencies.get(node) ?? []) visit(source, [...trail, node]);
visiting.delete(node);
visited.add(node);
};
for (const target of dependencies.keys()) visit(target, []);
}
function parseAndValidateExhibit(source, filename) {
const parsed = parseExhibit(source, filename);
if (!parsed.valid) return parsed;
return { ...validateExhibit(parsed.document, filename), document: parsed.document, source: parsed.source };
}
/* src/runtime/persistence.js */
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed.'));
});
}
function transactionDone(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted.'));
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
});
}
class PersistenceManager {
constructor({ indexedDBProvider = globalThis.indexedDB, diagnostics } = {}) {
this.indexedDB = indexedDBProvider;
this.diagnostics = diagnostics;
this.database = null;
this.available = false;
}
async open() {
if (!this.indexedDB) return this.failOpen('IndexedDB is unavailable in this browser context.');
try {
const request = this.indexedDB.open(DATABASE.name, DATABASE.version);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(DATABASE.exhibits)) database.createObjectStore(DATABASE.exhibits, { keyPath: 'id' });
if (!database.objectStoreNames.contains(DATABASE.preferences)) database.createObjectStore(DATABASE.preferences, { keyPath: 'key' });
};
this.database = await requestResult(request);
this.database.onversionchange = () => {
this.database.close();
this.database = null;
this.available = false;
};
this.available = true;
this.diagnostics?.info('INFO_STORAGE_READY', 'Local exhibit storage is ready.', { section: 'persistence' });
return true;
} catch (error) {
return this.failOpen(error.message);
}
}
failOpen(reason) {
this.available = false;
this.diagnostics?.warn('WARN_STORAGE_UNAVAILABLE', `Session-only mode: ${reason} Imported exhibits must be imported again on a later launch.`, { section: 'persistence' });
return false;
}
async loadSnapshot() {
if (!this.available) return { exhibits: [], preferences: {} };
const transaction = this.database.transaction([DATABASE.exhibits, DATABASE.preferences], 'readonly');
const exhibitsRequest = transaction.objectStore(DATABASE.exhibits).getAll();
const preferencesRequest = transaction.objectStore(DATABASE.preferences).getAll();
const [exhibits, rows] = await Promise.all([requestResult(exhibitsRequest), requestResult(preferencesRequest), transactionDone(transaction)]);
return { exhibits, preferences: Object.fromEntries(rows.map(({ key, value }) => [key, value])) };
}
async saveExhibit(record) {
if (!this.available) return false;
const transaction = this.database.transaction(DATABASE.exhibits, 'readwrite');
transaction.objectStore(DATABASE.exhibits).put(record);
await transactionDone(transaction);
return true;
}
async savePreference(key, value) {
if (!this.available) return false;
const transaction = this.database.transaction(DATABASE.preferences, 'readwrite');
transaction.objectStore(DATABASE.preferences).put({ key, value });
await transactionDone(transaction);
return true;
}
}
/* src/runtime/library.js */
function bytesToHex(bytes) {
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function sha256(source, cryptoProvider = globalThis.crypto) {
if (!cryptoProvider?.subtle) throw new Error('Web Crypto SHA-256 is unavailable.');
const digest = await cryptoProvider.subtle.digest('SHA-256', new TextEncoder().encode(source));
return bytesToHex(new Uint8Array(digest));
}
class LibraryManager {
constructor({ persistence, diagnostics, cryptoProvider = globalThis.crypto } = {}) {
this.persistence = persistence;
this.diagnostics = diagnostics;
this.crypto = cryptoProvider;
this.records = new Map();
}
list() {
return [...this.records.values()].sort((left, right) => left.document.meta.name.localeCompare(right.document.meta.name));
}
get(id) { return this.records.get(id) ?? null; }
restore(records) {
for (const record of records) {
const checked = validateStoredRecord(record);
if (!checked.valid) {
this.diagnostics?.error('ERR_CACHE_INVALID', `Cached exhibit was ignored: ${checked.message}`, { exhibitId: record?.id ?? null, section: 'persistence' });
continue;
}
this.records.set(record.id, record);
}
if (this.records.size > 0) this.diagnostics?.info('INFO_LIBRARY_RESTORED', `Restored ${this.records.size} cached exhibit${this.records.size === 1 ? '' : 's'}.`, { section: 'library' });
}
async importSource(source, filename, { confirmReplacement = async () => false } = {}) {
const result = parseAndValidateExhibit(source, filename);
if (!result.valid) {
for (const error of result.errors) this.diagnostics?.error(error.code, error.message, { exhibitId: result.document?.meta?.id ?? null, section: error.section ?? 'loader', objectId: error.objectId, property: error.property });
return { status: 'invalid', ...result };
}
let digest;
try {
digest = await sha256(result.source, this.crypto);
} catch (error) {
this.diagnostics?.error('ERR_IMPORT_DIGEST', error.message, { exhibitId: result.document.meta.id, section: 'loader' });
return { status: 'invalid', valid: false, errors: [{ code: 'ERR_IMPORT_DIGEST', path: '$', message: error.message }] };
}
const id = result.document.meta.id;
const existing = this.records.get(id);
if (existing?.digest === digest) {
this.diagnostics?.info('INFO_IMPORT_IDENTICAL', `${result.document.meta.name} is already in the library; no changes were made.`, { exhibitId: id, section: 'library' });
return { status: 'identical', record: existing };
}
if (existing && !(await confirmReplacement(existing, result.document))) {
this.diagnostics?.info('INFO_IMPORT_REPLACEMENT_CANCELLED', `Replacement of ${existing.document.meta.name} was cancelled.`, { exhibitId: id, section: 'library' });
return { status: 'cancelled', record: existing };
}
const record = Object.freeze({
id,
digest,
source: result.source,
document: result.document,
sourceInfo: { kind: 'file', name: filename },
importedAt: new Date().toISOString()
});
this.records.set(id, record);
try {
await this.persistence?.saveExhibit(record);
} catch (error) {
this.diagnostics?.warn('WARN_STORAGE_WRITE_FAILED', `The exhibit is available for this session but could not be cached: ${error.message}`, { exhibitId: id, section: 'persistence' });
}
this.diagnostics?.info(existing ? 'INFO_EXHIBIT_REPLACED' : 'INFO_EXHIBIT_IMPORTED', `${result.document.meta.name} ${existing ? 'replaced' : 'imported'}.`, { exhibitId: id, section: 'library' });
return { status: existing ? 'replaced' : 'imported', record };
}
}
function validateStoredRecord(record) {
if (!record || typeof record.source !== 'string' || typeof record.digest !== 'string' || record.id !== record.document?.meta?.id) {
return { valid: false, message: 'record shape is invalid.' };
}
const result = parseAndValidateExhibit(record.source, record.sourceInfo?.name ?? 'cached.xzbt');
if (!result.valid || result.document.meta.id !== record.id) return { valid: false, message: 'definition no longer validates.' };
return { valid: true };
}
/* src/runtime/resolution.js */
const STEP_SECONDS = 1 / 60;
const NO_STAGES = Object.freeze({ binding: true, automation: false, override: true, modulation: false });
const AUDIO_BUS_STAGES = Object.freeze({ binding: true, automation: true, override: true, modulation: true });
class SignalProvider {
constructor() {
this.values = new Map([
['signals.time.elapsed', 0], ['signals.time.delta', 0],
['signals.audio.low', 0], ['signals.audio.mid', 0], ['signals.audio.high', 0], ['signals.audio.energy', 0],
['signals.pointer.x', 0], ['signals.pointer.y', 0],
['signals.viewport.width', globalThis.innerWidth ?? 0], ['signals.viewport.height', globalThis.innerHeight ?? 0],
['signals.scenario.active', false]
]);
}
get(path) {
if (!this.values.has(path)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown runtime signal '${path}'.`, path);
return this.values.get(path);
}
set(path, value) {
const type = RUNTIME_SIGNAL_TYPES[path];
if (!type) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown runtime signal '${path}'.`, path);
if ((type === 'number' && (!Number.isFinite(value))) || (type === 'boolean' && typeof value !== 'boolean')) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Signal '${path}' requires ${type}.`, path);
this.values.set(path, value);
}
updateTime(elapsedSeconds, deltaSeconds) {
this.values.set('signals.time.elapsed', elapsedSeconds);
this.values.set('signals.time.delta', deltaSeconds);
}
}
class OverrideStack {
constructor(engine) {
this.engine = engine;
this.instances = new Map();
this.activationSequence = 0;
}
add(action, sampledValue, { owner = 'performance', inheritedPriority = 0 } = {}) {
const target = this.engine.target(action.target);
if (!target) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Override target '${action.target}' is not exposed.`, action.target);
const normalized = normalizeForSpec(target.spec, sampledValue);
if (action.scope !== 'scenario' && action.scope !== 'duration') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', "Override scope must be 'scenario' or 'duration'.");
const durationMs = action.scope === 'duration' ? parseDuration(action.duration, '$.duration') : null;
if (action.scope === 'duration' && durationMs <= 0) throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration overrides require duration greater than 0ms.');
if (action.scope === 'scenario' && action.duration !== undefined) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Scenario-scoped overrides cannot declare duration.');
const priority = action.priority ?? inheritedPriority;
if (!Number.isInteger(priority) || priority < -1000 || priority > 1000) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Override priority must be an integer from -1000 through 1000.');
const transition = action.transition ?? {};
const attackMs = parseDuration(transition.in ?? '0ms', '$.transition.in');
const releaseMs = parseDuration(transition.out ?? '0ms', '$.transition.out');
const easing = transition.easing ?? 'linear';
easingValue(easing, 0);
if (!isNumericType(target.spec.type) && (attackMs > 0 || releaseMs > 0)) throw new RuntimeFault('ERR_INVALID_TRANSITION', 'Non-numeric overrides require zero-duration transitions.');
const sequence = ++this.activationSequence;
const instance = {
id: `instances.override-${sequence}`,
authorId: action.id ?? null,
target: action.target,
owner,
scope: action.scope,
priority,
activationSequence: sequence,
sampledValue: normalized,
attackOrigin: this.engine.preModulationValue(action.target),
attackMs,
releaseMs,
easing,
activatedAt: this.engine.logicalMilliseconds,
expiresAt: durationMs === null ? null : this.engine.logicalMilliseconds + durationMs,
phase: 'active',
releaseStartedAt: null,
releaseStartValue: null,
lastValue: null
};
this.instances.set(instance.id, instance);
this.engine.invalidate();
return instance.id;
}
beginRelease(id) {
const instance = this.instances.get(id);
if (!instance || instance.phase === 'releasing') return false;
if (instance.releaseMs === 0) {
this.instances.delete(id);
} else {
instance.phase = 'releasing';
instance.releaseStartedAt = this.engine.logicalMilliseconds;
instance.releaseStartValue = instance.lastValue ?? instance.sampledValue;
}
this.engine.invalidate();
return true;
}
releaseOwner(owner) {
for (const instance of [...this.instances.values()]) if (instance.owner === owner && instance.scope === 'scenario') this.beginRelease(instance.id);
}
advance() {
const now = this.engine.logicalMilliseconds;
for (const instance of [...this.instances.values()]) {
if (instance.phase === 'active' && instance.expiresAt !== null && now >= instance.expiresAt) this.beginRelease(instance.id);
if (instance.phase === 'releasing' && now - instance.releaseStartedAt >= instance.releaseMs) this.instances.delete(instance.id);
}
}
forTarget(path) { return [...this.instances.values()].filter((instance) => instance.target === path); }
has(path) { return this.forTarget(path).length > 0; }
select(path) {
return this.forTarget(path).reduce((winner, candidate) => {
if (!winner || candidate.priority > winner.priority || (candidate.priority === winner.priority && candidate.activationSequence > winner.activationSequence)) return candidate;
return winner;
}, null);
}
value(instance, lowerValue) {
const now = this.engine.logicalMilliseconds;
let value = instance.sampledValue;
if (instance.phase === 'releasing') {
const progress = (now - instance.releaseStartedAt) / instance.releaseMs;
value = lerp(lowerValue, instance.releaseStartValue, 1 - easingValue(instance.easing, progress));
} else if (instance.attackMs > 0) {
const progress = (now - instance.activatedAt) / instance.attackMs;
value = lerp(instance.attackOrigin, instance.sampledValue, easingValue(instance.easing, progress));
}
instance.lastValue = value;
return value;
}
clear() { this.instances.clear(); this.engine.invalidate(); }
}
class ResolutionEngine {
constructor(document, rootRng, { diagnostics, initialParameters = {}, onParameterChange = () => {} } = {}) {
this.document = document;
this.rng = rootRng;
this.diagnostics = diagnostics;
this.onParameterChange = onParameterChange;
this.parameters = new Map();
this.state = new Map();
this.busValues = new Map();
this.visualValues = 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();
this.resolved = new Map();
this.resolving = new Set();
this.tickIndex = 0;
this.logicalMilliseconds = 0;
this.valueResolver = new ValueResolver((path) => this.get(path));
this.conditions = new ConditionEvaluator(this.valueResolver);
this.overrides = new OverrideStack(this);
for (const [id, spec] of Object.entries(document.parameters ?? {})) {
const candidate = Object.hasOwn(initialParameters, id) ? initialParameters[id] : spec.default;
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 }, stages: AUDIO_BUS_STAGES };
}
// The four visual target families of 19.1. A record with only a `reason` is
// a visual path that resolves to no capability; the caller reports it.
const visual = matchVisualTarget(this.document, path);
if (visual) return visual.reason ? null : visual;
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], stages: NO_STAGES };
if (namespace === 'state' && this.document.state?.[id]) return { namespace, id, spec: this.document.state[id], stages: NO_STAGES };
return null;
}
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);
}
if (target.namespace.startsWith('visual-')) {
// Visual system-level bases are sampled once, lazily, from the `visual`
// stream domain — the exhibit-scope instantiation boundary of 17.14.
if (!this.visualValues.has(path)) this.visualValues.set(path, this.valueResolver.evaluate(visualTargetSource(this.document, target), this.rng.stream('visual', `target:${path}`), path));
return this.visualValues.get(path);
}
return target.namespace === 'parameters' ? this.parameters.get(target.id) : this.state.get(target.id);
}
get(path) {
if (path.startsWith('signals.')) return this.signals.get(path);
if (this.resolved.has(path)) return this.resolved.get(path);
return this.resolveTarget(path);
}
resolveAll() {
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 path of this.visualTargetPaths()) this.resolveTarget(path);
for (const listener of this.listeners) listener(this);
return this.snapshot();
}
resolveTarget(path) {
if (this.resolved.has(path)) return this.resolved.get(path);
const target = this.target(path);
if (!target) {
// A visual path inside a family that names something undeclared is a
// reference error; one outside every family exposes no capability (19.1).
const visual = matchVisualTarget(this.document, path);
if (visual?.reason === 'unsupported') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `'${path}' exposes no resolution capability.`, path);
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown or unsupported reference '${path}'.`, path);
}
if (this.resolving.has(path)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Resolution cycle reached '${path}'.`, path);
this.resolving.add(path);
try {
let lower = this.base(path);
const binding = this.bindings.find((item) => item.definition.target === path);
const winner = this.overrides.select(path);
let value;
if (isNumericType(target.spec.type)) {
const track = target.stages?.automation ? 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.stages?.modulation ? [...(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;
} finally {
this.resolving.delete(path);
}
}
bindingValue(binding, target, baseValue) {
const definition = binding.definition;
const conditionStream = this.rng.stream('scenario', `binding-${binding.index}:condition`);
const enabled = definition.when === undefined || this.conditions.evaluate(definition.when, conditionStream, `$.bindings[${binding.index}].when`);
if (!enabled) {
binding.enabled = false;
binding.smoother = undefined;
return baseValue;
}
const source = this.get(definition.source);
const numeric = typeof source === 'number' && isNumericType(target.spec.type);
let value = source;
if (numeric) {
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 (tau === 0 || !binding.enabled || binding.smoother === undefined) {
binding.smoother = value;
binding.lastTick = this.tickIndex;
} else if (binding.lastTick !== this.tickIndex) {
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); }
/**
* Every target family the shared pipeline exposes, enumerated so bindings and
* overrides against them resolve on the same tick as everything else. The
* four visual families are system-level only (19.1); no per-object property
* appears here, and none may.
*/
visualTargetPaths() {
const visuals = this.document.visuals;
if (!visuals) return [];
const paths = [];
for (const field of Object.keys(CAMERA_FIELDS)) paths.push(`visuals.camera.${field}`);
for (const id of Object.keys(visuals.layers ?? {})) paths.push(`visuals.layers.${id}.opacity`);
for (const id of Object.keys(visuals.systems ?? {})) paths.push(`visuals.systems.${id}.visible`);
(visuals.effects ?? []).forEach((entry, index) => {
for (const parameter of Object.keys(POST_EFFECTS[entry?.type]?.numeric ?? {})) paths.push(`visuals.effects[${index}].${parameter}`);
});
return paths;
}
requireAudioStage(path) {
this.requireStage(path, 'automation');
}
requireStage(path, stage) {
const target = this.target(path);
if (!target?.stages?.[stage]) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `The ${stage} stage 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}`);
this.parameters.set(id, normalizeForSpec(spec, value));
this.invalidate();
this.onParameterChange(this.parameterSnapshot());
return this.resolveAll();
}
setState(path, value, transition = {}) {
const target = this.target(path);
if (!target || target.namespace !== 'state') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `set may target state only; got '${path}'.`, path);
const normalized = normalizeForSpec(target.spec, value);
const durationMs = parseDuration(transition.duration ?? '0ms', '$.transition.duration');
const easing = transition.easing ?? 'linear';
easingValue(easing, 0);
if (!isNumericType(target.spec.type) && durationMs > 0) throw new RuntimeFault('ERR_INVALID_TRANSITION', 'Non-numeric state values require zero-duration transitions.');
if (durationMs === 0) {
this.state.set(target.id, normalized);
this.transitions.delete(path);
} else {
this.transitions.set(path, { path, from: this.base(path), to: normalized, start: this.logicalMilliseconds, durationMs, easing, spec: target.spec });
}
this.invalidate();
return this.resolveAll();
}
advance(milliseconds = 1000 / 60) {
if (!Number.isFinite(milliseconds) || milliseconds < 0) throw new RangeError('Advance duration must be finite and nonnegative.');
this.logicalMilliseconds += milliseconds;
this.tickIndex += 1;
this.signals.updateTime(this.logicalMilliseconds / 1000, milliseconds / 1000);
for (const [path, transition] of [...this.transitions]) {
const progress = clamp((this.logicalMilliseconds - transition.start) / transition.durationMs, 0, 1);
let value = lerp(transition.from, transition.to, easingValue(transition.easing, progress));
if (transition.spec.type === 'integer') value = roundHalfAwayFromZero(value);
value = normalizeForSpec(transition.spec, value, { clampNumeric: true });
this.state.set(path.split('.')[1], value);
if (progress >= 1) this.transitions.delete(path);
}
this.overrides.advance();
return this.resolveAll();
}
evaluateValue(spec, stream, path) { return this.valueResolver.evaluate(spec, stream, path); }
evaluateCondition(spec, stream, path) { return this.conditions.evaluate(spec, stream, path); }
invalidate() { this.resolved.clear(); }
parameterSnapshot() { return Object.fromEntries(this.parameters); }
stateSnapshot() { return Object.fromEntries(this.state); }
snapshot() { return Object.fromEntries(this.resolved); }
dispose() {
this.listeners.clear(); this.automation.clear(); this.modulation.clear(); this.busValues.clear(); this.visualValues.clear(); this.preModulation.clear();
this.overrides.clear(); this.transitions.clear(); this.resolved.clear();
}
}
/* src/runtime/visual-engine.js */
// Slice 4d — the visual renderer core.
//
// The engine is renderer-neutral by construction (17.1): it resolves an
// exhibit's declared visual objects once at their instantiation boundary
// (17.14), composes the scene-to-device chain of 19.3, sorts by depth (17.6),
// applies depth fog and the compositing order of 17.12, and emits a **frame
// plan** — an ordered list of draw operations in device coordinates. A backend
// (visual-canvas2d.js) turns that plan into drawing calls.
//
// The plan is the testable surface. Every automated trace of 17.16 asks for a
// numeric oracle — a known scene point at a known display point, a documented
// perspective factor, a fog fraction, a sort order — and a plan carries those
// as data, where a canvas carries only pixels.
const RUNTIME = VISUAL_LIMITS.runtime;
const DEFAULT_STYLE = Object.freeze({
fill: null, stroke: null, strokeWidth: 1, strokeCap: 'butt', strokeJoin: 'miter',
strokeDash: undefined, strokeDashOffset: 0, pointSize: 1, opacity: 1, blend: 'normal',
glow: null, shadow: null, blur: 0, filters: [], clip: null, mask: null
});
// 17.12: inheritance is per field. `opacity` is deliberately absent — it
// *multiplies* with any inherited group opacity rather than replacing it, and
// is accumulated separately as the effective alpha of the compositing order.
const INHERITED_STYLE_FIELDS = Object.freeze([
'fill', 'stroke', 'strokeWidth', 'strokeCap', 'strokeJoin', 'strokeDash', 'strokeDashOffset',
'pointSize', 'blend', 'glow', 'shadow', 'blur', 'filters'
]);
/** 17.12: `line`, `polyline`, `arc`, and `bezier` have no interior. */
const STROKE_ONLY = new Set(['line', 'polyline', 'arc', 'bezier']);
// ---------------------------------------------------------------------------
// Instantiation (17.14)
// ---------------------------------------------------------------------------
function isValueSpec(value) {
if (!isRecord(value)) return false;
if (Object.hasOwn(value, 'ref') || Object.hasOwn(value, 'random') || Object.hasOwn(value, 'choose')) return true;
// A ValueSpec `op` carries `args`; a path command's `op` is a command token.
return Object.hasOwn(value, 'op') && Array.isArray(value.args);
}
/**
* Resolve every ValueSpec in a raw subtree, depth-first in property-document
* order, from one stream — the sampling rule 17.14 shares with 14.4 and 9.3.
* Everything that is not a ValueSpec is copied through unchanged.
*/
function sampleTree(raw, resolver, stream, path = '$') {
if (Array.isArray(raw)) return raw.map((entry, index) => sampleTree(entry, resolver, stream, `${path}[${index}]`));
if (!isRecord(raw)) return raw;
if (isValueSpec(raw)) return resolver.evaluate(raw, stream, path);
const result = {};
for (const key of Object.keys(raw)) result[key] = sampleTree(raw[key], resolver, stream, `${path}.${key}`);
return result;
}
function mergeStyle(inherited, own) {
if (!isRecord(own)) return inherited;
const merged = { ...inherited };
for (const field of INHERITED_STYLE_FIELDS) {
if (Object.hasOwn(own, field)) merged[field] = own[field];
}
// `clip` and `mask` are local: a clip intersects with any inherited one and a
// mask names a child of its own group (17.12), so neither inherits downward.
merged.clip = Object.hasOwn(own, 'clip') ? own.clip : null;
merged.mask = Object.hasOwn(own, 'mask') ? own.mask : null;
return merged;
}
function vector(value, fallback = 0) {
return { x: value?.x ?? fallback, y: value?.y ?? fallback, z: value?.z ?? fallback };
}
/** One instantiated visual object: resolved values, its local matrix, its subtree. */
function instantiateObject(key, raw, context, parentPath, depth) {
if (!isRecord(raw)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A visual object must be an object.', parentPath);
const path = parentPath ? `${parentPath}.${key}` : key;
if (depth > VISUAL_LIMITS.authoring.groupNesting) {
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `Group nesting deeper than ${VISUAL_LIMITS.authoring.groupNesting} levels.`, path);
}
const resolved = sampleTree(raw, context.resolver, context.stream, path);
const transform = resolved.transform ?? {};
const node = {
key,
path,
type: resolved.type,
raw: resolved,
position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 },
z: resolved.z ?? 0,
translateZ: transform.translate?.z ?? 0,
visible: resolved.visible ?? true,
matrix: localMatrix({
position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 },
translate: vector(transform.translate),
rotation: transform.rotation ?? 0,
skew: vector(transform.skew),
scale: { x: transform.scale?.x ?? 1, y: transform.scale?.y ?? 1 },
origin: vector(transform.origin)
}),
style: mergeStyle(context.style, resolved.style),
// 17.12: opacity multiplies down the tree rather than being inherited.
ownOpacity: typeof resolved.style?.opacity === 'number' ? resolved.style.opacity : 1,
children: []
};
if (resolved.type === 'group') {
const previousStyle = context.style;
context.style = node.style;
for (const childKey of Object.keys(resolved.children ?? {})) {
node.children.push(instantiateObject(childKey, raw.children[childKey], context, path, depth + 1));
}
context.style = previousStyle;
}
// 17.12: a `fill` declared on a stroke-only primitive is a validation error;
// one inherited from an ancestor is ignored for it and still applies to its
// fillable siblings. Inheritance never turns into a validation error.
if (STROKE_ONLY.has(node.type)) node.style = { ...node.style, fill: null };
node.geometry = primitiveSubpaths(resolved, path);
return node;
}
// ---------------------------------------------------------------------------
// Display, fit, and camera (17.4, 19.3)
// ---------------------------------------------------------------------------
/** 19.3: the fit matrix F, carrying the centering offsets contain and cover need. */
function resolveFit(scene, displayWidth, displayHeight) {
const space = scene?.coordinateSpace ?? 'virtual';
const sceneWidth = space === 'virtual' ? scene.width : space === 'normalized' ? 1 : displayWidth;
const sceneHeight = space === 'virtual' ? scene.height : space === 'normalized' ? 1 : displayHeight;
let sx = 1;
let sy = 1;
if (space !== 'viewport') {
// 17.4 (V17): in `viewport` the fit field takes no default and has no
// mapping effect, so the scale stays 1 whatever was authored.
const fit = scene?.fit ?? 'contain';
if (fit === 'stretch') {
sx = displayWidth / sceneWidth;
sy = displayHeight / sceneHeight;
} else {
const uniform = fit === 'cover'
? Math.max(displayWidth / sceneWidth, displayHeight / sceneHeight)
: Math.min(displayWidth / sceneWidth, displayHeight / sceneHeight);
sx = uniform;
sy = uniform;
}
}
const ox = (displayWidth - sx * sceneWidth) / 2;
const oy = (displayHeight - sy * sceneHeight) / 2;
return { space, sceneWidth, sceneHeight, sx, sy, ox, oy, matrix: multiply(translation(ox, oy), scaling(sx, sy)) };
}
/**
* 19.5 and A3: the device-pixel multiplier is `min(devicePixelRatio, 2)`,
* lowered — below `1` where a display larger than 4096 requires it — until the
* backing store fits `4096 x 4096`.
*/
function resolveBackingStore(displayWidth, displayHeight, devicePixelRatio) {
const requested = Math.min(devicePixelRatio, RUNTIME.devicePixelRatioCeiling);
const longest = Math.max(displayWidth, displayHeight);
const permitted = longest > 0 ? RUNTIME.backingStoreEdge / longest : requested;
const multiplier = Math.min(requested, permitted);
return {
multiplier,
reduced: multiplier < requested,
below1: multiplier < 1,
width: Math.max(1, Math.round(displayWidth * multiplier)),
height: Math.max(1, Math.round(displayHeight * multiplier))
};
}
/** 19.3: `V = T(c) x S(zoom) x R(-rotation) x T(-c) x T(-p * (q - c))`. */
function cameraMatrix({ center, zoom, rotationDegrees, cameraCss, parallax }) {
const [cx, cy] = center;
const [qx, qy] = cameraCss;
return compose(
translation(cx, cy),
scaling(zoom, zoom),
rotation(-rotationDegrees),
translation(-cx, -cy),
translation(-parallax * (qx - cx), -parallax * (qy - cy))
);
}
// ---------------------------------------------------------------------------
// The engine
// ---------------------------------------------------------------------------
class VisualEngine {
constructor(document, { resolution = null, rng = null, diagnostics = null, capabilities = {} } = {}) {
this.document = document;
this.resolution = resolution;
this.rng = rng;
this.capabilities = { conicGradient: true, ...capabilities };
this.cadence = new VisualDiagnosticCadence(diagnostics);
this.visuals = document?.visuals ?? null;
this.systems = [];
this.layers = [];
this.deferred = [];
this.deferredKeys = new Set();
if (this.visuals) this.instantiate();
}
/** 17.14: activation is the instantiation boundary of a persistent system. */
instantiate() {
const visuals = this.visuals;
const declared = visuals.layers ? Object.keys(visuals.layers) : [];
this.layers = declared.length > 0
? declared.map((id) => ({ id, ...visuals.layers[id] }))
: [{ id: null, implicit: true }];
const resolver = this.resolution?.valueResolver;
this.systems = [];
this.deferred = [];
this.deferredKeys = new Set();
let index = 0;
for (const [id, system] of Object.entries(visuals.systems ?? {})) {
// 19.2: a spawned system is a template. It is validated and counted at
// import and is not instantiated or drawn at activation.
if ((system.lifecycle ?? 'persistent') === 'spawned') {
this.defer(id, 'spawned template, instantiated only by a spawn action (19.2)');
continue;
}
if (system.type !== 'graphic') {
// 18.2-18.5 procedural systems execute in slice 4e. Their schema is
// carried and validated; nothing is drawn for them here.
this.defer(id, `'${system.type}' systems execute in slice 4e (18.2-18.5)`);
continue;
}
const stream = this.rng ? this.rng.stream('visual', `visuals.systems.${id}`) : null;
const context = { resolver, stream, style: DEFAULT_STYLE };
const objects = [];
let objectIndex = 0;
for (const key of Object.keys(system.content ?? {})) {
const node = instantiateObject(key, system.content[key], context, `visuals.systems.${id}.content`, 1);
node.order = objectIndex;
objectIndex += 1;
objects.push(node);
}
this.systems.push({ id, index, layer: system.layer ?? null, visible: system.visible ?? true, objects });
index += 1;
}
return this;
}
defer(id, reason) {
if (this.deferredKeys.has(id)) return;
this.deferredKeys.add(id);
this.deferred.push({ id, reason });
}
cameraValue(field) {
const path = `visuals.camera.${field}`;
if (this.resolution) {
try { return this.resolution.get(path); } catch { /* fall through to the authored default */ }
}
const authored = this.visuals?.camera?.[field];
if (typeof authored === 'number') return authored;
return CAMERA_FIELDS[field].default ?? 0;
}
layerValue(layer, field, fallback) {
if (layer.id && this.resolution) {
try { return this.resolution.get(`visuals.layers.${layer.id}.${field}`); } catch { /* authored value below */ }
}
const authored = layer[field];
return authored === undefined || isRecord(authored) ? fallback : authored;
}
systemVisible(system) {
if (this.resolution) {
try { return this.resolution.get(`visuals.systems.${system.id}.visible`) !== false; } catch { /* authored value below */ }
}
return system.visible !== false;
}
/**
* Build one frame's plan. `width` and `height` are the display rectangle in
* CSS pixels; everything in the returned plan is in device pixels.
*/
planFrame({ width, height, devicePixelRatio = 1, logicalMilliseconds = 0 } = {}) {
if (!this.visuals) return null;
const scene = this.visuals.scene ?? {};
const fit = resolveFit(scene, width, height);
const backing = resolveBackingStore(width, height, devicePixelRatio);
if (backing.below1) {
this.cadence.once('WARN_VISUAL_APPROXIMATION', `backing-store:${backing.width}x${backing.height}`,
`The display exceeds ${RUNTIME.backingStoreEdge} device pixels, so the frame is rendered at a multiplier of ${backing.multiplier.toFixed(4)} and upsampled.`);
}
const center = [width / 2, height / 2];
const B = scaling(backing.multiplier, backing.multiplier);
const projection = this.visuals.camera?.projection ?? 'orthographic';
const zoom = this.cameraValue('zoom');
const rotationDegrees = this.cameraValue('rotation');
const focalLength = clamp(this.cameraValue('focalLength'), CAMERA_FIELDS.focalLength.min, CAMERA_FIELDS.focalLength.max);
let cameraX = this.cameraValue('x');
let cameraY = this.cameraValue('y');
if (fit.space === 'viewport' && this.visuals.camera?.x === undefined) cameraX = center[0];
if (fit.space === 'viewport' && this.visuals.camera?.y === undefined) cameraY = center[1];
const cameraCss = fit.space === 'viewport' ? [cameraX, cameraY] : apply(fit.matrix, cameraX, cameraY);
const fog = this.resolveFog(scene);
// `stats` is shared by reference: each unit is emitted with a shallow copy
// of this context carrying its own layer view matrix, so per-frame counters
// have to live behind a reference rather than on the copy.
const context = {
fit, backing, B, center, projection, zoom, focalLength, fog,
logicalMilliseconds, buffers: [], stats: { culled: 0 }
};
const layers = [];
for (const layer of this.layers) {
const parallax = this.layerValue(layer, 'parallax', 1);
const view = cameraMatrix({ center, zoom, rotationDegrees, cameraCss, parallax });
const opacity = clamp(this.layerValue(layer, 'opacity', 1), 0, 1);
const visible = this.layerValue(layer, 'visible', true) !== false;
const blend = layer.blend ?? 'normal';
const units = this.sortableUnits(layer);
const nodes = [];
for (const unit of units) {
const plan = this.emitNode(unit.node, IDENTITY, 0, 1, { ...context, view, layerId: layer.id });
if (!plan) continue;
// The plan node is pushed by reference: buffer allocation runs after
// every layer is emitted and mutates the nodes it refuses.
plan.representativeDepth = unit.representativeDepth;
nodes.push(plan);
}
layers.push({
id: layer.id, opacity, blend, visible, parallax,
// 17.12: a layer whose opacity or blend is not the default needs a buffer.
buffered: opacity !== 1 || blend !== 'normal',
nodes
});
}
this.allocateBuffers(layers, context);
this.cadence.endTick();
return {
background: formatColor(parseColor(scene.background ?? '#000000', '$.visuals.scene.background')),
display: { width, height },
backing,
fit,
camera: { x: cameraX, y: cameraY, zoom, rotation: rotationDegrees, projection, focalLength, cameraCss, center },
fog,
layers,
culled: context.stats.culled,
buffers: context.bufferSummary,
deferred: this.deferred,
diagnostics: this.cadence.raised.slice()
};
}
resolveFog(scene) {
const declared = scene.depthFog;
if (!declared) return null;
const near = declared.near ?? 0;
const far = declared.far;
if (!(far > near)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'depthFog.far must exceed depthFog.near.', '$.visuals.scene.depthFog');
return { color: parseColor(declared.color, '$.visuals.scene.depthFog.color'), near, far, density: declared.density ?? 1 };
}
/**
* 17.6: within a layer the sortable units are each `graphic` system's
* top-level objects and each procedural system as one atomic unit. They sort
* by representative depth descending, ties by system key order then object
* key order, and the sort is stable.
*/
sortableUnits(layer) {
const units = [];
for (const system of this.systems) {
if ((system.layer ?? null) !== (layer.id ?? null)) continue;
if (!this.systemVisible(system)) continue;
for (const node of system.objects) {
units.push({ node, representativeDepth: node.z + node.translateZ, systemIndex: system.index, objectIndex: node.order });
}
}
return units.sort((a, b) => (b.representativeDepth - a.representativeDepth)
|| (a.systemIndex - b.systemIndex)
|| (a.objectIndex - b.objectIndex));
}
// -------------------------------------------------------------------------
// Emitting one object's plan node (17.6, 17.11, 17.12, 19.3)
// -------------------------------------------------------------------------
emitNode(node, parentMatrix, parentZ, parentAlpha, ctx) {
const zEffective = parentZ + node.z + node.translateZ;
// 17.7/17.10: a hidden object and its descendants are not drawn; their
// behaviors and automation still advance, which is not this pass's concern.
if (node.visible === false) return null;
if (ctx.projection === 'perspective' && zEffective <= -ctx.focalLength) {
// 17.6: at or behind the eye. Culled for the frame, no diagnostic.
ctx.stats.culled += 1;
return null;
}
if (node.type === 'component') {
// Recorded once, not once per frame: a plan is built every frame and the
// deferral is a property of the document, not of the frame.
this.defer(node.path, 'component expansion lands in slice 4e (18.1)');
return null;
}
const matrix = multiply(parentMatrix, node.matrix);
const alpha = parentAlpha * node.ownOpacity;
const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
// 19.3: the one uniform factor an axis-less appearance dimension takes.
const g = ctx.zoom * k * Math.sqrt(ctx.fit.sx * ctx.fit.sy);
const scalar = (value) => (value ?? 0) * g * ctx.backing.multiplier;
const project = (px, py, pz = 0) => {
let [x, y] = apply(matrix, px, py);
[x, y] = apply(ctx.fit.matrix, x, y);
[x, y] = apply(ctx.view, x, y);
if (ctx.projection === 'perspective') {
const factor = ctx.focalLength / (ctx.focalLength + zEffective + pz);
x = ctx.center[0] + (x - ctx.center[0]) * factor;
y = ctx.center[1] + (y - ctx.center[1]) * factor;
}
return apply(ctx.B, x, y);
};
const style = node.style;
const fogFraction = ctx.fog
? ctx.fog.density * clamp((zEffective - ctx.fog.near) / (ctx.fog.far - ctx.fog.near), 0, 1)
: 0;
const shade = (value, path) => {
const parsed = parseColor(value, path);
return formatColor(ctx.fog ? fogColor(parsed, ctx.fog.color, fogFraction) : parsed);
};
const plan = {
kind: node.type === 'group' ? 'group' : node.type === 'text' ? 'text' : node.type === 'point' ? 'point' : 'shape',
path: node.path,
type: node.type,
z: zEffective,
perspective: k,
alpha,
blend: style.blend ?? 'normal',
fill: this.buildPaint(style.fill, node, project, scalar, shade, ctx, 'fill'),
stroke: this.buildPaint(style.stroke, node, project, scalar, shade, ctx, 'stroke'),
strokeWidth: scalar(style.strokeWidth ?? 1),
strokeCap: style.strokeCap ?? 'butt',
strokeJoin: style.strokeJoin ?? 'miter',
strokeDash: Array.isArray(style.strokeDash) ? style.strokeDash.map(scalar) : undefined,
strokeDashOffset: scalar(style.strokeDashOffset ?? 0),
glow: style.glow ? { color: shade(style.glow.color, `${node.path}.style.glow.color`), radius: scalar(style.glow.radius), strength: style.glow.strength ?? 1 } : null,
shadow: style.shadow ? {
color: shade(style.shadow.color, `${node.path}.style.shadow.color`),
blurRadius: scalar(style.shadow.blurRadius ?? 0),
offsetX: scalar(style.shadow.offsetX ?? 0),
offsetY: scalar(style.shadow.offsetY ?? 0)
} : null,
blur: scalar(style.blur ?? 0),
filters: Array.isArray(style.filters) ? style.filters.map((entry) => ({ ...entry })) : [],
clip: this.buildClip(style.clip, project),
fogFraction,
children: [],
mask: null
};
if (node.type === 'group') {
const maskKey = style.mask ?? null;
for (const child of node.children) {
const childPlan = this.emitNode(child, matrix, zEffective, alpha, ctx);
if (!childPlan) continue;
// 17.12: the named child is not drawn; its rendered alpha multiplies
// the alpha of the group's remaining children.
if (maskKey !== null && child.key === maskKey) plan.mask = childPlan;
else plan.children.push(childPlan);
}
if (maskKey !== null && plan.mask === null) {
throw new RuntimeFault('ERR_INVALID_REFERENCE', `mask '${maskKey}' names no child of this group.`, node.path);
}
} else if (node.type === 'point') {
plan.center = project(0, 0);
plan.diameter = scalar(style.pointSize ?? 1);
} else if (node.type === 'text') {
// Text is the one primitive drawn by the backend's own text engine, so it
// carries the matrix rather than transformed geometry.
plan.matrix = this.deviceMatrix(matrix, zEffective, ctx);
plan.text = String(node.raw.text ?? '');
if (plan.text.length > VISUAL_LIMITS.authoring.textCharacters) {
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Resolved text exceeds ${VISUAL_LIMITS.authoring.textCharacters} characters.`, node.path);
}
plan.font = node.raw.font ?? 'sans-serif';
plan.size = node.raw.size ?? 16;
plan.weight = node.raw.weight ?? 'normal';
plan.italic = node.raw.italic === true;
plan.align = node.raw.align ?? 'left';
plan.baseline = node.raw.baseline ?? 'alphabetic';
plan.letterSpacing = node.raw.letterSpacing ?? 0;
plan.maxWidth = node.raw.maxWidth;
if (plan.maxWidth !== undefined && !(plan.maxWidth > 0)) {
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'maxWidth must be above zero.', node.path);
}
} else {
plan.subpaths = node.geometry.map((subpath) => ({
closed: subpath.closed,
start: project(subpath.start[0], subpath.start[1], subpath.start[2] ?? 0),
segments: subpath.segments.map((segment) => segment.type === 'line'
? { type: 'line', to: project(segment.to[0], segment.to[1], segment.to[2] ?? 0) }
: {
type: 'cubic',
c1: project(segment.c1[0], segment.c1[1], segment.c1[2] ?? 0),
c2: project(segment.c2[0], segment.c2[1], segment.c2[2] ?? 0),
to: project(segment.to[0], segment.to[1], segment.to[2] ?? 0)
})
}));
plan.fillRule = node.raw.fillRule ?? 'nonzero';
}
// 17.12: a non-default blend, a mask, a blur, a glow, or any filters entry
// requires an offscreen buffer for the object or group.
plan.needsBuffer = plan.blend !== 'normal' || plan.mask !== null || plan.blur > 0 || plan.glow !== null || plan.filters.length > 0;
if (plan.needsBuffer) ctx.buffers.push({ plan, depth: zEffective, order: ctx.buffers.length });
return plan;
}
/** The single affine chain for a constant-depth object: `B x P x V x F x M`. */
deviceMatrix(matrix, zEffective, ctx) {
const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
const perspective = compose(translation(ctx.center[0], ctx.center[1]), scaling(k, k), translation(-ctx.center[0], -ctx.center[1]));
return compose(ctx.B, perspective, ctx.view, ctx.fit.matrix, matrix);
}
buildClip(clip, project) {
if (!isRecord(clip)) return null;
const { shape, x, y, width, height } = clip;
if (shape === 'ellipse') {
const cx = x + width / 2;
const cy = y + height / 2;
return { shape: 'ellipse', center: project(cx, cy), corners: [project(x, y), project(x + width, y), project(x + width, y + height), project(x, y + height)] };
}
return { shape: 'rectangle', corners: [project(x, y), project(x + width, y), project(x + width, y + height), project(x, y + height)] };
}
/**
* 17.12: a paint is a color, a gradient object, or null. Gradient stops are
* fogged individually before the paint is constructed, so a fogged gradient
* keeps its offsets and its shape and loses only its contrast.
*/
buildPaint(paint, node, project, scalar, shade, ctx, role) {
if (paint === null || paint === undefined) return null;
if (typeof paint === 'string') return { type: 'color', color: shade(paint, `${node.path}.style.${role}`) };
if (!isRecord(paint)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'A paint must be a color, a paint object, or null.', node.path);
const stops = (paint.stops ?? []).map((stop) => ({ offset: stop.offset, color: shade(stop.color, `${node.path}.style.${role}.stops`) }));
if (paint.type === 'linear-gradient') {
return { type: 'linear-gradient', stops, from: project(paint.from?.x ?? 0, paint.from?.y ?? 0), to: project(paint.to?.x ?? 0, paint.to?.y ?? 0) };
}
if (paint.type === 'radial-gradient') {
return {
type: 'radial-gradient', stops,
center: project(paint.center?.x ?? 0, paint.center?.y ?? 0),
radius: scalar(paint.radius ?? 0),
innerRadius: scalar(paint.innerRadius ?? 0)
};
}
if (paint.type === 'conic-gradient') {
const center = { x: paint.center?.x ?? 0, y: paint.center?.y ?? 0 };
const angle = paint.angle ?? 0;
if (this.capabilities.conicGradient) {
return { type: 'conic-gradient', stops, center: project(center.x, center.y), angle };
}
return this.conicFallback(node, stops, center, angle, project, `${node.path}.style.${role}`);
}
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown paint type '${paint.type}'.`, node.path);
}
/**
* 17.12: the documented conic fallback. The axis runs from `center` along
* `angle` to the local-space bounding box's boundary; where the center is
* outside the box or the ray never meets it, it runs to the distance of the
* farthest corner, which reduces to the intersection case when one exists.
* The stops and their offsets are preserved exactly.
*/
conicFallback(node, stops, center, angle, project, path) {
const box = boundingBox(node.geometry);
const corners = [[box.x, box.y], [box.x + box.width, box.y], [box.x + box.width, box.y + box.height], [box.x, box.y + box.height]];
const distance = Math.max(...corners.map(([x, y]) => Math.hypot(x - center.x, y - center.y)), 0);
this.cadence.once('WARN_VISUAL_APPROXIMATION', path,
'The renderer has no conic gradient; the documented linear fallback was used with the same stops.');
if (distance === 0) {
// A degenerate box has no axis. A flat first stop is a materially milder
// substitution than omitting the paint.
return { type: 'color', color: stops[0]?.color ?? '#000000', approximated: true };
}
const radians = angle * (Math.PI / 180);
return {
type: 'linear-gradient', stops, approximated: true,
from: project(center.x, center.y),
to: project(center.x + distance * Math.cos(radians), center.y + distance * Math.sin(radians))
};
}
/**
* 17.12 and 19.5: sixteen buffer allocations per frame. Layer buffers are
* allocated first, then object buffers nearest-first, so what runs out is the
* far content and the refusals are taken farthest-first.
*/
allocateBuffers(layers, ctx) {
const layerBuffers = layers.filter((layer) => layer.visible && layer.buffered).length;
let budget = Math.max(0, RUNTIME.offscreenBuffersPerFrame - layerBuffers);
const candidates = ctx.buffers.slice().sort((a, b) => (a.depth - b.depth) || (a.order - b.order));
let granted = 0;
let shed = 0;
for (const candidate of candidates) {
if (budget > 0) {
candidate.plan.buffered = true;
budget -= 1;
granted += 1;
continue;
}
// Refused: the owner draws without its buffer-requiring features. This is
// a diagnosed exception to 17.1, not a silent omission.
const plan = candidate.plan;
plan.buffered = false;
plan.shed = true;
plan.blend = 'normal';
plan.mask = null;
plan.blur = 0;
plan.glow = null;
plan.filters = [];
shed += 1;
this.cadence.shed('WARN_VISUAL_APPROXIMATION', 'offscreen-buffers',
`The frame needs more than ${RUNTIME.offscreenBuffersPerFrame} offscreen buffers; the farthest objects drew without their buffered appearance features.`,
ctx.logicalMilliseconds);
}
ctx.bufferSummary = { limit: RUNTIME.offscreenBuffersPerFrame, layers: layerBuffers, granted, shed };
}
}
/* src/runtime/visual-canvas2d.js */
// Canvas 2D backend for the frame plans of visual-engine.js.
//
// XZBT 0.1 realizes the renderer-neutral schema of 17.1 with Canvas 2D (PRD
// 69). The plan arrives in device pixels with every scalar already converted by
// the uniform factor of 19.3, so this module sets no transform for geometry: it
// strokes with an explicit device-pixel `lineWidth`, which is what keeps a
// stroke from being distorted by a nonuniform `stretch` fit. Text is the one
// exception and carries its own matrix.
const BLEND_OPERATIONS = Object.freeze({
normal: 'source-over', add: 'lighter', screen: 'screen', multiply: 'multiply',
overlay: 'overlay', lighten: 'lighten', darken: 'darken', difference: 'difference'
});
const FILTER_UNITS = Object.freeze({
brightness: '', contrast: '', saturate: '', 'hue-rotate': 'deg', grayscale: '', sepia: '', invert: ''
});
function tracePath(context, node) {
context.beginPath();
for (const subpath of node.subpaths ?? []) {
context.moveTo(subpath.start[0], subpath.start[1]);
for (const segment of subpath.segments) {
if (segment.type === 'line') context.lineTo(segment.to[0], segment.to[1]);
else context.bezierCurveTo(segment.c1[0], segment.c1[1], segment.c2[0], segment.c2[1], segment.to[0], segment.to[1]);
}
if (subpath.closed) context.closePath();
}
}
function buildPaint(context, paint) {
if (!paint) return null;
if (paint.type === 'color') return paint.color;
let gradient;
if (paint.type === 'linear-gradient') {
gradient = context.createLinearGradient(paint.from[0], paint.from[1], paint.to[0], paint.to[1]);
} else if (paint.type === 'radial-gradient') {
gradient = context.createRadialGradient(paint.center[0], paint.center[1], paint.innerRadius ?? 0, paint.center[0], paint.center[1], paint.radius);
} else if (paint.type === 'conic-gradient') {
if (typeof context.createConicGradient !== 'function') return paint.stops[0]?.color ?? null;
gradient = context.createConicGradient((paint.angle ?? 0) * (Math.PI / 180), paint.center[0], paint.center[1]);
} else {
return null;
}
for (const stop of paint.stops ?? []) gradient.addColorStop(stop.offset, stop.color);
return gradient;
}
function filterString(node) {
const parts = [];
for (const entry of node.filters ?? []) {
const unit = FILTER_UNITS[entry.type] ?? '';
parts.push(`${entry.type}(${entry.amount ?? 0}${unit})`);
}
if (node.blur > 0) parts.push(`blur(${node.blur}px)`);
return parts.join(' ');
}
function applyClip(context, clip) {
if (!clip) return;
context.beginPath();
const [a, b, c, d] = clip.corners;
if (clip.shape === 'ellipse') {
// The clip rectangle's device corners describe the ellipse's bounding
// parallelogram; its axes are the half-diagonals of that shape.
const cx = clip.center[0];
const cy = clip.center[1];
const rx = Math.hypot(b[0] - a[0], b[1] - a[1]) / 2;
const ry = Math.hypot(d[0] - a[0], d[1] - a[1]) / 2;
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]);
context.ellipse(cx, cy, rx, ry, angle, 0, Math.PI * 2);
} else {
context.moveTo(a[0], a[1]);
context.lineTo(b[0], b[1]);
context.lineTo(c[0], c[1]);
context.lineTo(d[0], d[1]);
context.closePath();
}
context.clip();
}
/**
* The compositing order of 17.12, stage for stage: shadow, geometry, glow,
* blur, filters, alpha, clip and mask, blend.
*/
function drawNode(context, node, surface) {
if (node.alpha === 0) return;
context.save();
context.globalAlpha = node.alpha;
context.globalCompositeOperation = BLEND_OPERATIONS[node.blend] ?? 'source-over';
applyClip(context, node.clip);
const filters = filterString(node);
if (filters && 'filter' in context) context.filter = filters;
if (node.kind === 'group') {
if (node.mask) {
const masked = surface.create();
for (const child of node.children) drawNode(masked.context, child, surface);
masked.context.save();
masked.context.globalCompositeOperation = 'destination-in';
drawNode(masked.context, node.mask, surface);
masked.context.restore();
context.drawImage(masked.canvas, 0, 0);
surface.release(masked);
} else {
for (const child of node.children) drawNode(context, child, surface);
}
context.restore();
return;
}
if (node.shadow) {
context.shadowColor = node.shadow.color;
context.shadowBlur = node.shadow.blurRadius;
context.shadowOffsetX = node.shadow.offsetX;
context.shadowOffsetY = node.shadow.offsetY;
}
if (node.kind === 'point') {
context.beginPath();
context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2);
if (node.fill) { context.fillStyle = buildPaint(context, node.fill); context.fill(); }
if (node.stroke && node.strokeWidth > 0) {
context.strokeStyle = buildPaint(context, node.stroke);
context.lineWidth = node.strokeWidth;
context.stroke();
}
} else if (node.kind === 'text') {
const matrix = node.matrix;
context.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]);
context.font = `${node.italic ? 'italic ' : ''}${node.weight === 'bold' ? 'bold ' : ''}${node.size}px ${node.font}`;
context.textAlign = node.align === 'center' ? 'center' : node.align === 'right' ? 'right' : 'left';
context.textBaseline = node.baseline;
if ('letterSpacing' in context) context.letterSpacing = `${node.letterSpacing}px`;
// 17.13: `maxWidth` condenses horizontally about the align anchor and never
// wraps or truncates. Canvas 2D's own maxWidth argument does exactly that.
if (node.fill) { context.fillStyle = buildPaint(context, node.fill); context.fillText(node.text, 0, 0, node.maxWidth); }
if (node.stroke && node.strokeWidth > 0) {
context.strokeStyle = buildPaint(context, node.stroke);
context.lineWidth = node.strokeWidth;
context.strokeText(node.text, 0, 0, node.maxWidth);
}
} else {
tracePath(context, node);
if (node.fill) {
context.fillStyle = buildPaint(context, node.fill);
context.fill(node.fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
}
if (node.stroke && node.strokeWidth > 0) {
context.strokeStyle = buildPaint(context, node.stroke);
context.lineWidth = node.strokeWidth;
context.lineCap = node.strokeCap;
context.lineJoin = node.strokeJoin;
if (node.strokeDash) context.setLineDash(node.strokeDash);
context.lineDashOffset = node.strokeDashOffset;
context.stroke();
}
}
// 17.12: glow is added around the drawn result. Canvas 2D expresses it as a
// zero-offset shadow, which is the documented realization, not a substitution.
if (node.glow && node.glow.strength > 0) {
context.save();
context.globalAlpha = node.alpha * node.glow.strength;
context.shadowColor = node.glow.color;
context.shadowBlur = node.glow.radius;
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
if (node.kind === 'point') {
context.beginPath();
context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2);
context.fillStyle = node.glow.color;
context.fill();
} else if (node.kind !== 'text') {
tracePath(context, node);
context.strokeStyle = node.glow.color;
context.lineWidth = Math.max(node.strokeWidth, 1);
context.stroke();
}
context.restore();
}
context.restore();
}
/** A pool of offscreen surfaces for buffered layers, masks, and groups. */
class SurfacePool {
constructor(factory, width, height) {
this.factory = factory;
this.width = width;
this.height = height;
this.free = [];
this.allocated = 0;
}
create() {
const reused = this.free.pop();
if (reused) {
reused.context.setTransform(1, 0, 0, 1, 0, 0);
reused.context.clearRect(0, 0, this.width, this.height);
return reused;
}
this.allocated += 1;
const canvas = this.factory(this.width, this.height);
return { canvas, context: canvas.getContext('2d') };
}
release(surface) { this.free.push(surface); }
}
/**
* Render one frame plan into a Canvas 2D context. `createSurface(w, h)` returns
* an offscreen canvas; in a browser that is `new OffscreenCanvas(w, h)` or a
* detached `<canvas>`.
*/
function renderFrame(context, plan, { createSurface } = {}) {
const { width, height } = plan.backing;
context.setTransform(1, 0, 0, 1, 0, 0);
context.globalAlpha = 1;
context.globalCompositeOperation = 'source-over';
if ('filter' in context) context.filter = 'none';
context.fillStyle = plan.background;
context.fillRect(0, 0, width, height);
const surfaces = createSurface ? new SurfacePool(createSurface, width, height) : null;
for (const layer of plan.layers) {
if (!layer.visible || layer.opacity === 0) continue;
const buffered = layer.buffered && surfaces !== null;
const target = buffered ? surfaces.create() : null;
const layerContext = buffered ? target.context : context;
for (const node of layer.nodes) drawNode(layerContext, node, surfaces ?? { create: () => target, release: () => {} });
if (buffered) {
context.save();
context.globalAlpha = layer.opacity;
context.globalCompositeOperation = BLEND_OPERATIONS[layer.blend] ?? 'source-over';
context.drawImage(target.canvas, 0, 0);
context.restore();
surfaces.release(target);
}
}
return { allocated: surfaces?.allocated ?? 0 };
}
/* src/runtime/visual-subsystem.js */
// The visual subsystem's attachment to a display surface.
//
// The engine (visual-engine.js) is renderer-neutral and produces a frame plan;
// this module owns the canvas element, the display rectangle, and the backing
// store, and hands each plan to the Canvas 2D backend. A frame between two
// logical ticks draws the most recent tick's state and advances nothing (9.1).
class VisualSubsystem {
constructor({ diagnostics = null } = {}) {
this.diagnostics = diagnostics;
this.canvas = null;
this.context = null;
this.engine = null;
this.lastPlan = null;
this.frames = 0;
}
attach(canvas) {
this.canvas = canvas;
this.context = canvas?.getContext ? canvas.getContext('2d') : null;
return this;
}
/** 17.14: activation is the instantiation boundary of every persistent system. */
activate(document, { resolution, rng }) {
if (!document?.visuals) {
this.engine = null;
return null;
}
const capabilities = { conicGradient: typeof this.context?.createConicGradient === 'function' };
this.engine = new VisualEngine(document, { resolution, rng, diagnostics: this.diagnostics, capabilities });
return this.engine;
}
displayRectangle() {
const canvas = this.canvas;
if (!canvas) return { width: 0, height: 0 };
const width = canvas.clientWidth || canvas.width || 0;
const height = canvas.clientHeight || canvas.height || 0;
return { width, height };
}
render({ logicalMilliseconds = 0 } = {}) {
if (!this.engine || !this.context) return null;
const { width, height } = this.displayRectangle();
if (width <= 0 || height <= 0) return null;
const devicePixelRatio = globalThis.devicePixelRatio ?? 1;
const plan = this.engine.planFrame({ width, height, devicePixelRatio, logicalMilliseconds });
if (!plan) return null;
if (this.canvas.width !== plan.backing.width) this.canvas.width = plan.backing.width;
if (this.canvas.height !== plan.backing.height) this.canvas.height = plan.backing.height;
renderFrame(this.context, plan, {
createSurface: (surfaceWidth, surfaceHeight) => {
if (typeof OffscreenCanvas === 'function') return new OffscreenCanvas(surfaceWidth, surfaceHeight);
const surface = globalThis.document?.createElement('canvas');
if (!surface) return null;
surface.width = surfaceWidth;
surface.height = surfaceHeight;
return surface;
}
});
this.lastPlan = plan;
this.frames += 1;
return plan;
}
deactivate() {
if (this.context && this.canvas) {
this.context.setTransform(1, 0, 0, 1, 0, 0);
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
this.engine = null;
this.lastPlan = null;
}
}
/* src/runtime/actions.js */
class ActionExecutor {
constructor(engine, { diagnostics } = {}) {
this.engine = engine;
this.diagnostics = diagnostics;
this.invocationOrdinal = 0;
}
execute(actions, context = {}) {
if (!Array.isArray(actions)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Actions must be an array.');
const stream = this.engine.rng.stream('scenario', `actions:${++this.invocationOrdinal}`);
const results = [];
for (let index = 0; index < actions.length; index += 1) {
const action = actions[index];
try {
results.push(this.executeOne(action, stream, context, `$.actions[${index}]`));
} catch (error) {
const fault = error instanceof RuntimeFault ? error : new RuntimeFault('ERR_ACTION_FAILURE', error.message);
this.diagnostics?.error(fault.code, fault.message, { exhibitId: this.engine.document.meta.id, section: 'actions', objectId: action?.id ?? null, property: fault.path });
results.push({ status: 'failed', error: fault });
if (action?.critical !== false) throw fault;
}
}
return results;
}
executeOne(action, stream, context, path) {
if (!isRecord(action) || typeof action.type !== 'string') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Action requires a type.', path);
if (action.when !== undefined && !this.engine.evaluateCondition(action.when, stream, `${path}.when`)) return { status: 'skipped', reason: 'condition' };
if (action.chance !== undefined) {
if (!Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Action chance must be from 0 through 1.', `${path}.chance`);
if (stream.nextFloat() >= action.chance) return { status: 'skipped', reason: 'chance' };
}
if (action.type === 'set') {
const value = this.engine.evaluateValue(action.value, stream, `${path}.value`);
this.engine.setState(action.target, value, action.transition);
return { status: 'executed', type: 'set', target: action.target, value };
}
if (action.type === 'override') {
const value = this.engine.evaluateValue(action.value, stream, `${path}.value`);
const instanceId = this.engine.overrides.add(action, value, { owner: context.owner, inheritedPriority: context.priority });
this.engine.resolveAll();
return { status: 'executed', type: 'override', target: action.target, value, instanceId };
}
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Action '${action.type}' belongs to a later subsystem phase.`, `${path}.type`);
}
}
/* src/runtime/performance.js */
class CommonGrammarPerformance {
constructor(record, rootSeed, options = {}) {
this.record = record;
this.rootSeed = rootSeed;
this.rng = new SeededRNG(rootSeed);
this.diagnostics = options.diagnostics;
this.onUpdate = options.onUpdate ?? (() => {});
// Called once per animation frame, after any logical ticks. A frame draws
// the most recent tick's state and advances nothing (9.1, 18.2).
this.onFrame = options.onFrame ?? (() => {});
this.engine = new ResolutionEngine(record.document, this.rng, {
diagnostics: this.diagnostics,
initialParameters: options.initialParameters,
onParameterChange: options.onParameterChange
});
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
this.state = 'prepared';
this.resources = new Set();
this.frameRequest = null;
this.lastFrame = undefined;
this.accumulator = 0;
this.boundFrame = (now) => this.frame(now);
this.boundPointer = (event) => {
this.engine.signals.set('signals.pointer.x', event.clientX);
this.engine.signals.set('signals.pointer.y', event.clientY);
this.engine.invalidate();
};
this.boundResize = () => {
this.engine.signals.set('signals.viewport.width', globalThis.innerWidth ?? 0);
this.engine.signals.set('signals.viewport.height', globalThis.innerHeight ?? 0);
this.engine.invalidate();
};
}
async activate() {
if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`);
this.state = 'active';
this.onUpdate(this.engine);
if (typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener('pointermove', this.boundPointer, { passive: true });
globalThis.addEventListener('resize', this.boundResize, { passive: true });
}
if (typeof globalThis.requestAnimationFrame === 'function') this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
}
frame(now) {
if (this.state !== 'active') return;
if (this.lastFrame === undefined) this.lastFrame = now;
const observed = Math.max(0, now - this.lastFrame);
this.lastFrame = now;
const accepted = Math.min(observed, 250);
this.accumulator += accepted;
const step = 1000 / 60;
let ticks = 0;
while (this.accumulator + 1e-9 >= step && ticks < 8) {
this.engine.advance(step);
this.accumulator -= step;
ticks += 1;
}
if (observed > 250 || this.accumulator >= step) {
this.accumulator = Math.min(this.accumulator, step - 1e-9);
this.diagnostics?.warn('WARN_CLOCK_STALL', 'Discarded excess elapsed time to preserve the fixed-step work bound.', { exhibitId: this.record.id, section: 'scheduler' });
}
if (ticks > 0) this.onUpdate(this.engine);
this.onFrame(this.engine);
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
}
setParameter(id, value) {
const result = this.engine.setParameter(id, value);
this.onUpdate(this.engine);
return result;
}
execute(actionArray, context) {
const result = this.actions.execute(actionArray, context);
this.onUpdate(this.engine);
return result;
}
releaseOverride(id) {
const released = this.engine.overrides.beginRelease(id);
this.engine.resolveAll();
this.onUpdate(this.engine);
return released;
}
async deactivate() {
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
this.frameRequest = null;
this.lastFrame = undefined;
if (typeof globalThis.removeEventListener === 'function') {
globalThis.removeEventListener('pointermove', this.boundPointer);
globalThis.removeEventListener('resize', this.boundResize);
}
if (this.state === 'active') this.state = 'inactive';
this.engine.overrides.clear();
this.resources.clear();
}
async dispose() {
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
this.frameRequest = null;
if (typeof globalThis.removeEventListener === 'function') {
globalThis.removeEventListener('pointermove', this.boundPointer);
globalThis.removeEventListener('resize', this.boundResize);
}
this.engine.dispose();
this.resources.clear();
this.state = 'disposed';
}
}
/* src/runtime/activation.js */
class InertPerformance {
constructor(record, rootSeed) {
this.record = record;
this.rootSeed = rootSeed;
this.rng = new SeededRNG(rootSeed);
this.state = 'prepared';
this.resources = new Set();
}
async activate() {
if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`);
this.state = 'active';
}
async deactivate() {
if (this.state === 'active') this.state = 'inactive';
this.resources.clear();
}
async dispose() {
this.resources.clear();
this.state = 'disposed';
}
}
class ActivationController {
constructor({ diagnostics, persistence, performanceFactory, cryptoProvider = globalThis.crypto } = {}) {
this.diagnostics = diagnostics;
this.persistence = persistence;
this.crypto = cryptoProvider;
this.performanceFactory = performanceFactory ?? ((record, seed) => new CommonGrammarPerformance(record, seed));
this.current = null;
}
prepare(record) {
const seed = resolveRootSeed(record.document.runtime?.seed ?? 'random', this.crypto);
const performance = this.performanceFactory(record, seed);
if (!performance || typeof performance.activate !== 'function' || typeof performance.deactivate !== 'function' || typeof performance.dispose !== 'function') {
throw new TypeError('Performance factory returned an invalid lifecycle object.');
}
return { record, seed, performance };
}
async activate(record) {
let candidate;
try {
candidate = this.prepare(record);
} catch (error) {
this.diagnostics?.error('ERR_ACTIVATION_PREPARE', `Could not prepare exhibit: ${error.message}`, { exhibitId: record.id, section: 'activation' });
return false;
}
const previous = this.current;
if (previous) {
try {
await previous.performance.deactivate('replacement');
await previous.performance.dispose();
} catch (error) {
await candidate.performance.dispose();
this.diagnostics?.error('ERR_DEACTIVATION', `Could not safely deactivate the current exhibit: ${error.message}`, { exhibitId: previous.record.id, section: 'activation' });
return false;
}
this.current = null;
}
try {
await candidate.performance.activate();
this.current = candidate;
try {
await this.persistence?.savePreference('lastExhibitId', record.id);
} catch (error) {
this.diagnostics?.warn('WARN_STORAGE_WRITE_FAILED', `The active selection could not be remembered: ${error.message}`, { exhibitId: record.id, section: 'persistence' });
}
this.diagnostics?.info('INFO_EXHIBIT_ACTIVATED', `${record.document.meta.name} activated with resolved seed ${candidate.seed}.`, { exhibitId: record.id, section: 'activation', property: 'runtime.seed' });
return true;
} catch (error) {
await candidate.performance.dispose();
this.diagnostics?.error('ERR_ACTIVATION', `Activation failed: ${error.message}`, { exhibitId: record.id, section: 'activation' });
if (previous) await this.recover(previous);
return false;
}
}
async recover(previous) {
try {
const recovery = this.prepare(previous.record);
await recovery.performance.activate();
this.current = recovery;
this.diagnostics?.warn('WARN_ACTIVATION_RECOVERED', `The previous exhibit was restarted after activation failure.`, { exhibitId: previous.record.id, section: 'activation' });
} catch (error) {
this.diagnostics?.error('ERR_ACTIVATION_RECOVERY', `The previous exhibit could not be restarted: ${error.message}`, { exhibitId: previous.record.id, section: 'activation' });
}
}
async deactivate() {
if (!this.current) return true;
const current = this.current;
this.current = null;
try {
await current.performance.deactivate('manual');
await current.performance.dispose();
this.diagnostics?.info('INFO_EXHIBIT_DEACTIVATED', `${current.record.document.meta.name} deactivated.`, { exhibitId: current.record.id, section: 'activation' });
return true;
} catch (error) {
this.diagnostics?.error('ERR_DEACTIVATION', `Exhibit teardown failed: ${error.message}`, { exhibitId: current.record.id, section: 'activation' });
return false;
}
}
}
/* src/runtime/audio-engine.js */
// Deterministic instantiation of an expanded audio graph, plus its Web Audio realization.
// Instantiation is pure and testable without an AudioContext (Format Specification 14.4-14.6).
function soundInstanceKey(soundId, ordinal) {
return `${soundId}#${ordinal}`;
}
function sampleHoldStreamKey(instanceKey, nodePath) {
return `${instanceKey}|node|${nodePath}`;
}
function instantiateSoundGraph(document, soundId, options = {}) {
const {
sampleRate = 48000,
rng = null,
ordinal = 0,
resolveReference = () => { throw new RuntimeFault('ERR_INVALID_REFERENCE', 'No reference resolver supplied.'); }
} = options;
const expansion = options.expansion ?? expandSoundGraph(document, soundId);
if (expansion.errors.length > 0) {
return {
nodes: [],
routes: [],
warnings: [],
errors: expansion.errors,
mode: expansion.mode,
release: expansion.release,
endingBoundMs: null
};
}
const ceiling = audioMaxFrequency(sampleRate);
const instanceKey = soundInstanceKey(soundId, ordinal);
const stream = rng ? rng.stream('sound', instanceKey) : null;
const warnings = [];
const componentValues = new Map();
const resolver = new ValueResolver((reference) => {
if (typeof reference === 'string' && reference.startsWith('inputs.')) {
const scope = resolver.currentScope;
const values = componentValues.get(scope);
const name = reference.slice('inputs.'.length);
if (!values || !Object.hasOwn(values, name)) {
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component input '${name}' is not available here.`);
}
return values[name];
}
return resolveReference(reference);
});
const evaluate = (spec, scope, path) => {
resolver.currentScope = scope;
return resolver.evaluate(spec, stream, path);
};
const 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;
if (spec.ceiling === 'audio' && ceiling < spec.max) limitMax = ceiling;
const bounded = clamp(value, spec.min, limitMax);
if (spec.ceiling === 'audio' && value > limitMax) {
warnings.push({ code: 'WARN_AUDIO_RATE_CLAMP', path: `${nodePath}.${field}`, message: `Clamped ${value} Hz to the device ceiling ${limitMax} Hz.` });
}
return bounded;
};
const nodes = 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 = {};
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;
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, expressions, exposes: declared, scope, passthrough: true });
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 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') {
values.harmonics = values.harmonics.filter((partial) => partial.ratio * values.frequency <= ceiling);
}
if (node.type === 'resonator') {
values.modes = values.modes.map((mode) => ({ ...mode, frequency: mode.frequency ?? mode.ratio * values.fundamental })).filter((mode) => mode.frequency <= ceiling);
}
if (node.type === 'sample-hold') {
const slewLimit = 1000 / values.rate;
values.slew = Math.min(values.slew, slewLimit);
values.streamKey = sampleHoldStreamKey(instanceKey, node.path);
values.stream = rng ? rng.stream('sound', values.streamKey) : null;
}
nodes.push({ path: node.path, type: node.type, values, expressions, scope });
}
let endingBoundMs = null;
if (expansion.mode === 'oneshot') {
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,
release: expansion.release,
endingBoundMs,
instanceKey,
ceiling
};
}
/* ------------------------------------------------------------------ *
* Web Audio realization
* ------------------------------------------------------------------ */
const NOISE_SECONDS = 2;
function noiseBuffer(context, color) {
const length = Math.floor(context.sampleRate * NOISE_SECONDS);
const buffer = context.createBuffer(1, length, context.sampleRate);
const data = buffer.getChannelData(0);
if (color === 'white') {
for (let index = 0; index < length; index += 1) data[index] = (Math.random() * 2) - 1;
} else if (color === 'pink') {
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
for (let index = 0; index < length; index += 1) {
const white = (Math.random() * 2) - 1;
b0 = 0.99886 * b0 + white * 0.0555179;
b1 = 0.99332 * b1 + white * 0.0750759;
b2 = 0.969 * b2 + white * 0.153852;
b3 = 0.8665 * b3 + white * 0.3104856;
b4 = 0.55 * b4 + white * 0.5329522;
b5 = -0.7616 * b5 - white * 0.016898;
data[index] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
b6 = white * 0.115926;
}
} else {
let last = 0;
for (let index = 0; index < length; index += 1) {
const white = (Math.random() * 2) - 1;
last = (last + 0.02 * white) / 1.02;
data[index] = last;
}
}
let sum = 0;
for (let index = 0; index < length; index += 1) sum += data[index] * data[index];
const rms = Math.sqrt(sum / length) || 1;
for (let index = 0; index < length; index += 1) data[index] = clamp(data[index] / rms * 0.2, -1, 1);
return buffer;
}
function shaperCurve(shape, amount) {
const points = 1024;
const curve = new Float32Array(points);
const drive = 1 + (amount * 24);
for (let index = 0; index < points; index += 1) {
const x = (index * 2 / (points - 1)) - 1;
if (amount === 0) curve[index] = x;
else if (shape === 'hard-clip') curve[index] = clamp(x * drive, -1, 1);
else if (shape === 'saturation') curve[index] = Math.tanh(x * drive);
else curve[index] = Math.sign(x) * (1 - Math.exp(-Math.abs(x * drive))) / (1 - Math.exp(-drive));
}
return curve;
}
function reverbBuffer(context, { size, decay, damping }) {
const seconds = Math.max(0.05, (decay / 1000) * (0.4 + (size * 0.6)));
const length = Math.max(1, Math.floor(context.sampleRate * seconds));
const buffer = context.createBuffer(2, length, context.sampleRate);
for (let channel = 0; channel < 2; channel += 1) {
const data = buffer.getChannelData(channel);
let smoothed = 0;
for (let index = 0; index < length; index += 1) {
const envelope = (1 - (index / length)) ** (2 + (damping * 4));
const impulse = ((Math.random() * 2) - 1) * envelope;
smoothed += (impulse - smoothed) * (1 - (damping * 0.7));
data[index] = smoothed;
}
}
return buffer;
}
function periodicWave(context, partials) {
const count = Math.max(2, partials.reduce((highest, partial) => Math.max(highest, Math.round(partial.ratio)), 1) + 1);
const real = new Float32Array(count);
const imaginary = new Float32Array(count);
for (const partial of partials) {
const index = Math.round(partial.ratio);
if (index < 1 || index >= count) continue;
const radians = (partial.phase ?? 0) * Math.PI / 180;
real[index] += partial.gain * Math.cos(radians);
imaginary[index] += partial.gain * Math.sin(radians);
}
return context.createPeriodicWave(real, imaginary, { disableNormalization: false });
}
// Builds one 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 = [];
const starters = [];
const sink = context.createGain();
sink.gain.value = 1;
const releaseGain = context.createGain();
releaseGain.gain.value = 1;
sink.connect(releaseGain).connect(destination);
disposers.push(() => releaseGain.disconnect());
created.set('output', { input: sink, output: sink });
const now = () => context.currentTime;
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 {
constructor({
soundId,
mode = 'oneshot',
releaseMs = AUDIO_DEFAULT_RELEASE_MS,
endingBoundMs = null,
plan = null,
subsystem = null,
bus = null,
context = null,
creationOrder = 0
} = {}) {
this.soundId = soundId;
this.mode = mode;
this.releaseMs = releaseMs ?? AUDIO_DEFAULT_RELEASE_MS;
this.endingBoundMs = endingBoundMs;
this.plan = plan;
this.subsystem = subsystem;
this.bus = bus;
this.context = context;
this.creationOrder = creationOrder;
this.state = 'CREATED';
this.realized = null;
this.startTime = null;
this.releaseTimer = null;
this.endingTimer = null;
}
canTransition(nextState) {
const allowed = AUDIO_LIFECYCLE_TRANSITIONS[this.state] ?? [];
return allowed.includes(nextState);
}
transition(nextState) {
if (!this.canTransition(nextState)) {
throw new RuntimeFault('ERR_RUNTIME_FAULT', `Invalid lifecycle transition from '${this.state}' to '${nextState}'.`);
}
this.state = nextState;
return true;
}
realize(context, destination) {
const guard = this.subsystem?.createVoiceGuard(this);
this.guard = guard;
if (guard) guard.connect(destination);
this.realized = realizeSoundGraph(context, this.plan, guard ?? destination);
return this.realized;
}
stop() {
if (this.state === 'RELEASING' || this.state === 'FINISHED' || this.state === 'DISPOSED' || this.state === 'FAILED') {
return;
}
if (this.subsystem) {
this.subsystem.voices.delete(this);
}
if (this.state === 'CREATED') {
this.transition('FINISHED');
return;
}
if (this.state === 'SCHEDULED') {
this.transition('RELEASING');
if (this.releaseMs === 0) {
this.transition('FINISHED');
} else {
this.releaseTimer = setTimeout(() => {
if (this.state === 'RELEASING') {
this.transition('FINISHED');
}
}, this.releaseMs);
if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
}
return;
}
if (this.state === 'ACTIVE') {
if (this.endingTimer) {
clearTimeout(this.endingTimer);
this.endingTimer = null;
}
this.transition('RELEASING');
if (this.releaseMs === 0) {
if (this.realized?.releaseGain?.gain) {
const now = this.context?.currentTime ?? 0;
this.realized.releaseGain.gain.cancelScheduledValues?.(now);
this.realized.releaseGain.gain.setValueAtTime?.(0, now);
}
this.transition('FINISHED');
} else {
const now = this.context?.currentTime ?? 0;
const durationSec = this.releaseMs / 1000;
const gainParam = this.realized?.releaseGain?.gain;
if (gainParam) {
gainParam.cancelScheduledValues?.(now);
gainParam.setValueAtTime?.(gainParam.value, now);
gainParam.linearRampToValueAtTime?.(0, now + durationSec);
}
this.releaseTimer = setTimeout(() => {
if (this.state === 'RELEASING') {
this.transition('FINISHED');
}
}, this.releaseMs);
if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
}
}
}
forceFinishAndDispose() {
if (this.state === 'RELEASING') {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer);
this.releaseTimer = null;
}
if (this.realized?.releaseGain?.gain) {
const now = this.context?.currentTime ?? 0;
this.realized.releaseGain.gain.cancelScheduledValues?.(now);
this.realized.releaseGain.gain.setValueAtTime?.(0, now);
}
this.transition('FINISHED');
}
this.dispose();
}
dispose() {
if (this.state === 'DISPOSED') return;
if (this.releaseTimer) {
clearTimeout(this.releaseTimer);
this.releaseTimer = null;
}
if (this.endingTimer) {
clearTimeout(this.endingTimer);
this.endingTimer = null;
}
if (this.subsystem) {
this.subsystem.voices.delete(this);
this.subsystem.oneshotVoices?.delete(this);
this.subsystem.continuousVoices?.delete(this);
}
if (this.state !== 'FINISHED' && this.state !== 'FAILED') {
if (this.state === 'ACTIVE' || this.state === 'SCHEDULED') {
this.transition('RELEASING');
this.transition('FINISHED');
} else if (this.state === 'CREATED') {
this.transition('FINISHED');
} else if (this.state === 'RELEASING') {
this.transition('FINISHED');
}
}
// 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;
if (this.guard) {
this.guard.port.onmessage = null;
this.guard.onprocessorerror = null;
this.guard.port.close();
this.guard.disconnect();
this.guard = null;
}
this.bus = null;
this.context = null;
}
}
/* ------------------------------------------------------------------ *
* Runtime subsystem
* ------------------------------------------------------------------ */
// Owns the AudioContext, the declared buses, and the engine master chain, and turns a
// sound definition into a realized voice. All buses and volume controls are upstream
// of the final protection worklet; hardware/listening acceptance is tracked in GC6.
class AudioSubsystem {
constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null, resolutionEngine = null,
protectionFactory = { load: loadProtectionWorklet, create: createProtectionNode } } = {}) {
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;
this.master = null;
this.protection = null;
this.buses = new Map();
this.voices = new Set();
this.oneshotVoices = new Set();
this.continuousVoices = new Set();
this.voiceLimits = voiceLimits ?? { ...AUDIO_VOICE_LIMITS };
this.ordinals = new Map();
this.nextCreationOrder = 0;
this.masterVolume = 0.8;
this.protectionFactory = protectionFactory;
this.ready = false;
this.disposed = false;
this.unlockPending = null;
this.capturePending = null;
this.captureSequence = 0;
this.unavailableWarned = false;
}
get available() {
return this.hasCustomContext || typeof (globalThis.AudioContext ?? globalThis.webkitAudioContext) === 'function';
}
get unlocked() {
return this.ready && this.context !== null && this.context.state === 'running';
}
// Must be called from a user gesture; browsers refuse to start audio otherwise.
async unlock() {
if (this.disposed) return false;
if (this.unlockPending) return this.unlockPending;
this.unlockPending = this.initializeAudio();
try { return await this.unlockPending; }
finally { this.unlockPending = null; }
}
async initializeAudio() {
try {
if (this.protectionFailed) throw new Error('Master protection failed; reactivate the exhibit to restart audio.');
if (!this.available) throw new Error('This browser exposes no AudioContext.');
if (!this.context) this.context = this.contextFactory();
const context = this.context;
// Resume synchronously with the gesture before awaiting module loading.
const resume = context.state === 'suspended' ? context.resume() : Promise.resolve();
await Promise.all([resume, this.ready ? Promise.resolve() : this.buildMaster()]);
if (this.disposed || this.context !== context) return false;
if (context.state !== 'running') throw new Error('The audio context did not enter the running state.');
if (!this.ready) this.buildBuses();
this.ready = true;
return this.unlocked;
} catch (error) {
this.ready = false;
for (const instance of [...this.oneshotVoices, ...this.continuousVoices]) instance.dispose();
this.disconnectMaster();
const context = this.context;
this.context = null;
try { await context?.close(); } catch { /* already closed */ }
if (!this.unavailableWarned && !this.disposed) {
this.unavailableWarned = true;
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', `Audio disabled: ${error.message}`, { section: 'audio' });
}
return false;
}
}
async buildMaster() {
const context = this.context;
await this.protectionFactory.load(context);
if (this.disposed || this.context !== context) return;
this.protection = this.protectionFactory.create(context, false);
this.protection.port.onmessage = ({ data }) => {
if (data.type === 'nonfinite') this.warnNonfinite('master');
if (data.type === 'capture' && data.id === this.capturePending?.id) {
const pending = this.capturePending;
this.capturePending = null;
pending.resolve(data);
}
};
this.protection.onprocessorerror = () => {
this.ready = false;
this.protectionFailed = true;
this.capturePending?.reject(new Error('Master protection processor failed.'));
this.capturePending = null;
this.protection?.disconnect();
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', 'Master protection failed; output is muted.', { section: 'audio' });
};
this.master = context.createGain();
this.master.gain.value = this.masterVolume;
this.master.connect(this.protection).connect(context.destination);
}
warnNonfinite(instanceId) {
this.diagnostics?.warn('WARN_AUDIO_NONFINITE', 'A non-finite audio sample was detected; the containing output block was muted.', { section: 'audio', objectId: instanceId });
}
createVoiceGuard(instance) {
const guard = this.protectionFactory.create(this.context, true);
const instanceId = instance.plan?.instanceKey ?? soundInstanceKey(instance.soundId, instance.creationOrder);
guard.port.onmessage = ({ data }) => {
if (data.type === 'nonfinite') this.warnNonfinite(instanceId);
};
guard.onprocessorerror = () => {
instance.dispose();
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', `Audio guard failed for '${instance.soundId}'; voice disposed.`, { section: 'audio', objectId: instance.soundId });
};
try { guard.connect(this.protection, 1, 1); }
catch (error) {
guard.port.onmessage = null;
guard.onprocessorerror = null;
guard.port.close();
guard.disconnect();
throw error;
}
return guard;
}
captureOutput({ seconds = 120, warmupSeconds = 30 } = {}) {
if (!this.unlocked) return Promise.reject(new Error('Unlock protected audio before capturing.'));
if (this.capturePending) return Promise.reject(new Error('An output capture is already running.'));
if (!Number.isFinite(seconds) || seconds <= 0 || !Number.isFinite(warmupSeconds) || warmupSeconds < 0) return Promise.reject(new Error('Invalid capture duration.'));
return new Promise((resolve, reject) => {
const id = ++this.captureSequence;
this.capturePending = { id, resolve, reject };
this.protection.port.postMessage({ type: 'capture', id,
frames: Math.max(1, Math.round(seconds * this.context.sampleRate)),
warmupFrames: Math.round(warmupSeconds * this.context.sampleRate) });
});
}
disconnectMaster() {
for (const bus of this.buses.values()) bus.disconnect();
this.buses.clear();
this.master?.disconnect();
if (this.protection) {
this.protection.port.onmessage = null;
this.protection.port.close();
this.protection.onprocessorerror = null;
this.protection.disconnect();
}
this.master = null;
this.protection = null;
}
buildBuses() {
const declared = this.document?.audio?.buses ?? {};
for (const id of Object.keys(declared)) {
const gain = this.context.createGain();
gain.gain.value = this.resolution.get(`audio.buses.${id}.gain`);
gain.connect(this.master);
this.buses.set(id, gain);
}
}
busFor(soundId) {
const name = this.document?.sounds?.[soundId]?.bus;
return (name && this.buses.get(name)) || this.master;
}
setBusGain(id, 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) {
if (!Number.isFinite(value)) return;
this.masterVolume = Math.min(1, Math.max(0, value));
if (this.master) this.master.gain.value = this.masterVolume;
}
nextOrdinal(soundId) {
const next = (this.ordinals.get(soundId) ?? -1) + 1;
this.ordinals.set(soundId, next);
return next;
}
play(soundId, { resolveReference = (path) => this.resolution.get(path) } = {}) {
if (!this.unlocked) return null;
const sound = this.document?.sounds?.[soundId];
if (!sound) return null;
const expansion = expandSoundGraph(this.document, soundId);
if (expansion.errors.length > 0) {
for (const error of expansion.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
return null;
}
const mode = expansion.mode ?? 'oneshot';
const pool = mode === 'oneshot' ? this.oneshotVoices : this.continuousVoices;
const ceiling = mode === 'oneshot' ? this.voiceLimits.oneshot : this.voiceLimits.continuous;
if (pool.size >= ceiling) {
// 16.6 Eviction policy applied in strict order:
// 1. Dispose the oldest instance already in FINISHED.
const finishedInstances = [...pool].filter((i) => i.state === 'FINISHED');
if (finishedInstances.length > 0) {
finishedInstances.sort((a, b) => a.creationOrder - b.creationOrder);
const candidate = finishedInstances[0];
candidate.dispose();
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; disposed oldest FINISHED instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
} else {
// 2. Evict the oldest instance in RELEASING by advancing its release ramp to immediate completion and disposing it.
const releasingInstances = [...pool].filter((i) => i.state === 'RELEASING');
if (releasingInstances.length > 0) {
releasingInstances.sort((a, b) => a.creationOrder - b.creationOrder);
const candidate = releasingInstances[0];
candidate.forceFinishAndDispose();
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicted oldest RELEASING instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
} else if (mode === 'oneshot') {
// 3. For a one-shot request only: evict the oldest ACTIVE one-shot by starting its release.
const activeInstances = [...pool].filter((i) => i.state === 'ACTIVE' || i.state === 'SCHEDULED');
if (activeInstances.length > 0) {
activeInstances.sort((a, b) => a.creationOrder - b.creationOrder);
const candidate = activeInstances[0];
candidate.stop();
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicting oldest ACTIVE oneshot instance of '${candidate.soundId}' via release.`, { section: 'audio', objectId: soundId });
} else {
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; request refused.`, { section: 'audio', objectId: soundId });
return null;
}
} else {
// 4. Otherwise refuse the new instance.
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; continuous request refused.`, { section: 'audio', objectId: soundId });
return null;
}
}
}
const plan = instantiateSoundGraph(this.document, soundId, {
sampleRate: this.context.sampleRate,
rng: this.rng,
ordinal: this.nextOrdinal(soundId),
resolveReference,
expansion
});
for (const error of plan.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
if (plan.errors.length > 0) return null;
for (const warning of plan.warnings) this.diagnostics?.warn(warning.code, warning.message, { section: 'audio', objectId: soundId, property: warning.path });
const instance = new SoundInstance({
soundId,
mode: plan.mode,
releaseMs: plan.release,
endingBoundMs: plan.endingBoundMs,
plan,
subsystem: this,
bus: this.busFor(soundId),
context: this.context,
creationOrder: this.nextCreationOrder++
});
pool.add(instance);
try {
instance.transition('SCHEDULED');
instance.realize(this.context, this.busFor(soundId));
instance.transition('ACTIVE');
instance.startTime = 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 });
instance.transition('FAILED');
instance.dispose();
return null;
}
if (mode === 'oneshot' && instance.endingBoundMs != null && Number.isFinite(instance.endingBoundMs)) {
instance.endingTimer = setTimeout(() => {
if (instance.state === 'ACTIVE') {
this.voices.delete(instance);
instance.transition('FINISHED');
}
}, instance.endingBoundMs);
if (typeof instance.endingTimer?.unref === 'function') instance.endingTimer.unref();
}
return instance;
}
stopAll() {
for (const instance of [...this.voices]) instance.stop();
}
async dispose() {
this.disposed = true;
this.ready = false;
this.capturePending?.reject(new Error('Audio disposed during output capture.'));
this.capturePending = null;
this.unsubscribeResolution?.();
this.unsubscribeResolution = null;
this.stopAll();
for (const instance of [...this.oneshotVoices]) instance.dispose();
for (const instance of [...this.continuousVoices]) instance.dispose();
this.disconnectMaster();
if (this.context) {
try { await this.context.close(); } catch { /* already closed */ }
}
this.context = null;
this.master = null;
this.protection = null;
if (this.ownsResolution) this.resolution.dispose();
}
}
/* src/runtime/app.js */
function element(id) { return document.getElementById(id); }
function text(tag, value, className) {
const node = document.createElement(tag);
node.textContent = value;
if (className) node.className = className;
return node;
}
class XZBTApplication {
constructor() {
this.diagnostics = new Diagnostics({ onChange: (entries) => this.renderDiagnostics(entries) });
this.persistence = new PersistenceManager({ diagnostics: this.diagnostics });
this.library = new LibraryManager({ persistence: this.persistence, diagnostics: this.diagnostics });
this.parameterValues = {};
this.visual = new VisualSubsystem({ diagnostics: this.diagnostics });
this.activation = new ActivationController({
persistence: this.persistence,
diagnostics: this.diagnostics,
performanceFactory: (record, seed) => new CommonGrammarPerformance(record, seed, {
diagnostics: this.diagnostics,
initialParameters: this.parameterValues[record.id],
onParameterChange: (values) => this.rememberParameters(record.id, values),
onUpdate: (engine) => {
if (this.activation.current?.record.id === record.id) this.renderValues(engine);
},
// The renderer runs at display rate; the simulation stays on the fixed
// logical tick (9.1), so a frame draws the most recent tick's state.
onFrame: (engine) => {
if (this.activation.current?.record.id !== record.id) return;
try {
this.visual.render({ logicalMilliseconds: engine.logicalMilliseconds });
} catch (error) {
this.diagnostics.error(error.code ?? 'ERR_SCHEMA_VALIDATION', `Visual frame failed: ${error.message}`, { section: 'visual', exhibitId: record.id, objectId: error.path });
this.visual.deactivate();
}
}
})
});
this.busy = false;
this.audio = null;
}
async start() {
element('runtime-version').textContent = `Runtime ${XZBT_RUNTIME_VERSION}`;
this.bindEvents();
await this.persistence.open();
this.renderStorage();
try {
const snapshot = await this.persistence.loadSnapshot();
for (const [key, value] of Object.entries(snapshot.preferences)) {
if (key.startsWith('parameters:')) this.parameterValues[key.slice('parameters:'.length)] = value;
}
this.library.restore(snapshot.exhibits);
this.renderLibrary();
const last = this.library.get(snapshot.preferences.lastExhibitId);
if (last) await this.activate(last.id);
} catch (error) {
this.diagnostics.warn('WARN_STORAGE_READ_FAILED', `Cached exhibits could not be restored: ${error.message}`, { section: 'persistence' });
this.renderLibrary();
}
this.setStatus(this.activation.current ? 'Exhibit active' : (this.library.list().length ? 'Library ready' : 'Import exhibits to begin'));
}
bindEvents() {
element('import-files').addEventListener('change', async (event) => {
await this.importFiles([...event.target.files]);
event.target.value = '';
});
element('import-button').addEventListener('click', () => element('import-files').click());
element('deactivate-button').addEventListener('click', () => this.deactivate());
element('audio-unlock').addEventListener('click', () => this.unlockAudio());
element('audio-stop').addEventListener('click', () => {
this.audio?.stopAll();
this.renderAudio();
});
element('master-volume').addEventListener('input', (event) => {
this.audio?.setMasterVolume(Number(event.target.value));
});
element('clear-diagnostics').addEventListener('click', () => this.diagnostics.clear());
const dropZone = element('drop-zone');
for (const type of ['dragenter', 'dragover']) dropZone.addEventListener(type, (event) => {
event.preventDefault();
dropZone.classList.add('is-dragging');
});
for (const type of ['dragleave', 'drop']) dropZone.addEventListener(type, (event) => {
event.preventDefault();
dropZone.classList.remove('is-dragging');
});
dropZone.addEventListener('drop', (event) => this.importFiles([...event.dataTransfer.files]));
window.addEventListener('unhandledrejection', (event) => {
this.diagnostics.error('ERR_RUNTIME', event.reason?.message ?? String(event.reason), { section: 'runtime' });
});
}
async importFiles(files) {
if (this.busy) return;
const exhibitFiles = files.filter((file) => file.name.toLowerCase().endsWith('.xzbt'));
if (!exhibitFiles.length) {
this.diagnostics.warn('WARN_IMPORT_EMPTY', 'Choose one or more files with the .xzbt extension.', { section: 'loader' });
return;
}
this.setBusy(true, `Importing ${exhibitFiles.length} exhibit${exhibitFiles.length === 1 ? '' : 's'}…`);
try {
for (const file of exhibitFiles) {
let source;
try {
source = await file.text();
} catch (error) {
this.diagnostics.error('ERR_IMPORT_READ', `Could not read ${file.name}: ${error.message}`, { section: 'loader' });
continue;
}
await this.library.importSource(source, file.name, {
confirmReplacement: async (existing, candidate) => window.confirm(
`${candidate.meta.name} has the same exhibit ID as ${existing.document.meta.name}, but different source bytes. Replace the cached exhibit?`
)
});
}
this.renderLibrary();
} finally {
this.setBusy(false, 'Library ready');
}
}
async activate(id) {
if (this.busy || this.activation.current?.record.id === id) return;
const record = this.library.get(id);
if (!record) return;
this.setBusy(true, `Preparing ${record.document.meta.name}…`);
try {
await this.activation.activate(record);
await this.disposeAudio();
this.attachVisuals();
this.renderLibrary();
this.renderStage();
} finally {
this.setBusy(false, this.activation.current ? 'Exhibit active' : 'Library ready');
}
}
async deactivate() {
if (this.busy || !this.activation.current) return;
this.setBusy(true, 'Deactivating exhibit…');
try {
await this.disposeAudio();
this.visual.deactivate();
await this.activation.deactivate();
this.renderLibrary();
this.renderStage();
} finally {
this.setBusy(false, 'Library ready');
}
}
/**
* 17.14: activation is the instantiation boundary of every persistent visual
* system, so the engine is built once here and never per frame.
*/
attachVisuals() {
const current = this.activation.current;
const canvas = element('stage-canvas');
if (!current || !canvas) {
this.visual.deactivate();
return;
}
this.visual.attach(canvas);
try {
const engine = this.visual.activate(current.record.document, {
resolution: current.performance.engine,
rng: current.performance.rng
});
canvas.hidden = engine === null;
if (engine) this.visual.render({ logicalMilliseconds: current.performance.engine.logicalMilliseconds });
} catch (error) {
canvas.hidden = true;
this.visual.deactivate();
this.diagnostics.error(error.code ?? 'ERR_SCHEMA_VALIDATION', `The visual subsystem could not be instantiated: ${error.message}`, { section: 'visual', exhibitId: current.record.id, objectId: error.path });
}
}
renderLibrary() {
const records = this.library.list();
const container = element('library-list');
container.replaceChildren();
element('empty-state').hidden = records.length > 0;
for (const record of records) {
const meta = record.document.meta;
const active = this.activation.current?.record.id === record.id;
const card = document.createElement('article');
card.className = `exhibit-card${active ? ' is-active' : ''}`;
card.append(text('h3', meta.name));
card.append(text('p', meta.description || 'Minimal XZBT 0.1 exhibit.', 'description'));
const details = [meta.author, meta.version && `v${meta.version}`, record.id].filter(Boolean).join(' · ');
card.append(text('p', details, 'meta'));
const button = text('button', active ? 'Active' : 'Activate');
button.type = 'button';
button.disabled = active || this.busy;
button.addEventListener('click', () => this.activate(record.id));
card.append(button);
container.append(card);
}
}
renderStage() {
const current = this.activation.current;
element('deactivate-button').disabled = !current || this.busy;
element('stage-empty').hidden = Boolean(current);
element('stage-active').hidden = !current;
if (!current) {
element('configuration').replaceChildren();
element('resolved-values').replaceChildren();
return;
}
const { meta } = current.record.document;
element('active-name').textContent = meta.name;
element('active-id').textContent = meta.id;
element('active-seed').textContent = String(current.seed);
const preview = current.performance.rng.stream('visual', 'runtime-preview:1');
element('active-sequence').textContent = [preview.nextUint32(), preview.nextUint32(), preview.nextUint32()].join(' · ');
this.renderConfiguration(current.performance);
this.renderValues(current.performance.engine);
this.renderAudio();
}
async unlockAudio() {
const current = this.activation.current;
if (!current) return;
if (!this.audio) {
this.audio = new AudioSubsystem({
document: current.record.document,
rng: current.performance.rng,
resolutionEngine: current.performance.engine,
diagnostics: this.diagnostics
});
this.audio.setMasterVolume(Number(element('master-volume').value));
}
await this.audio.unlock();
this.renderAudio();
}
async disposeAudio() {
if (!this.audio) return;
await this.audio.dispose();
this.audio = null;
this.renderAudio();
}
playSound(id) {
const current = this.activation.current;
if (!this.audio?.unlocked || !current) return;
this.audio.play(id, { resolveReference: (path) => current.performance.engine.get(path) });
this.renderAudio();
}
renderAudio() {
const current = this.activation.current;
const state = element('audio-state');
const stopButton = element('audio-stop');
const busContainer = element('audio-buses');
const soundContainer = element('sound-list');
busContainer.replaceChildren();
soundContainer.replaceChildren();
if (!current) {
state.textContent = 'Audio is locked until you start it.';
stopButton.disabled = true;
return;
}
const document_ = current.record.document;
const sounds = document_.sounds ?? {};
const unlocked = Boolean(this.audio?.unlocked);
element('audio-unlock').disabled = unlocked;
stopButton.disabled = !unlocked || (this.audio?.voices.size ?? 0) === 0;
state.textContent = unlocked
? `Audio running at ${this.audio.context.sampleRate} Hz · ${this.audio.voices.size} live voice${this.audio.voices.size === 1 ? '' : 's'}`
: 'Audio is locked until you start it. Browsers require a user gesture.';
for (const [id, bus] of Object.entries(document_.audio?.buses ?? {})) {
const row = document.createElement('div');
row.className = 'parameter-control';
const label = text('label', `Bus ${id}`);
label.htmlFor = `bus-${id}`;
const input = document.createElement('input');
input.id = `bus-${id}`;
input.type = 'range';
input.min = '0';
input.max = '4';
input.step = '0.01';
input.value = String(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);
busContainer.append(row);
}
if (Object.keys(sounds).length === 0) {
soundContainer.append(text('p', 'This exhibit declares no sounds.', 'empty'));
return;
}
for (const [id, sound] of Object.entries(sounds)) {
const row = document.createElement('div');
row.className = 'sound-row';
row.append(text('span', sound.name ?? id));
const button = text('button', 'Play');
button.type = 'button';
button.disabled = !unlocked;
button.addEventListener('click', () => this.playSound(id));
row.append(button);
soundContainer.append(row);
}
}
renderConfiguration(performance) {
const container = element('configuration');
container.replaceChildren();
const definitions = performance.record.document.parameters ?? {};
if (Object.keys(definitions).length === 0) {
container.append(text('p', 'This exhibit declares no parameters.', 'empty'));
return;
}
for (const [id, spec] of Object.entries(definitions)) {
const path = `parameters.${id}`;
const row = document.createElement('div');
row.className = 'parameter-control';
const label = text('label', spec.label ?? id);
label.htmlFor = `parameter-${id}`;
const badge = text('span', 'overridden', 'override-badge');
badge.dataset.overrideFor = path;
badge.hidden = true;
label.append(badge);
const input = this.parameterInput(id, spec, performance.engine.parameters.get(id));
const update = () => {
try {
const value = spec.type === 'boolean' ? input.checked : (spec.type === 'number' || spec.type === 'integer' ? Number(input.value) : input.value);
performance.setParameter(id, value);
} catch (error) {
this.diagnostics.error(error.code ?? 'ERR_TYPE_MISMATCH', error.message, { exhibitId: performance.record.id, section: 'parameters', objectId: id });
}
};
input.addEventListener(spec.type === 'number' ? 'input' : 'change', update);
row.append(label, input);
const override = text('button', 'Temporary override');
override.type = 'button';
override.className = 'compact';
override.addEventListener('click', () => {
const value = this.demoOverrideValue(spec, performance.engine.parameters.get(id));
try {
performance.execute([{
type: 'override', target: path, value, scope: 'duration', duration: '3s',
transition: { in: spec.type === 'number' || spec.type === 'integer' ? '250ms' : '0ms', out: spec.type === 'number' || spec.type === 'integer' ? '750ms' : '0ms', easing: 'ease-in-out' }
}], { owner: 'phase2-ui', priority: 0 });
} catch {}
});
row.append(override);
container.append(row);
}
}
parameterInput(id, spec, value) {
let input;
if (spec.type === 'enum') {
input = document.createElement('select');
for (const optionValue of spec.values) {
const option = document.createElement('option');
option.value = optionValue;
option.textContent = optionValue;
input.append(option);
}
input.value = value;
} else {
input = document.createElement('input');
if (spec.type === 'boolean') { input.type = 'checkbox'; input.checked = value; }
else if (spec.type === 'color') { input.type = 'color'; input.value = value; }
else if (spec.type === 'number' || spec.type === 'integer') {
input.type = spec.min !== undefined && spec.max !== undefined ? 'range' : 'number';
if (spec.min !== undefined) input.min = String(spec.min);
if (spec.max !== undefined) input.max = String(spec.max);
input.step = String(spec.step ?? (spec.type === 'integer' ? 1 : 'any'));
input.value = String(value);
} else { input.type = 'text'; input.value = value; }
}
input.id = `parameter-${id}`;
input.dataset.parameter = id;
return input;
}
demoOverrideValue(spec, stored) {
if (spec.type === 'number' || spec.type === 'integer') return spec.max ?? (stored + 1);
if (spec.type === 'boolean') return !stored;
if (spec.type === 'enum') return spec.values[(spec.values.indexOf(stored) + 1) % spec.values.length];
if (spec.type === 'color') return stored.toLowerCase() === '#b8ff5a' ? '#ffca5c' : '#b8ff5a';
return `${stored}*`;
}
renderValues(engine) {
const container = element('resolved-values');
if (!container) return;
const snapshot = engine.snapshot();
container.replaceChildren();
for (const [path, value] of Object.entries(snapshot)) {
const row = document.createElement('li');
row.append(text('code', path), text('output', typeof value === 'number' ? value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '') : String(value)));
container.append(row);
}
for (const badge of document.querySelectorAll('[data-override-for]')) badge.hidden = !engine.overrides.has(badge.dataset.overrideFor);
const first = Object.entries(engine.document.parameters ?? {})[0];
if (first) {
const [id, spec] = first;
const stored = engine.parameters.get(id);
const condition = (spec.type === 'number' || spec.type === 'integer')
? { op: 'gt', left: { ref: `parameters.${id}` }, right: ((spec.min ?? 0) + (spec.max ?? 1)) / 2 }
: { op: 'eq', left: { ref: `parameters.${id}` }, right: stored };
element('condition-result').textContent = String(engine.evaluateCondition(condition, engine.rng.stream('scenario', 'phase2-condition:1')));
} else element('condition-result').textContent = 'n/a';
}
rememberParameters(exhibitId, values) {
this.parameterValues[exhibitId] = values;
this.persistence.savePreference(`parameters:${exhibitId}`, values).catch((error) => {
this.diagnostics.warn('WARN_STORAGE_WRITE_FAILED', `Parameter changes remain session-only: ${error.message}`, { exhibitId, section: 'persistence' });
});
}
renderDiagnostics(entries) {
const container = element('diagnostic-list');
if (!container) return;
container.replaceChildren();
const newest = [...entries].reverse();
element('diagnostic-count').textContent = String(entries.length);
element('diagnostics-empty').hidden = newest.length > 0;
for (const entry of newest) {
const row = document.createElement('li');
row.className = `diagnostic ${entry.severity}`;
row.append(text('span', entry.severity.toUpperCase(), 'severity'));
row.append(text('code', entry.code));
row.append(text('span', entry.message, 'diagnostic-message'));
const context = [entry.exhibitId, entry.section, entry.objectId, entry.property].filter(Boolean).join(' ');
if (context) row.append(text('small', context));
container.append(row);
}
}
renderStorage() {
const node = element('storage-status');
node.textContent = this.persistence.available ? 'Local cache available' : 'Session only';
node.className = this.persistence.available ? 'storage-ready' : 'storage-warning';
}
setBusy(busy, status) {
this.busy = busy;
element('import-button').disabled = busy;
element('deactivate-button').disabled = busy || !this.activation.current;
this.setStatus(status);
this.renderLibrary();
}
setStatus(value) { element('app-status').textContent = value; }
}
async function bootstrap() {
const application = new XZBTApplication();
globalThis.XZBT = Object.freeze({ application });
await application.start();
}
void bootstrap();
})();</script>
</body>
</html>