Review Phase 3a before building on it, then implement Phase 3b. The Phase 3a draft had four blocking defects: nodes were described as a keyed map while every documented example carried an inline `id` field, so under the strict unknown-field policy each minimal example would have failed its own acceptance trace; no section said where a node lives; the `audioMaxFrequency` ceiling was declared a semantic-stage error while depending on a live AudioContext sample rate; and the sample-hold PRNG child key that section 9.3 requires was undocumented. Close all four, plus nine further gaps in noise seeding, spectral definitions, impulse decay math, Nyquist handling, missing-field codes, LFO phase origin, the units table, node-type staging, and a duplicated diagnostics table. Add Format Specification section 15 for Phase 3b: nine processing and routing node contracts, the component instance node, audio routing and modulation with an explicit modulatable-property registry, twelve graph legality rules, authoring limits, components with a component-scoped `inputs.*` namespace, sound definitions and recipes, and buses. Implement the subsystem in three modules. audio-contract.js holds the declarative node, limit, and modulation tables every consumer reads. audio-graph.js validates, expands components, and checks legality without ever opening an AudioContext. audio-engine.js resolves node fields once from the seeded stream, clamps frequencies to the live device ceiling, realizes the graph through Web Audio, and owns the runtime AudioSubsystem. Extend the schema, delegate the standalone validator's audio checks to the shared module rather than carrying a second implementation, and add a generic audio fixture. Phase 3 is not accepted. Automation precedence, the lifecycle state machine, unlock behavior, voice ceilings, and master protection are Phase 3c. No sound has been heard from any build, so the audio acceptance challenge, peak and finite-sample capture, and listening observations remain open. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011FWPdCqKaaDnP9NC3JAwh6
3334 lines
155 KiB
HTML
3334 lines
155 KiB
HTML
<!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;
|
||
}</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>
|
||
<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; the visual engine attaches in a later phase.</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-phase3b';
|
||
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;
|
||
}
|
||
|
||
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;
|
||
|
||
const AUDIO_LIMITS = Object.freeze({
|
||
nodesPerSound: 128,
|
||
routesPerSound: 256,
|
||
componentDepth: 8,
|
||
resonatorModes: 16,
|
||
oscillatorPartials: 64
|
||
});
|
||
|
||
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']);
|
||
const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'mode']);
|
||
const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'parameters', 'input']);
|
||
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']);
|
||
|
||
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-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 validateGraphObject(document, graph, path, errors, helpers, { allowedFields, scope }) {
|
||
if (!isObject(graph)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Audio graph must be an object.');
|
||
for (const field of Object.keys(graph)) {
|
||
if (!allowedFields.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized audio graph field '${field}'.`);
|
||
}
|
||
if (!isObject(graph.nodes) || Object.keys(graph.nodes).length === 0) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.nodes`, 'An audio graph requires a non-empty nodes object.');
|
||
} else {
|
||
for (const [key, node] of Object.entries(graph.nodes)) validateNode(document, key, node, `${path}.nodes.${key}`, errors, helpers, scope);
|
||
}
|
||
if (graph.routes !== undefined) {
|
||
if (!Array.isArray(graph.routes)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.routes`, 'routes must be an array.');
|
||
else graph.routes.forEach((route, index) => validateRoute(document, route, `${path}.routes[${index}]`, errors, helpers, scope));
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Expansion (section 15.11 / 15.15) and legality (section 15.14)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function recipeGraphFor(document, sound, path, errors) {
|
||
const recipe = sound?.recipe;
|
||
if (!isObject(recipe)) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.recipe`, 'A sound requires a recipe object.');
|
||
return null;
|
||
}
|
||
if (Object.hasOwn(recipe, 'use')) {
|
||
if (Object.keys(recipe).some((field) => field !== 'use')) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.recipe`, 'A recipe reference declares only use.');
|
||
return null;
|
||
}
|
||
const shared = document.audio?.recipes?.[recipe.use];
|
||
if (!isObject(shared)) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.recipe.use`, `Recipe '${recipe.use}' is not declared.`);
|
||
return null;
|
||
}
|
||
return { graph: shared, path: `$.audio.recipes.${recipe.use}` };
|
||
}
|
||
return { graph: recipe, path: `${path}.recipe` };
|
||
}
|
||
|
||
function expandSoundGraph(document, soundId) {
|
||
const errors = [];
|
||
const sound = document.sounds?.[soundId];
|
||
const soundPath = `$.sounds.${soundId}`;
|
||
const located = recipeGraphFor(document, sound, soundPath, errors);
|
||
if (!located) return { nodes: [], routes: [], errors, mode: 'oneshot' };
|
||
|
||
const nodes = new Map();
|
||
const routes = [];
|
||
const components = document.components?.audio ?? {};
|
||
|
||
const join = (prefix, key) => (prefix ? `${prefix}.${key}` : key);
|
||
|
||
function expand(graph, prefix, depth, trail, graphPath) {
|
||
if (depth > AUDIO_LIMITS.componentDepth) {
|
||
fail(errors, 'ERR_COMPONENT_RECURSION', graphPath, `Component nesting exceeds ${AUDIO_LIMITS.componentDepth} levels.`);
|
||
return;
|
||
}
|
||
const declared = isObject(graph.nodes) ? graph.nodes : {};
|
||
for (const [key, node] of Object.entries(declared)) {
|
||
if (!isObject(node)) continue;
|
||
const path = join(prefix, key);
|
||
if (node.type === 'component') {
|
||
const component = components[node.use];
|
||
if (!isObject(component)) continue;
|
||
if (trail.includes(node.use)) {
|
||
fail(errors, 'ERR_COMPONENT_RECURSION', `${graphPath}.nodes.${key}`, `Component '${node.use}' instantiates itself.`);
|
||
continue;
|
||
}
|
||
nodes.set(`${path}.output`, { path: `${path}.output`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
|
||
if (component.input === true) {
|
||
nodes.set(`${path}.input`, { path: `${path}.input`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
|
||
}
|
||
nodes.set(path, {
|
||
path, type: 'component', spec: node, implicit: false, scope: prefix || null,
|
||
component: node.use, acceptsAudio: component.input === true, exposes: component.parameters ?? {}
|
||
});
|
||
expand(component, path, depth + 1, [...trail, node.use], `$.components.audio.${node.use}`);
|
||
} else {
|
||
nodes.set(path, { path, type: node.type, spec: node, implicit: false, scope: prefix || null });
|
||
}
|
||
}
|
||
|
||
const declaresInput = prefix ? graph.input === true : false;
|
||
const resolve = (name, side, routePath) => {
|
||
if (typeof name !== 'string') return null;
|
||
if (name === 'output') return prefix ? `${prefix}.output` : 'output';
|
||
if (name === 'input') {
|
||
if (!declaresInput) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'input' is only available inside a component declaring input: true.");
|
||
return null;
|
||
}
|
||
return `${prefix}.input`;
|
||
}
|
||
const head = name.split('.')[0];
|
||
const local = declared[head];
|
||
if (!isObject(local)) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Route endpoint '${name}' does not resolve in this graph.`);
|
||
return null;
|
||
}
|
||
const localPath = join(prefix, head);
|
||
const property = name.includes('.') ? name.slice(head.length + 1) : null;
|
||
if (local.type !== 'component') {
|
||
if (side === 'from' && property !== null) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Route source '${name}' names a property; only nodes emit signal.`);
|
||
return null;
|
||
}
|
||
return property === null ? localPath : `${localPath}::${property}`;
|
||
}
|
||
if (side === 'from') {
|
||
if (property !== null) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Component internals are encapsulated; '${name}' is not reachable.`);
|
||
return null;
|
||
}
|
||
return `${localPath}.output`;
|
||
}
|
||
if (property !== null) return `${localPath}::${property}`;
|
||
const component = components[local.use];
|
||
if (component?.input !== true) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Component '${local.use}' declares no audio input.`);
|
||
return null;
|
||
}
|
||
return `${localPath}.input`;
|
||
};
|
||
|
||
const declaredRoutes = Array.isArray(graph.routes) ? graph.routes : [];
|
||
declaredRoutes.forEach((route, index) => {
|
||
if (!isObject(route)) return;
|
||
const routePath = `${graphPath}.routes[${index}]`;
|
||
const from = resolve(route.from, 'from', routePath);
|
||
if (from === null) return;
|
||
if (from === 'output' || from.endsWith('.output') && route.from === 'output') {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'output' is sink-only and cannot be a route source.");
|
||
return;
|
||
}
|
||
const isModulation = typeof route.to === 'string' && route.to.includes('.');
|
||
const to = resolve(route.to, 'to', routePath);
|
||
if (to === null) return;
|
||
if (isModulation) {
|
||
const [target, property] = to.includes('::') ? to.split('::') : [to, null];
|
||
if (property === null) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Modulation target '${route.to}' does not name a node property.`);
|
||
return;
|
||
}
|
||
routes.push({ kind: 'modulation', from, to: target, property, depth: route.depth, path: routePath });
|
||
} else {
|
||
if (to.includes('::')) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Audio route target '${route.to}' names a property.`);
|
||
return;
|
||
}
|
||
routes.push({ kind: 'audio', from, to, path: routePath });
|
||
}
|
||
});
|
||
}
|
||
|
||
expand(located.graph, '', 1, [], located.path);
|
||
const mode = AUDIO_RECIPE_MODES.includes(located.graph.mode) ? located.graph.mode : 'oneshot';
|
||
return { nodes: [...nodes.values()], routes, errors, mode, graphPath: located.path };
|
||
}
|
||
|
||
function detectCycle(adjacency) {
|
||
const visiting = new Set();
|
||
const done = new Set();
|
||
let cycle = null;
|
||
const visit = (node, trail) => {
|
||
if (cycle) return;
|
||
if (visiting.has(node)) { cycle = [...trail, node]; return; }
|
||
if (done.has(node)) return;
|
||
visiting.add(node);
|
||
for (const next of adjacency.get(node) ?? []) visit(next, [...trail, node]);
|
||
visiting.delete(node);
|
||
done.add(node);
|
||
};
|
||
for (const node of adjacency.keys()) visit(node, []);
|
||
return cycle;
|
||
}
|
||
|
||
function checkGraphLegality(document, soundId, expansion, errors) {
|
||
const { nodes, routes, graphPath } = expansion;
|
||
const byPath = new Map(nodes.map((node) => [node.path, node]));
|
||
const audible = new Map();
|
||
const combined = new Map();
|
||
const link = (map, from, to) => {
|
||
if (!map.has(from)) map.set(from, []);
|
||
map.get(from).push(to);
|
||
};
|
||
|
||
const authored = nodes.filter((node) => !node.implicit && node.type !== 'component');
|
||
if (authored.length > AUDIO_LIMITS.nodesPerSound) {
|
||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${authored.length} nodes; the limit is ${AUDIO_LIMITS.nodesPerSound}.`);
|
||
}
|
||
if (routes.length > AUDIO_LIMITS.routesPerSound) {
|
||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${routes.length} routes; the limit is ${AUDIO_LIMITS.routesPerSound}.`);
|
||
}
|
||
|
||
for (const route of routes) {
|
||
if (route.from === route.to) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', route.path, 'A route cannot connect a node to itself.');
|
||
continue;
|
||
}
|
||
if (route.kind === 'audio') {
|
||
const target = byPath.get(route.to);
|
||
if (route.to !== 'output') {
|
||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Route target '${route.to}' does not resolve.`); continue; }
|
||
const contract = AUDIO_NODE_TYPES[target.type];
|
||
const accepts = target.implicit || (target.type === 'component' ? target.acceptsAudio : contract?.acceptsAudio === true);
|
||
if (!accepts) { fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node '${route.to}' does not accept audio input.`); continue; }
|
||
}
|
||
link(audible, route.from, route.to);
|
||
link(combined, route.from, route.to);
|
||
continue;
|
||
}
|
||
const target = byPath.get(route.to);
|
||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation target '${route.to}' does not resolve.`); continue; }
|
||
const source = byPath.get(route.from);
|
||
if (!source) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation source '${route.from}' does not resolve.`); continue; }
|
||
const sourceType = source.implicit ? 'gain' : source.type;
|
||
if (!AUDIO_MODULATION_SOURCE_TYPES.includes(sourceType)) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node type '${sourceType}' cannot drive a modulation route.`);
|
||
continue;
|
||
}
|
||
const permitted = target.type === 'component'
|
||
? Object.hasOwn(target.exposes ?? {}, route.property)
|
||
: Object.hasOwn(AUDIO_MODULATABLE[target.type] ?? {}, route.property);
|
||
if (!permitted) {
|
||
fail(errors, 'ERR_UNSUPPORTED_TARGET', route.path, `'${target.type}.${route.property}' is not a modulatable property.`);
|
||
continue;
|
||
}
|
||
link(combined, route.from, route.to);
|
||
}
|
||
|
||
const audioCycle = detectCycle(audible);
|
||
if (audioCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Audio route cycle: ${audioCycle.join(' -> ')}.`);
|
||
const combinedCycle = detectCycle(combined);
|
||
if (!audioCycle && combinedCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Modulation dependency cycle: ${combinedCycle.join(' -> ')}.`);
|
||
|
||
if (audioCycle) return;
|
||
const reaches = (start) => {
|
||
const seen = new Set();
|
||
const queue = [start];
|
||
while (queue.length) {
|
||
const current = queue.shift();
|
||
if (current === 'output') return true;
|
||
if (seen.has(current)) continue;
|
||
seen.add(current);
|
||
for (const next of audible.get(current) ?? []) queue.push(next);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
let audiblePath = false;
|
||
for (const node of nodes) {
|
||
if (node.implicit || node.type === 'component') continue;
|
||
const contract = AUDIO_NODE_TYPES[node.type];
|
||
if (!contract) continue;
|
||
if (contract.acceptsAudio === false && reaches(node.path)) {
|
||
if (contract.class === 'control') {
|
||
fail(errors, 'ERR_INVALID_ROUTE', node.path, `Control source '${node.path}' reaches audible output.`);
|
||
} else {
|
||
audiblePath = true;
|
||
}
|
||
}
|
||
if (contract.acceptsAudio === false) {
|
||
for (const route of routes) {
|
||
if (route.kind === 'audio' && route.to === node.path) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Source node '${node.path}' cannot receive audio input.`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!audiblePath) fail(errors, 'ERR_NO_AUDIBLE_PATH', graphPath, `Sound '${soundId}' has no audio path from a source to output.`);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Document-level entry point
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function validateAudioSubsystem(document, errors, helpers) {
|
||
const audio = document.audio;
|
||
if (audio !== undefined) {
|
||
if (!isObject(audio)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio', 'audio must be an object.');
|
||
else {
|
||
for (const field of Object.keys(audio)) {
|
||
if (field === 'master') fail(errors, 'ERR_UNKNOWN_FIELD', '$.audio.master', 'audio.master is engine-provided and is reserved for the Phase 3c master-protection contract.');
|
||
else if (!['buses', 'recipes'].includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `$.audio.${field}`, `Unrecognized audio field '${field}'.`);
|
||
}
|
||
if (audio.buses !== undefined) {
|
||
if (!isObject(audio.buses)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.buses', 'buses must be an object.');
|
||
else for (const [id, bus] of Object.entries(audio.buses)) {
|
||
const path = `$.audio.buses.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Bus ID '${id}' is not a valid identifier.`);
|
||
if (id === 'master') fail(errors, 'ERR_INVALID_ID', path, "'master' is engine-provided and cannot be declared.");
|
||
if (!isObject(bus)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Bus must be an object.'); continue; }
|
||
for (const field of Object.keys(bus)) if (!AUDIO_BUS_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized bus field '${field}'.`);
|
||
if (Object.hasOwn(bus, 'gain')) {
|
||
validateNumericField(document, bus.gain, { min: AUDIO_BUS_GAIN_RANGE.min, max: AUDIO_BUS_GAIN_RANGE.max, valuespec: true }, `${path}.gain`, errors, helpers, null);
|
||
}
|
||
}
|
||
}
|
||
if (audio.recipes !== undefined) {
|
||
if (!isObject(audio.recipes)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.recipes', 'recipes must be an object.');
|
||
else for (const [id, recipe] of Object.entries(audio.recipes)) {
|
||
const path = `$.audio.recipes.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Recipe ID '${id}' is not a valid identifier.`);
|
||
validateGraphObject(document, recipe, path, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||
if (Object.hasOwn(recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(recipe.mode)) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const componentRoot = document.components?.audio;
|
||
if (componentRoot !== undefined) {
|
||
if (!isObject(componentRoot)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.components.audio', 'components.audio must be an object.');
|
||
else for (const [id, component] of Object.entries(componentRoot)) {
|
||
const path = `$.components.audio.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Component ID '${id}' is not a valid identifier.`);
|
||
if (!isObject(component)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Component must be an object.'); continue; }
|
||
const exposed = new Set();
|
||
if (component.parameters !== undefined) {
|
||
if (!isObject(component.parameters)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.parameters`, 'parameters must be an object.');
|
||
else for (const [name, rule] of Object.entries(component.parameters)) {
|
||
const rulePath = `${path}.parameters.${name}`;
|
||
if (!ID_PATTERN.test(name)) fail(errors, 'ERR_INVALID_ID', rulePath, `Exposed parameter '${name}' is not a valid identifier.`);
|
||
if (!isObject(rule)) { fail(errors, 'ERR_SCHEMA_VALIDATION', rulePath, 'Exposed parameter must be an object.'); continue; }
|
||
for (const field of Object.keys(rule)) if (!AUDIO_COMPONENT_PARAMETER_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${rulePath}.${field}`, `Unrecognized parameter field '${field}'.`);
|
||
if (rule.type !== 'number') fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.type`, "Exposed audio parameters must declare type 'number' in 0.1.");
|
||
for (const field of ['default', 'min', 'max']) {
|
||
if (Object.hasOwn(rule, field) && !Number.isFinite(rule[field])) fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.${field}`, `${field} must be a finite number.`);
|
||
}
|
||
if (Number.isFinite(rule.min) && Number.isFinite(rule.max) && rule.min > rule.max) fail(errors, 'ERR_OUT_OF_BOUNDS', `${rulePath}.min`, 'min cannot exceed max.');
|
||
exposed.add(name);
|
||
}
|
||
}
|
||
if (Object.hasOwn(component, 'input') && typeof component.input !== 'boolean') {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.input`, 'input must be a boolean.');
|
||
}
|
||
validateGraphObject(document, component, path, errors, helpers, {
|
||
allowedFields: AUDIO_COMPONENT_FIELDS,
|
||
scope: { componentParameters: exposed }
|
||
});
|
||
}
|
||
}
|
||
|
||
const sounds = document.sounds;
|
||
if (sounds === undefined) return;
|
||
if (!isObject(sounds)) return fail(errors, 'ERR_SCHEMA_VALIDATION', '$.sounds', 'sounds must be an object.');
|
||
for (const [id, sound] of Object.entries(sounds)) {
|
||
const path = `$.sounds.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Sound ID '${id}' is not a valid identifier.`);
|
||
if (!isObject(sound)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Sound must be an object.'); continue; }
|
||
for (const field of Object.keys(sound)) if (!AUDIO_SOUND_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized sound field '${field}'.`);
|
||
if (typeof sound.name !== 'string' || sound.name.trim().length === 0 || sound.name.length > 128) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.name`, 'Sound name must be a non-empty string of at most 128 characters.');
|
||
}
|
||
if (sound.tags !== undefined && (!Array.isArray(sound.tags) || sound.tags.length > 16 || sound.tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.tags`, 'Tags must contain at most 16 strings of at most 32 characters.');
|
||
}
|
||
if (sound.usage !== undefined && (!Array.isArray(sound.usage) || sound.usage.length === 0 || sound.usage.some((entry) => !AUDIO_SOUND_USAGE.includes(entry)))) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.usage`, `usage entries must be among: ${AUDIO_SOUND_USAGE.join(', ')}.`);
|
||
}
|
||
if (sound.cadence !== undefined && !isObject(sound.cadence)) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.cadence`, 'cadence must be an object.');
|
||
}
|
||
if (sound.bus !== undefined && !isObject(document.audio?.buses?.[sound.bus])) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.bus`, `Bus '${sound.bus}' is not declared.`);
|
||
}
|
||
if (isObject(sound.recipe) && !Object.hasOwn(sound.recipe, 'use')) {
|
||
validateGraphObject(document, sound.recipe, `${path}.recipe`, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||
if (Object.hasOwn(sound.recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(sound.recipe.mode)) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.recipe.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
|
||
}
|
||
}
|
||
const expansion = expandSoundGraph(document, id);
|
||
errors.push(...expansion.errors);
|
||
if (expansion.nodes.length > 0) checkGraphLegality(document, id, expansion, errors);
|
||
}
|
||
}
|
||
|
||
/* 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 });
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
validateReference(document, binding.target, `${path}.target`, errors);
|
||
if (!(typeof binding.target === 'string' && (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(binding.target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(binding.target)))) pushError(errors, 'ERR_UNSUPPORTED_TARGET', `${path}.target`, `Binding target '${binding.target}' is not exposed.`);
|
||
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 = referenceType(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;
|
||
|
||
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.get(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.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);
|
||
this.resolveAll();
|
||
}
|
||
|
||
target(path) {
|
||
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] };
|
||
if (namespace === 'state' && this.document.state?.[id]) return { namespace, id, spec: this.document.state[id] };
|
||
return null;
|
||
}
|
||
|
||
base(path) {
|
||
const target = this.target(path);
|
||
if (!target) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown value reference '${path}'.`, 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}`);
|
||
return this.snapshot();
|
||
}
|
||
|
||
resolveTarget(path) {
|
||
if (this.resolved.has(path)) return this.resolved.get(path);
|
||
const target = this.target(path);
|
||
if (!target) 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);
|
||
if (binding) lower = this.bindingValue(binding, target, lower);
|
||
const winner = this.overrides.select(path);
|
||
let value = winner ? this.overrides.value(winner, lower) : lower;
|
||
if (isNumericType(target.spec.type)) {
|
||
if (target.spec.type === 'integer') value = roundHalfAwayFromZero(value);
|
||
value = clamp(value, target.spec.min ?? -Infinity, target.spec.max ?? Infinity);
|
||
}
|
||
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 (!binding.enabled || binding.smoother === undefined) {
|
||
binding.smoother = value;
|
||
binding.lastTick = this.tickIndex;
|
||
} else if (binding.lastTick !== this.tickIndex) {
|
||
if (tau === 0) binding.smoother = value;
|
||
else binding.smoother += (1 - Math.exp(-STEP_SECONDS / tau)) * (value - binding.smoother);
|
||
binding.lastTick = this.tickIndex;
|
||
}
|
||
value = binding.smoother;
|
||
}
|
||
binding.enabled = true;
|
||
return normalizeForSpec(target.spec, value, { clampNumeric: true });
|
||
}
|
||
|
||
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.overrides.clear(); this.transitions.clear(); this.resolved.clear(); }
|
||
}
|
||
|
||
/* 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 ?? (() => {});
|
||
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.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 };
|
||
}
|
||
|
||
const ceiling = audioMaxFrequency(sampleRate);
|
||
const instanceKey = soundInstanceKey(soundId, ordinal);
|
||
const stream = rng ? rng.stream('sound', instanceKey) : null;
|
||
const warnings = [];
|
||
const componentValues = new Map();
|
||
|
||
const resolver = new ValueResolver((reference) => {
|
||
if (typeof reference === 'string' && reference.startsWith('inputs.')) {
|
||
const scope = resolver.currentScope;
|
||
const values = componentValues.get(scope);
|
||
const name = reference.slice('inputs.'.length);
|
||
if (!values || !Object.hasOwn(values, name)) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component input '${name}' is not available here.`);
|
||
}
|
||
return values[name];
|
||
}
|
||
return resolveReference(reference);
|
||
});
|
||
|
||
const evaluate = (spec, scope, path) => {
|
||
resolver.currentScope = scope;
|
||
return resolver.evaluate(spec, stream, path);
|
||
};
|
||
|
||
const clampField = (value, spec, nodePath, field) => {
|
||
let limitMax = spec.max;
|
||
if (spec.ceiling === 'audio' && ceiling < spec.max) limitMax = ceiling;
|
||
const bounded = clamp(value, spec.min, limitMax);
|
||
if (spec.ceiling === 'audio' && value > limitMax) {
|
||
warnings.push({ code: 'WARN_AUDIO_RATE_CLAMP', path: `${nodePath}.${field}`, message: `Clamped ${value} Hz to the device ceiling ${limitMax} Hz.` });
|
||
}
|
||
return bounded;
|
||
};
|
||
|
||
const nodes = [];
|
||
for (const node of expansion.nodes) {
|
||
if (node.implicit) {
|
||
nodes.push({ path: node.path, type: 'gain', implicit: true, values: { gain: 1 } });
|
||
continue;
|
||
}
|
||
const contract = AUDIO_NODE_TYPES[node.type];
|
||
if (!contract) continue;
|
||
const scope = node.scope;
|
||
const values = {};
|
||
|
||
if (node.type === 'component') {
|
||
const declared = node.exposes ?? {};
|
||
const supplied = node.spec.values ?? {};
|
||
const resolved = {};
|
||
for (const [name, rule] of Object.entries(declared)) {
|
||
const raw = Object.hasOwn(supplied, name) ? supplied[name] : rule.default;
|
||
const value = evaluate(raw, scope, `${node.path}.values.${name}`);
|
||
resolved[name] = clamp(value, rule.min ?? -Infinity, rule.max ?? Infinity);
|
||
}
|
||
componentValues.set(node.path, resolved);
|
||
nodes.push({ path: node.path, type: 'component', component: node.component, values: resolved, passthrough: true });
|
||
continue;
|
||
}
|
||
|
||
for (const [field, spec] of Object.entries(contract.fields)) {
|
||
if (spec.kind === 'partials' || spec.kind === 'modes') continue;
|
||
const authored = Object.hasOwn(node.spec, field) ? node.spec[field] : spec.default;
|
||
if (authored === undefined) continue;
|
||
if (spec.kind === 'enum') { values[field] = authored; continue; }
|
||
if (spec.kind === 'duration') { values[field] = durationMilliseconds(authored); continue; }
|
||
const raw = evaluate(authored, scope, `${node.path}.${field}`);
|
||
values[field] = clampField(raw, spec, node.path, field);
|
||
}
|
||
|
||
if (node.type === 'oscillator' && values.waveform === 'custom') {
|
||
const partials = [];
|
||
for (const [index, partial] of (node.spec.harmonics ?? []).entries()) {
|
||
const ratio = evaluate(partial.ratio, scope, `${node.path}.harmonics[${index}].ratio`);
|
||
const gain = evaluate(partial.gain, scope, `${node.path}.harmonics[${index}].gain`);
|
||
const phase = Object.hasOwn(partial, 'phase')
|
||
? evaluate(partial.phase, scope, `${node.path}.harmonics[${index}].phase`)
|
||
: AUDIO_PARTIAL_FIELDS.phase.default;
|
||
if (ratio * values.frequency > ceiling) continue;
|
||
partials.push({ ratio, gain, phase });
|
||
}
|
||
values.harmonics = partials;
|
||
}
|
||
|
||
if (node.type === 'resonator') {
|
||
const modes = [];
|
||
for (const [index, mode] of (node.spec.modes ?? []).entries()) {
|
||
const entry = {
|
||
gain: Object.hasOwn(mode, 'gain') ? evaluate(mode.gain, scope, `${node.path}.modes[${index}].gain`) : AUDIO_MODE_FIELDS.gain.default,
|
||
decay: durationMilliseconds(Object.hasOwn(mode, 'decay') ? mode.decay : AUDIO_MODE_FIELDS.decay.default)
|
||
};
|
||
entry.frequency = Object.hasOwn(mode, 'frequency')
|
||
? evaluate(mode.frequency, scope, `${node.path}.modes[${index}].frequency`)
|
||
: evaluate(mode.ratio, scope, `${node.path}.modes[${index}].ratio`) * values.fundamental;
|
||
if (entry.frequency > ceiling) continue;
|
||
modes.push(entry);
|
||
}
|
||
values.modes = modes;
|
||
}
|
||
|
||
if (node.type === 'sample-hold') {
|
||
const slewLimit = 1000 / values.rate;
|
||
values.slew = Math.min(values.slew, slewLimit);
|
||
values.streamKey = sampleHoldStreamKey(instanceKey, node.path);
|
||
values.stream = rng ? rng.stream('sound', values.streamKey) : null;
|
||
}
|
||
|
||
nodes.push({ path: node.path, type: node.type, values, scope });
|
||
}
|
||
|
||
const routes = expansion.routes.map((route) => {
|
||
if (route.kind !== 'modulation') return { ...route };
|
||
const owner = expansion.nodes.find((node) => node.path === route.to);
|
||
return { ...route, depth: evaluate(route.depth, owner?.scope ?? null, `${route.path}.depth`) };
|
||
});
|
||
|
||
return { nodes, routes, warnings, errors: [], mode: expansion.mode, instanceKey, ceiling };
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Web Audio realization
|
||
* ------------------------------------------------------------------ */
|
||
|
||
const NOISE_SECONDS = 2;
|
||
|
||
function noiseBuffer(context, color) {
|
||
const length = Math.floor(context.sampleRate * NOISE_SECONDS);
|
||
const buffer = context.createBuffer(1, length, context.sampleRate);
|
||
const data = buffer.getChannelData(0);
|
||
if (color === 'white') {
|
||
for (let index = 0; index < length; index += 1) data[index] = (Math.random() * 2) - 1;
|
||
} else if (color === 'pink') {
|
||
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||
for (let index = 0; index < length; index += 1) {
|
||
const white = (Math.random() * 2) - 1;
|
||
b0 = 0.99886 * b0 + white * 0.0555179;
|
||
b1 = 0.99332 * b1 + white * 0.0750759;
|
||
b2 = 0.969 * b2 + white * 0.153852;
|
||
b3 = 0.8665 * b3 + white * 0.3104856;
|
||
b4 = 0.55 * b4 + white * 0.5329522;
|
||
b5 = -0.7616 * b5 - white * 0.016898;
|
||
data[index] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
||
b6 = white * 0.115926;
|
||
}
|
||
} else {
|
||
let last = 0;
|
||
for (let index = 0; index < length; index += 1) {
|
||
const white = (Math.random() * 2) - 1;
|
||
last = (last + 0.02 * white) / 1.02;
|
||
data[index] = last;
|
||
}
|
||
}
|
||
let sum = 0;
|
||
for (let index = 0; index < length; index += 1) sum += data[index] * data[index];
|
||
const rms = Math.sqrt(sum / length) || 1;
|
||
for (let index = 0; index < length; index += 1) data[index] = clamp(data[index] / rms * 0.2, -1, 1);
|
||
return buffer;
|
||
}
|
||
|
||
function shaperCurve(shape, amount) {
|
||
const points = 1024;
|
||
const curve = new Float32Array(points);
|
||
const drive = 1 + (amount * 24);
|
||
for (let index = 0; index < points; index += 1) {
|
||
const x = (index * 2 / (points - 1)) - 1;
|
||
if (amount === 0) curve[index] = x;
|
||
else if (shape === 'hard-clip') curve[index] = clamp(x * drive, -1, 1);
|
||
else if (shape === 'saturation') curve[index] = Math.tanh(x * drive);
|
||
else curve[index] = Math.sign(x) * (1 - Math.exp(-Math.abs(x * drive))) / (1 - Math.exp(-drive));
|
||
}
|
||
return curve;
|
||
}
|
||
|
||
function reverbBuffer(context, { size, decay, damping }) {
|
||
const seconds = Math.max(0.05, (decay / 1000) * (0.4 + (size * 0.6)));
|
||
const length = Math.max(1, Math.floor(context.sampleRate * seconds));
|
||
const buffer = context.createBuffer(2, length, context.sampleRate);
|
||
for (let channel = 0; channel < 2; channel += 1) {
|
||
const data = buffer.getChannelData(channel);
|
||
let smoothed = 0;
|
||
for (let index = 0; index < length; index += 1) {
|
||
const envelope = (1 - (index / length)) ** (2 + (damping * 4));
|
||
const impulse = ((Math.random() * 2) - 1) * envelope;
|
||
smoothed += (impulse - smoothed) * (1 - (damping * 0.7));
|
||
data[index] = smoothed;
|
||
}
|
||
}
|
||
return buffer;
|
||
}
|
||
|
||
function periodicWave(context, partials) {
|
||
const count = Math.max(2, partials.reduce((highest, partial) => Math.max(highest, Math.round(partial.ratio)), 1) + 1);
|
||
const real = new Float32Array(count);
|
||
const imaginary = new Float32Array(count);
|
||
for (const partial of partials) {
|
||
const index = Math.round(partial.ratio);
|
||
if (index < 1 || index >= count) continue;
|
||
const radians = (partial.phase ?? 0) * Math.PI / 180;
|
||
real[index] += partial.gain * Math.cos(radians);
|
||
imaginary[index] += partial.gain * Math.sin(radians);
|
||
}
|
||
return context.createPeriodicWave(real, imaginary, { disableNormalization: false });
|
||
}
|
||
|
||
// Builds one realized voice. Lifecycle states (PRD 57) arrive in Phase 3c; this returns the
|
||
// created endpoints plus a disposer so the caller can release everything it made.
|
||
function realizeSoundGraph(context, plan, destination) {
|
||
const created = new Map();
|
||
const disposers = [];
|
||
const starters = [];
|
||
const sink = context.createGain();
|
||
sink.gain.value = 1;
|
||
sink.connect(destination);
|
||
created.set('output', { input: sink, output: sink });
|
||
|
||
const now = () => context.currentTime;
|
||
|
||
for (const node of plan.nodes) {
|
||
const { path, type, values } = node;
|
||
if (type === 'component') continue;
|
||
let entry = null;
|
||
|
||
if (type === 'oscillator') {
|
||
const oscillator = context.createOscillator();
|
||
if (values.waveform === 'custom') oscillator.setPeriodicWave(periodicWave(context, values.harmonics ?? []));
|
||
else oscillator.type = values.waveform;
|
||
oscillator.frequency.value = values.frequency;
|
||
oscillator.detune.value = values.detune;
|
||
entry = { input: null, output: oscillator, params: { frequency: oscillator.frequency, detune: oscillator.detune } };
|
||
starters.push(() => oscillator.start());
|
||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); });
|
||
} else if (type === 'noise') {
|
||
const source = context.createBufferSource();
|
||
source.buffer = noiseBuffer(context, values.color);
|
||
source.loop = true;
|
||
entry = { input: null, output: source, params: {} };
|
||
starters.push(() => source.start());
|
||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||
} else if (type === 'impulse') {
|
||
const source = context.createBufferSource();
|
||
source.buffer = noiseBuffer(context, values.color);
|
||
const envelope = context.createGain();
|
||
const seconds = values.duration / 1000;
|
||
const fade = Math.min(0.001, seconds * 0.1);
|
||
const start = now();
|
||
envelope.gain.setValueAtTime(values.amplitude, start);
|
||
if (values.decay === 'linear') envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||
else if (values.decay === 'exponential') {
|
||
envelope.gain.exponentialRampToValueAtTime(Math.max(1e-4, values.amplitude * 0.001), start + seconds - fade);
|
||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||
} else {
|
||
envelope.gain.setValueAtTime(values.amplitude, start + seconds - fade);
|
||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||
}
|
||
source.connect(envelope);
|
||
entry = { input: null, output: envelope, params: {} };
|
||
starters.push(() => source.start(undefined, 0, seconds));
|
||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); envelope.disconnect(); });
|
||
} else if (type === 'constant') {
|
||
const source = context.createConstantSource();
|
||
source.offset.value = values.value;
|
||
entry = { input: null, output: source, params: { value: source.offset } };
|
||
starters.push(() => source.start());
|
||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||
} else if (type === 'lfo') {
|
||
const oscillator = context.createOscillator();
|
||
oscillator.type = values.waveform;
|
||
oscillator.frequency.value = values.frequency;
|
||
const depth = context.createGain();
|
||
depth.gain.value = values.polarity === 'unipolar' ? values.amplitude / 2 : values.amplitude;
|
||
oscillator.connect(depth);
|
||
let output = depth;
|
||
if (values.polarity === 'unipolar') {
|
||
const offset = context.createConstantSource();
|
||
offset.offset.value = values.amplitude / 2;
|
||
const sum = context.createGain();
|
||
depth.connect(sum);
|
||
offset.connect(sum);
|
||
output = sum;
|
||
starters.push(() => offset.start());
|
||
disposers.push(() => { try { offset.stop(); } catch { /* already stopped */ } offset.disconnect(); sum.disconnect(); });
|
||
}
|
||
entry = { input: null, output, params: {} };
|
||
starters.push(() => oscillator.start(now() + ((values.phase / 360) / Math.max(values.frequency, 1e-6))));
|
||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); depth.disconnect(); });
|
||
} else if (type === 'sample-hold') {
|
||
const source = context.createConstantSource();
|
||
const period = 1 / values.rate;
|
||
const slew = values.slew / 1000;
|
||
let held = 0;
|
||
source.offset.value = 0;
|
||
const schedule = (index) => {
|
||
const target = values.stream
|
||
? values.min + (values.stream.nextFloat() * (values.max - values.min))
|
||
: values.min;
|
||
const at = now() + (index * period);
|
||
if (slew > 0) source.offset.linearRampToValueAtTime(target, at + slew);
|
||
else source.offset.setValueAtTime(target, at);
|
||
held = target;
|
||
return held;
|
||
};
|
||
let tick = 0;
|
||
const timer = setInterval(() => { schedule(0); tick += 1; }, Math.max(10, period * 1000));
|
||
schedule(0);
|
||
entry = { input: null, output: source, params: {} };
|
||
starters.push(() => source.start());
|
||
disposers.push(() => { clearInterval(timer); try { source.stop(); } catch { /* already stopped */ } source.disconnect(); void tick; });
|
||
} else if (type === 'gain') {
|
||
const gain = context.createGain();
|
||
gain.gain.value = values.gain ?? 1;
|
||
entry = { input: gain, output: gain, params: { gain: gain.gain } };
|
||
disposers.push(() => gain.disconnect());
|
||
} else if (type === 'filter') {
|
||
const filter = context.createBiquadFilter();
|
||
filter.type = values.mode;
|
||
filter.frequency.value = values.frequency;
|
||
filter.Q.value = values.q;
|
||
filter.gain.value = values.gain;
|
||
filter.detune.value = values.detune;
|
||
entry = { input: filter, output: filter, params: { frequency: filter.frequency, q: filter.Q, gain: filter.gain, detune: filter.detune } };
|
||
disposers.push(() => filter.disconnect());
|
||
} else if (type === 'compressor') {
|
||
const compressor = context.createDynamicsCompressor();
|
||
compressor.threshold.value = values.threshold;
|
||
compressor.knee.value = values.knee;
|
||
compressor.ratio.value = values.ratio;
|
||
compressor.attack.value = values.attack / 1000;
|
||
compressor.release.value = values.release / 1000;
|
||
entry = { input: compressor, output: compressor, params: {} };
|
||
disposers.push(() => compressor.disconnect());
|
||
} else if (type === 'waveshaper') {
|
||
const shaper = context.createWaveShaper();
|
||
shaper.curve = shaperCurve(values.shape, values.amount);
|
||
shaper.oversample = values.oversample;
|
||
entry = { input: shaper, output: shaper, params: {} };
|
||
disposers.push(() => shaper.disconnect());
|
||
} else if (type === 'delay') {
|
||
const input = context.createGain();
|
||
const delay = context.createDelay(10);
|
||
delay.delayTime.value = values.time / 1000;
|
||
const feedback = context.createGain();
|
||
feedback.gain.value = values.feedback;
|
||
const dry = context.createGain();
|
||
const wet = context.createGain();
|
||
const output = context.createGain();
|
||
dry.gain.value = 1 - values.mix;
|
||
wet.gain.value = values.mix;
|
||
input.connect(dry).connect(output);
|
||
input.connect(delay);
|
||
delay.connect(feedback).connect(delay);
|
||
delay.connect(wet).connect(output);
|
||
entry = { input, output, params: { time: delay.delayTime } };
|
||
disposers.push(() => [input, delay, feedback, dry, wet, output].forEach((item) => item.disconnect()));
|
||
} else if (type === 'reverb') {
|
||
const input = context.createGain();
|
||
const convolver = context.createConvolver();
|
||
convolver.buffer = reverbBuffer(context, values);
|
||
const predelay = context.createDelay(1);
|
||
predelay.delayTime.value = values.predelay / 1000;
|
||
const dry = context.createGain();
|
||
const wet = context.createGain();
|
||
const output = context.createGain();
|
||
dry.gain.value = 1 - values.mix;
|
||
wet.gain.value = values.mix;
|
||
input.connect(dry).connect(output);
|
||
input.connect(predelay).connect(convolver).connect(wet).connect(output);
|
||
entry = { input, output, params: {} };
|
||
disposers.push(() => [input, convolver, predelay, dry, wet, output].forEach((item) => item.disconnect()));
|
||
} else if (type === 'stereo-pan') {
|
||
const panner = context.createStereoPanner();
|
||
panner.pan.value = values.pan;
|
||
entry = { input: panner, output: panner, params: { pan: panner.pan } };
|
||
disposers.push(() => panner.disconnect());
|
||
} else if (type === 'mixer') {
|
||
const mixer = context.createGain();
|
||
mixer.gain.value = 1;
|
||
entry = { input: mixer, output: mixer, params: {} };
|
||
disposers.push(() => mixer.disconnect());
|
||
} else if (type === 'resonator') {
|
||
const input = context.createGain();
|
||
const output = context.createGain();
|
||
const dry = context.createGain();
|
||
dry.gain.value = 1 - values.mix;
|
||
input.connect(dry).connect(output);
|
||
const wet = context.createGain();
|
||
wet.gain.value = values.mix;
|
||
wet.connect(output);
|
||
const bands = [];
|
||
for (const mode of values.modes ?? []) {
|
||
const band = context.createBiquadFilter();
|
||
band.type = 'bandpass';
|
||
band.frequency.value = mode.frequency;
|
||
band.Q.value = Math.max(1, (mode.frequency * (mode.decay / 1000)) / 3);
|
||
const level = context.createGain();
|
||
level.gain.value = mode.gain;
|
||
input.connect(band).connect(level).connect(wet);
|
||
bands.push(band, level);
|
||
}
|
||
entry = { input, output, params: {} };
|
||
disposers.push(() => [input, output, dry, wet, ...bands].forEach((item) => item.disconnect()));
|
||
}
|
||
|
||
if (entry) created.set(path, entry);
|
||
}
|
||
|
||
for (const route of plan.routes) {
|
||
const source = created.get(route.from);
|
||
if (!source?.output) continue;
|
||
if (route.kind === 'audio') {
|
||
const target = created.get(route.to);
|
||
if (target?.input) source.output.connect(target.input);
|
||
continue;
|
||
}
|
||
const target = created.get(route.to);
|
||
const param = target?.params?.[route.property];
|
||
if (!param) continue;
|
||
const depth = context.createGain();
|
||
depth.gain.value = route.property === 'time' ? route.depth / 1000 : route.depth;
|
||
source.output.connect(depth).connect(param);
|
||
disposers.push(() => depth.disconnect());
|
||
}
|
||
|
||
for (const start of starters) start();
|
||
|
||
return {
|
||
sink,
|
||
dispose() {
|
||
for (const release of disposers.reverse()) {
|
||
try { release(); } catch { /* disposal is best effort */ }
|
||
}
|
||
sink.disconnect();
|
||
created.clear();
|
||
}
|
||
};
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Runtime subsystem
|
||
* ------------------------------------------------------------------ */
|
||
|
||
// Owns the AudioContext, the declared buses, and the engine master chain, and turns a
|
||
// sound definition into a realized voice. The lifecycle state machine (PRD 57), voice
|
||
// ceilings, and the measured master-protection contract (PRD 58) are Phase 3c; the master
|
||
// chain built here is engine-owned and unbypassable but its ceiling is not yet verified.
|
||
class AudioSubsystem {
|
||
constructor({ document, rng, diagnostics = null, contextFactory = null } = {}) {
|
||
this.document = document;
|
||
this.rng = rng;
|
||
this.diagnostics = diagnostics;
|
||
this.hasCustomContext = Boolean(contextFactory);
|
||
this.contextFactory = contextFactory ?? (() => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)());
|
||
this.context = null;
|
||
this.master = null;
|
||
this.protection = null;
|
||
this.buses = new Map();
|
||
this.voices = new Set();
|
||
this.ordinals = new Map();
|
||
this.masterVolume = 0.8;
|
||
}
|
||
|
||
get available() {
|
||
return this.hasCustomContext || typeof (globalThis.AudioContext ?? globalThis.webkitAudioContext) === 'function';
|
||
}
|
||
|
||
get unlocked() {
|
||
return this.context !== null && this.context.state === 'running';
|
||
}
|
||
|
||
// Must be called from a user gesture; browsers refuse to start audio otherwise.
|
||
async unlock() {
|
||
if (!this.available) {
|
||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', 'This browser exposes no AudioContext; audio is disabled.', { section: 'audio' });
|
||
return false;
|
||
}
|
||
if (!this.context) {
|
||
this.context = this.contextFactory();
|
||
this.buildMaster();
|
||
this.buildBuses();
|
||
}
|
||
if (this.context.state === 'suspended') await this.context.resume();
|
||
return this.unlocked;
|
||
}
|
||
|
||
buildMaster() {
|
||
const context = this.context;
|
||
this.protection = context.createDynamicsCompressor();
|
||
this.protection.threshold.value = -3;
|
||
this.protection.knee.value = 0;
|
||
this.protection.ratio.value = 20;
|
||
this.protection.attack.value = 0.003;
|
||
this.protection.release.value = 0.25;
|
||
this.master = context.createGain();
|
||
this.master.gain.value = this.masterVolume;
|
||
this.protection.connect(this.master).connect(context.destination);
|
||
}
|
||
|
||
buildBuses() {
|
||
const declared = this.document?.audio?.buses ?? {};
|
||
for (const [id, bus] of Object.entries(declared)) {
|
||
const gain = this.context.createGain();
|
||
gain.gain.value = typeof bus.gain === 'number' ? bus.gain : 1;
|
||
gain.connect(this.protection);
|
||
this.buses.set(id, gain);
|
||
}
|
||
}
|
||
|
||
busFor(soundId) {
|
||
const name = this.document?.sounds?.[soundId]?.bus;
|
||
return (name && this.buses.get(name)) || this.protection;
|
||
}
|
||
|
||
setBusGain(id, value) {
|
||
const bus = this.buses.get(id);
|
||
if (bus) bus.gain.value = Math.min(4, Math.max(0, value));
|
||
}
|
||
|
||
setMasterVolume(value) {
|
||
this.masterVolume = Math.min(1, Math.max(0, value));
|
||
if (this.master) this.master.gain.value = this.masterVolume;
|
||
}
|
||
|
||
nextOrdinal(soundId) {
|
||
const next = (this.ordinals.get(soundId) ?? -1) + 1;
|
||
this.ordinals.set(soundId, next);
|
||
return next;
|
||
}
|
||
|
||
play(soundId, { resolveReference } = {}) {
|
||
if (!this.unlocked) return null;
|
||
const plan = instantiateSoundGraph(this.document, soundId, {
|
||
sampleRate: this.context.sampleRate,
|
||
rng: this.rng,
|
||
ordinal: this.nextOrdinal(soundId),
|
||
resolveReference
|
||
});
|
||
for (const error of plan.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
|
||
if (plan.errors.length > 0) return null;
|
||
for (const warning of plan.warnings) this.diagnostics?.warn(warning.code, warning.message, { section: 'audio', objectId: soundId, property: warning.path });
|
||
let voice;
|
||
try {
|
||
voice = realizeSoundGraph(this.context, plan, this.busFor(soundId));
|
||
} catch (error) {
|
||
this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
|
||
return null;
|
||
}
|
||
const handle = {
|
||
soundId,
|
||
mode: plan.mode,
|
||
stop: () => {
|
||
if (!this.voices.has(handle)) return;
|
||
this.voices.delete(handle);
|
||
voice.dispose();
|
||
}
|
||
};
|
||
this.voices.add(handle);
|
||
return handle;
|
||
}
|
||
|
||
stopAll() {
|
||
for (const handle of [...this.voices]) handle.stop();
|
||
}
|
||
|
||
async dispose() {
|
||
this.stopAll();
|
||
this.buses.clear();
|
||
if (this.context) {
|
||
try { await this.context.close(); } catch { /* already closed */ }
|
||
}
|
||
this.context = null;
|
||
this.master = null;
|
||
this.protection = null;
|
||
}
|
||
}
|
||
|
||
/* 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.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);
|
||
}
|
||
})
|
||
});
|
||
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);
|
||
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();
|
||
await this.activation.deactivate();
|
||
this.renderLibrary();
|
||
this.renderStage();
|
||
} finally {
|
||
this.setBusy(false, 'Library ready');
|
||
}
|
||
}
|
||
|
||
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,
|
||
diagnostics: this.diagnostics
|
||
});
|
||
this.audio.setMasterVolume(Number(element('master-volume').value));
|
||
}
|
||
await this.audio.unlock();
|
||
this.renderAudio();
|
||
}
|
||
|
||
async disposeAudio() {
|
||
if (!this.audio) return;
|
||
await this.audio.dispose();
|
||
this.audio = null;
|
||
this.renderAudio();
|
||
}
|
||
|
||
playSound(id) {
|
||
const current = this.activation.current;
|
||
if (!this.audio?.unlocked || !current) return;
|
||
const handle = this.audio.play(id, { resolveReference: (path) => current.performance.engine.get(path) });
|
||
if (handle?.mode === 'oneshot') {
|
||
// Phase 3b has no lifecycle contract yet (PRD 57 is Phase 3c), so a one-shot voice is
|
||
// released on a fixed development timer rather than on a determinable ending.
|
||
setTimeout(() => handle.stop(), 4000);
|
||
}
|
||
this.renderAudio();
|
||
}
|
||
|
||
renderAudio() {
|
||
const current = this.activation.current;
|
||
const state = element('audio-state');
|
||
const stopButton = element('audio-stop');
|
||
const busContainer = element('audio-buses');
|
||
const soundContainer = element('sound-list');
|
||
busContainer.replaceChildren();
|
||
soundContainer.replaceChildren();
|
||
if (!current) {
|
||
state.textContent = 'Audio is locked until you start it.';
|
||
stopButton.disabled = true;
|
||
return;
|
||
}
|
||
const document_ = current.record.document;
|
||
const sounds = document_.sounds ?? {};
|
||
const unlocked = Boolean(this.audio?.unlocked);
|
||
element('audio-unlock').disabled = unlocked;
|
||
stopButton.disabled = !unlocked || (this.audio?.voices.size ?? 0) === 0;
|
||
state.textContent = unlocked
|
||
? `Audio running at ${this.audio.context.sampleRate} Hz · ${this.audio.voices.size} live voice${this.audio.voices.size === 1 ? '' : 's'}`
|
||
: 'Audio is locked until you start it. Browsers require a user gesture.';
|
||
|
||
for (const [id, bus] of Object.entries(document_.audio?.buses ?? {})) {
|
||
const row = document.createElement('div');
|
||
row.className = 'parameter-control';
|
||
const label = text('label', `Bus ${id}`);
|
||
label.htmlFor = `bus-${id}`;
|
||
const input = document.createElement('input');
|
||
input.id = `bus-${id}`;
|
||
input.type = 'range';
|
||
input.min = '0';
|
||
input.max = '4';
|
||
input.step = '0.01';
|
||
input.value = String(typeof bus.gain === 'number' ? bus.gain : 1);
|
||
input.disabled = !unlocked;
|
||
input.addEventListener('input', (event) => this.audio?.setBusGain(id, Number(event.target.value)));
|
||
row.append(label, input);
|
||
busContainer.append(row);
|
||
}
|
||
|
||
if (Object.keys(sounds).length === 0) {
|
||
soundContainer.append(text('p', 'This exhibit declares no sounds.', 'empty'));
|
||
return;
|
||
}
|
||
for (const [id, sound] of Object.entries(sounds)) {
|
||
const row = document.createElement('div');
|
||
row.className = 'sound-row';
|
||
row.append(text('span', sound.name ?? id));
|
||
const button = text('button', 'Play');
|
||
button.type = 'button';
|
||
button.disabled = !unlocked;
|
||
button.addEventListener('click', () => this.playSound(id));
|
||
row.append(button);
|
||
soundContainer.append(row);
|
||
}
|
||
}
|
||
|
||
renderConfiguration(performance) {
|
||
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>
|