3745 lines
169 KiB
HTML
3745 lines
169 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', 'release']);
|
||
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']);
|
||
|
||
const AUDIO_VOICE_LIMITS = Object.freeze({
|
||
oneshot: 64,
|
||
continuous: 16
|
||
});
|
||
|
||
const AUDIO_DEFAULT_RELEASE_MS = 50;
|
||
const AUDIO_MAX_RELEASE_MS = 10000;
|
||
|
||
const AUDIO_LIFECYCLE_STATES = Object.freeze([
|
||
'CREATED',
|
||
'SCHEDULED',
|
||
'ACTIVE',
|
||
'RELEASING',
|
||
'FINISHED',
|
||
'DISPOSED',
|
||
'FAILED'
|
||
]);
|
||
|
||
const AUDIO_LIFECYCLE_TRANSITIONS = Object.freeze({
|
||
CREATED: Object.freeze(['SCHEDULED', 'FINISHED', 'FAILED']),
|
||
SCHEDULED: Object.freeze(['ACTIVE', 'RELEASING', 'FAILED']),
|
||
ACTIVE: Object.freeze(['RELEASING', 'FINISHED', 'FAILED']),
|
||
RELEASING: Object.freeze(['FINISHED', 'FAILED']),
|
||
FINISHED: Object.freeze(['DISPOSED']),
|
||
DISPOSED: Object.freeze([]),
|
||
FAILED: Object.freeze([])
|
||
});
|
||
|
||
function audioMaxFrequency(sampleRate) {
|
||
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return AUDIO_STATIC_MAX_FREQUENCY;
|
||
return Math.min(AUDIO_STATIC_MAX_FREQUENCY, sampleRate * AUDIO_NYQUIST_FACTOR);
|
||
}
|
||
|
||
function isControlSourceType(type) {
|
||
return AUDIO_NODE_TYPES[type]?.class === 'control';
|
||
}
|
||
|
||
function isSourceType(type) {
|
||
const entry = AUDIO_NODE_TYPES[type];
|
||
return entry?.class === 'source' || entry?.class === 'control';
|
||
}
|
||
|
||
/* src/runtime/audio-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', release: AUDIO_DEFAULT_RELEASE_MS };
|
||
|
||
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';
|
||
const release = Object.hasOwn(located.graph, 'release')
|
||
? (durationMilliseconds(located.graph.release) ?? AUDIO_DEFAULT_RELEASE_MS)
|
||
: AUDIO_DEFAULT_RELEASE_MS;
|
||
return { nodes: [...nodes.values()], routes, errors, mode, release, graphPath: located.path };
|
||
}
|
||
|
||
function detectCycle(adjacency) {
|
||
const visiting = new Set();
|
||
const done = new Set();
|
||
let cycle = null;
|
||
const visit = (node, trail) => {
|
||
if (cycle) return;
|
||
if (visiting.has(node)) { cycle = [...trail, node]; return; }
|
||
if (done.has(node)) return;
|
||
visiting.add(node);
|
||
for (const next of adjacency.get(node) ?? []) visit(next, [...trail, node]);
|
||
visiting.delete(node);
|
||
done.add(node);
|
||
};
|
||
for (const node of adjacency.keys()) visit(node, []);
|
||
return cycle;
|
||
}
|
||
|
||
function checkGraphLegality(document, soundId, expansion, errors) {
|
||
const { nodes, routes, graphPath } = expansion;
|
||
const byPath = new Map(nodes.map((node) => [node.path, node]));
|
||
const audible = new Map();
|
||
const combined = new Map();
|
||
const link = (map, from, to) => {
|
||
if (!map.has(from)) map.set(from, []);
|
||
map.get(from).push(to);
|
||
};
|
||
|
||
const authored = nodes.filter((node) => !node.implicit && node.type !== 'component');
|
||
if (authored.length > AUDIO_LIMITS.nodesPerSound) {
|
||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${authored.length} nodes; the limit is ${AUDIO_LIMITS.nodesPerSound}.`);
|
||
}
|
||
if (routes.length > AUDIO_LIMITS.routesPerSound) {
|
||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${routes.length} routes; the limit is ${AUDIO_LIMITS.routesPerSound}.`);
|
||
}
|
||
|
||
for (const route of routes) {
|
||
if (route.from === route.to) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', route.path, 'A route cannot connect a node to itself.');
|
||
continue;
|
||
}
|
||
if (route.kind === 'audio') {
|
||
const target = byPath.get(route.to);
|
||
if (route.to !== 'output') {
|
||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Route target '${route.to}' does not resolve.`); continue; }
|
||
const contract = AUDIO_NODE_TYPES[target.type];
|
||
const accepts = target.implicit || (target.type === 'component' ? target.acceptsAudio : contract?.acceptsAudio === true);
|
||
if (!accepts) { fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node '${route.to}' does not accept audio input.`); continue; }
|
||
}
|
||
link(audible, route.from, route.to);
|
||
link(combined, route.from, route.to);
|
||
continue;
|
||
}
|
||
const target = byPath.get(route.to);
|
||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation target '${route.to}' does not resolve.`); continue; }
|
||
const source = byPath.get(route.from);
|
||
if (!source) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation source '${route.from}' does not resolve.`); continue; }
|
||
const sourceType = source.implicit ? 'gain' : source.type;
|
||
if (!AUDIO_MODULATION_SOURCE_TYPES.includes(sourceType)) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node type '${sourceType}' cannot drive a modulation route.`);
|
||
continue;
|
||
}
|
||
const permitted = target.type === 'component'
|
||
? Object.hasOwn(target.exposes ?? {}, route.property)
|
||
: Object.hasOwn(AUDIO_MODULATABLE[target.type] ?? {}, route.property);
|
||
if (!permitted) {
|
||
fail(errors, 'ERR_UNSUPPORTED_TARGET', route.path, `'${target.type}.${route.property}' is not a modulatable property.`);
|
||
continue;
|
||
}
|
||
link(combined, route.from, route.to);
|
||
}
|
||
|
||
const audioCycle = detectCycle(audible);
|
||
if (audioCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Audio route cycle: ${audioCycle.join(' -> ')}.`);
|
||
const combinedCycle = detectCycle(combined);
|
||
if (!audioCycle && combinedCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Modulation dependency cycle: ${combinedCycle.join(' -> ')}.`);
|
||
|
||
if (audioCycle) return;
|
||
const reaches = (start) => {
|
||
const seen = new Set();
|
||
const queue = [start];
|
||
while (queue.length) {
|
||
const current = queue.shift();
|
||
if (current === 'output') return true;
|
||
if (seen.has(current)) continue;
|
||
seen.add(current);
|
||
for (const next of audible.get(current) ?? []) queue.push(next);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
let audiblePath = false;
|
||
for (const node of nodes) {
|
||
if (node.implicit || node.type === 'component') continue;
|
||
const contract = AUDIO_NODE_TYPES[node.type];
|
||
if (!contract) continue;
|
||
if (contract.acceptsAudio === false && reaches(node.path)) {
|
||
if (contract.class === 'control') {
|
||
fail(errors, 'ERR_INVALID_ROUTE', node.path, `Control source '${node.path}' reaches audible output.`);
|
||
} else {
|
||
audiblePath = true;
|
||
if (expansion.mode === 'oneshot' && (node.type === 'oscillator' || node.type === 'noise')) {
|
||
fail(errors, 'ERR_INDETERMINATE_ONESHOT', graphPath, `Sound '${soundId}' is a oneshot with an unbounded audible path from '${node.path}' (${node.type}).`);
|
||
}
|
||
}
|
||
}
|
||
if (contract.acceptsAudio === false) {
|
||
for (const route of routes) {
|
||
if (route.kind === 'audio' && route.to === node.path) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Source node '${node.path}' cannot receive audio input.`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!audiblePath) fail(errors, 'ERR_NO_AUDIBLE_PATH', graphPath, `Sound '${soundId}' has no audio path from a source to output.`);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Document-level entry point
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function validateAudioSubsystem(document, errors, helpers) {
|
||
const audio = document.audio;
|
||
if (audio !== undefined) {
|
||
if (!isObject(audio)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio', 'audio must be an object.');
|
||
else {
|
||
for (const field of Object.keys(audio)) {
|
||
if (field === 'master') fail(errors, 'ERR_UNKNOWN_FIELD', '$.audio.master', 'audio.master is engine-provided and is reserved for the Phase 3c master-protection contract.');
|
||
else if (!['buses', 'recipes'].includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `$.audio.${field}`, `Unrecognized audio field '${field}'.`);
|
||
}
|
||
if (audio.buses !== undefined) {
|
||
if (!isObject(audio.buses)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.buses', 'buses must be an object.');
|
||
else for (const [id, bus] of Object.entries(audio.buses)) {
|
||
const path = `$.audio.buses.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Bus ID '${id}' is not a valid identifier.`);
|
||
if (id === 'master') fail(errors, 'ERR_INVALID_ID', path, "'master' is engine-provided and cannot be declared.");
|
||
if (!isObject(bus)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Bus must be an object.'); continue; }
|
||
for (const field of Object.keys(bus)) if (!AUDIO_BUS_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized bus field '${field}'.`);
|
||
if (Object.hasOwn(bus, 'gain')) {
|
||
validateNumericField(document, bus.gain, { min: AUDIO_BUS_GAIN_RANGE.min, max: AUDIO_BUS_GAIN_RANGE.max, valuespec: true }, `${path}.gain`, errors, helpers, null);
|
||
}
|
||
}
|
||
}
|
||
if (audio.recipes !== undefined) {
|
||
if (!isObject(audio.recipes)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.recipes', 'recipes must be an object.');
|
||
else for (const [id, recipe] of Object.entries(audio.recipes)) {
|
||
const path = `$.audio.recipes.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Recipe ID '${id}' is not a valid identifier.`);
|
||
validateGraphObject(document, recipe, path, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||
if (Object.hasOwn(recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(recipe.mode)) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
|
||
}
|
||
if (Object.hasOwn(recipe, 'release')) {
|
||
validateDurationField(recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.release`, errors);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const componentRoot = document.components?.audio;
|
||
if (componentRoot !== undefined) {
|
||
if (!isObject(componentRoot)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.components.audio', 'components.audio must be an object.');
|
||
else for (const [id, component] of Object.entries(componentRoot)) {
|
||
const path = `$.components.audio.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Component ID '${id}' is not a valid identifier.`);
|
||
if (!isObject(component)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Component must be an object.'); continue; }
|
||
const exposed = new Set();
|
||
if (component.parameters !== undefined) {
|
||
if (!isObject(component.parameters)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.parameters`, 'parameters must be an object.');
|
||
else for (const [name, rule] of Object.entries(component.parameters)) {
|
||
const rulePath = `${path}.parameters.${name}`;
|
||
if (!ID_PATTERN.test(name)) fail(errors, 'ERR_INVALID_ID', rulePath, `Exposed parameter '${name}' is not a valid identifier.`);
|
||
if (!isObject(rule)) { fail(errors, 'ERR_SCHEMA_VALIDATION', rulePath, 'Exposed parameter must be an object.'); continue; }
|
||
for (const field of Object.keys(rule)) if (!AUDIO_COMPONENT_PARAMETER_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${rulePath}.${field}`, `Unrecognized parameter field '${field}'.`);
|
||
if (rule.type !== 'number') fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.type`, "Exposed audio parameters must declare type 'number' in 0.1.");
|
||
for (const field of ['default', 'min', 'max']) {
|
||
if (Object.hasOwn(rule, field) && !Number.isFinite(rule[field])) fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.${field}`, `${field} must be a finite number.`);
|
||
}
|
||
if (Number.isFinite(rule.min) && Number.isFinite(rule.max) && rule.min > rule.max) fail(errors, 'ERR_OUT_OF_BOUNDS', `${rulePath}.min`, 'min cannot exceed max.');
|
||
exposed.add(name);
|
||
}
|
||
}
|
||
if (Object.hasOwn(component, 'input') && typeof component.input !== 'boolean') {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.input`, 'input must be a boolean.');
|
||
}
|
||
validateGraphObject(document, component, path, errors, helpers, {
|
||
allowedFields: AUDIO_COMPONENT_FIELDS,
|
||
scope: { componentParameters: exposed }
|
||
});
|
||
}
|
||
}
|
||
|
||
const sounds = document.sounds;
|
||
if (sounds === undefined) return;
|
||
if (!isObject(sounds)) return fail(errors, 'ERR_SCHEMA_VALIDATION', '$.sounds', 'sounds must be an object.');
|
||
for (const [id, sound] of Object.entries(sounds)) {
|
||
const path = `$.sounds.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Sound ID '${id}' is not a valid identifier.`);
|
||
if (!isObject(sound)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Sound must be an object.'); continue; }
|
||
for (const field of Object.keys(sound)) if (!AUDIO_SOUND_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized sound field '${field}'.`);
|
||
if (typeof sound.name !== 'string' || sound.name.trim().length === 0 || sound.name.length > 128) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.name`, 'Sound name must be a non-empty string of at most 128 characters.');
|
||
}
|
||
if (sound.tags !== undefined && (!Array.isArray(sound.tags) || sound.tags.length > 16 || sound.tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.tags`, 'Tags must contain at most 16 strings of at most 32 characters.');
|
||
}
|
||
if (sound.usage !== undefined && (!Array.isArray(sound.usage) || sound.usage.length === 0 || sound.usage.some((entry) => !AUDIO_SOUND_USAGE.includes(entry)))) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.usage`, `usage entries must be among: ${AUDIO_SOUND_USAGE.join(', ')}.`);
|
||
}
|
||
if (sound.cadence !== undefined && !isObject(sound.cadence)) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.cadence`, 'cadence must be an object.');
|
||
}
|
||
if (sound.bus !== undefined && !isObject(document.audio?.buses?.[sound.bus])) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.bus`, `Bus '${sound.bus}' is not declared.`);
|
||
}
|
||
if (isObject(sound.recipe) && !Object.hasOwn(sound.recipe, 'use')) {
|
||
validateGraphObject(document, sound.recipe, `${path}.recipe`, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||
if (Object.hasOwn(sound.recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(sound.recipe.mode)) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.recipe.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
|
||
}
|
||
if (Object.hasOwn(sound.recipe, 'release')) {
|
||
validateDurationField(sound.recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.recipe.release`, errors);
|
||
}
|
||
}
|
||
const expansion = expandSoundGraph(document, id);
|
||
errors.push(...expansion.errors);
|
||
if (expansion.nodes.length > 0) checkGraphLegality(document, id, expansion, errors);
|
||
}
|
||
}
|
||
|
||
function computeDeterminableEndingBound(expansion, resolvedNodes = null) {
|
||
const { nodes, routes, release = AUDIO_DEFAULT_RELEASE_MS } = expansion;
|
||
const resolvedMap = resolvedNodes instanceof Map
|
||
? resolvedNodes
|
||
: Array.isArray(resolvedNodes)
|
||
? new Map(resolvedNodes.map((n) => [n.path, n]))
|
||
: null;
|
||
|
||
const nodeMap = new Map(nodes.map((n) => [n.path, n]));
|
||
|
||
function nodeContribution(path) {
|
||
if (path === 'output') return 0;
|
||
const node = nodeMap.get(path);
|
||
if (!node || node.implicit || node.type === 'component') return 0;
|
||
const type = node.type;
|
||
const resolved = resolvedMap?.get(path)?.values;
|
||
|
||
if (type === 'impulse') {
|
||
if (resolved && typeof resolved.duration === 'number') return resolved.duration;
|
||
return durationMilliseconds(node.spec?.duration ?? '10ms') ?? 10;
|
||
}
|
||
if (type === 'oscillator' || type === 'noise') {
|
||
return Infinity;
|
||
}
|
||
if (type === 'delay') {
|
||
let time, feedback;
|
||
if (resolved) {
|
||
time = resolved.time ?? 250;
|
||
feedback = resolved.feedback ?? 0.2;
|
||
} else {
|
||
time = durationMilliseconds(node.spec?.time ?? '250ms') ?? 250;
|
||
feedback = typeof node.spec?.feedback === 'number' ? node.spec.feedback : 0.2;
|
||
}
|
||
if (feedback > 0) {
|
||
const clampedFeedback = Math.min(0.9999, Math.max(1e-6, feedback));
|
||
const multiplier = Math.ceil(Math.log(0.001) / Math.log(clampedFeedback));
|
||
return time * multiplier;
|
||
}
|
||
return time;
|
||
}
|
||
if (type === 'reverb') {
|
||
let predelay, decay;
|
||
if (resolved) {
|
||
predelay = resolved.predelay ?? 0;
|
||
decay = resolved.decay ?? 2000;
|
||
} else {
|
||
predelay = durationMilliseconds(node.spec?.predelay ?? '0ms') ?? 0;
|
||
decay = durationMilliseconds(node.spec?.decay ?? '2s') ?? 2000;
|
||
}
|
||
return predelay + decay;
|
||
}
|
||
if (type === 'resonator') {
|
||
if (resolved?.modes && Array.isArray(resolved.modes) && resolved.modes.length > 0) {
|
||
return Math.max(...resolved.modes.map((m) => m.decay ?? 1000));
|
||
}
|
||
const modes = node.spec?.modes ?? [];
|
||
if (modes.length > 0) {
|
||
return Math.max(...modes.map((m) => durationMilliseconds(m.decay ?? '1s') ?? 1000));
|
||
}
|
||
return 1000;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
const audioAdjacency = new Map();
|
||
const inDegree = new Map();
|
||
const allNodes = new Set();
|
||
for (const n of nodes) allNodes.add(n.path);
|
||
allNodes.add('output');
|
||
|
||
for (const n of allNodes) {
|
||
audioAdjacency.set(n, []);
|
||
inDegree.set(n, 0);
|
||
}
|
||
|
||
for (const route of routes) {
|
||
if (route.kind === 'audio') {
|
||
if (!audioAdjacency.has(route.from)) audioAdjacency.set(route.from, []);
|
||
audioAdjacency.get(route.from).push(route.to);
|
||
inDegree.set(route.to, (inDegree.get(route.to) ?? 0) + 1);
|
||
}
|
||
}
|
||
|
||
const dist = new Map();
|
||
for (const n of allNodes) dist.set(n, -Infinity);
|
||
|
||
for (const n of nodes) {
|
||
if (n.implicit || n.type === 'component') continue;
|
||
if (n.type === 'impulse' || n.type === 'oscillator' || n.type === 'noise') {
|
||
dist.set(n.path, nodeContribution(n.path));
|
||
}
|
||
}
|
||
|
||
const queue = [];
|
||
for (const [node, deg] of inDegree.entries()) {
|
||
if (deg === 0) queue.push(node);
|
||
}
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift();
|
||
const currentDist = dist.get(current);
|
||
for (const next of audioAdjacency.get(current) ?? []) {
|
||
if (currentDist !== -Infinity) {
|
||
const nextContrib = nodeContribution(next);
|
||
const newDist = currentDist === Infinity ? Infinity : currentDist + nextContrib;
|
||
if (newDist > dist.get(next)) dist.set(next, newDist);
|
||
}
|
||
inDegree.set(next, inDegree.get(next) - 1);
|
||
if (inDegree.get(next) === 0) queue.push(next);
|
||
}
|
||
}
|
||
|
||
const outDist = dist.get('output');
|
||
if (outDist === Infinity) return Infinity;
|
||
if (outDist === -Infinity) return release;
|
||
return outDist + release;
|
||
}
|
||
|
||
/* src/runtime/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,
|
||
release: expansion.release,
|
||
endingBoundMs: null
|
||
};
|
||
}
|
||
|
||
const ceiling = audioMaxFrequency(sampleRate);
|
||
const instanceKey = soundInstanceKey(soundId, ordinal);
|
||
const stream = rng ? rng.stream('sound', instanceKey) : null;
|
||
const warnings = [];
|
||
const componentValues = new Map();
|
||
|
||
const resolver = new ValueResolver((reference) => {
|
||
if (typeof reference === 'string' && reference.startsWith('inputs.')) {
|
||
const scope = resolver.currentScope;
|
||
const values = componentValues.get(scope);
|
||
const name = reference.slice('inputs.'.length);
|
||
if (!values || !Object.hasOwn(values, name)) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component input '${name}' is not available here.`);
|
||
}
|
||
return values[name];
|
||
}
|
||
return resolveReference(reference);
|
||
});
|
||
|
||
const evaluate = (spec, scope, path) => {
|
||
resolver.currentScope = scope;
|
||
return resolver.evaluate(spec, stream, path);
|
||
};
|
||
|
||
const 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`) };
|
||
});
|
||
|
||
let endingBoundMs = null;
|
||
if (expansion.mode === 'oneshot') {
|
||
endingBoundMs = computeDeterminableEndingBound(expansion, nodes);
|
||
}
|
||
|
||
return {
|
||
nodes,
|
||
routes,
|
||
warnings,
|
||
errors: [],
|
||
mode: expansion.mode,
|
||
release: expansion.release,
|
||
endingBoundMs,
|
||
instanceKey,
|
||
ceiling
|
||
};
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Web Audio realization
|
||
* ------------------------------------------------------------------ */
|
||
|
||
const NOISE_SECONDS = 2;
|
||
|
||
function noiseBuffer(context, color) {
|
||
const length = Math.floor(context.sampleRate * NOISE_SECONDS);
|
||
const buffer = context.createBuffer(1, length, context.sampleRate);
|
||
const data = buffer.getChannelData(0);
|
||
if (color === 'white') {
|
||
for (let index = 0; index < length; index += 1) data[index] = (Math.random() * 2) - 1;
|
||
} else if (color === 'pink') {
|
||
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||
for (let index = 0; index < length; index += 1) {
|
||
const white = (Math.random() * 2) - 1;
|
||
b0 = 0.99886 * b0 + white * 0.0555179;
|
||
b1 = 0.99332 * b1 + white * 0.0750759;
|
||
b2 = 0.969 * b2 + white * 0.153852;
|
||
b3 = 0.8665 * b3 + white * 0.3104856;
|
||
b4 = 0.55 * b4 + white * 0.5329522;
|
||
b5 = -0.7616 * b5 - white * 0.016898;
|
||
data[index] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
||
b6 = white * 0.115926;
|
||
}
|
||
} else {
|
||
let last = 0;
|
||
for (let index = 0; index < length; index += 1) {
|
||
const white = (Math.random() * 2) - 1;
|
||
last = (last + 0.02 * white) / 1.02;
|
||
data[index] = last;
|
||
}
|
||
}
|
||
let sum = 0;
|
||
for (let index = 0; index < length; index += 1) sum += data[index] * data[index];
|
||
const rms = Math.sqrt(sum / length) || 1;
|
||
for (let index = 0; index < length; index += 1) data[index] = clamp(data[index] / rms * 0.2, -1, 1);
|
||
return buffer;
|
||
}
|
||
|
||
function shaperCurve(shape, amount) {
|
||
const points = 1024;
|
||
const curve = new Float32Array(points);
|
||
const drive = 1 + (amount * 24);
|
||
for (let index = 0; index < points; index += 1) {
|
||
const x = (index * 2 / (points - 1)) - 1;
|
||
if (amount === 0) curve[index] = x;
|
||
else if (shape === 'hard-clip') curve[index] = clamp(x * drive, -1, 1);
|
||
else if (shape === 'saturation') curve[index] = Math.tanh(x * drive);
|
||
else curve[index] = Math.sign(x) * (1 - Math.exp(-Math.abs(x * drive))) / (1 - Math.exp(-drive));
|
||
}
|
||
return curve;
|
||
}
|
||
|
||
function reverbBuffer(context, { size, decay, damping }) {
|
||
const seconds = Math.max(0.05, (decay / 1000) * (0.4 + (size * 0.6)));
|
||
const length = Math.max(1, Math.floor(context.sampleRate * seconds));
|
||
const buffer = context.createBuffer(2, length, context.sampleRate);
|
||
for (let channel = 0; channel < 2; channel += 1) {
|
||
const data = buffer.getChannelData(channel);
|
||
let smoothed = 0;
|
||
for (let index = 0; index < length; index += 1) {
|
||
const envelope = (1 - (index / length)) ** (2 + (damping * 4));
|
||
const impulse = ((Math.random() * 2) - 1) * envelope;
|
||
smoothed += (impulse - smoothed) * (1 - (damping * 0.7));
|
||
data[index] = smoothed;
|
||
}
|
||
}
|
||
return buffer;
|
||
}
|
||
|
||
function periodicWave(context, partials) {
|
||
const count = Math.max(2, partials.reduce((highest, partial) => Math.max(highest, Math.round(partial.ratio)), 1) + 1);
|
||
const real = new Float32Array(count);
|
||
const imaginary = new Float32Array(count);
|
||
for (const partial of partials) {
|
||
const index = Math.round(partial.ratio);
|
||
if (index < 1 || index >= count) continue;
|
||
const radians = (partial.phase ?? 0) * Math.PI / 180;
|
||
real[index] += partial.gain * Math.cos(radians);
|
||
imaginary[index] += partial.gain * Math.sin(radians);
|
||
}
|
||
return context.createPeriodicWave(real, imaginary, { disableNormalization: false });
|
||
}
|
||
|
||
// Builds one 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;
|
||
const releaseGain = context.createGain();
|
||
releaseGain.gain.value = 1;
|
||
sink.connect(releaseGain).connect(destination);
|
||
disposers.push(() => releaseGain.disconnect());
|
||
created.set('output', { input: sink, output: sink });
|
||
|
||
const now = () => context.currentTime;
|
||
|
||
for (const node of plan.nodes) {
|
||
const { path, type, values } = node;
|
||
if (type === 'component') continue;
|
||
let entry = null;
|
||
|
||
if (type === 'oscillator') {
|
||
const oscillator = context.createOscillator();
|
||
if (values.waveform === 'custom') oscillator.setPeriodicWave(periodicWave(context, values.harmonics ?? []));
|
||
else oscillator.type = values.waveform;
|
||
oscillator.frequency.value = values.frequency;
|
||
oscillator.detune.value = values.detune;
|
||
entry = { input: null, output: oscillator, params: { frequency: oscillator.frequency, detune: oscillator.detune } };
|
||
starters.push(() => oscillator.start());
|
||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); });
|
||
} else if (type === 'noise') {
|
||
const source = context.createBufferSource();
|
||
source.buffer = noiseBuffer(context, values.color);
|
||
source.loop = true;
|
||
entry = { input: null, output: source, params: {} };
|
||
starters.push(() => source.start());
|
||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||
} else if (type === 'impulse') {
|
||
const source = context.createBufferSource();
|
||
source.buffer = noiseBuffer(context, values.color);
|
||
const envelope = context.createGain();
|
||
const seconds = values.duration / 1000;
|
||
const fade = Math.min(0.001, seconds * 0.1);
|
||
const start = now();
|
||
envelope.gain.setValueAtTime(values.amplitude, start);
|
||
if (values.decay === 'linear') envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||
else if (values.decay === 'exponential') {
|
||
envelope.gain.exponentialRampToValueAtTime(Math.max(1e-4, values.amplitude * 0.001), start + seconds - fade);
|
||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||
} else {
|
||
envelope.gain.setValueAtTime(values.amplitude, start + seconds - fade);
|
||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||
}
|
||
source.connect(envelope);
|
||
entry = { input: null, output: envelope, params: {} };
|
||
starters.push(() => source.start(undefined, 0, seconds));
|
||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); envelope.disconnect(); });
|
||
} else if (type === 'constant') {
|
||
const source = context.createConstantSource();
|
||
source.offset.value = values.value;
|
||
entry = { input: null, output: source, params: { value: source.offset } };
|
||
starters.push(() => source.start());
|
||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||
} else if (type === 'lfo') {
|
||
const oscillator = context.createOscillator();
|
||
oscillator.type = values.waveform;
|
||
oscillator.frequency.value = values.frequency;
|
||
const depth = context.createGain();
|
||
depth.gain.value = values.polarity === 'unipolar' ? values.amplitude / 2 : values.amplitude;
|
||
oscillator.connect(depth);
|
||
let output = depth;
|
||
if (values.polarity === 'unipolar') {
|
||
const offset = context.createConstantSource();
|
||
offset.offset.value = values.amplitude / 2;
|
||
const sum = context.createGain();
|
||
depth.connect(sum);
|
||
offset.connect(sum);
|
||
output = sum;
|
||
starters.push(() => offset.start());
|
||
disposers.push(() => { try { offset.stop(); } catch { /* already stopped */ } offset.disconnect(); sum.disconnect(); });
|
||
}
|
||
entry = { input: null, output, params: {} };
|
||
starters.push(() => oscillator.start(now() + ((values.phase / 360) / Math.max(values.frequency, 1e-6))));
|
||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); depth.disconnect(); });
|
||
} else if (type === 'sample-hold') {
|
||
const source = context.createConstantSource();
|
||
const period = 1 / values.rate;
|
||
const slew = values.slew / 1000;
|
||
let held = 0;
|
||
source.offset.value = 0;
|
||
const schedule = (index) => {
|
||
const target = values.stream
|
||
? values.min + (values.stream.nextFloat() * (values.max - values.min))
|
||
: values.min;
|
||
const at = now() + (index * period);
|
||
if (slew > 0) source.offset.linearRampToValueAtTime(target, at + slew);
|
||
else source.offset.setValueAtTime(target, at);
|
||
held = target;
|
||
return held;
|
||
};
|
||
let tick = 0;
|
||
const timer = setInterval(() => { schedule(0); tick += 1; }, Math.max(10, period * 1000));
|
||
if (typeof timer.unref === 'function') timer.unref();
|
||
schedule(0);
|
||
entry = { input: null, output: source, params: {} };
|
||
starters.push(() => source.start());
|
||
disposers.push(() => { clearInterval(timer); try { source.stop(); } catch { /* already stopped */ } source.disconnect(); void tick; });
|
||
} else if (type === 'gain') {
|
||
const gain = context.createGain();
|
||
gain.gain.value = values.gain ?? 1;
|
||
entry = { input: gain, output: gain, params: { gain: gain.gain } };
|
||
disposers.push(() => gain.disconnect());
|
||
} else if (type === 'filter') {
|
||
const filter = context.createBiquadFilter();
|
||
filter.type = values.mode;
|
||
filter.frequency.value = values.frequency;
|
||
filter.Q.value = values.q;
|
||
filter.gain.value = values.gain;
|
||
filter.detune.value = values.detune;
|
||
entry = { input: filter, output: filter, params: { frequency: filter.frequency, q: filter.Q, gain: filter.gain, detune: filter.detune } };
|
||
disposers.push(() => filter.disconnect());
|
||
} else if (type === 'compressor') {
|
||
const compressor = context.createDynamicsCompressor();
|
||
compressor.threshold.value = values.threshold;
|
||
compressor.knee.value = values.knee;
|
||
compressor.ratio.value = values.ratio;
|
||
compressor.attack.value = values.attack / 1000;
|
||
compressor.release.value = values.release / 1000;
|
||
entry = { input: compressor, output: compressor, params: {} };
|
||
disposers.push(() => compressor.disconnect());
|
||
} else if (type === 'waveshaper') {
|
||
const shaper = context.createWaveShaper();
|
||
shaper.curve = shaperCurve(values.shape, values.amount);
|
||
shaper.oversample = values.oversample;
|
||
entry = { input: shaper, output: shaper, params: {} };
|
||
disposers.push(() => shaper.disconnect());
|
||
} else if (type === 'delay') {
|
||
const input = context.createGain();
|
||
const delay = context.createDelay(10);
|
||
delay.delayTime.value = values.time / 1000;
|
||
const feedback = context.createGain();
|
||
feedback.gain.value = values.feedback;
|
||
const dry = context.createGain();
|
||
const wet = context.createGain();
|
||
const output = context.createGain();
|
||
dry.gain.value = 1 - values.mix;
|
||
wet.gain.value = values.mix;
|
||
input.connect(dry).connect(output);
|
||
input.connect(delay);
|
||
delay.connect(feedback).connect(delay);
|
||
delay.connect(wet).connect(output);
|
||
entry = { input, output, params: { time: delay.delayTime } };
|
||
disposers.push(() => [input, delay, feedback, dry, wet, output].forEach((item) => item.disconnect()));
|
||
} else if (type === 'reverb') {
|
||
const input = context.createGain();
|
||
const convolver = context.createConvolver();
|
||
convolver.buffer = reverbBuffer(context, values);
|
||
const predelay = context.createDelay(1);
|
||
predelay.delayTime.value = values.predelay / 1000;
|
||
const dry = context.createGain();
|
||
const wet = context.createGain();
|
||
const output = context.createGain();
|
||
dry.gain.value = 1 - values.mix;
|
||
wet.gain.value = values.mix;
|
||
input.connect(dry).connect(output);
|
||
input.connect(predelay).connect(convolver).connect(wet).connect(output);
|
||
entry = { input, output, params: {} };
|
||
disposers.push(() => [input, convolver, predelay, dry, wet, output].forEach((item) => item.disconnect()));
|
||
} else if (type === 'stereo-pan') {
|
||
const panner = context.createStereoPanner();
|
||
panner.pan.value = values.pan;
|
||
entry = { input: panner, output: panner, params: { pan: panner.pan } };
|
||
disposers.push(() => panner.disconnect());
|
||
} else if (type === 'mixer') {
|
||
const mixer = context.createGain();
|
||
mixer.gain.value = 1;
|
||
entry = { input: mixer, output: mixer, params: {} };
|
||
disposers.push(() => mixer.disconnect());
|
||
} else if (type === 'resonator') {
|
||
const input = context.createGain();
|
||
const output = context.createGain();
|
||
const dry = context.createGain();
|
||
dry.gain.value = 1 - values.mix;
|
||
input.connect(dry).connect(output);
|
||
const wet = context.createGain();
|
||
wet.gain.value = values.mix;
|
||
wet.connect(output);
|
||
const bands = [];
|
||
for (const mode of values.modes ?? []) {
|
||
const band = context.createBiquadFilter();
|
||
band.type = 'bandpass';
|
||
band.frequency.value = mode.frequency;
|
||
band.Q.value = Math.max(1, (mode.frequency * (mode.decay / 1000)) / 3);
|
||
const level = context.createGain();
|
||
level.gain.value = mode.gain;
|
||
input.connect(band).connect(level).connect(wet);
|
||
bands.push(band, level);
|
||
}
|
||
entry = { input, output, params: {} };
|
||
disposers.push(() => [input, output, dry, wet, ...bands].forEach((item) => item.disconnect()));
|
||
}
|
||
|
||
if (entry) created.set(path, entry);
|
||
}
|
||
|
||
for (const route of plan.routes) {
|
||
const source = created.get(route.from);
|
||
if (!source?.output) continue;
|
||
if (route.kind === 'audio') {
|
||
const target = created.get(route.to);
|
||
if (target?.input) source.output.connect(target.input);
|
||
continue;
|
||
}
|
||
const target = created.get(route.to);
|
||
const param = target?.params?.[route.property];
|
||
if (!param) continue;
|
||
const depth = context.createGain();
|
||
depth.gain.value = route.property === 'time' ? route.depth / 1000 : route.depth;
|
||
source.output.connect(depth).connect(param);
|
||
disposers.push(() => depth.disconnect());
|
||
}
|
||
|
||
for (const start of starters) start();
|
||
|
||
return {
|
||
sink,
|
||
releaseGain,
|
||
dispose() {
|
||
for (const release of disposers.reverse()) {
|
||
try { release(); } catch { /* disposal is best effort */ }
|
||
}
|
||
sink.disconnect();
|
||
releaseGain.disconnect();
|
||
created.clear();
|
||
}
|
||
};
|
||
}
|
||
|
||
class SoundInstance {
|
||
constructor({
|
||
soundId,
|
||
mode = 'oneshot',
|
||
releaseMs = AUDIO_DEFAULT_RELEASE_MS,
|
||
endingBoundMs = null,
|
||
plan = null,
|
||
subsystem = null,
|
||
bus = null,
|
||
context = null,
|
||
creationOrder = 0
|
||
} = {}) {
|
||
this.soundId = soundId;
|
||
this.mode = mode;
|
||
this.releaseMs = releaseMs ?? AUDIO_DEFAULT_RELEASE_MS;
|
||
this.endingBoundMs = endingBoundMs;
|
||
this.plan = plan;
|
||
this.subsystem = subsystem;
|
||
this.bus = bus;
|
||
this.context = context;
|
||
this.creationOrder = creationOrder;
|
||
this.state = 'CREATED';
|
||
this.realized = null;
|
||
this.startTime = null;
|
||
this.releaseTimer = null;
|
||
this.endingTimer = null;
|
||
}
|
||
|
||
canTransition(nextState) {
|
||
const allowed = AUDIO_LIFECYCLE_TRANSITIONS[this.state] ?? [];
|
||
return allowed.includes(nextState);
|
||
}
|
||
|
||
transition(nextState) {
|
||
if (!this.canTransition(nextState)) {
|
||
throw new RuntimeFault('ERR_RUNTIME_FAULT', `Invalid lifecycle transition from '${this.state}' to '${nextState}'.`);
|
||
}
|
||
this.state = nextState;
|
||
return true;
|
||
}
|
||
|
||
realize(context, destination) {
|
||
this.realized = realizeSoundGraph(context, this.plan, destination);
|
||
return this.realized;
|
||
}
|
||
|
||
stop() {
|
||
if (this.state === 'RELEASING' || this.state === 'FINISHED' || this.state === 'DISPOSED' || this.state === 'FAILED') {
|
||
return;
|
||
}
|
||
if (this.subsystem) {
|
||
this.subsystem.voices.delete(this);
|
||
}
|
||
if (this.state === 'CREATED') {
|
||
this.transition('FINISHED');
|
||
return;
|
||
}
|
||
if (this.state === 'SCHEDULED') {
|
||
this.transition('RELEASING');
|
||
if (this.releaseMs === 0) {
|
||
this.transition('FINISHED');
|
||
} else {
|
||
this.releaseTimer = setTimeout(() => {
|
||
if (this.state === 'RELEASING') {
|
||
this.transition('FINISHED');
|
||
}
|
||
}, this.releaseMs);
|
||
if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
|
||
}
|
||
return;
|
||
}
|
||
if (this.state === 'ACTIVE') {
|
||
if (this.endingTimer) {
|
||
clearTimeout(this.endingTimer);
|
||
this.endingTimer = null;
|
||
}
|
||
this.transition('RELEASING');
|
||
if (this.releaseMs === 0) {
|
||
if (this.realized?.releaseGain?.gain) {
|
||
const now = this.context?.currentTime ?? 0;
|
||
this.realized.releaseGain.gain.cancelScheduledValues?.(now);
|
||
this.realized.releaseGain.gain.setValueAtTime?.(0, now);
|
||
}
|
||
this.transition('FINISHED');
|
||
} else {
|
||
const now = this.context?.currentTime ?? 0;
|
||
const durationSec = this.releaseMs / 1000;
|
||
const gainParam = this.realized?.releaseGain?.gain;
|
||
if (gainParam) {
|
||
gainParam.cancelScheduledValues?.(now);
|
||
gainParam.setValueAtTime?.(gainParam.value, now);
|
||
gainParam.linearRampToValueAtTime?.(0, now + durationSec);
|
||
}
|
||
this.releaseTimer = setTimeout(() => {
|
||
if (this.state === 'RELEASING') {
|
||
this.transition('FINISHED');
|
||
}
|
||
}, this.releaseMs);
|
||
if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
|
||
}
|
||
}
|
||
}
|
||
|
||
forceFinishAndDispose() {
|
||
if (this.state === 'RELEASING') {
|
||
if (this.releaseTimer) {
|
||
clearTimeout(this.releaseTimer);
|
||
this.releaseTimer = null;
|
||
}
|
||
if (this.realized?.releaseGain?.gain) {
|
||
const now = this.context?.currentTime ?? 0;
|
||
this.realized.releaseGain.gain.cancelScheduledValues?.(now);
|
||
this.realized.releaseGain.gain.setValueAtTime?.(0, now);
|
||
}
|
||
this.transition('FINISHED');
|
||
}
|
||
this.dispose();
|
||
}
|
||
|
||
dispose() {
|
||
if (this.state === 'DISPOSED') return;
|
||
if (this.releaseTimer) {
|
||
clearTimeout(this.releaseTimer);
|
||
this.releaseTimer = null;
|
||
}
|
||
if (this.endingTimer) {
|
||
clearTimeout(this.endingTimer);
|
||
this.endingTimer = null;
|
||
}
|
||
if (this.subsystem) {
|
||
this.subsystem.voices.delete(this);
|
||
this.subsystem.oneshotVoices?.delete(this);
|
||
this.subsystem.continuousVoices?.delete(this);
|
||
}
|
||
if (this.state !== 'FINISHED' && this.state !== 'FAILED') {
|
||
if (this.state === 'ACTIVE' || this.state === 'SCHEDULED') {
|
||
this.transition('RELEASING');
|
||
this.transition('FINISHED');
|
||
} else if (this.state === 'CREATED') {
|
||
this.transition('FINISHED');
|
||
} else if (this.state === 'RELEASING') {
|
||
this.transition('FINISHED');
|
||
}
|
||
}
|
||
this.transition('DISPOSED');
|
||
if (this.realized) {
|
||
try { this.realized.dispose(); } catch { /* best effort */ }
|
||
this.realized = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 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 (16.6), and the measured master-protection contract (PRD 58) are Phase 3c;
|
||
// the master chain built here is engine-owned and unbypassable.
|
||
class AudioSubsystem {
|
||
constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null } = {}) {
|
||
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.oneshotVoices = new Set();
|
||
this.continuousVoices = new Set();
|
||
this.voiceLimits = voiceLimits ?? { ...AUDIO_VOICE_LIMITS };
|
||
this.ordinals = new Map();
|
||
this.nextCreationOrder = 0;
|
||
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 sound = this.document?.sounds?.[soundId];
|
||
if (!sound) return null;
|
||
|
||
const expansion = expandSoundGraph(this.document, soundId);
|
||
if (expansion.errors.length > 0) {
|
||
for (const error of expansion.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
|
||
return null;
|
||
}
|
||
|
||
const mode = expansion.mode ?? 'oneshot';
|
||
const pool = mode === 'oneshot' ? this.oneshotVoices : this.continuousVoices;
|
||
const ceiling = mode === 'oneshot' ? this.voiceLimits.oneshot : this.voiceLimits.continuous;
|
||
|
||
if (pool.size >= ceiling) {
|
||
// 16.6 Eviction policy applied in strict order:
|
||
// 1. Dispose the oldest instance already in FINISHED.
|
||
const finishedInstances = [...pool].filter((i) => i.state === 'FINISHED');
|
||
if (finishedInstances.length > 0) {
|
||
finishedInstances.sort((a, b) => a.creationOrder - b.creationOrder);
|
||
const candidate = finishedInstances[0];
|
||
candidate.dispose();
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; disposed oldest FINISHED instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
|
||
} else {
|
||
// 2. Evict the oldest instance in RELEASING by advancing its release ramp to immediate completion and disposing it.
|
||
const releasingInstances = [...pool].filter((i) => i.state === 'RELEASING');
|
||
if (releasingInstances.length > 0) {
|
||
releasingInstances.sort((a, b) => a.creationOrder - b.creationOrder);
|
||
const candidate = releasingInstances[0];
|
||
candidate.forceFinishAndDispose();
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicted oldest RELEASING instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
|
||
} else if (mode === 'oneshot') {
|
||
// 3. For a one-shot request only: evict the oldest ACTIVE one-shot by starting its release.
|
||
const activeInstances = [...pool].filter((i) => i.state === 'ACTIVE' || i.state === 'SCHEDULED');
|
||
if (activeInstances.length > 0) {
|
||
activeInstances.sort((a, b) => a.creationOrder - b.creationOrder);
|
||
const candidate = activeInstances[0];
|
||
candidate.stop();
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicting oldest ACTIVE oneshot instance of '${candidate.soundId}' via release.`, { section: 'audio', objectId: soundId });
|
||
} else {
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; request refused.`, { section: 'audio', objectId: soundId });
|
||
return null;
|
||
}
|
||
} else {
|
||
// 4. Otherwise refuse the new instance.
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; continuous request refused.`, { section: 'audio', objectId: soundId });
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
const plan = instantiateSoundGraph(this.document, soundId, {
|
||
sampleRate: this.context.sampleRate,
|
||
rng: this.rng,
|
||
ordinal: this.nextOrdinal(soundId),
|
||
resolveReference,
|
||
expansion
|
||
});
|
||
for (const error of plan.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
|
||
if (plan.errors.length > 0) return null;
|
||
for (const warning of plan.warnings) this.diagnostics?.warn(warning.code, warning.message, { section: 'audio', objectId: soundId, property: warning.path });
|
||
|
||
const instance = new SoundInstance({
|
||
soundId,
|
||
mode: plan.mode,
|
||
releaseMs: plan.release,
|
||
endingBoundMs: plan.endingBoundMs,
|
||
plan,
|
||
subsystem: this,
|
||
bus: this.busFor(soundId),
|
||
context: this.context,
|
||
creationOrder: this.nextCreationOrder++
|
||
});
|
||
pool.add(instance);
|
||
|
||
try {
|
||
instance.transition('SCHEDULED');
|
||
instance.realize(this.context, this.busFor(soundId));
|
||
instance.transition('ACTIVE');
|
||
instance.startTime = this.context.currentTime;
|
||
this.voices.add(instance);
|
||
} catch (error) {
|
||
this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
|
||
instance.transition('FAILED');
|
||
instance.dispose();
|
||
return null;
|
||
}
|
||
|
||
if (mode === 'oneshot' && instance.endingBoundMs != null && Number.isFinite(instance.endingBoundMs)) {
|
||
instance.endingTimer = setTimeout(() => {
|
||
if (instance.state === 'ACTIVE') {
|
||
this.voices.delete(instance);
|
||
instance.transition('FINISHED');
|
||
}
|
||
}, instance.endingBoundMs);
|
||
if (typeof instance.endingTimer?.unref === 'function') instance.endingTimer.unref();
|
||
}
|
||
|
||
return instance;
|
||
}
|
||
|
||
stopAll() {
|
||
for (const instance of [...this.voices]) instance.stop();
|
||
}
|
||
|
||
async dispose() {
|
||
this.stopAll();
|
||
for (const instance of [...this.oneshotVoices]) instance.dispose();
|
||
for (const instance of [...this.continuousVoices]) instance.dispose();
|
||
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;
|
||
this.audio.play(id, { resolveReference: (path) => current.performance.engine.get(path) });
|
||
this.renderAudio();
|
||
}
|
||
|
||
renderAudio() {
|
||
const current = this.activation.current;
|
||
const state = element('audio-state');
|
||
const stopButton = element('audio-stop');
|
||
const busContainer = element('audio-buses');
|
||
const soundContainer = element('sound-list');
|
||
busContainer.replaceChildren();
|
||
soundContainer.replaceChildren();
|
||
if (!current) {
|
||
state.textContent = 'Audio is locked until you start it.';
|
||
stopButton.disabled = true;
|
||
return;
|
||
}
|
||
const document_ = current.record.document;
|
||
const sounds = document_.sounds ?? {};
|
||
const unlocked = Boolean(this.audio?.unlocked);
|
||
element('audio-unlock').disabled = unlocked;
|
||
stopButton.disabled = !unlocked || (this.audio?.voices.size ?? 0) === 0;
|
||
state.textContent = unlocked
|
||
? `Audio running at ${this.audio.context.sampleRate} Hz · ${this.audio.voices.size} live voice${this.audio.voices.size === 1 ? '' : 's'}`
|
||
: 'Audio is locked until you start it. Browsers require a user gesture.';
|
||
|
||
for (const [id, bus] of Object.entries(document_.audio?.buses ?? {})) {
|
||
const row = document.createElement('div');
|
||
row.className = 'parameter-control';
|
||
const label = text('label', `Bus ${id}`);
|
||
label.htmlFor = `bus-${id}`;
|
||
const input = document.createElement('input');
|
||
input.id = `bus-${id}`;
|
||
input.type = 'range';
|
||
input.min = '0';
|
||
input.max = '4';
|
||
input.step = '0.01';
|
||
input.value = String(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>
|