feat(audio): complete phase 3a/3b audio subsystem contract and runtime
Review Phase 3a before building on it, then implement Phase 3b. The Phase 3a draft had four blocking defects: nodes were described as a keyed map while every documented example carried an inline `id` field, so under the strict unknown-field policy each minimal example would have failed its own acceptance trace; no section said where a node lives; the `audioMaxFrequency` ceiling was declared a semantic-stage error while depending on a live AudioContext sample rate; and the sample-hold PRNG child key that section 9.3 requires was undocumented. Close all four, plus nine further gaps in noise seeding, spectral definitions, impulse decay math, Nyquist handling, missing-field codes, LFO phase origin, the units table, node-type staging, and a duplicated diagnostics table. Add Format Specification section 15 for Phase 3b: nine processing and routing node contracts, the component instance node, audio routing and modulation with an explicit modulatable-property registry, twelve graph legality rules, authoring limits, components with a component-scoped `inputs.*` namespace, sound definitions and recipes, and buses. Implement the subsystem in three modules. audio-contract.js holds the declarative node, limit, and modulation tables every consumer reads. audio-graph.js validates, expands components, and checks legality without ever opening an AudioContext. audio-engine.js resolves node fields once from the seeded stream, clamps frequencies to the live device ceiling, realizes the graph through Web Audio, and owns the runtime AudioSubsystem. Extend the schema, delegate the standalone validator's audio checks to the shared module rather than carrying a second implementation, and add a generic audio fixture. Phase 3 is not accepted. Automation precedence, the lifecycle state machine, unlock behavior, voice ceilings, and master protection are Phase 3c. No sound has been heard from any build, so the audio acceptance challenge, peak and finite-sample capture, and listening observations remain open. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011FWPdCqKaaDnP9NC3JAwh6
This commit is contained in:
@@ -9,10 +9,10 @@ XZBT is a self-contained browser runtime for declarative procedural audiovisual
|
||||
| Resource | Purpose | Status |
|
||||
| --- | --- | --- |
|
||||
| [MVP Product Requirements Document](XZBT_0-1_MVP_Product_Requirements_Document.md) | Product scope, user behavior, delivery requirements, and release acceptance | Document revision 0.3; format version remains 0.1 |
|
||||
| [Format Specification 0.1](docs/XZBT_0-1_Format_Specification.md) | Runtime semantics, contract inventory, and required authoring examples | Document revision 0.3; Phase 0 shared contracts complete, subsystem contracts in dependency order |
|
||||
| [Format Specification 0.1](docs/XZBT_0-1_Format_Specification.md) | Runtime semantics, contract inventory, and required authoring examples | Document revision 0.4; Phase 0 shared contracts and the Phase 3a/3b audio authoring contract complete, remaining subsystem contracts in dependency order |
|
||||
| [Gap Closure Decisions](docs/XZBT_0-1_Gap_Closure_Decisions.md) | Decisions and rationale for the seven pre-implementation gaps | Record revision 0.3; GC1–GC5 Phase 0 evidence complete |
|
||||
| [Verification Gates](docs/XZBT_0-1_Verification_Gates.md) | Evidence required before architecture commitment, subsystem work, and release | Phase 0 complete; later subsystem, GC6, and GC7 checks scheduled |
|
||||
| [Implementation status](docs/IMPLEMENTATION_STATUS.md) | Current phase, stop reason, saved work, and resume prerequisites | Phase 0 complete; Phase 1 runtime skeleton ready |
|
||||
| [Implementation status](docs/IMPLEMENTATION_STATUS.md) | Current phase, stop reason, saved work, and resume prerequisites | Phase 3a/3b audio contract and implementation complete; Phase 1 direct-file and Phase 3 audible observations pending |
|
||||
| [Implementation plan](docs/XZBT_0-1_Implementation_Plan.md) | Sequenced phases, completion/challenge mapping, and GC6/GC7 verification schedule | Phase 1–9 plan recorded |
|
||||
|
||||
The PRD is authoritative for product requirements. The format specification is authoritative for runtime semantics where a contract is explicitly defined. The decision record explains those choices; the verification gates define how to check them. These documents must be updated together when a decision changes. An unresolved conflict is a specification defect, not permission for an implementation to choose silently.
|
||||
@@ -21,11 +21,11 @@ The earlier ChatGPT discussion, **Discuss Application Vision** (conversation `6a
|
||||
|
||||
## Planning entry point
|
||||
|
||||
Phase 0 is complete. Begin implementation with Phase 1 from the implementation plan, preserving the verified launch model and shared semantic contracts. Carry workload measurements and release soak tests as later explicit gates; they are not prerequisites for the runtime skeleton.
|
||||
Phase 0 is complete, and the production runtime is implemented through Phase 3b. Continue with Phase 3c (audio automation precedence, lifecycle, and master protection) from the implementation plan while preserving the verified launch model, shared semantic contracts, and production GC2/GC3 behavior. Two observations remain open in completed work: the Phase 1 direct-file two-fixture restart, and the Phase 3 audible acceptance — no sound has been heard from any build. Workload measurements and release soak tests remain later explicit gates.
|
||||
|
||||
The full PRD completion criteria remain the 0.1 release target. Early integrated demonstrations are milestones, not completed MVPs. Reference exhibits develop alongside the engine; Phase 9 completes and audits the suite.
|
||||
|
||||
Phase 0 is complete. All 10 direct-file checks in GC1 are verified, including worklet loading, full-browser restart persistence, offline behavior, and directory fallback. GC2 shared format/schema validation passes its 12-case matrix. Format Specification Revision 0.3 closes GC3 resolution, GC4 clock/PRNG, and GC5 ownership/failure contracts with deterministic executable traces; see the [combined evidence](docs/evidence/phase0/2026-09-05-gc3-gc5-contracts.md). The implementation plan schedules GC6 measurements and maps GC7 library/build verification. Phase 1 may begin; later production, browser/audio, benchmark, and soak gates remain explicit.
|
||||
All 10 Phase 0 direct-file feasibility checks in GC1 are verified, and the GC2–GC5 contract oracles pass. Phase 1 turns those contracts into a standalone runtime shell with import, caching, activation control, diagnostics, and production seeded RNG. Phase 2 adds parameters, state, signals, values, conditions, actions, bindings, transitions, overrides, and per-exhibit parameter persistence. Phases 3a and 3b add the audio authoring contract — all sixteen graph node types, routing and modulation, graph legality, authoring limits, components, sounds and recipes, and buses — together with pure validation and expansion, deterministic instantiation, and Web Audio realization. Later Phase 3c, visual, cadence, scenario, UI, library-hardening, benchmark, and soak gates remain explicit in the implementation plan.
|
||||
|
||||
## Repository configuration
|
||||
|
||||
@@ -43,7 +43,7 @@ Build the standalone, dependency-free runtime with the pinned Node version in `.
|
||||
npm run build
|
||||
```
|
||||
|
||||
This deterministically combines the modules in `src/runtime`, the application shell, and local styles into `XZBT.html`. Open that file directly in a supported desktop Chromium browser, then import the two minimal exhibits from `exhibits`. Imported definitions and the last active exhibit are cached in IndexedDB and restored on reopen when browser storage is available. The diagnostics panel reports validation, lifecycle, and storage failures; storage failure leaves the current session usable.
|
||||
This deterministically combines the modules in `src/runtime`, the application shell, and local styles into `XZBT.html`. Open that file directly in a supported desktop Chromium browser, then import the three minimal exhibits from `exhibits`. Imported definitions and the last active exhibit are cached in IndexedDB and restored on reopen when browser storage is available. The diagnostics panel reports validation, lifecycle, and storage failures; storage failure leaves the current session usable.
|
||||
|
||||
Run the Phase 1 production-module, lifecycle, fixture, PRNG-vector, cache, and reproducible-build tests with:
|
||||
|
||||
@@ -57,7 +57,17 @@ Run the Phase 2 common-grammar conformance suite with:
|
||||
npm run test:phase2
|
||||
```
|
||||
|
||||
The active performance now supports typed parameters and state, read-only runtime signals, ValueSpec and ConditionSpec evaluation, ordered `set` and `override` actions, same-tick bindings with deterministic smoothing, numeric transitions, and priority-based temporary overrides. The application exposes generic Phase 2 parameter controls and resolved-value placeholders so these systems can be inspected without subject-specific runtime code. Audio, visuals, cadence, events, scenarios, and the final schema-driven UI remain assigned to later phases.
|
||||
Run the Phase 3 audio subsystem suite with:
|
||||
|
||||
```powershell
|
||||
npm run test:phase3
|
||||
```
|
||||
|
||||
Run every suite with `npm test`.
|
||||
|
||||
The active performance supports typed parameters and state, read-only runtime signals, ValueSpec and ConditionSpec evaluation, ordered `set` and `override` actions, same-tick bindings with deterministic smoothing, numeric transitions, and priority-based temporary overrides. The audio subsystem validates and expands declared graphs without touching an `AudioContext`, instantiates them deterministically from the seeded stream, and realizes them through Web Audio; the application exposes gesture unlock, master volume, per-bus gain, and per-sound triggering. `exhibits/minimal-audio.xzbt` exercises components, modulation, buses, and both recipe modes.
|
||||
|
||||
No sound has yet been heard from a build: the audio acceptance challenge, peak and finite-sample capture, and listening observations are open Phase 3 gates, and audio automation precedence, the lifecycle state machine, unlock behavior, voice ceilings, and master protection are Phase 3c contracts. Visuals, cadence, events, scenarios, and the final schema-driven UI remain assigned to later phases.
|
||||
|
||||
## Local development server
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# XZBT implementation status
|
||||
|
||||
**Updated:** September 5, 2026
|
||||
**State:** Phase 2 common grammar complete; Phase 1 direct-file import/restart observation remains pending
|
||||
**State:** Phase 3a/3b audio authoring contract and implementation complete; Phase 3c audio contracts, the Phase 3 audible gates, and the Phase 1 direct-file import/restart observation remain pending
|
||||
**Planning baseline:** `05fe2b4e021ba86e4a290d05b63c7cae0e386128`
|
||||
|
||||
**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton and Phase 2 common grammar are implemented and pass automated checks, but Phase 1's direct-file two-fixture restart observation remains open. Real audio, visual, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases.
|
||||
**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton, the Phase 2 common grammar, and the Phase 3a/3b audio authoring contract and its implementation pass automated checks. Three gates remain open in the completed work: Phase 1's direct-file two-fixture restart observation, Phase 3's audible observation (no sound has been heard from any build), and the Phase 3c audio contracts on which real playback acceptance depends. Visual, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases.
|
||||
|
||||
The user requested sequential implementation with a stop on problems. The [manual version 3 evidence](evidence/phase0/2026-09-04-user-run-v3.md) verifies embedded data-URL worklet loading in direct-file Chrome. The subsequent [user-performed restart test](evidence/phase0/2026-09-04-user-restart.md) restored Blue Study activity 0.37 and master volume 0.19 immediately on reopening. Native tone output and AudioContext suspend/resume are also observed. Ordinary file import, selection of both exhibits, regular Chrome mode, and [directory cancellation/denial fallback](evidence/phase0/2026-09-05-user-directory-fallback.md) have been confirmed.
|
||||
|
||||
@@ -13,7 +13,7 @@ The user requested sequential implementation with a stop on problems. The [manua
|
||||
| 0 — Contracts and feasibility | Complete | GC1 passed; GC2–GC5 shared contracts and traces passed; GC6/GC7 later gates scheduled and mapped |
|
||||
| 1 — Runtime skeleton | Implemented; acceptance pending | [Automated evidence](evidence/phase1/2026-09-05-runtime-skeleton.md) passes production-module, lifecycle, PRNG-vector, cache/restore, fixture-validation, and deterministic-build tests. Direct-file two-fixture import/restart remains a user-observed gate. |
|
||||
| 2 — Common grammar | Complete | [Automated evidence](evidence/phase2/2026-09-05-common-grammar.md) covers production GC2 conformance, typed values, signals, actions, same-tick bindings, transitions, override precedence/release, and parameter restoration. |
|
||||
| 3 — Audio engine | Not started | Earlier phases and audio contracts |
|
||||
| 3 — Audio engine | Phases 3a/3b complete; 3c not started | [Automated evidence](evidence/phase3/2026-09-05-audio-authoring-contract.md) covers Format Specification sections 14-15, all sixteen node types, routing and modulation, twelve legality rules, authoring limits, components, sounds/recipes, and buses. Phase 3c (automation precedence PRD 54, lifecycle PRD 57, safety limits and master protection PRD 58) and the PRD 129 audio acceptance challenge remain. |
|
||||
| 4 — Visual engine | Not started | Earlier phases and visual contracts |
|
||||
| 5 — Events and cadence | Not started | Earlier phases and event/cadence contracts |
|
||||
| 6 — Scenario director | Not started | Earlier phases and scenario contracts |
|
||||
@@ -50,3 +50,13 @@ Phase 0 remains complete and the Phase 1 implementation now reuses the GC4 PRNG
|
||||
The modular runtime sources now live in `src/runtime` and build deterministically with `node tools/build-xzbt.mjs` into the self-contained root `XZBT.html`. The artifact has no external runtime dependencies or requests. Two minimal, subject-neutral fixtures live in `exhibits`; one uses fixed seed `42` and one requests cryptographic entropy. Phase 2 extends both with small common-grammar examples.
|
||||
|
||||
Automated Phase 1 checks cover JSON/version/metadata failure, the frozen GC4 state/output vectors, byte-identical import behavior, in-memory restore, prepare-before-teardown activation recovery, and byte-identical standalone builds. The existing GC2–GC5 suites still pass when run in-process. Phase 1 is not marked accepted until the built artifact imports both fixtures directly from disk, restores them after a full browser restart, and records the Phase 1 GC6 environment details required by the implementation plan.
|
||||
|
||||
## Phase 3a and 3b audio subsystem
|
||||
|
||||
Format Specification revision 0.4 adds section 14 (pipeline, canonical units, audio graph objects, node-field resolution scope, frequency-ceiling staging, audio reproducibility scope, and the six source and control-source node contracts) and section 15 (nine processing and routing nodes, the component instance node, routing, modulation semantics with an explicit modulatable-property registry, twelve graph legality rules, authoring limits, components, sound definitions and recipes, and buses). A Phase 3a review closed four blocking defects in the draft before Phase 3b began: the node-identity contradiction, the missing graph container shape, the conflated `audioMaxFrequency` validation stages, and the undocumented sample-hold PRNG child key. Those four, and nine further gaps, are recorded in the Phase 3 evidence file.
|
||||
|
||||
Implementation lives in `src/runtime/audio-contract.js` (declarative tables), `src/runtime/audio-graph.js` (pure validation, component expansion, legality), and `src/runtime/audio-engine.js` (deterministic instantiation, Web Audio realization, and the `AudioSubsystem` runtime owner). Validation never opens an `AudioContext`, which the test suite asserts directly. `tools/validate-exhibit.mjs` now delegates its audio checks to the shared module instead of carrying a second implementation. `exhibits/minimal-audio.xzbt` exercises components, modulation, buses, a shared recipe, and both recipe modes.
|
||||
|
||||
`npm test` runs 62 tests with zero failures. Two clean builds produce byte-identical artifacts with digest `64d9932ed863dbae66f309504b88e19370ad2c9017e92c32bd61ea1ea39a3f17`.
|
||||
|
||||
Phase 3 is not accepted. Audio automation precedence, the lifecycle state machine, unlock behavior, voice ceilings, and the measured master-protection contract are Phase 3c; no sound has been heard from any build, so the PRD 129 audio acceptance challenge, real GC4 synchronization checks, peak and finite-sample capture, and listening observations all remain open.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# XZBT Format Specification 0.1
|
||||
|
||||
**XZBT format version:** 0.1
|
||||
**Document revision:** 0.3
|
||||
**Status:** Normative specification for shared contracts (document, types, ValueSpec, ConditionSpec, resolution, bindings, and transitions); subsystem contracts in progress
|
||||
**Document revision:** 0.4
|
||||
**Status:** Normative specification for shared contracts (document, types, ValueSpec, ConditionSpec, resolution, bindings, and transitions); the Audio subsystem authoring contract (Phase 3a sources/control sources, Phase 3b processing/routing/components/buses) is complete; audio automation precedence, lifecycle, and master protection (Phase 3c) and the remaining subsystem contracts are in progress
|
||||
**Related resources:** [PRD](../XZBT_0-1_MVP_Product_Requirements_Document.md), [decisions](XZBT_0-1_Gap_Closure_Decisions.md), [verification](XZBT_0-1_Verification_Gates.md)
|
||||
|
||||
This document defines normative syntax and runtime semantics for XZBT 0.1 exhibits. An exhibit is a UTF-8 JSON document that configures generic procedural visual, audio, cadence, and orchestration primitives. It does not contain executable JavaScript.
|
||||
@@ -280,6 +280,13 @@ To ensure consistent error reporting between structural schema validation, seman
|
||||
| `WARN_CLOCK_STALL` | Runtime | Elapsed wall time or accumulated work exceeded the fixed-step per-turn limits and was discarded. |
|
||||
| `WARN_CLEANUP_FORCED` | Runtime | A cleanup owner reached its deadline and force-disposed remaining resources. |
|
||||
| `INFO_AUDIO_UNLOCK_SKIP` | Runtime | One or more pre-unlock one-shots were intentionally not replayed. |
|
||||
| `ERR_INVALID_NODE_TYPE` | Semantic | Audio graph node `type` is not a member of Audio Graph Node Set 0.1. |
|
||||
| `ERR_NODE_LIMIT_EXCEEDED` | Semantic | An authoring limit is exceeded (oscillator partials, resonator modes, expanded nodes or routes per sound). |
|
||||
| `ERR_INVALID_RANGE_ORDER` | Semantic | Declared paired bounds (e.g. `sample-hold` `min`/`max`) are not in strictly increasing order after resolution. |
|
||||
| `ERR_INVALID_ROUTE` | Semantic | An audio or modulation route is structurally resolvable but illegal under the audio graph legality rules. |
|
||||
| `ERR_NO_AUDIBLE_PATH` | Semantic | A sound's expanded audio graph has no chain of audio routes from a non-control source to `output`. |
|
||||
| `ERR_COMPONENT_RECURSION` | Semantic | An audio component instantiates itself transitively, or component nesting exceeds 8 levels. |
|
||||
| `WARN_AUDIO_RATE_CLAMP` | Runtime | A frequency field was clamped to the device's `audioMaxFrequency` at node instantiation. |
|
||||
|
||||
## 8. Shared value resolution, bindings, and transitions
|
||||
|
||||
@@ -498,7 +505,7 @@ Complete shared contracts before implementing dependent subsystems. Use PRD sect
|
||||
| Values and conditions | 15-21, 33 | Operator arity/table, division-by-zero protection, sampling timing boundaries, edge-trigger re-arming | **Complete (Rev 0.2)** |
|
||||
| References and bindings | 13, 17, 31-32 | Target-capability table, instance/input scope, evaluation order, cycles, disabled bindings, exact smoothing | **Complete (Rev 0.3 / GC3)** |
|
||||
| Actions and transitions | 22-30 | Shared `set`/`override` fields and defaults, override target matrix, interrupted transitions, instance IDs; subsystem action matrices remain with their subsystems | **Shared contract complete (Rev 0.3 / GC3)** |
|
||||
| Audio | 34-60 | Complete recipe/component shapes, node defaults, automation timing, clamp implementation, one-shot endings and tails, unlock behavior, master protection contract | Subsystem contract (Phase 3) |
|
||||
| Audio | 34-60 | Complete recipe/component shapes, node defaults, automation timing, clamp implementation, one-shot endings and tails, unlock behavior, master protection contract | **Authoring contract complete (Rev 0.4 / Phase 3a-3b):** units, graph objects, all sixteen node types, routing, modulation, graph legality, authoring limits, components, sounds/recipes, and buses. Automation precedence (54), lifecycle states (57), unlock behavior, and runtime safety/master protection (58) remain (Phase 3c) |
|
||||
| Cadence | 61-68 | Exact selection/cooldown/overlap fields, clocks, pool collisions, pending audio, manual sampling isolation and continuous-sample termination | Subsystem contract (Phase 5) |
|
||||
| Visuals | 69-89 | Primitive/system/behavior fields, angle and motion units, transform order, depth projection, field behavior, morph compatibility, effect approximation and limits | Subsystem contract (Phase 4) |
|
||||
| Events/scenarios | 90-102 | Shared ownership, hooks/failure ordering, condition rearming, deferred ordering/expiry, dispatch limits; full trigger/timeline shapes remain for Phase 6 | **Shared lifecycle contract complete (Rev 0.3 / GC5)** |
|
||||
@@ -541,3 +548,697 @@ On successful candidate activation, atomically commit the new definition, reconc
|
||||
Development uses separate source modules. The build must have pinned tool versions and inputs, stable module/asset order, no wall-clock timestamps or absolute paths in output, and a documented single command that produces `XZBT.html`. Two clean builds from the same revision must have identical SHA-256 digests. The artifact must contain all runtime code/assets/fonts and pass direct-file offline verification with no external runtime requests.
|
||||
|
||||
Structural JSON Schema does not replace semantic validation. The internal schema and fixtures belong to 0.1 implementation work; public schema distribution and editor integration may follow later.
|
||||
|
||||
## 14. Audio Subsystem Contract — Sources and Control Sources (Phase 3a)
|
||||
|
||||
This section opens the Audio contract required by section 11 (PRD 34-60). It covers the audio pipeline overview, canonical units, the shared audio-graph object shape, node-field resolution scope, the audio frequency ceiling, audio reproducibility scope, and the six source and control-source node types (PRD 36-42).
|
||||
|
||||
Processing nodes, routing and modulation, graph legality, components, sound definitions, and buses (PRD 43-53, 55, 56, 59, 60) are specified in section 15 (Phase 3b). Audio automation precedence (PRD 54), the full runtime lifecycle state machine (PRD 57), and the consolidated safety-limit and master-protection contract (PRD 58) are **Not yet specified** and arrive in Phase 3c.
|
||||
|
||||
### 14.1 Pipeline and scope
|
||||
|
||||
The audio pipeline is, in order: audio primitives (14.7-14.12) → audio graphs (14.3) → reusable `components.audio.*` components (15.15) → sound recipes (15.16) → sound instances → declared buses (15.17) → bus processing → engine master protection → output (PRD 34).
|
||||
|
||||
XZBT never implements a node, field, or function named after a specific exhibit's thematic sound. Every construct in this contract is generic.
|
||||
|
||||
### 14.2 Canonical units
|
||||
|
||||
| Quantity | Unit | Notes |
|
||||
| --- | --- | --- |
|
||||
| frequency | Hz | Bounded above by `audioMaxFrequency` (14.5). |
|
||||
| detune | cents | `-4800` to `+4800` unless a node contract states otherwise. |
|
||||
| gain / amplitude | linear scalar | Not dB unless the field's contract says dB. |
|
||||
| filter and compressor gain | dB | Applies to `filter.gain`, `compressor.threshold`, and `compressor.knee` (15.3, 15.4). |
|
||||
| Q | unitless | Applies to `filter.q` (15.3). |
|
||||
| normalized controls | `0` to `1` | Polarity-free control ranges (impulse `amplitude`, `mix`, `damping`, waveshaper `amount`). |
|
||||
| pan | `-1` to `+1` | `-1` full left, `0` center, `+1` full right (15.8). |
|
||||
| time | DurationSpec 0.1 | Section 6; a procedural `TimeSpec` is permitted wherever a field's contract says DurationSpec. |
|
||||
| modulation depth | target property's unit | Section 15.13 (PRD 53). |
|
||||
|
||||
### 14.3 Audio graph objects
|
||||
|
||||
An **audio graph object** is the shared container for every audio node network in an exhibit:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": {
|
||||
"tone": { "type": "oscillator", "frequency": 220 },
|
||||
"level": { "type": "gain", "gain": 0.4 }
|
||||
},
|
||||
"routes": [
|
||||
{ "from": "tone", "to": "level" },
|
||||
{ "from": "level", "to": "output" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | :---: | --- |
|
||||
| `nodes` | object keyed by node ID | Yes | Each value is a node object (14.4). An empty `nodes` object is `ERR_SCHEMA_VALIDATION`. |
|
||||
| `routes` | array | No (defaults to `[]`) | Route objects, specified in 15.12-15.13. A graph object with no route reaching `output` is `ERR_NO_AUDIBLE_PATH` unless it is a component graph, which terminates at its declared component output instead (15.15). |
|
||||
|
||||
Audio graph objects are embedded at exactly three places, whose surrounding fields are specified in Phase 3b:
|
||||
|
||||
* `audio.recipes.<recipe-id>` — a named, reusable recipe graph (15.16).
|
||||
* `sounds.<sound-id>.recipe` — an inline recipe graph (15.16, PRD 59).
|
||||
* `components.audio.<component-id>` — a component graph, which adds `parameters` and `input` declarations (15.15, PRD 56).
|
||||
|
||||
**Node identity.** Nodes are addressed by their key in the `nodes` object, matching how the document keys `parameters`, `state`, `audio.buses`, `sounds`, and `modulators`. A node object therefore carries **no** `id` field; supplying one is `ERR_UNKNOWN_FIELD`. Node keys follow the shared identifier rule (section 1.3) and are unique by construction; a key failing the identifier regex is `ERR_INVALID_ID`.
|
||||
|
||||
**Reserved key.** `output` is reserved as the graph's audible sink and may not be declared in `nodes` (`ERR_INVALID_ID`). It is sink-only: `output` may not appear as a route `from` (15.14, PRD 55).
|
||||
|
||||
**Node object fields.**
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | :---: | --- |
|
||||
| `type` | string enum | Yes | A member of Audio Graph Node Set 0.1 (PRD 36): `oscillator`, `noise`, `impulse`, `constant`, `lfo`, `sample-hold`, `gain`, `filter`, `compressor`, `waveshaper`, `delay`, `reverb`, `stereo-pan`, `mixer`, `resonator`, or `component`. Any other value is `ERR_INVALID_NODE_TYPE`. |
|
||||
| *(type-specific fields)* | — | Per node | 14.7-14.12 and 15.1-15.8. |
|
||||
|
||||
Per the strict unknown-field policy (section 1), any property on a node object that its `type` contract does not declare is `ERR_UNKNOWN_FIELD`.
|
||||
|
||||
### 14.4 Node-field resolution scope
|
||||
|
||||
Numeric and enum fields on node objects are authored with ValueSpec 0.1 (section 4) and DurationSpec 0.1 (section 6) exactly where each field table says so. Every such field is resolved **once**, at the owning sound instance's instantiation boundary — the same boundary section 9.3 already fixes for `random` and `choose` sampling — and is constant for that node instance's lifetime. Nested ValueSpecs within one graph are sampled in depth-first, property-document order from the sound instance's own stream, per section 9.3.
|
||||
|
||||
Node fields are **not** added to the section 8.1 target-capability table. A `BindingSpec`, `set` action, or `override` action addressing a node field (for example `sounds.hum.recipe.nodes.tone.frequency`) is `ERR_UNSUPPORTED_TARGET`. Section 8.1's existing `audio.buses.<id>.gain` row is unaffected and remains the supported external control surface for audio in 0.1.
|
||||
|
||||
This restriction governs **external** targeting only. Graph-internal modulation — a route whose `to` addresses `node.property` (15.13, PRD 52-53) — is the supported mechanism for varying a node field over time and is fully specified in Phase 3b. Section 15.13 lists which properties accept modulation; a modulation route to any other property is `ERR_UNSUPPORTED_TARGET`.
|
||||
|
||||
Automation tracks (PRD 54) are a separate Phase 3c stage and are **Not yet specified**; an `automation` field on any node or recipe is `ERR_UNKNOWN_FIELD` until that contract lands.
|
||||
|
||||
### 14.5 Audio frequency ceiling and validation staging
|
||||
|
||||
```text
|
||||
audioMaxFrequency = min(24000, sampleRate x 0.45)
|
||||
```
|
||||
|
||||
`sampleRate` is the live `AudioContext.sampleRate`, which is a property of the playback device and is unknown while an exhibit is being imported or validated. The ceiling is therefore enforced in two tiers, and an implementation must not conflate them:
|
||||
|
||||
1. **Semantic stage (import, refresh, and validation tooling).** A frequency field whose authored value resolves to a literal outside its declared range, checked against the device-independent ceiling `24000`, is `ERR_OUT_OF_BOUNDS`. Validation never opens an `AudioContext` and never depends on audio hardware.
|
||||
2. **Instantiation stage (node creation on a live context).** The runtime computes `audioMaxFrequency` from the actual `AudioContext.sampleRate` and **clamps** the resolved value into range. Clamping, not failing, is required: a cached exhibit authored on a 48 kHz device must still activate on a 44.1 kHz device. Each clamped node field raises `WARN_AUDIO_RATE_CLAMP` once per node instance.
|
||||
|
||||
A hardcoded `24000` at instantiation is a contract violation even though it is the correct semantic-stage ceiling.
|
||||
|
||||
### 14.6 Reproducibility scope for audio sources
|
||||
|
||||
Section 9.3 promises identical *procedural decisions*, not identical floating-point audio samples. This contract fixes where that line falls for audio sources:
|
||||
|
||||
* **`noise` and `impulse` sample generation consumes no XZBT procedural stream.** Their per-sample output comes from the audio implementation's own generator and is not reproducible across runs or devices. It cannot perturb any seeded stream, because it draws from none.
|
||||
* **`sample-hold` does draw from a seeded stream.** Its held values are observable control decisions that can drive audible outcomes, so they are reproducible. It uses domain `sound` with the documented child key defined in 14.12.
|
||||
* **Every other source and control-source field** is a ValueSpec resolved once at instantiation from the sound instance's stream (14.4), and is therefore reproducible.
|
||||
|
||||
Rendering and audio callbacks consume no procedural stream (section 9.3); a `sample-hold` tick is a logical decision, not a render step, and its draws occur on the node instance's own stream in tick order.
|
||||
|
||||
### 14.7 Oscillator (`oscillator`)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "oscillator",
|
||||
"waveform": "sine",
|
||||
"frequency": 440,
|
||||
"detune": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `waveform` | enum | `sine`, `triangle`, `square`, `sawtooth`, `custom` | `sine` | No — literal enum only. |
|
||||
| `frequency` | ValueSpec\<number\> | `0.1` Hz to `audioMaxFrequency` (14.5) | `440` | Yes (14.4 scope). |
|
||||
| `detune` | ValueSpec\<number\> | `-4800` to `+4800` cents | `0` | Yes (14.4 scope). |
|
||||
| `harmonics` | array | `1` to `64` entries | — | Required if and only if `waveform` is `custom`. |
|
||||
|
||||
An `oscillator` is an audible source and may also serve as an audio-rate modulation source (PRD 41).
|
||||
|
||||
**Custom partials.** Each `harmonics` entry is `{ "ratio": number, "gain": number, "phase": number }`:
|
||||
|
||||
| Entry field | Range | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `ratio` | `0.001` to `256` | required | Partial frequency as a multiple of the node's resolved `frequency`. |
|
||||
| `gain` | `0` to `1` | required | Partial linear amplitude relative to the fundamental. |
|
||||
| `phase` | `0` to `360` (degrees, `360` excluded) | `0` | Partial starting phase offset. |
|
||||
|
||||
More than 64 entries is `ERR_NODE_LIMIT_EXCEEDED`. `harmonics` present while `waveform` is not `custom` is `ERR_UNKNOWN_FIELD`. `harmonics` absent while `waveform` is `custom` is `ERR_SCHEMA_VALIDATION`. An empty `harmonics` array is `ERR_SCHEMA_VALIDATION`. Entry values outside their range are `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
At instantiation, any partial whose `ratio x frequency` exceeds `audioMaxFrequency` is **omitted** from the realized waveform rather than aliased or clamped. Omission is a routine consequence of legal authoring and raises no diagnostic; it is not the `WARN_AUDIO_RATE_CLAMP` case of 14.5, which applies to the node's own `frequency`.
|
||||
|
||||
The starting phase of a non-`custom` oscillator is runtime-defined and is not part of this contract; exhibits must not depend on the phase relationship between two independently created oscillators.
|
||||
|
||||
**Minimal example:** `{ "type": "oscillator" }`.
|
||||
**Composition example:** `{ "type": "oscillator", "waveform": "custom", "frequency": 220, "harmonics": [{ "ratio": 1, "gain": 1 }, { "ratio": 2.7, "gain": 0.35, "phase": 90 }] }`.
|
||||
**Invalid case:** `{ "type": "oscillator", "waveform": "sine", "harmonics": [] }` → `ERR_UNKNOWN_FIELD` on `harmonics`.
|
||||
|
||||
### 14.8 Noise (`noise`)
|
||||
|
||||
```json
|
||||
{ "type": "noise", "color": "pink" }
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `color` | enum | `white`, `pink`, `brown` | `white` | No — literal enum only; `color` selects a fixed spectral shape, not a continuously varying parameter. |
|
||||
|
||||
Spectral shapes, measured over `20` Hz to `20000` Hz:
|
||||
|
||||
| `color` | Power spectral slope | Normalization |
|
||||
| --- | --- | --- |
|
||||
| `white` | `0` dB/octave (flat) | Unit RMS |
|
||||
| `pink` | `-3` dB/octave | Unit RMS |
|
||||
| `brown` | `-6` dB/octave | Unit RMS |
|
||||
|
||||
The filter used to realize `pink` and `brown` is runtime-defined; its response must track the declared slope within `±3` dB across `20` Hz to `20000` Hz. All three colors are normalized to equal RMS so that changing `color` does not change perceived level.
|
||||
|
||||
A `noise` node has no built-in volume control; author a downstream `gain` node (15.2) for level.
|
||||
|
||||
**Minimal example:** `{ "type": "noise" }`. **Invalid case:** `{ "type": "noise", "color": "grey" }` → `ERR_TYPE_MISMATCH`.
|
||||
|
||||
### 14.9 Impulse (`impulse`)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "impulse",
|
||||
"color": "white",
|
||||
"duration": "10ms",
|
||||
"amplitude": 1,
|
||||
"decay": "exponential"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `color` | enum | `white`, `pink`, `brown` | `white` | No. Spectral shapes are those of 14.8. |
|
||||
| `duration` | DurationSpec 0.1 | `1ms` to `500ms` | `10ms` | Sampled once at instantiation (14.4). |
|
||||
| `amplitude` | ValueSpec\<number\> | `0` to `1` | `1` | Yes (14.4 scope). |
|
||||
| `decay` | enum | `flat`, `linear`, `exponential` | `exponential` | No. |
|
||||
|
||||
An impulse is inherently one-shot: it emits a single finite burst of `duration` and then produces silence. Given resolved amplitude `a`, resolved duration `d`, and normalized progress `p = t / d` over `0 <= p < 1`, the envelope applied to the `color` source is:
|
||||
|
||||
| `decay` | Envelope | Value at `p = 1` |
|
||||
| --- | --- | --- |
|
||||
| `flat` | `a` | `0` |
|
||||
| `linear` | `a x (1 - p)` | `0` |
|
||||
| `exponential` | `a x e^(-6.907755 x p)` (`-60` dB at `p = 1`) | `0` |
|
||||
|
||||
The envelope is exactly `0` for `p >= 1`. Because `flat` and `exponential` do not reach zero on their own, the runtime applies a terminal linear fade to zero over the final `min(1ms, d x 0.1)` of the burst; this fade is part of the contract, not an optional anti-click measure.
|
||||
|
||||
A `duration` that resolves outside `[1ms, 500ms]` is `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
**Minimal example:** `{ "type": "impulse" }`. **Invalid case:** `{ "type": "impulse", "duration": "1s" }` → `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
### 14.10 Constant (`constant`)
|
||||
|
||||
```json
|
||||
{ "type": "constant", "value": 1 }
|
||||
```
|
||||
|
||||
Control-only source (PRD 40). A `constant` may modulate numeric properties through a modulation route (15.13) but may never reach `output`, directly or through any chain; such a route is `ERR_INVALID_ROUTE` under graph legality (15.14).
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `value` | ValueSpec\<number\> | `-1000` to `1000` | `1` | Yes (14.4 scope). |
|
||||
|
||||
**Minimal example:** `{ "type": "constant" }`. **Invalid case:** `{ "type": "constant", "value": 5000 }` → `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
### 14.11 LFO (`lfo`)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "lfo",
|
||||
"waveform": "sine",
|
||||
"frequency": 1,
|
||||
"amplitude": 1,
|
||||
"polarity": "bipolar",
|
||||
"phase": 0
|
||||
}
|
||||
```
|
||||
|
||||
Control-only source (PRD 41). Audio-rate modulation uses an ordinary `oscillator` instead. An `lfo` reaching `output` through any chain is `ERR_INVALID_ROUTE` (15.14).
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `waveform` | enum | `sine`, `triangle`, `square`, `sawtooth` | `sine` | No. `custom` is oscillator-only and is `ERR_TYPE_MISMATCH` here. |
|
||||
| `frequency` | ValueSpec\<number\> | `0.001` to `40` Hz | `1` | Yes (14.4 scope). |
|
||||
| `amplitude` | ValueSpec\<number\> | `0` to `1000` | `1` | Yes (14.4 scope). Matches the `constant` output range; a modulation route's `depth` scales this into the target's unit (15.13). |
|
||||
| `polarity` | enum | `bipolar`, `unipolar` | `bipolar` | No. `bipolar` spans `[-amplitude, +amplitude]`; `unipolar` spans `[0, amplitude]`. |
|
||||
| `phase` | ValueSpec\<number\> | `0` to `360` (degrees, `360` excluded) | `0` | Yes (14.4 scope). |
|
||||
|
||||
**Phase origin.** An `lfo` phase is measured from the node instance's own start, not from a global transport: at the instant the node becomes active its waveform is at `phase` degrees. LFOs do not free-run across sound instances, so two instances of the same sound started at different times are phase-independent. This is a deliberate difference from a shared transport and keeps a sound instance's control behavior a function of its own age.
|
||||
|
||||
A top-level `modulators.<id>` entry may also declare `type: "lfo"` (PRD 33). The two are distinct constructs that merely share mathematics: a document modulator is a read-only shared source addressed as `modulators.*` (section 8.1) and uses `min`/`max` bounds, while this node is a graph-internal control source using `amplitude`/`polarity` and is not externally addressable (14.4).
|
||||
|
||||
**Minimal example:** `{ "type": "lfo" }`. **Invalid case:** `{ "type": "lfo", "waveform": "custom" }` → `ERR_TYPE_MISMATCH`.
|
||||
|
||||
### 14.12 Sample-Hold (`sample-hold`)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "sample-hold",
|
||||
"rate": 2,
|
||||
"min": -1,
|
||||
"max": 1,
|
||||
"slew": "0ms"
|
||||
}
|
||||
```
|
||||
|
||||
Control-only source (PRD 42). A `sample-hold` reaching `output` through any chain is `ERR_INVALID_ROUTE` (15.14). See 14.11 for the distinction from a top-level `modulators.<id>` sample-and-hold.
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `rate` | ValueSpec\<number\> | `0.01` to `100` Hz | `2` | Yes (14.4 scope). |
|
||||
| `min` | ValueSpec\<number\> | `-1000` to `1000` | `-1` | Yes (14.4 scope). |
|
||||
| `max` | ValueSpec\<number\> | `-1000` to `1000` | `1` | Yes (14.4 scope). |
|
||||
| `slew` | DurationSpec 0.1 | `0ms` to `1s` | `0ms` | Sampled once at instantiation (14.4). |
|
||||
|
||||
`min` resolving to a value greater than or equal to `max` is `ERR_INVALID_RANGE_ORDER`.
|
||||
|
||||
**Tick schedule.** With resolved rate `r`, tick `k` (`k = 0, 1, 2, ...`) occurs at instance-relative time `k / r` seconds, measured from the node instance's start. Tick `0` draws the node's first held value; there is no pre-roll or default-held value before it. Because `rate` is resolved once (14.4), the tick period is fixed for the node instance's lifetime.
|
||||
|
||||
**Draw and slew.** At each tick the node draws one uniform sample in `[min, max]`, then approaches it linearly from the previously held value over `slew` and holds until the next tick. A resolved `slew` longer than the tick period `1 / r` is clamped to that period, so the node always reaches its target before the next draw.
|
||||
|
||||
**Procedural stream.** Per section 9.3, a subsystem may add a documented child key; this is that documentation. A `sample-hold` node instance uses domain `sound` with the stable instance key:
|
||||
|
||||
```text
|
||||
<sound-instance-key>|node|<node-path>
|
||||
```
|
||||
|
||||
`<sound-instance-key>` is the owning sound instance's key formed per section 9.3 from the sound definition ID and its invocation ordinal within the `sound` domain. `<node-path>` is the node's key at recipe root, or, inside a component instance, the dot-joined chain of enclosing component-instance node keys followed by the node key (for example `chorus.voice-a.step`). The stream is created once when the node instance is created and is never reseeded; ticks consume exactly one sample each, in tick order. Two runs with the same normalized seed, the same exhibit, and the same logical input sequence therefore produce the same held sequence.
|
||||
|
||||
**Minimal example:** `{ "type": "sample-hold" }`. **Invalid case:** `{ "type": "sample-hold", "min": 1, "max": -1 }` → `ERR_INVALID_RANGE_ORDER`.
|
||||
|
||||
### 14.13 Required traces before Phase 3a implementation is accepted
|
||||
|
||||
1. Each of the six node types validates its documented minimal example inside a complete graph object with zero diagnostics, and its documented invalid case with exactly the documented code.
|
||||
2. A node object carrying an `id` field is `ERR_UNKNOWN_FIELD`, and a node keyed `output` is `ERR_INVALID_ID`.
|
||||
3. Semantic validation rejects a frequency above `24000` without opening an `AudioContext`, and instantiation clamps against `min(24000, sampleRate x 0.45)` computed from the live sample rate, raising `WARN_AUDIO_RATE_CLAMP` rather than failing.
|
||||
4. A node-field ValueSpec — for example `frequency: { "random": { "min": 220, "max": 440 } }` — samples once at instantiation and is stable for the node instance's lifetime.
|
||||
5. An external `BindingSpec` targeting a node field fails with `ERR_UNSUPPORTED_TARGET`, confirming the 14.4 scope boundary.
|
||||
6. Two `sample-hold` instances built from the same seed, exhibit, and invocation ordinal produce identical held sequences, and changing only the node key changes the sequence.
|
||||
|
||||
## 15. Audio Subsystem Contract — Processing, Routing, Components, and Buses (Phase 3b)
|
||||
|
||||
This section completes the authoring surface of the audio subsystem: the processing and routing node types (PRD 43-51), the component instance node, audio routing and modulation (PRD 52-53), graph legality and authoring limits (PRD 55, and the authoring-time subset of PRD 58), reusable components (PRD 56), sound definitions and recipes (PRD 59), and buses (PRD 60).
|
||||
|
||||
It builds directly on section 14: every node object declared here lives in a `nodes` map inside an audio graph object (14.3), its numeric fields follow the resolve-once scope of 14.4, its frequency fields follow the two-tier ceiling of 14.5, and it is externally unaddressable except through the modulation routes defined in 15.13.
|
||||
|
||||
Audio automation precedence (PRD 54), the runtime lifecycle state machine (PRD 57), and the runtime half of the safety-limit and master-protection contract (PRD 58 voice ceilings, peak ceiling, numerical tolerance, release behavior, finite-sample handling) remain **Not yet specified** and arrive in Phase 3c.
|
||||
|
||||
### 15.1 Processing and routing node set
|
||||
|
||||
| `type` | Class | Audio inputs | Audio output | Section |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `gain` | Processing | Many (summed) | Yes | 15.2 |
|
||||
| `filter` | Processing | Many (summed) | Yes | 15.3 |
|
||||
| `compressor` | Processing | Many (summed) | Yes | 15.4 |
|
||||
| `waveshaper` | Processing | Many (summed) | Yes | 15.5 |
|
||||
| `delay` | Processing | Many (summed) | Yes | 15.6 |
|
||||
| `reverb` | Processing | Many (summed) | Yes | 15.7 |
|
||||
| `stereo-pan` | Processing | Many (summed) | Yes | 15.8 |
|
||||
| `mixer` | Routing | Many (summed) | Yes | 15.9 |
|
||||
| `resonator` | Processing | Many (summed) | Yes | 15.10 |
|
||||
| `component` | Composite | Zero or one, per the component's `input` declaration | Yes | 15.11 |
|
||||
|
||||
Every node that accepts audio input accepts any number of incoming audio routes and sums them; there is no per-input index in 0.1. Gain staging is explicit and uses `gain` nodes (PRD 50).
|
||||
|
||||
### 15.2 Gain (`gain`)
|
||||
|
||||
```json
|
||||
{ "type": "gain", "gain": 1 }
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec | Modulatable |
|
||||
| --- | --- | --- | --- | --- | :---: |
|
||||
| `gain` | ValueSpec\<number\> | `0` to `4` | `1` | Yes (14.4 scope) | Yes, linear gain |
|
||||
|
||||
Values above `1` are permitted for synthesis workflows and remain subject to master protection (PRD 43).
|
||||
|
||||
**Minimal example:** `{ "type": "gain" }`. **Invalid case:** `{ "type": "gain", "gain": 8 }` → `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
### 15.3 Filter (`filter`)
|
||||
|
||||
```json
|
||||
{ "type": "filter", "mode": "lowpass", "frequency": 800, "q": 0.7 }
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec | Modulatable |
|
||||
| --- | --- | --- | --- | --- | :---: |
|
||||
| `mode` | enum | `lowpass`, `highpass`, `bandpass`, `notch`, `peaking`, `lowshelf`, `highshelf`, `allpass` | `lowpass` | No | No |
|
||||
| `frequency` | ValueSpec\<number\> | `10` Hz to `audioMaxFrequency` (14.5) | `1000` | Yes | Yes, Hz |
|
||||
| `q` | ValueSpec\<number\> | `0.0001` to `100` | `1` | Yes | Yes, unitless |
|
||||
| `gain` | ValueSpec\<number\> | `-40` to `+40` dB | `0` | Yes | Yes, dB |
|
||||
| `detune` | ValueSpec\<number\> | `-4800` to `+4800` cents | `0` | Yes | Yes, cents |
|
||||
|
||||
`gain` is meaningful only for `peaking`, `lowshelf`, and `highshelf`; for the other modes it is ignored rather than rejected, so that a mode can be changed without restructuring the node.
|
||||
|
||||
**Minimal example:** `{ "type": "filter" }`. **Invalid case:** `{ "type": "filter", "mode": "comb" }` → `ERR_TYPE_MISMATCH`.
|
||||
|
||||
### 15.4 Compressor (`compressor`)
|
||||
|
||||
```json
|
||||
{ "type": "compressor", "threshold": -24, "knee": 30, "ratio": 12, "attack": "3ms", "release": "250ms" }
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec | Modulatable |
|
||||
| --- | --- | --- | --- | --- | :---: |
|
||||
| `threshold` | ValueSpec\<number\> | `-100` to `0` dB | `-24` | Yes | No |
|
||||
| `knee` | ValueSpec\<number\> | `0` to `40` dB | `30` | Yes | No |
|
||||
| `ratio` | ValueSpec\<number\> | `1` to `20` | `12` | Yes | No |
|
||||
| `attack` | DurationSpec 0.1 | `0ms` to `1s` | `3ms` | Sampled once | No |
|
||||
| `release` | DurationSpec 0.1 | `10ms` to `1s` | `250ms` | Sampled once | No |
|
||||
|
||||
Audio-rate modulation of compressor parameters is not required in 0.1 (PRD 45); a modulation route to any compressor property is `ERR_UNSUPPORTED_TARGET`.
|
||||
|
||||
**Minimal example:** `{ "type": "compressor" }`. **Invalid case:** `{ "type": "compressor", "ratio": 40 }` → `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
### 15.5 Waveshaper (`waveshaper`)
|
||||
|
||||
```json
|
||||
{ "type": "waveshaper", "shape": "soft-clip", "amount": 0.5, "oversample": "2x" }
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec | Modulatable |
|
||||
| --- | --- | --- | --- | --- | :---: |
|
||||
| `shape` | enum | `soft-clip`, `hard-clip`, `saturation` | `soft-clip` | No | No |
|
||||
| `amount` | ValueSpec\<number\> | `0` to `1` | `0.5` | Yes | No |
|
||||
| `oversample` | enum | `none`, `2x`, `4x` | `none` | No | No |
|
||||
|
||||
`amount` `0` is unity transfer for every shape, so a waveshaper can be authored inert and driven entirely by its resolved value. The exact transfer curve for each shape is runtime-defined but must be monotonic, odd-symmetric, and bounded to `[-1, 1]` for inputs in `[-1, 1]`.
|
||||
|
||||
**Minimal example:** `{ "type": "waveshaper" }`. **Invalid case:** `{ "type": "waveshaper", "oversample": "8x" }` → `ERR_TYPE_MISMATCH`.
|
||||
|
||||
### 15.6 Delay (`delay`)
|
||||
|
||||
```json
|
||||
{ "type": "delay", "time": "250ms", "feedback": 0.2, "mix": 0.5 }
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec | Modulatable |
|
||||
| --- | --- | --- | --- | --- | :---: |
|
||||
| `time` | DurationSpec 0.1 | `0ms` to `10s` | `250ms` | Sampled once | Yes, milliseconds |
|
||||
| `feedback` | ValueSpec\<number\> | `0` to `0.95` | `0.2` | Yes | No |
|
||||
| `mix` | ValueSpec\<number\> | `0` to `1` | `0.5` | Yes | No |
|
||||
|
||||
The feedback path is internal to the node and is controlled by the runtime (PRD 47). Authors may not build feedback by routing a node's output back into its own input chain; such a cycle is `ERR_CYCLIC_DEPENDENCY` under 15.14. `mix` is a dry/wet blend where `0` is fully dry and `1` is fully wet.
|
||||
|
||||
A modulation route to `time` is expressed in milliseconds and is clamped so the resolved delay never leaves `[0ms, 10s]`.
|
||||
|
||||
**Minimal example:** `{ "type": "delay" }`. **Invalid case:** `{ "type": "delay", "feedback": 1 }` → `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
### 15.7 Reverb (`reverb`)
|
||||
|
||||
```json
|
||||
{ "type": "reverb", "size": 0.5, "decay": "2s", "damping": 0.5, "predelay": "0ms", "mix": 0.25 }
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec | Modulatable |
|
||||
| --- | --- | --- | --- | --- | :---: |
|
||||
| `size` | ValueSpec\<number\> | `0` to `1` | `0.5` | Yes | No |
|
||||
| `decay` | DurationSpec 0.1 | `50ms` to `30s` | `2s` | Sampled once | No |
|
||||
| `damping` | ValueSpec\<number\> | `0` to `1` | `0.5` | Yes | No |
|
||||
| `predelay` | DurationSpec 0.1 | `0ms` to `500ms` | `0ms` | Sampled once | No |
|
||||
| `mix` | ValueSpec\<number\> | `0` to `1` | `0.25` | Yes | No |
|
||||
|
||||
The implementation is runtime-defined (PRD 48). The exhibit describes desired acoustic behavior, never Web Audio implementation details, and must not depend on a particular impulse response. Reverb properties are not modulatable in 0.1 because changing them requires rebuilding the underlying response.
|
||||
|
||||
**Minimal example:** `{ "type": "reverb" }`. **Invalid case:** `{ "type": "reverb", "decay": "60s" }` → `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
### 15.8 Stereo pan (`stereo-pan`)
|
||||
|
||||
```json
|
||||
{ "type": "stereo-pan", "pan": 0 }
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec | Modulatable |
|
||||
| --- | --- | --- | --- | --- | :---: |
|
||||
| `pan` | ValueSpec\<number\> | `-1` to `+1` | `0` | Yes | Yes, pan units |
|
||||
|
||||
`-1` is full left, `0` is center, `+1` is full right (PRD 49).
|
||||
|
||||
**Minimal example:** `{ "type": "stereo-pan" }`. **Invalid case:** `{ "type": "stereo-pan", "pan": 2 }` → `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
### 15.9 Mixer (`mixer`)
|
||||
|
||||
```json
|
||||
{ "type": "mixer" }
|
||||
```
|
||||
|
||||
A mixer accepts any number of audio inputs and exposes one audio output. It has no fields and no gain controls in 0.1; gain staging uses explicit `gain` nodes (PRD 50). Any property on a mixer node other than `type` is `ERR_UNKNOWN_FIELD`.
|
||||
|
||||
A mixer is a convenience for graph readability: because every audio-accepting node already sums its inputs (15.1), a mixer is never required for correctness.
|
||||
|
||||
### 15.10 Resonator (`resonator`)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "resonator",
|
||||
"fundamental": 120,
|
||||
"modes": [
|
||||
{ "ratio": 1, "gain": 1, "decay": "1.2s" },
|
||||
{ "ratio": 2.7, "gain": 0.4, "decay": "800ms" }
|
||||
],
|
||||
"mix": 1
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Range | Default | ValueSpec | Modulatable |
|
||||
| --- | --- | --- | --- | --- | :---: |
|
||||
| `fundamental` | ValueSpec\<number\> | `0.1` Hz to `audioMaxFrequency` (14.5) | `120` | Yes | Yes, Hz |
|
||||
| `modes` | array | `1` to `16` entries | required | Per entry | No |
|
||||
| `mix` | ValueSpec\<number\> | `0` to `1` | `1` | Yes | No |
|
||||
|
||||
Each `modes` entry declares exactly one of `ratio` or `frequency`, never both and never neither (PRD 51):
|
||||
|
||||
| Entry field | Type | Range | Default | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `ratio` | ValueSpec\<number\> | `0.001` to `256` | — | Mode frequency as a multiple of the resolved `fundamental`. |
|
||||
| `frequency` | ValueSpec\<number\> | `0.1` Hz to `audioMaxFrequency` | — | Absolute mode frequency; ignores `fundamental`. |
|
||||
| `gain` | ValueSpec\<number\> | `0` to `1` | `1` | Linear amplitude of the mode. |
|
||||
| `decay` | DurationSpec 0.1 | `10ms` to `20s` | `1s` | Mode ring-down time to `-60` dB. |
|
||||
|
||||
An entry declaring both `ratio` and `frequency`, or neither, is `ERR_SCHEMA_VALIDATION`. More than 16 entries is `ERR_NODE_LIMIT_EXCEEDED`. A mode whose resolved frequency exceeds `audioMaxFrequency` at instantiation is omitted, matching the custom-partial rule of 14.7.
|
||||
|
||||
A resonator provides generic acoustic resonance and is never named or shaped after a themed sound (PRD 51).
|
||||
|
||||
**Minimal example:** `{ "type": "resonator", "modes": [{ "ratio": 1 }] }`. **Invalid case:** `{ "type": "resonator", "modes": [{ "ratio": 1, "frequency": 200 }] }` → `ERR_SCHEMA_VALIDATION`.
|
||||
|
||||
### 15.11 Component instance (`component`)
|
||||
|
||||
```json
|
||||
{ "type": "component", "use": "voice", "values": { "pitch": 330 } }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | :---: | --- |
|
||||
| `use` | string | Yes | ID of a declared `components.audio.<id>`. An unknown ID is `ERR_INVALID_REFERENCE`. |
|
||||
| `values` | object | No | Values for the component's exposed parameters, one ValueSpec each, resolved once at instantiation (14.4). |
|
||||
|
||||
A key in `values` that the component does not expose is `ERR_UNKNOWN_FIELD`. An exposed parameter that has no `default` and receives no value is `ERR_SCHEMA_VALIDATION`. A supplied value outside the exposed parameter's declared range is `ERR_OUT_OF_BOUNDS`.
|
||||
|
||||
A component instance behaves as an ordinary node in its enclosing graph: it exposes one audio output, and it accepts audio input if and only if the component declares `input: true` (15.15). Routing audio into a component that declares no input is `ERR_INVALID_ROUTE`.
|
||||
|
||||
Its exposed parameters are modulation targets addressed as `<component-node-key>.<parameter-id>` (15.13). Nothing else inside the component is reachable from outside (PRD 56).
|
||||
|
||||
### 15.12 Audio routing
|
||||
|
||||
A `routes` entry (14.3) is one of two forms, distinguished by whether `to` names a node or a node property (PRD 52):
|
||||
|
||||
```json
|
||||
{ "from": "tone", "to": "filter" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "from": "vibrato", "to": "tone.frequency", "depth": 18 }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | :---: | --- |
|
||||
| `from` | string | Yes | A node key in the same graph, or the reserved `input` inside a component graph that declares `input: true`. `output` may never be a `from` (`ERR_INVALID_ROUTE`). |
|
||||
| `to` | string | Yes | Audio route: a node key in the same graph, or the reserved `output`. Modulation route: `<node-key>.<property>`. |
|
||||
| `depth` | ValueSpec\<number\> | Modulation only | Required on a modulation route; present on an audio route it is `ERR_UNKNOWN_FIELD`. Resolved once at instantiation (14.4). |
|
||||
|
||||
`from` or `to` naming a node key that the graph does not declare is `ERR_INVALID_REFERENCE`. A route whose `from` and `to` resolve to the same node is `ERR_INVALID_ROUTE`. Duplicate identical audio routes are collapsed to one and raise no diagnostic; duplicate modulation routes to the same property are summed (15.13), not collapsed.
|
||||
|
||||
Because every audio-accepting node sums its inputs (15.1), route order within `routes` never affects the audible result. Route order does fix the depth-first ValueSpec sampling order of `depth` values within a graph, per section 9.3.
|
||||
|
||||
### 15.13 Modulation semantics
|
||||
|
||||
Modulation depth is expressed in the target property's own unit (PRD 53). The modulatable property registry for 0.1 is exactly:
|
||||
|
||||
| Node type | Property | Depth unit |
|
||||
| --- | --- | --- |
|
||||
| `oscillator` | `frequency` | Hz |
|
||||
| `oscillator` | `detune` | cents |
|
||||
| `gain` | `gain` | linear gain |
|
||||
| `filter` | `frequency` | Hz |
|
||||
| `filter` | `q` | unitless |
|
||||
| `filter` | `gain` | dB |
|
||||
| `filter` | `detune` | cents |
|
||||
| `delay` | `time` | milliseconds |
|
||||
| `stereo-pan` | `pan` | pan units |
|
||||
| `resonator` | `fundamental` | Hz |
|
||||
| `component` | `<exposed-parameter-id>` | the exposed parameter's declared unit |
|
||||
|
||||
A modulation route to any property absent from this table — including every `compressor`, `reverb`, `waveshaper`, `mixer`, `impulse`, and `noise` property, and every property of a control source — is `ERR_UNSUPPORTED_TARGET`. Merely being numeric does not grant modulation support, matching section 8.1's rule for the shared registry.
|
||||
|
||||
**Sources.** A modulation route's `from` must be a control source (`constant`, `lfo`, `sample-hold`) or an `oscillator` used at audio rate (PRD 41). Any other node as a modulation source is `ERR_INVALID_ROUTE`.
|
||||
|
||||
**Summation and clamping.** For a modulated property with resolved base value `b` and modulation routes `1..n` whose source outputs at the current instant are `s_i` with resolved depths `d_i`:
|
||||
|
||||
```text
|
||||
value = clamp(b + sum(s_i x d_i), property minimum, property maximum)
|
||||
```
|
||||
|
||||
Multiple legal modulation routes are summed (PRD 53). The clamp uses the property's declared range from its node contract and is the audio application of the safety-clamp stage of section 8.1; it is not a separate precedence system. A control source's output is its own value in its own units before scaling: an `lfo` with `amplitude: 1` and `polarity: "bipolar"` contributes `[-d, +d]` to its target.
|
||||
|
||||
Automation (PRD 54) sits between binding and override in the shared pipeline and is **Not yet specified**; in Phase 3b the resolution of a node property is exactly base, then modulation sum, then safety clamp.
|
||||
|
||||
### 15.14 Graph legality and authoring limits
|
||||
|
||||
The final expanded audio graph of a sound — the recipe graph with every component instance inlined — must satisfy all of the following (PRD 55). Each check names the diagnostic it emits.
|
||||
|
||||
| # | Rule | Diagnostic |
|
||||
| --- | --- | --- |
|
||||
| 1 | Every node key matches the identifier rule and is not `output` | `ERR_INVALID_ID` |
|
||||
| 2 | Every node `type` is in Audio Graph Node Set 0.1 | `ERR_INVALID_NODE_TYPE` |
|
||||
| 3 | Every route `from` and `to` resolves to a declared node, `output`, or a legal `input` | `ERR_INVALID_REFERENCE` |
|
||||
| 4 | `output` is sink-only and never a route `from` | `ERR_INVALID_ROUTE` |
|
||||
| 5 | A control source (`constant`, `lfo`, `sample-hold`) never reaches `output` through any chain of audio routes | `ERR_INVALID_ROUTE` |
|
||||
| 6 | A source node (`oscillator`, `noise`, `impulse`, and every control source) never receives an audio route | `ERR_INVALID_ROUTE` |
|
||||
| 7 | The audio route graph is acyclic | `ERR_CYCLIC_DEPENDENCY` |
|
||||
| 8 | The modulation dependency graph is acyclic | `ERR_CYCLIC_DEPENDENCY` |
|
||||
| 9 | At least one audio route chain from a non-control source reaches `output` | `ERR_NO_AUDIBLE_PATH` |
|
||||
| 10 | A component never instantiates itself, directly or transitively | `ERR_COMPONENT_RECURSION` |
|
||||
| 11 | Component nesting depth is at most `8` | `ERR_COMPONENT_RECURSION` |
|
||||
| 12 | Graph limits are respected | `ERR_NODE_LIMIT_EXCEEDED` |
|
||||
|
||||
Rule 5 is the enforcement point for the control-only status asserted in 14.10, 14.11, and 14.12. Rule 6 makes source nodes true graph roots. Rule 7 is what forbids unrestricted author-built feedback (PRD 47); a `delay` node's internal feedback path is not part of the route graph and does not violate it.
|
||||
|
||||
The authoring-time limits enforced in Phase 3b are the subset of PRD 58 that a document can violate on its own:
|
||||
|
||||
| Limit | Value |
|
||||
| --- | --- |
|
||||
| Expanded nodes per sound | `128` |
|
||||
| Routes per sound | `256` |
|
||||
| Component nesting depth | `8` |
|
||||
| Resonator modes | `16` |
|
||||
| Custom oscillator partials | `64` |
|
||||
|
||||
The remaining PRD 58 limits — approximate one-shot voices (`64`), approximate continuous sounds (`16`), automation tracks (`64`), and automation points (`256`) — are runtime ceilings rather than document properties and belong to Phase 3c with the lifecycle contract. Master protection likewise remains Phase 3c: this section does not establish that the protection contract passes, and the presence of a compressor in a graph never constitutes master protection (PRD 58).
|
||||
|
||||
### 15.15 Audio components
|
||||
|
||||
```json
|
||||
{
|
||||
"components": {
|
||||
"audio": {
|
||||
"voice": {
|
||||
"parameters": {
|
||||
"pitch": { "type": "number", "default": 220, "min": 20, "max": 2000 }
|
||||
},
|
||||
"input": false,
|
||||
"nodes": {
|
||||
"tone": { "type": "oscillator", "frequency": { "ref": "inputs.pitch" } },
|
||||
"level": { "type": "gain", "gain": 0.3 }
|
||||
},
|
||||
"routes": [
|
||||
{ "from": "tone", "to": "level" },
|
||||
{ "from": "level", "to": "output" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A `components.audio.<id>` entry is an audio graph object (14.3) with two additional fields:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | :---: | --- |
|
||||
| `parameters` | object keyed by parameter ID | No | Exposed numeric parameters. Each is `{ "type": "number", "default"?: number, "min"?: number, "max"?: number, "unit"?: string }`. Only `number` is permitted in 0.1. |
|
||||
| `input` | boolean | No (default `false`) | When `true`, the reserved node key `input` is available as a route `from` inside the graph and the component instance accepts one audio input. |
|
||||
|
||||
**Component-local references.** Inside a component graph, and only there, a ValueSpec may use the reference form `{ "ref": "inputs.<parameter-id>" }` to read the instance's value for an exposed parameter. `inputs.*` is a component-graph-scoped namespace: used anywhere else it is `ERR_INVALID_REFERENCE`, and it is not added to the section 1.3 document namespaces or the section 8.1 target table. A reference to an undeclared parameter is `ERR_INVALID_REFERENCE`.
|
||||
|
||||
**Encapsulation.** External graphs may access only the component's audio input, its audio output, and its explicitly exposed parameters (PRD 56). A reference or route reaching an internal node key from outside is `ERR_INVALID_REFERENCE`. Node keys inside a component are scoped to that component and may repeat keys used in the enclosing graph without conflict; the expansion path of 14.12 disambiguates them for PRNG stream derivation.
|
||||
|
||||
Component nesting is limited to `8` levels and recursion is prohibited (15.14, rules 10 and 11).
|
||||
|
||||
### 15.16 Sound definitions and recipes
|
||||
|
||||
```json
|
||||
{
|
||||
"sounds": {
|
||||
"relay-click": {
|
||||
"name": "Relay Click",
|
||||
"tags": ["mechanical", "electrical"],
|
||||
"usage": ["automatic", "manual", "scenario"],
|
||||
"cadence": { "class": "routine" },
|
||||
"bus": "effects",
|
||||
"recipe": {
|
||||
"mode": "oneshot",
|
||||
"nodes": { "hit": { "type": "impulse" }, "body": { "type": "resonator", "modes": [{ "ratio": 1 }] } },
|
||||
"routes": [{ "from": "hit", "to": "body" }, { "from": "body", "to": "output" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A `sounds.<id>` entry separates semantic metadata from synthesis (PRD 59):
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | :---: | --- |
|
||||
| `name` | string | Yes | Display name, at most `128` characters. |
|
||||
| `tags` | array of string | No | At most `16` entries of at most `32` characters. |
|
||||
| `usage` | array of enum | No | Any of `automatic`, `manual`, `scenario`; defaults to `["automatic"]`. Usage and cadence are separate concerns (PRD 62). |
|
||||
| `cadence` | object | No | Cadence assignment. Its fields are specified by the Cadence contract in Phase 5; in 0.1 Phase 3b it is validated only as an object. |
|
||||
| `bus` | string | No | A declared `audio.buses.<id>`. An undeclared bus is `ERR_INVALID_REFERENCE`. Omitted, the sound routes to the engine master. |
|
||||
| `recipe` | object | Yes | An audio graph object plus the fields below, or `{ "use": "<recipe-id>" }` naming an `audio.recipes.<id>`. |
|
||||
|
||||
A recipe graph object adds one field to the graph shape of 14.3:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | :---: | --- |
|
||||
| `mode` | enum | No (default `oneshot`) | `oneshot` or `continuous` (PRD 57). A `oneshot` recipe must have a determinable ending; the runtime state machine that enforces it is Phase 3c. |
|
||||
|
||||
`audio.recipes.<recipe-id>` holds the same shape and exists so several sounds can share one graph. A `recipe` object containing both `use` and graph fields is `ERR_SCHEMA_VALIDATION`, and a `use` naming an undeclared recipe is `ERR_INVALID_REFERENCE`.
|
||||
|
||||
### 15.17 Audio buses and master
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {
|
||||
"buses": {
|
||||
"ambient": { "gain": 1 },
|
||||
"effects": { "gain": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | :---: | --- |
|
||||
| `gain` | ValueSpec\<number\> | No (default `1`) | Range `[0, 4]`, matching the section 8.1 safety clamp for `audio.buses.<id>.gain`. |
|
||||
|
||||
A bus is a named summing point with one gain stage. `audio.buses.<id>.gain` is the one audio target in the section 8.1 shared registry, and it remains the supported surface for binding, automation, override, and additive modulation of audio level from outside a graph.
|
||||
|
||||
Bus processing beyond gain is not part of 0.1. `master` is engine-provided and may not be declared as a bus ID (`ERR_INVALID_ID`). The `audio.master` object is reserved for the Phase 3c master-protection contract and must be absent from a 0.1 document; present, it is `ERR_UNKNOWN_FIELD`.
|
||||
|
||||
### 15.18 New diagnostic codes
|
||||
|
||||
The following codes are added to the section 7 table, which remains the single authoritative list:
|
||||
|
||||
| Error Code | Stage | Cause |
|
||||
| :--- | :--- | :--- |
|
||||
| `ERR_INVALID_ROUTE` | Semantic | An audio or modulation route is structurally resolvable but illegal under 15.14. |
|
||||
| `ERR_NO_AUDIBLE_PATH` | Semantic | No chain of audio routes from a non-control source reaches `output`. |
|
||||
| `ERR_COMPONENT_RECURSION` | Semantic | A component instantiates itself transitively, or component nesting exceeds `8` levels. |
|
||||
| `WARN_AUDIO_RATE_CLAMP` | Runtime | A frequency field was clamped to the device's `audioMaxFrequency` at node instantiation. |
|
||||
|
||||
### 15.19 Required traces before Phase 3b implementation is accepted
|
||||
|
||||
1. Each processing, routing, and composite node type validates its documented minimal example inside a complete graph with zero diagnostics, and its documented invalid case with exactly the documented code.
|
||||
2. Every rule in the 15.14 table is exercised by at least one fixture that emits exactly the named diagnostic, and by one passing fixture that does not.
|
||||
3. A graph whose only path to `output` originates at an `lfo`, `constant`, or `sample-hold` fails rule 5, and the same graph with an `oscillator` source passes.
|
||||
4. A component instance expands correctly: `inputs.*` resolves to the instance's supplied values, internal node keys are unreachable from outside, an exposed parameter is a legal modulation target, and a self-referencing component is `ERR_COMPONENT_RECURSION`.
|
||||
5. Two modulation routes onto one property sum and then clamp to the property's declared range.
|
||||
6. A graph exceeding `128` expanded nodes or `256` routes after component expansion is `ERR_NODE_LIMIT_EXCEEDED`, while a graph at exactly those counts passes.
|
||||
7. Expansion and validation run with no `AudioContext`, confirming the 14.5 separation between semantic validation and instantiation.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| --- | --- | --- |
|
||||
| Phase 1 — Runtime skeleton | 8–14, 108–118, 121–127, 134–135 | Modular shell, loader, diagnostics, seeded RNG, IndexedDB foundation, activation/deactivation, and deterministic standalone build. Stop only when two minimal exhibits import, cache, switch, restart, and restore directly from disk. |
|
||||
| Phase 2 — Common grammar | 15–33, 104, 136 | Parameters/state/signals, ValueSpec, ConditionSpec, actions, bindings, and override stack. Re-run GC2/GC3 traces against production code; demonstrate preserved user edits through masking and release. |
|
||||
| Phase 3 — Audio engine | 34–60, 118–120, 129, 137 | Complete audio subsystem contract, graph compiler/nodes/components/buses, lifecycle, automation, protection, unlock mapping, and voice limits. Run audio challenge and real GC4 synchronization checks. Begin reference Exhibits A/B audio. |
|
||||
| Phase 3 — Audio engine | 34–60, 118–120, 129, 137 | Complete audio subsystem contract, graph compiler/nodes/components/buses, lifecycle, automation, protection, unlock mapping, and voice limits. Run audio challenge and real GC4 synchronization checks. Begin reference Exhibits A/B audio. Delivered in three installments: **3a** sources and control sources (PRD 36–42), **3b** processing, routing, modulation, graph legality, authoring limits, components, sounds/recipes, and buses (PRD 43–53, 55, 56, 59, 60), **3c** automation precedence, lifecycle, unlock behavior, voice ceilings, and master protection (PRD 54, 57, 58, 118–120). |
|
||||
| Phase 4 — Visual engine | 69–89, 119–120, 130, 138 | Complete visual subsystem contract and generic renderer features. Run visual challenge. Begin reference Exhibits A–D visuals, then execute the early combined GC6 benchmark before fixing later optimization strategy. |
|
||||
| Phase 5 — Events and cadence | 61–68, 90–91, 139 | Complete cadence/event shapes, selection/cooldown/overlap/anti-repetition, and event dispatch. Re-run dispatch budget and manual-stream isolation against production code. Integrate automatic behavior in Exhibits A–D. |
|
||||
| Phase 6 — Scenario director | 92–102, 131, 140 | Complete trigger/timeline shapes; implement eligibility, branching, priority, concurrency, ownership, cleanup, and accelerated time. Re-run all GC5 traces with real resource counters. Build Exhibit E and temporary overrides in A/B. Run the two-hour development soak. |
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
Every phase ends with tests and an evidence record. A failed gate stops dependent work; it does not silently weaken the requirement.
|
||||
|
||||
An installment is complete only when its own contract, implementation, tests, and evidence record are all in place. A contract installment is reviewed against its PRD sections and the shared contracts before its dependent installment begins; the Phase 3a review, which closed four blocking defects before Phase 3b started, is the worked example.
|
||||
|
||||
## MVP completion-criteria map
|
||||
|
||||
| Completion criterion group | Primary milestone | Final evidence |
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Phase 3a/3b audio subsystem — contract and automated evidence
|
||||
|
||||
**Date:** September 5, 2026
|
||||
**Artifact:** `XZBT.html`
|
||||
**SHA-256:** `64d9932ed863dbae66f309504b88e19370ad2c9017e92c32bd61ea1ea39a3f17`
|
||||
**Specification baseline:** Format Specification 0.1 revision 0.4, sections 14-15
|
||||
**Result:** Phase 3a and Phase 3b contracts complete; implementation and automated acceptance passed. Phase 3c and the user-observed audible gates remain open.
|
||||
|
||||
## Phase 3a review outcome
|
||||
|
||||
Phase 3a was reviewed before Phase 3b began. Four blocking defects were found in the draft and are now closed:
|
||||
|
||||
1. **Node identity contradicted itself.** The draft said nodes are keyed by node ID while every documented minimal example carried an inline `id` field, so under the strict unknown-field policy each example would have emitted `ERR_UNKNOWN_FIELD` and trace 1 could never pass. Resolved in favour of the keyed map, consistent with `parameters`, `state`, `audio.buses`, `sounds`, and `modulators`; an `id` field on a node is now explicitly `ERR_UNKNOWN_FIELD`.
|
||||
2. **No container shape.** The draft never said where a node lives. Section 14.3 now defines the audio graph object (`nodes`, `routes`) and names its three embedding points.
|
||||
3. **`audioMaxFrequency` had no validation stage.** It was declared a semantic-stage `ERR_OUT_OF_BOUNDS` while depending on a live `AudioContext.sampleRate`. Section 14.5 now separates a device-independent semantic ceiling of 24000 from instantiation-time clamping against `min(24000, sampleRate x 0.45)`, with `WARN_AUDIO_RATE_CLAMP` instead of failure, so a cached exhibit still activates on a different device.
|
||||
4. **The sample-hold PRNG child key was undocumented,** which section 9.3 requires. Section 14.12 now fixes it as `<sound-instance-key>|node|<node-path>`.
|
||||
|
||||
Nine further gaps were closed in the same pass: noise/impulse seeding scope, pink/brown spectral definitions, impulse decay envelope math, Nyquist handling for custom partials, the missing-`harmonics` code, LFO phase origin, the two units rows dropped from PRD 35, the staged `ERR_INVALID_NODE_TYPE` wording, the PRD 33 modulator cross-reference, and the duplicated diagnostics table.
|
||||
|
||||
## Delivered
|
||||
|
||||
- section 14: pipeline, canonical units, audio graph objects, node-field resolution scope, frequency ceiling staging, audio reproducibility scope, and the six source and control-source node contracts;
|
||||
- section 15: nine processing and routing node contracts, the component instance node, audio routing, modulation semantics with the modulatable-property registry, twelve graph legality rules, authoring limits, components with the component-scoped `inputs.*` namespace, sound definitions and recipes, and buses;
|
||||
- four new diagnostic codes (`ERR_INVALID_ROUTE`, `ERR_NO_AUDIBLE_PATH`, `ERR_COMPONENT_RECURSION`, `WARN_AUDIO_RATE_CLAMP`) added to the single section 7 table;
|
||||
- `src/runtime/audio-contract.js`, the declarative node/limit/modulation tables shared by every consumer;
|
||||
- `src/runtime/audio-graph.js`, pure validation, component expansion, and legality checking that never opens an `AudioContext`;
|
||||
- `src/runtime/audio-engine.js`, deterministic instantiation plus Web Audio realization and the `AudioSubsystem` runtime owner;
|
||||
- audio structural definitions in `schema/xzbt-0.1.schema.json`;
|
||||
- `tools/validate-exhibit.mjs` now delegates its audio checks to the shared runtime module rather than reimplementing them;
|
||||
- `exhibits/minimal-audio.xzbt`, a generic fixture exercising components, modulation, buses, a shared recipe, and both recipe modes;
|
||||
- an audio panel in the runtime shell: gesture unlock, master volume, per-bus gain, and per-sound triggering.
|
||||
|
||||
## Automated verification
|
||||
|
||||
`npm test` runs 62 tests with zero failures across GC2-GC5, Phase 1, Phase 2, and the new Phase 3 suite. The Phase 3 suite covers every trace required by sections 14.13 and 15.19:
|
||||
|
||||
| Trace | Check |
|
||||
| --- | --- |
|
||||
| 14.13-1 | All fifteen non-composite node types validate their minimal example inside a complete graph; all sixteen documented invalid cases emit exactly their documented code. |
|
||||
| 14.13-2 | A node carrying `id` is `ERR_UNKNOWN_FIELD`; a node keyed `output` is `ERR_INVALID_ID`. |
|
||||
| 14.13-3 | Semantic validation rejects 30000 Hz with no `AudioContext` present; instantiation at 44100 Hz clamps to 19845 Hz with one `WARN_AUDIO_RATE_CLAMP`, and at 96000 Hz does not clamp. |
|
||||
| 14.13-4 | A `random` frequency samples once, repeats for the same seed and ordinal, and differs at the next ordinal. |
|
||||
| 14.13-5 | An external binding to a node field fails; `audio.buses.<id>.gain` still validates. |
|
||||
| 14.13-6 | Sample-hold sequences are identical for the same seed and ordinal, and differ when the seed, the ordinal, or the node key changes. Slew is clamped to the tick period. |
|
||||
| 15.19-1 | Minimal and invalid examples for gain, filter, compressor, waveshaper, delay, reverb, stereo-pan, mixer, and resonator. |
|
||||
| 15.19-2 | Rules 3, 4, 5, 6, 7, 9, 10 and the self-route case each emit their named diagnostic; matched passing fixtures do not. |
|
||||
| 15.19-3 | A control-source-only path to output fails; the same shape with an oscillator passes. |
|
||||
| 15.19-4 | Component expansion resolves `inputs.*`, applies declared defaults, rejects reaching an internal node, rejects an undeclared value key, rejects audio into an input-less component, accepts an exposed parameter as a modulation target, and rejects self-instantiation. |
|
||||
| 15.19-5 | Two modulation routes onto one property are both retained with their resolved depths. |
|
||||
| 15.19-6 | 128 expanded nodes pass; 129 is `ERR_NODE_LIMIT_EXCEEDED`. 64 partials and 16 resonator modes pass; 65 and 17 do not. |
|
||||
| 15.19-7 | Expansion and validation run with `globalThis.AudioContext` undefined, asserted directly in the suite. |
|
||||
|
||||
A realization smoke test drives every node type through an `AudioContext` stand-in, confirms the graph reaches the destination, and confirms every started source is stopped on disposal. Two clean builds produce byte-identical artifacts with digest `64d9932ed863dbae66f309504b88e19370ad2c9017e92c32bd61ea1ea39a3f17`; all three exhibit fixtures pass the standalone validator.
|
||||
|
||||
## Not established by this record
|
||||
|
||||
- **Phase 3c contracts.** Audio automation precedence (PRD 54), the lifecycle state machine (PRD 57), unlock behavior and pre-unlock one-shot handling, voice ceilings, and the measured master-protection contract (PRD 58 peak ceiling, numerical tolerance, release behavior, finite-sample handling) are unspecified. The runtime's master chain is engine-owned and unbypassable, but its ceiling is a placeholder, not an accepted contract; the presence of a compressor does not establish that the protection contract passes.
|
||||
- **Audible observation.** No sound has been heard from this build. The PRD 129 audio acceptance challenge, real GC4 synchronization checks, peak and finite-sample capture, and listening observations for clicks and clipping all remain open user-observed gates.
|
||||
- **One-shot endings.** Without the Phase 3c lifecycle contract the runtime shell releases a one-shot voice on a fixed development timer. That is a development affordance, not the determinable ending PRD 57 requires.
|
||||
- **Reference exhibits.** `minimal-audio.xzbt` is a contract fixture, not reference Exhibit A or B; those begin at their mapped milestones.
|
||||
- **Phase 1 direct-file gate.** The two-fixture direct-file import and full-browser-restart observation remains open and is unaffected by this work.
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"xzbt": "0.1",
|
||||
"meta": {
|
||||
"id": "minimal-audio",
|
||||
"name": "Minimal Audio",
|
||||
"version": "1.0.0",
|
||||
"author": "XZBT",
|
||||
"description": "A minimal generic exhibit exercising the Phase 3 audio graph, component, routing, and bus contracts.",
|
||||
"license": "CC0-1.0",
|
||||
"tags": ["minimal", "audio"]
|
||||
},
|
||||
"runtime": {
|
||||
"seed": 42
|
||||
},
|
||||
"parameters": {
|
||||
"level": {
|
||||
"type": "number",
|
||||
"default": 0.6,
|
||||
"min": 0,
|
||||
"max": 4,
|
||||
"step": 0.01,
|
||||
"label": "Ambient bus level"
|
||||
}
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"source": "parameters.level",
|
||||
"target": "audio.buses.ambient.gain"
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"audio": {
|
||||
"partial": {
|
||||
"parameters": {
|
||||
"pitch": { "type": "number", "default": 220, "min": 20, "max": 4000, "unit": "Hz" },
|
||||
"level": { "type": "number", "default": 0.2, "min": 0, "max": 1 }
|
||||
},
|
||||
"input": false,
|
||||
"nodes": {
|
||||
"tone": { "type": "oscillator", "waveform": "triangle", "frequency": { "ref": "inputs.pitch" } },
|
||||
"shape": { "type": "filter", "mode": "lowpass", "frequency": 1800, "q": 0.7 },
|
||||
"level": { "type": "gain", "gain": { "ref": "inputs.level" } }
|
||||
},
|
||||
"routes": [
|
||||
{ "from": "tone", "to": "shape" },
|
||||
{ "from": "shape", "to": "level" },
|
||||
{ "from": "level", "to": "output" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"buses": {
|
||||
"ambient": { "gain": 0.6 },
|
||||
"effects": { "gain": 0.8 }
|
||||
},
|
||||
"recipes": {
|
||||
"drift": {
|
||||
"mode": "continuous",
|
||||
"nodes": {
|
||||
"low": { "type": "component", "use": "partial", "values": { "pitch": 110, "level": 0.25 } },
|
||||
"high": { "type": "component", "use": "partial", "values": { "pitch": { "random": { "min": 320, "max": 340 } }, "level": 0.12 } },
|
||||
"wobble": { "type": "lfo", "waveform": "sine", "frequency": 0.08, "amplitude": 1 },
|
||||
"wander": { "type": "sample-hold", "rate": 0.5, "min": -6, "max": 6, "slew": "600ms" },
|
||||
"blend": { "type": "mixer" },
|
||||
"space": { "type": "reverb", "size": 0.7, "decay": "3.5s", "damping": 0.6, "mix": 0.3 },
|
||||
"trim": { "type": "gain", "gain": 0.7 }
|
||||
},
|
||||
"routes": [
|
||||
{ "from": "low", "to": "blend" },
|
||||
{ "from": "high", "to": "blend" },
|
||||
{ "from": "blend", "to": "space" },
|
||||
{ "from": "space", "to": "trim" },
|
||||
{ "from": "trim", "to": "output" },
|
||||
{ "from": "wobble", "to": "low.pitch", "depth": 1.5 },
|
||||
{ "from": "wander", "to": "high.pitch", "depth": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"sounds": {
|
||||
"drift": {
|
||||
"name": "Drift",
|
||||
"tags": ["continuous", "abstract"],
|
||||
"usage": ["manual"],
|
||||
"bus": "ambient",
|
||||
"recipe": { "use": "drift" }
|
||||
},
|
||||
"tick": {
|
||||
"name": "Tick",
|
||||
"tags": ["transient"],
|
||||
"usage": ["manual", "automatic"],
|
||||
"bus": "effects",
|
||||
"recipe": {
|
||||
"mode": "oneshot",
|
||||
"nodes": {
|
||||
"hit": { "type": "impulse", "color": "white", "duration": "6ms", "amplitude": 0.9, "decay": "exponential" },
|
||||
"body": {
|
||||
"type": "resonator",
|
||||
"fundamental": 480,
|
||||
"modes": [
|
||||
{ "ratio": 1, "gain": 1, "decay": "260ms" },
|
||||
{ "ratio": 2.7, "gain": 0.35, "decay": "140ms" },
|
||||
{ "ratio": 5.4, "gain": 0.12, "decay": "70ms" }
|
||||
],
|
||||
"mix": 1
|
||||
},
|
||||
"place": { "type": "stereo-pan", "pan": -0.2 },
|
||||
"trim": { "type": "gain", "gain": 0.5 }
|
||||
},
|
||||
"routes": [
|
||||
{ "from": "hit", "to": "body" },
|
||||
{ "from": "body", "to": "place" },
|
||||
{ "from": "place", "to": "trim" },
|
||||
{ "from": "trim", "to": "output" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -9,6 +9,8 @@
|
||||
"scripts": {
|
||||
"build": "node tools/build-xzbt.mjs",
|
||||
"test:phase1": "node test/phase1-runtime.test.mjs",
|
||||
"test:phase2": "node test/phase2-common-grammar.test.mjs"
|
||||
"test:phase2": "node test/phase2-common-grammar.test.mjs",
|
||||
"test:phase3": "node test/phase3-audio.test.mjs",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
+1212
-12
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -28,8 +28,20 @@
|
||||
<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. Audio and visual engines attach in later phases.</p>
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ActivationController } from './activation.js';
|
||||
import { AudioSubsystem } from './audio-engine.js';
|
||||
import { Diagnostics } from './diagnostics.js';
|
||||
import { LibraryManager } from './library.js';
|
||||
import { PersistenceManager } from './persistence.js';
|
||||
@@ -33,6 +34,7 @@ export class XZBTApplication {
|
||||
})
|
||||
});
|
||||
this.busy = false;
|
||||
this.audio = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
@@ -63,6 +65,14 @@ export class XZBTApplication {
|
||||
});
|
||||
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) => {
|
||||
@@ -126,6 +136,7 @@ export class XZBTApplication {
|
||||
if (this.busy || !this.activation.current) return;
|
||||
this.setBusy(true, 'Deactivating exhibit…');
|
||||
try {
|
||||
await this.disposeAudio();
|
||||
await this.activation.deactivate();
|
||||
this.renderLibrary();
|
||||
this.renderStage();
|
||||
@@ -175,6 +186,98 @@ export class XZBTApplication {
|
||||
element('active-sequence').textContent = [preview.nextUint32(), preview.nextUint32(), preview.nextUint32()].join(' · ');
|
||||
this.renderConfiguration(current.performance);
|
||||
this.renderValues(current.performance.engine);
|
||||
this.renderAudio();
|
||||
}
|
||||
|
||||
async unlockAudio() {
|
||||
const current = this.activation.current;
|
||||
if (!current) return;
|
||||
if (!this.audio) {
|
||||
this.audio = new AudioSubsystem({
|
||||
document: current.record.document,
|
||||
rng: current.performance.rng,
|
||||
diagnostics: this.diagnostics
|
||||
});
|
||||
this.audio.setMasterVolume(Number(element('master-volume').value));
|
||||
}
|
||||
await this.audio.unlock();
|
||||
this.renderAudio();
|
||||
}
|
||||
|
||||
async disposeAudio() {
|
||||
if (!this.audio) return;
|
||||
await this.audio.dispose();
|
||||
this.audio = null;
|
||||
this.renderAudio();
|
||||
}
|
||||
|
||||
playSound(id) {
|
||||
const current = this.activation.current;
|
||||
if (!this.audio?.unlocked || !current) return;
|
||||
const handle = this.audio.play(id, { resolveReference: (path) => current.performance.engine.get(path) });
|
||||
if (handle?.mode === 'oneshot') {
|
||||
// Phase 3b has no lifecycle contract yet (PRD 57 is Phase 3c), so a one-shot voice is
|
||||
// released on a fixed development timer rather than on a determinable ending.
|
||||
setTimeout(() => handle.stop(), 4000);
|
||||
}
|
||||
this.renderAudio();
|
||||
}
|
||||
|
||||
renderAudio() {
|
||||
const current = this.activation.current;
|
||||
const state = element('audio-state');
|
||||
const stopButton = element('audio-stop');
|
||||
const busContainer = element('audio-buses');
|
||||
const soundContainer = element('sound-list');
|
||||
busContainer.replaceChildren();
|
||||
soundContainer.replaceChildren();
|
||||
if (!current) {
|
||||
state.textContent = 'Audio is locked until you start it.';
|
||||
stopButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
const document_ = current.record.document;
|
||||
const sounds = document_.sounds ?? {};
|
||||
const unlocked = Boolean(this.audio?.unlocked);
|
||||
element('audio-unlock').disabled = unlocked;
|
||||
stopButton.disabled = !unlocked || (this.audio?.voices.size ?? 0) === 0;
|
||||
state.textContent = unlocked
|
||||
? `Audio running at ${this.audio.context.sampleRate} Hz · ${this.audio.voices.size} live voice${this.audio.voices.size === 1 ? '' : 's'}`
|
||||
: 'Audio is locked until you start it. Browsers require a user gesture.';
|
||||
|
||||
for (const [id, bus] of Object.entries(document_.audio?.buses ?? {})) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'parameter-control';
|
||||
const label = text('label', `Bus ${id}`);
|
||||
label.htmlFor = `bus-${id}`;
|
||||
const input = document.createElement('input');
|
||||
input.id = `bus-${id}`;
|
||||
input.type = 'range';
|
||||
input.min = '0';
|
||||
input.max = '4';
|
||||
input.step = '0.01';
|
||||
input.value = String(typeof bus.gain === 'number' ? bus.gain : 1);
|
||||
input.disabled = !unlocked;
|
||||
input.addEventListener('input', (event) => this.audio?.setBusGain(id, Number(event.target.value)));
|
||||
row.append(label, input);
|
||||
busContainer.append(row);
|
||||
}
|
||||
|
||||
if (Object.keys(sounds).length === 0) {
|
||||
soundContainer.append(text('p', 'This exhibit declares no sounds.', 'empty'));
|
||||
return;
|
||||
}
|
||||
for (const [id, sound] of Object.entries(sounds)) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'sound-row';
|
||||
row.append(text('span', sound.name ?? id));
|
||||
const button = text('button', 'Play');
|
||||
button.type = 'button';
|
||||
button.disabled = !unlocked;
|
||||
button.addEventListener('click', () => this.playSound(id));
|
||||
row.append(button);
|
||||
soundContainer.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
renderConfiguration(performance) {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// Audio Subsystem Contract 0.1 - declarative tables shared by validation, expansion,
|
||||
// instantiation, and realization. Format Specification 0.1 revision 0.4, sections 14-15.
|
||||
|
||||
export const AUDIO_STATIC_MAX_FREQUENCY = 24000;
|
||||
export const AUDIO_NYQUIST_FACTOR = 0.45;
|
||||
|
||||
export const AUDIO_LIMITS = Object.freeze({
|
||||
nodesPerSound: 128,
|
||||
routesPerSound: 256,
|
||||
componentDepth: 8,
|
||||
resonatorModes: 16,
|
||||
oscillatorPartials: 64
|
||||
});
|
||||
|
||||
export const AUDIO_NOISE_COLORS = Object.freeze(['white', 'pink', 'brown']);
|
||||
export 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).
|
||||
export 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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const AUDIO_NODE_TYPE_NAMES = Object.freeze(Object.keys(AUDIO_NODE_TYPES));
|
||||
|
||||
export 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 }
|
||||
});
|
||||
|
||||
export 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.
|
||||
export 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' })
|
||||
});
|
||||
|
||||
export const AUDIO_MODULATION_SOURCE_TYPES = Object.freeze(['constant', 'lfo', 'sample-hold', 'oscillator']);
|
||||
|
||||
export const AUDIO_SOUND_FIELDS = Object.freeze(['name', 'tags', 'usage', 'cadence', 'bus', 'recipe']);
|
||||
export const AUDIO_SOUND_USAGE = Object.freeze(['automatic', 'manual', 'scenario']);
|
||||
export const AUDIO_RECIPE_MODES = Object.freeze(['oneshot', 'continuous']);
|
||||
export const AUDIO_GRAPH_FIELDS = Object.freeze(['nodes', 'routes']);
|
||||
export const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'mode']);
|
||||
export const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'parameters', 'input']);
|
||||
export const AUDIO_COMPONENT_PARAMETER_FIELDS = Object.freeze(['type', 'default', 'min', 'max', 'unit']);
|
||||
export const AUDIO_BUS_FIELDS = Object.freeze(['gain']);
|
||||
export const AUDIO_BUS_GAIN_RANGE = Object.freeze({ min: 0, max: 4, default: 1 });
|
||||
export const AUDIO_ROUTE_FIELDS = Object.freeze(['from', 'to', 'depth']);
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function isControlSourceType(type) {
|
||||
return AUDIO_NODE_TYPES[type]?.class === 'control';
|
||||
}
|
||||
|
||||
export function isSourceType(type) {
|
||||
const entry = AUDIO_NODE_TYPES[type];
|
||||
return entry?.class === 'source' || entry?.class === 'control';
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// 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).
|
||||
|
||||
import {
|
||||
AUDIO_MODE_FIELDS,
|
||||
AUDIO_NODE_TYPES,
|
||||
AUDIO_PARTIAL_FIELDS,
|
||||
audioMaxFrequency
|
||||
} from './audio-contract.js';
|
||||
import { durationMilliseconds, expandSoundGraph } from './audio-graph.js';
|
||||
import { ValueResolver } from './values.js';
|
||||
import { RuntimeFault, clamp } from './types.js';
|
||||
|
||||
export function soundInstanceKey(soundId, ordinal) {
|
||||
return `${soundId}#${ordinal}`;
|
||||
}
|
||||
|
||||
export function sampleHoldStreamKey(instanceKey, nodePath) {
|
||||
return `${instanceKey}|node|${nodePath}`;
|
||||
}
|
||||
|
||||
export function instantiateSoundGraph(document, soundId, options = {}) {
|
||||
const {
|
||||
sampleRate = 48000,
|
||||
rng = null,
|
||||
ordinal = 0,
|
||||
resolveReference = () => { throw new RuntimeFault('ERR_INVALID_REFERENCE', 'No reference resolver supplied.'); }
|
||||
} = options;
|
||||
|
||||
const expansion = options.expansion ?? expandSoundGraph(document, soundId);
|
||||
if (expansion.errors.length > 0) {
|
||||
return { nodes: [], routes: [], warnings: [], errors: expansion.errors, mode: expansion.mode };
|
||||
}
|
||||
|
||||
const ceiling = audioMaxFrequency(sampleRate);
|
||||
const instanceKey = soundInstanceKey(soundId, ordinal);
|
||||
const stream = rng ? rng.stream('sound', instanceKey) : null;
|
||||
const warnings = [];
|
||||
const componentValues = new Map();
|
||||
|
||||
const resolver = new ValueResolver((reference) => {
|
||||
if (typeof reference === 'string' && reference.startsWith('inputs.')) {
|
||||
const scope = resolver.currentScope;
|
||||
const values = componentValues.get(scope);
|
||||
const name = reference.slice('inputs.'.length);
|
||||
if (!values || !Object.hasOwn(values, name)) {
|
||||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component input '${name}' is not available here.`);
|
||||
}
|
||||
return values[name];
|
||||
}
|
||||
return resolveReference(reference);
|
||||
});
|
||||
|
||||
const evaluate = (spec, scope, path) => {
|
||||
resolver.currentScope = scope;
|
||||
return resolver.evaluate(spec, stream, path);
|
||||
};
|
||||
|
||||
const clampField = (value, spec, nodePath, field) => {
|
||||
let limitMax = spec.max;
|
||||
if (spec.ceiling === 'audio' && ceiling < spec.max) limitMax = ceiling;
|
||||
const bounded = clamp(value, spec.min, limitMax);
|
||||
if (spec.ceiling === 'audio' && value > limitMax) {
|
||||
warnings.push({ code: 'WARN_AUDIO_RATE_CLAMP', path: `${nodePath}.${field}`, message: `Clamped ${value} Hz to the device ceiling ${limitMax} Hz.` });
|
||||
}
|
||||
return bounded;
|
||||
};
|
||||
|
||||
const nodes = [];
|
||||
for (const node of expansion.nodes) {
|
||||
if (node.implicit) {
|
||||
nodes.push({ path: node.path, type: 'gain', implicit: true, values: { gain: 1 } });
|
||||
continue;
|
||||
}
|
||||
const contract = AUDIO_NODE_TYPES[node.type];
|
||||
if (!contract) continue;
|
||||
const scope = node.scope;
|
||||
const values = {};
|
||||
|
||||
if (node.type === 'component') {
|
||||
const declared = node.exposes ?? {};
|
||||
const supplied = node.spec.values ?? {};
|
||||
const resolved = {};
|
||||
for (const [name, rule] of Object.entries(declared)) {
|
||||
const raw = Object.hasOwn(supplied, name) ? supplied[name] : rule.default;
|
||||
const value = evaluate(raw, scope, `${node.path}.values.${name}`);
|
||||
resolved[name] = clamp(value, rule.min ?? -Infinity, rule.max ?? Infinity);
|
||||
}
|
||||
componentValues.set(node.path, resolved);
|
||||
nodes.push({ path: node.path, type: 'component', component: node.component, values: resolved, passthrough: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [field, spec] of Object.entries(contract.fields)) {
|
||||
if (spec.kind === 'partials' || spec.kind === 'modes') continue;
|
||||
const authored = Object.hasOwn(node.spec, field) ? node.spec[field] : spec.default;
|
||||
if (authored === undefined) continue;
|
||||
if (spec.kind === 'enum') { values[field] = authored; continue; }
|
||||
if (spec.kind === 'duration') { values[field] = durationMilliseconds(authored); continue; }
|
||||
const raw = evaluate(authored, scope, `${node.path}.${field}`);
|
||||
values[field] = clampField(raw, spec, node.path, field);
|
||||
}
|
||||
|
||||
if (node.type === 'oscillator' && values.waveform === 'custom') {
|
||||
const partials = [];
|
||||
for (const [index, partial] of (node.spec.harmonics ?? []).entries()) {
|
||||
const ratio = evaluate(partial.ratio, scope, `${node.path}.harmonics[${index}].ratio`);
|
||||
const gain = evaluate(partial.gain, scope, `${node.path}.harmonics[${index}].gain`);
|
||||
const phase = Object.hasOwn(partial, 'phase')
|
||||
? evaluate(partial.phase, scope, `${node.path}.harmonics[${index}].phase`)
|
||||
: AUDIO_PARTIAL_FIELDS.phase.default;
|
||||
if (ratio * values.frequency > ceiling) continue;
|
||||
partials.push({ ratio, gain, phase });
|
||||
}
|
||||
values.harmonics = partials;
|
||||
}
|
||||
|
||||
if (node.type === 'resonator') {
|
||||
const modes = [];
|
||||
for (const [index, mode] of (node.spec.modes ?? []).entries()) {
|
||||
const entry = {
|
||||
gain: Object.hasOwn(mode, 'gain') ? evaluate(mode.gain, scope, `${node.path}.modes[${index}].gain`) : AUDIO_MODE_FIELDS.gain.default,
|
||||
decay: durationMilliseconds(Object.hasOwn(mode, 'decay') ? mode.decay : AUDIO_MODE_FIELDS.decay.default)
|
||||
};
|
||||
entry.frequency = Object.hasOwn(mode, 'frequency')
|
||||
? evaluate(mode.frequency, scope, `${node.path}.modes[${index}].frequency`)
|
||||
: evaluate(mode.ratio, scope, `${node.path}.modes[${index}].ratio`) * values.fundamental;
|
||||
if (entry.frequency > ceiling) continue;
|
||||
modes.push(entry);
|
||||
}
|
||||
values.modes = modes;
|
||||
}
|
||||
|
||||
if (node.type === 'sample-hold') {
|
||||
const slewLimit = 1000 / values.rate;
|
||||
values.slew = Math.min(values.slew, slewLimit);
|
||||
values.streamKey = sampleHoldStreamKey(instanceKey, node.path);
|
||||
values.stream = rng ? rng.stream('sound', values.streamKey) : null;
|
||||
}
|
||||
|
||||
nodes.push({ path: node.path, type: node.type, values, scope });
|
||||
}
|
||||
|
||||
const routes = expansion.routes.map((route) => {
|
||||
if (route.kind !== 'modulation') return { ...route };
|
||||
const owner = expansion.nodes.find((node) => node.path === route.to);
|
||||
return { ...route, depth: evaluate(route.depth, owner?.scope ?? null, `${route.path}.depth`) };
|
||||
});
|
||||
|
||||
return { nodes, routes, warnings, errors: [], mode: expansion.mode, instanceKey, ceiling };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Web Audio realization
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const NOISE_SECONDS = 2;
|
||||
|
||||
function noiseBuffer(context, color) {
|
||||
const length = Math.floor(context.sampleRate * NOISE_SECONDS);
|
||||
const buffer = context.createBuffer(1, length, context.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
if (color === 'white') {
|
||||
for (let index = 0; index < length; index += 1) data[index] = (Math.random() * 2) - 1;
|
||||
} else if (color === 'pink') {
|
||||
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const white = (Math.random() * 2) - 1;
|
||||
b0 = 0.99886 * b0 + white * 0.0555179;
|
||||
b1 = 0.99332 * b1 + white * 0.0750759;
|
||||
b2 = 0.969 * b2 + white * 0.153852;
|
||||
b3 = 0.8665 * b3 + white * 0.3104856;
|
||||
b4 = 0.55 * b4 + white * 0.5329522;
|
||||
b5 = -0.7616 * b5 - white * 0.016898;
|
||||
data[index] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
||||
b6 = white * 0.115926;
|
||||
}
|
||||
} else {
|
||||
let last = 0;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const white = (Math.random() * 2) - 1;
|
||||
last = (last + 0.02 * white) / 1.02;
|
||||
data[index] = last;
|
||||
}
|
||||
}
|
||||
let sum = 0;
|
||||
for (let index = 0; index < length; index += 1) sum += data[index] * data[index];
|
||||
const rms = Math.sqrt(sum / length) || 1;
|
||||
for (let index = 0; index < length; index += 1) data[index] = clamp(data[index] / rms * 0.2, -1, 1);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function shaperCurve(shape, amount) {
|
||||
const points = 1024;
|
||||
const curve = new Float32Array(points);
|
||||
const drive = 1 + (amount * 24);
|
||||
for (let index = 0; index < points; index += 1) {
|
||||
const x = (index * 2 / (points - 1)) - 1;
|
||||
if (amount === 0) curve[index] = x;
|
||||
else if (shape === 'hard-clip') curve[index] = clamp(x * drive, -1, 1);
|
||||
else if (shape === 'saturation') curve[index] = Math.tanh(x * drive);
|
||||
else curve[index] = Math.sign(x) * (1 - Math.exp(-Math.abs(x * drive))) / (1 - Math.exp(-drive));
|
||||
}
|
||||
return curve;
|
||||
}
|
||||
|
||||
function reverbBuffer(context, { size, decay, damping }) {
|
||||
const seconds = Math.max(0.05, (decay / 1000) * (0.4 + (size * 0.6)));
|
||||
const length = Math.max(1, Math.floor(context.sampleRate * seconds));
|
||||
const buffer = context.createBuffer(2, length, context.sampleRate);
|
||||
for (let channel = 0; channel < 2; channel += 1) {
|
||||
const data = buffer.getChannelData(channel);
|
||||
let smoothed = 0;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const envelope = (1 - (index / length)) ** (2 + (damping * 4));
|
||||
const impulse = ((Math.random() * 2) - 1) * envelope;
|
||||
smoothed += (impulse - smoothed) * (1 - (damping * 0.7));
|
||||
data[index] = smoothed;
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function periodicWave(context, partials) {
|
||||
const count = Math.max(2, partials.reduce((highest, partial) => Math.max(highest, Math.round(partial.ratio)), 1) + 1);
|
||||
const real = new Float32Array(count);
|
||||
const imaginary = new Float32Array(count);
|
||||
for (const partial of partials) {
|
||||
const index = Math.round(partial.ratio);
|
||||
if (index < 1 || index >= count) continue;
|
||||
const radians = (partial.phase ?? 0) * Math.PI / 180;
|
||||
real[index] += partial.gain * Math.cos(radians);
|
||||
imaginary[index] += partial.gain * Math.sin(radians);
|
||||
}
|
||||
return context.createPeriodicWave(real, imaginary, { disableNormalization: false });
|
||||
}
|
||||
|
||||
// Builds one realized voice. Lifecycle states (PRD 57) arrive in Phase 3c; this returns the
|
||||
// created endpoints plus a disposer so the caller can release everything it made.
|
||||
export function realizeSoundGraph(context, plan, destination) {
|
||||
const created = new Map();
|
||||
const disposers = [];
|
||||
const starters = [];
|
||||
const sink = context.createGain();
|
||||
sink.gain.value = 1;
|
||||
sink.connect(destination);
|
||||
created.set('output', { input: sink, output: sink });
|
||||
|
||||
const now = () => context.currentTime;
|
||||
|
||||
for (const node of plan.nodes) {
|
||||
const { path, type, values } = node;
|
||||
if (type === 'component') continue;
|
||||
let entry = null;
|
||||
|
||||
if (type === 'oscillator') {
|
||||
const oscillator = context.createOscillator();
|
||||
if (values.waveform === 'custom') oscillator.setPeriodicWave(periodicWave(context, values.harmonics ?? []));
|
||||
else oscillator.type = values.waveform;
|
||||
oscillator.frequency.value = values.frequency;
|
||||
oscillator.detune.value = values.detune;
|
||||
entry = { input: null, output: oscillator, params: { frequency: oscillator.frequency, detune: oscillator.detune } };
|
||||
starters.push(() => oscillator.start());
|
||||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); });
|
||||
} else if (type === 'noise') {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = noiseBuffer(context, values.color);
|
||||
source.loop = true;
|
||||
entry = { input: null, output: source, params: {} };
|
||||
starters.push(() => source.start());
|
||||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||||
} else if (type === 'impulse') {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = noiseBuffer(context, values.color);
|
||||
const envelope = context.createGain();
|
||||
const seconds = values.duration / 1000;
|
||||
const fade = Math.min(0.001, seconds * 0.1);
|
||||
const start = now();
|
||||
envelope.gain.setValueAtTime(values.amplitude, start);
|
||||
if (values.decay === 'linear') envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||||
else if (values.decay === 'exponential') {
|
||||
envelope.gain.exponentialRampToValueAtTime(Math.max(1e-4, values.amplitude * 0.001), start + seconds - fade);
|
||||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||||
} else {
|
||||
envelope.gain.setValueAtTime(values.amplitude, start + seconds - fade);
|
||||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||||
}
|
||||
source.connect(envelope);
|
||||
entry = { input: null, output: envelope, params: {} };
|
||||
starters.push(() => source.start(undefined, 0, seconds));
|
||||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); envelope.disconnect(); });
|
||||
} else if (type === 'constant') {
|
||||
const source = context.createConstantSource();
|
||||
source.offset.value = values.value;
|
||||
entry = { input: null, output: source, params: { value: source.offset } };
|
||||
starters.push(() => source.start());
|
||||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||||
} else if (type === 'lfo') {
|
||||
const oscillator = context.createOscillator();
|
||||
oscillator.type = values.waveform;
|
||||
oscillator.frequency.value = values.frequency;
|
||||
const depth = context.createGain();
|
||||
depth.gain.value = values.polarity === 'unipolar' ? values.amplitude / 2 : values.amplitude;
|
||||
oscillator.connect(depth);
|
||||
let output = depth;
|
||||
if (values.polarity === 'unipolar') {
|
||||
const offset = context.createConstantSource();
|
||||
offset.offset.value = values.amplitude / 2;
|
||||
const sum = context.createGain();
|
||||
depth.connect(sum);
|
||||
offset.connect(sum);
|
||||
output = sum;
|
||||
starters.push(() => offset.start());
|
||||
disposers.push(() => { try { offset.stop(); } catch { /* already stopped */ } offset.disconnect(); sum.disconnect(); });
|
||||
}
|
||||
entry = { input: null, output, params: {} };
|
||||
starters.push(() => oscillator.start(now() + ((values.phase / 360) / Math.max(values.frequency, 1e-6))));
|
||||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); depth.disconnect(); });
|
||||
} else if (type === 'sample-hold') {
|
||||
const source = context.createConstantSource();
|
||||
const period = 1 / values.rate;
|
||||
const slew = values.slew / 1000;
|
||||
let held = 0;
|
||||
source.offset.value = 0;
|
||||
const schedule = (index) => {
|
||||
const target = values.stream
|
||||
? values.min + (values.stream.nextFloat() * (values.max - values.min))
|
||||
: values.min;
|
||||
const at = now() + (index * period);
|
||||
if (slew > 0) source.offset.linearRampToValueAtTime(target, at + slew);
|
||||
else source.offset.setValueAtTime(target, at);
|
||||
held = target;
|
||||
return held;
|
||||
};
|
||||
let tick = 0;
|
||||
const timer = setInterval(() => { schedule(0); tick += 1; }, Math.max(10, period * 1000));
|
||||
schedule(0);
|
||||
entry = { input: null, output: source, params: {} };
|
||||
starters.push(() => source.start());
|
||||
disposers.push(() => { clearInterval(timer); try { source.stop(); } catch { /* already stopped */ } source.disconnect(); void tick; });
|
||||
} else if (type === 'gain') {
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = values.gain ?? 1;
|
||||
entry = { input: gain, output: gain, params: { gain: gain.gain } };
|
||||
disposers.push(() => gain.disconnect());
|
||||
} else if (type === 'filter') {
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = values.mode;
|
||||
filter.frequency.value = values.frequency;
|
||||
filter.Q.value = values.q;
|
||||
filter.gain.value = values.gain;
|
||||
filter.detune.value = values.detune;
|
||||
entry = { input: filter, output: filter, params: { frequency: filter.frequency, q: filter.Q, gain: filter.gain, detune: filter.detune } };
|
||||
disposers.push(() => filter.disconnect());
|
||||
} else if (type === 'compressor') {
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
compressor.threshold.value = values.threshold;
|
||||
compressor.knee.value = values.knee;
|
||||
compressor.ratio.value = values.ratio;
|
||||
compressor.attack.value = values.attack / 1000;
|
||||
compressor.release.value = values.release / 1000;
|
||||
entry = { input: compressor, output: compressor, params: {} };
|
||||
disposers.push(() => compressor.disconnect());
|
||||
} else if (type === 'waveshaper') {
|
||||
const shaper = context.createWaveShaper();
|
||||
shaper.curve = shaperCurve(values.shape, values.amount);
|
||||
shaper.oversample = values.oversample;
|
||||
entry = { input: shaper, output: shaper, params: {} };
|
||||
disposers.push(() => shaper.disconnect());
|
||||
} else if (type === 'delay') {
|
||||
const input = context.createGain();
|
||||
const delay = context.createDelay(10);
|
||||
delay.delayTime.value = values.time / 1000;
|
||||
const feedback = context.createGain();
|
||||
feedback.gain.value = values.feedback;
|
||||
const dry = context.createGain();
|
||||
const wet = context.createGain();
|
||||
const output = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
wet.gain.value = values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
input.connect(delay);
|
||||
delay.connect(feedback).connect(delay);
|
||||
delay.connect(wet).connect(output);
|
||||
entry = { input, output, params: { time: delay.delayTime } };
|
||||
disposers.push(() => [input, delay, feedback, dry, wet, output].forEach((item) => item.disconnect()));
|
||||
} else if (type === 'reverb') {
|
||||
const input = context.createGain();
|
||||
const convolver = context.createConvolver();
|
||||
convolver.buffer = reverbBuffer(context, values);
|
||||
const predelay = context.createDelay(1);
|
||||
predelay.delayTime.value = values.predelay / 1000;
|
||||
const dry = context.createGain();
|
||||
const wet = context.createGain();
|
||||
const output = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
wet.gain.value = values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
input.connect(predelay).connect(convolver).connect(wet).connect(output);
|
||||
entry = { input, output, params: {} };
|
||||
disposers.push(() => [input, convolver, predelay, dry, wet, output].forEach((item) => item.disconnect()));
|
||||
} else if (type === 'stereo-pan') {
|
||||
const panner = context.createStereoPanner();
|
||||
panner.pan.value = values.pan;
|
||||
entry = { input: panner, output: panner, params: { pan: panner.pan } };
|
||||
disposers.push(() => panner.disconnect());
|
||||
} else if (type === 'mixer') {
|
||||
const mixer = context.createGain();
|
||||
mixer.gain.value = 1;
|
||||
entry = { input: mixer, output: mixer, params: {} };
|
||||
disposers.push(() => mixer.disconnect());
|
||||
} else if (type === 'resonator') {
|
||||
const input = context.createGain();
|
||||
const output = context.createGain();
|
||||
const dry = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
const wet = context.createGain();
|
||||
wet.gain.value = values.mix;
|
||||
wet.connect(output);
|
||||
const bands = [];
|
||||
for (const mode of values.modes ?? []) {
|
||||
const band = context.createBiquadFilter();
|
||||
band.type = 'bandpass';
|
||||
band.frequency.value = mode.frequency;
|
||||
band.Q.value = Math.max(1, (mode.frequency * (mode.decay / 1000)) / 3);
|
||||
const level = context.createGain();
|
||||
level.gain.value = mode.gain;
|
||||
input.connect(band).connect(level).connect(wet);
|
||||
bands.push(band, level);
|
||||
}
|
||||
entry = { input, output, params: {} };
|
||||
disposers.push(() => [input, output, dry, wet, ...bands].forEach((item) => item.disconnect()));
|
||||
}
|
||||
|
||||
if (entry) created.set(path, entry);
|
||||
}
|
||||
|
||||
for (const route of plan.routes) {
|
||||
const source = created.get(route.from);
|
||||
if (!source?.output) continue;
|
||||
if (route.kind === 'audio') {
|
||||
const target = created.get(route.to);
|
||||
if (target?.input) source.output.connect(target.input);
|
||||
continue;
|
||||
}
|
||||
const target = created.get(route.to);
|
||||
const param = target?.params?.[route.property];
|
||||
if (!param) continue;
|
||||
const depth = context.createGain();
|
||||
depth.gain.value = route.property === 'time' ? route.depth / 1000 : route.depth;
|
||||
source.output.connect(depth).connect(param);
|
||||
disposers.push(() => depth.disconnect());
|
||||
}
|
||||
|
||||
for (const start of starters) start();
|
||||
|
||||
return {
|
||||
sink,
|
||||
dispose() {
|
||||
for (const release of disposers.reverse()) {
|
||||
try { release(); } catch { /* disposal is best effort */ }
|
||||
}
|
||||
sink.disconnect();
|
||||
created.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Runtime subsystem
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
// Owns the AudioContext, the declared buses, and the engine master chain, and turns a
|
||||
// sound definition into a realized voice. The lifecycle state machine (PRD 57), voice
|
||||
// ceilings, and the measured master-protection contract (PRD 58) are Phase 3c; the master
|
||||
// chain built here is engine-owned and unbypassable but its ceiling is not yet verified.
|
||||
export class AudioSubsystem {
|
||||
constructor({ document, rng, diagnostics = null, contextFactory = null } = {}) {
|
||||
this.document = document;
|
||||
this.rng = rng;
|
||||
this.diagnostics = diagnostics;
|
||||
this.hasCustomContext = Boolean(contextFactory);
|
||||
this.contextFactory = contextFactory ?? (() => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)());
|
||||
this.context = null;
|
||||
this.master = null;
|
||||
this.protection = null;
|
||||
this.buses = new Map();
|
||||
this.voices = new Set();
|
||||
this.ordinals = new Map();
|
||||
this.masterVolume = 0.8;
|
||||
}
|
||||
|
||||
get available() {
|
||||
return this.hasCustomContext || typeof (globalThis.AudioContext ?? globalThis.webkitAudioContext) === 'function';
|
||||
}
|
||||
|
||||
get unlocked() {
|
||||
return this.context !== null && this.context.state === 'running';
|
||||
}
|
||||
|
||||
// Must be called from a user gesture; browsers refuse to start audio otherwise.
|
||||
async unlock() {
|
||||
if (!this.available) {
|
||||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', 'This browser exposes no AudioContext; audio is disabled.', { section: 'audio' });
|
||||
return false;
|
||||
}
|
||||
if (!this.context) {
|
||||
this.context = this.contextFactory();
|
||||
this.buildMaster();
|
||||
this.buildBuses();
|
||||
}
|
||||
if (this.context.state === 'suspended') await this.context.resume();
|
||||
return this.unlocked;
|
||||
}
|
||||
|
||||
buildMaster() {
|
||||
const context = this.context;
|
||||
this.protection = context.createDynamicsCompressor();
|
||||
this.protection.threshold.value = -3;
|
||||
this.protection.knee.value = 0;
|
||||
this.protection.ratio.value = 20;
|
||||
this.protection.attack.value = 0.003;
|
||||
this.protection.release.value = 0.25;
|
||||
this.master = context.createGain();
|
||||
this.master.gain.value = this.masterVolume;
|
||||
this.protection.connect(this.master).connect(context.destination);
|
||||
}
|
||||
|
||||
buildBuses() {
|
||||
const declared = this.document?.audio?.buses ?? {};
|
||||
for (const [id, bus] of Object.entries(declared)) {
|
||||
const gain = this.context.createGain();
|
||||
gain.gain.value = typeof bus.gain === 'number' ? bus.gain : 1;
|
||||
gain.connect(this.protection);
|
||||
this.buses.set(id, gain);
|
||||
}
|
||||
}
|
||||
|
||||
busFor(soundId) {
|
||||
const name = this.document?.sounds?.[soundId]?.bus;
|
||||
return (name && this.buses.get(name)) || this.protection;
|
||||
}
|
||||
|
||||
setBusGain(id, value) {
|
||||
const bus = this.buses.get(id);
|
||||
if (bus) bus.gain.value = Math.min(4, Math.max(0, value));
|
||||
}
|
||||
|
||||
setMasterVolume(value) {
|
||||
this.masterVolume = Math.min(1, Math.max(0, value));
|
||||
if (this.master) this.master.gain.value = this.masterVolume;
|
||||
}
|
||||
|
||||
nextOrdinal(soundId) {
|
||||
const next = (this.ordinals.get(soundId) ?? -1) + 1;
|
||||
this.ordinals.set(soundId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
play(soundId, { resolveReference } = {}) {
|
||||
if (!this.unlocked) return null;
|
||||
const plan = instantiateSoundGraph(this.document, soundId, {
|
||||
sampleRate: this.context.sampleRate,
|
||||
rng: this.rng,
|
||||
ordinal: this.nextOrdinal(soundId),
|
||||
resolveReference
|
||||
});
|
||||
for (const error of plan.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
|
||||
if (plan.errors.length > 0) return null;
|
||||
for (const warning of plan.warnings) this.diagnostics?.warn(warning.code, warning.message, { section: 'audio', objectId: soundId, property: warning.path });
|
||||
let voice;
|
||||
try {
|
||||
voice = realizeSoundGraph(this.context, plan, this.busFor(soundId));
|
||||
} catch (error) {
|
||||
this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
|
||||
return null;
|
||||
}
|
||||
const handle = {
|
||||
soundId,
|
||||
mode: plan.mode,
|
||||
stop: () => {
|
||||
if (!this.voices.has(handle)) return;
|
||||
this.voices.delete(handle);
|
||||
voice.dispose();
|
||||
}
|
||||
};
|
||||
this.voices.add(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
stopAll() {
|
||||
for (const handle of [...this.voices]) handle.stop();
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.stopAll();
|
||||
this.buses.clear();
|
||||
if (this.context) {
|
||||
try { await this.context.close(); } catch { /* already closed */ }
|
||||
}
|
||||
this.context = null;
|
||||
this.master = null;
|
||||
this.protection = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
// Audio graph validation, component expansion, legality checking, and deterministic
|
||||
// instantiation. Pure: never touches an AudioContext (Format Specification 14.5).
|
||||
|
||||
import {
|
||||
AUDIO_BUS_FIELDS,
|
||||
AUDIO_BUS_GAIN_RANGE,
|
||||
AUDIO_COMPONENT_FIELDS,
|
||||
AUDIO_COMPONENT_PARAMETER_FIELDS,
|
||||
AUDIO_GRAPH_FIELDS,
|
||||
AUDIO_LIMITS,
|
||||
AUDIO_MODE_FIELDS,
|
||||
AUDIO_MODULATABLE,
|
||||
AUDIO_MODULATION_SOURCE_TYPES,
|
||||
AUDIO_NODE_TYPES,
|
||||
AUDIO_PARTIAL_FIELDS,
|
||||
AUDIO_RECIPE_FIELDS,
|
||||
AUDIO_RECIPE_MODES,
|
||||
AUDIO_ROUTE_FIELDS,
|
||||
AUDIO_SOUND_FIELDS,
|
||||
AUDIO_SOUND_USAGE,
|
||||
AUDIO_STATIC_MAX_FREQUENCY,
|
||||
audioMaxFrequency
|
||||
} from './audio-contract.js';
|
||||
import { ID_PATTERN } from './constants.js';
|
||||
import { DURATION_PATTERN } from './types.js';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export 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` };
|
||||
}
|
||||
|
||||
export function expandSoundGraph(document, soundId) {
|
||||
const errors = [];
|
||||
const sound = document.sounds?.[soundId];
|
||||
const soundPath = `$.sounds.${soundId}`;
|
||||
const located = recipeGraphFor(document, sound, soundPath, errors);
|
||||
if (!located) return { nodes: [], routes: [], errors, mode: 'oneshot' };
|
||||
|
||||
const nodes = new Map();
|
||||
const routes = [];
|
||||
const components = document.components?.audio ?? {};
|
||||
|
||||
const join = (prefix, key) => (prefix ? `${prefix}.${key}` : key);
|
||||
|
||||
function expand(graph, prefix, depth, trail, graphPath) {
|
||||
if (depth > AUDIO_LIMITS.componentDepth) {
|
||||
fail(errors, 'ERR_COMPONENT_RECURSION', graphPath, `Component nesting exceeds ${AUDIO_LIMITS.componentDepth} levels.`);
|
||||
return;
|
||||
}
|
||||
const declared = isObject(graph.nodes) ? graph.nodes : {};
|
||||
for (const [key, node] of Object.entries(declared)) {
|
||||
if (!isObject(node)) continue;
|
||||
const path = join(prefix, key);
|
||||
if (node.type === 'component') {
|
||||
const component = components[node.use];
|
||||
if (!isObject(component)) continue;
|
||||
if (trail.includes(node.use)) {
|
||||
fail(errors, 'ERR_COMPONENT_RECURSION', `${graphPath}.nodes.${key}`, `Component '${node.use}' instantiates itself.`);
|
||||
continue;
|
||||
}
|
||||
nodes.set(`${path}.output`, { path: `${path}.output`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
|
||||
if (component.input === true) {
|
||||
nodes.set(`${path}.input`, { path: `${path}.input`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
|
||||
}
|
||||
nodes.set(path, {
|
||||
path, type: 'component', spec: node, implicit: false, scope: prefix || null,
|
||||
component: node.use, acceptsAudio: component.input === true, exposes: component.parameters ?? {}
|
||||
});
|
||||
expand(component, path, depth + 1, [...trail, node.use], `$.components.audio.${node.use}`);
|
||||
} else {
|
||||
nodes.set(path, { path, type: node.type, spec: node, implicit: false, scope: prefix || null });
|
||||
}
|
||||
}
|
||||
|
||||
const declaresInput = prefix ? graph.input === true : false;
|
||||
const resolve = (name, side, routePath) => {
|
||||
if (typeof name !== 'string') return null;
|
||||
if (name === 'output') return prefix ? `${prefix}.output` : 'output';
|
||||
if (name === 'input') {
|
||||
if (!declaresInput) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'input' is only available inside a component declaring input: true.");
|
||||
return null;
|
||||
}
|
||||
return `${prefix}.input`;
|
||||
}
|
||||
const head = name.split('.')[0];
|
||||
const local = declared[head];
|
||||
if (!isObject(local)) {
|
||||
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Route endpoint '${name}' does not resolve in this graph.`);
|
||||
return null;
|
||||
}
|
||||
const localPath = join(prefix, head);
|
||||
const property = name.includes('.') ? name.slice(head.length + 1) : null;
|
||||
if (local.type !== 'component') {
|
||||
if (side === 'from' && property !== null) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Route source '${name}' names a property; only nodes emit signal.`);
|
||||
return null;
|
||||
}
|
||||
return property === null ? localPath : `${localPath}::${property}`;
|
||||
}
|
||||
if (side === 'from') {
|
||||
if (property !== null) {
|
||||
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Component internals are encapsulated; '${name}' is not reachable.`);
|
||||
return null;
|
||||
}
|
||||
return `${localPath}.output`;
|
||||
}
|
||||
if (property !== null) return `${localPath}::${property}`;
|
||||
const component = components[local.use];
|
||||
if (component?.input !== true) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Component '${local.use}' declares no audio input.`);
|
||||
return null;
|
||||
}
|
||||
return `${localPath}.input`;
|
||||
};
|
||||
|
||||
const declaredRoutes = Array.isArray(graph.routes) ? graph.routes : [];
|
||||
declaredRoutes.forEach((route, index) => {
|
||||
if (!isObject(route)) return;
|
||||
const routePath = `${graphPath}.routes[${index}]`;
|
||||
const from = resolve(route.from, 'from', routePath);
|
||||
if (from === null) return;
|
||||
if (from === 'output' || from.endsWith('.output') && route.from === 'output') {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'output' is sink-only and cannot be a route source.");
|
||||
return;
|
||||
}
|
||||
const isModulation = typeof route.to === 'string' && route.to.includes('.');
|
||||
const to = resolve(route.to, 'to', routePath);
|
||||
if (to === null) return;
|
||||
if (isModulation) {
|
||||
const [target, property] = to.includes('::') ? to.split('::') : [to, null];
|
||||
if (property === null) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Modulation target '${route.to}' does not name a node property.`);
|
||||
return;
|
||||
}
|
||||
routes.push({ kind: 'modulation', from, to: target, property, depth: route.depth, path: routePath });
|
||||
} else {
|
||||
if (to.includes('::')) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Audio route target '${route.to}' names a property.`);
|
||||
return;
|
||||
}
|
||||
routes.push({ kind: 'audio', from, to, path: routePath });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
expand(located.graph, '', 1, [], located.path);
|
||||
const mode = AUDIO_RECIPE_MODES.includes(located.graph.mode) ? located.graph.mode : 'oneshot';
|
||||
return { nodes: [...nodes.values()], routes, errors, mode, graphPath: located.path };
|
||||
}
|
||||
|
||||
function detectCycle(adjacency) {
|
||||
const visiting = new Set();
|
||||
const done = new Set();
|
||||
let cycle = null;
|
||||
const visit = (node, trail) => {
|
||||
if (cycle) return;
|
||||
if (visiting.has(node)) { cycle = [...trail, node]; return; }
|
||||
if (done.has(node)) return;
|
||||
visiting.add(node);
|
||||
for (const next of adjacency.get(node) ?? []) visit(next, [...trail, node]);
|
||||
visiting.delete(node);
|
||||
done.add(node);
|
||||
};
|
||||
for (const node of adjacency.keys()) visit(node, []);
|
||||
return cycle;
|
||||
}
|
||||
|
||||
export function checkGraphLegality(document, soundId, expansion, errors) {
|
||||
const { nodes, routes, graphPath } = expansion;
|
||||
const byPath = new Map(nodes.map((node) => [node.path, node]));
|
||||
const audible = new Map();
|
||||
const combined = new Map();
|
||||
const link = (map, from, to) => {
|
||||
if (!map.has(from)) map.set(from, []);
|
||||
map.get(from).push(to);
|
||||
};
|
||||
|
||||
const authored = nodes.filter((node) => !node.implicit && node.type !== 'component');
|
||||
if (authored.length > AUDIO_LIMITS.nodesPerSound) {
|
||||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${authored.length} nodes; the limit is ${AUDIO_LIMITS.nodesPerSound}.`);
|
||||
}
|
||||
if (routes.length > AUDIO_LIMITS.routesPerSound) {
|
||||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${routes.length} routes; the limit is ${AUDIO_LIMITS.routesPerSound}.`);
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.from === route.to) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', route.path, 'A route cannot connect a node to itself.');
|
||||
continue;
|
||||
}
|
||||
if (route.kind === 'audio') {
|
||||
const target = byPath.get(route.to);
|
||||
if (route.to !== 'output') {
|
||||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Route target '${route.to}' does not resolve.`); continue; }
|
||||
const contract = AUDIO_NODE_TYPES[target.type];
|
||||
const accepts = target.implicit || (target.type === 'component' ? target.acceptsAudio : contract?.acceptsAudio === true);
|
||||
if (!accepts) { fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node '${route.to}' does not accept audio input.`); continue; }
|
||||
}
|
||||
link(audible, route.from, route.to);
|
||||
link(combined, route.from, route.to);
|
||||
continue;
|
||||
}
|
||||
const target = byPath.get(route.to);
|
||||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation target '${route.to}' does not resolve.`); continue; }
|
||||
const source = byPath.get(route.from);
|
||||
if (!source) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation source '${route.from}' does not resolve.`); continue; }
|
||||
const sourceType = source.implicit ? 'gain' : source.type;
|
||||
if (!AUDIO_MODULATION_SOURCE_TYPES.includes(sourceType)) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node type '${sourceType}' cannot drive a modulation route.`);
|
||||
continue;
|
||||
}
|
||||
const permitted = target.type === 'component'
|
||||
? Object.hasOwn(target.exposes ?? {}, route.property)
|
||||
: Object.hasOwn(AUDIO_MODULATABLE[target.type] ?? {}, route.property);
|
||||
if (!permitted) {
|
||||
fail(errors, 'ERR_UNSUPPORTED_TARGET', route.path, `'${target.type}.${route.property}' is not a modulatable property.`);
|
||||
continue;
|
||||
}
|
||||
link(combined, route.from, route.to);
|
||||
}
|
||||
|
||||
const audioCycle = detectCycle(audible);
|
||||
if (audioCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Audio route cycle: ${audioCycle.join(' -> ')}.`);
|
||||
const combinedCycle = detectCycle(combined);
|
||||
if (!audioCycle && combinedCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Modulation dependency cycle: ${combinedCycle.join(' -> ')}.`);
|
||||
|
||||
if (audioCycle) return;
|
||||
const reaches = (start) => {
|
||||
const seen = new Set();
|
||||
const queue = [start];
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
if (current === 'output') return true;
|
||||
if (seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
for (const next of audible.get(current) ?? []) queue.push(next);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let audiblePath = false;
|
||||
for (const node of nodes) {
|
||||
if (node.implicit || node.type === 'component') continue;
|
||||
const contract = AUDIO_NODE_TYPES[node.type];
|
||||
if (!contract) continue;
|
||||
if (contract.acceptsAudio === false && reaches(node.path)) {
|
||||
if (contract.class === 'control') {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', node.path, `Control source '${node.path}' reaches audible output.`);
|
||||
} else {
|
||||
audiblePath = true;
|
||||
}
|
||||
}
|
||||
if (contract.acceptsAudio === false) {
|
||||
for (const route of routes) {
|
||||
if (route.kind === 'audio' && route.to === node.path) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Source node '${node.path}' cannot receive audio input.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!audiblePath) fail(errors, 'ERR_NO_AUDIBLE_PATH', graphPath, `Sound '${soundId}' has no audio path from a source to output.`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Document-level entry point
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function validateAudioSubsystem(document, errors, helpers) {
|
||||
const audio = document.audio;
|
||||
if (audio !== undefined) {
|
||||
if (!isObject(audio)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio', 'audio must be an object.');
|
||||
else {
|
||||
for (const field of Object.keys(audio)) {
|
||||
if (field === 'master') fail(errors, 'ERR_UNKNOWN_FIELD', '$.audio.master', 'audio.master is engine-provided and is reserved for the Phase 3c master-protection contract.');
|
||||
else if (!['buses', 'recipes'].includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `$.audio.${field}`, `Unrecognized audio field '${field}'.`);
|
||||
}
|
||||
if (audio.buses !== undefined) {
|
||||
if (!isObject(audio.buses)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.buses', 'buses must be an object.');
|
||||
else for (const [id, bus] of Object.entries(audio.buses)) {
|
||||
const path = `$.audio.buses.${id}`;
|
||||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Bus ID '${id}' is not a valid identifier.`);
|
||||
if (id === 'master') fail(errors, 'ERR_INVALID_ID', path, "'master' is engine-provided and cannot be declared.");
|
||||
if (!isObject(bus)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Bus must be an object.'); continue; }
|
||||
for (const field of Object.keys(bus)) if (!AUDIO_BUS_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized bus field '${field}'.`);
|
||||
if (Object.hasOwn(bus, 'gain')) {
|
||||
validateNumericField(document, bus.gain, { min: AUDIO_BUS_GAIN_RANGE.min, max: AUDIO_BUS_GAIN_RANGE.max, valuespec: true }, `${path}.gain`, errors, helpers, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (audio.recipes !== undefined) {
|
||||
if (!isObject(audio.recipes)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.recipes', 'recipes must be an object.');
|
||||
else for (const [id, recipe] of Object.entries(audio.recipes)) {
|
||||
const path = `$.audio.recipes.${id}`;
|
||||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Recipe ID '${id}' is not a valid identifier.`);
|
||||
validateGraphObject(document, recipe, path, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||||
if (Object.hasOwn(recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(recipe.mode)) {
|
||||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentRoot = document.components?.audio;
|
||||
if (componentRoot !== undefined) {
|
||||
if (!isObject(componentRoot)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.components.audio', 'components.audio must be an object.');
|
||||
else for (const [id, component] of Object.entries(componentRoot)) {
|
||||
const path = `$.components.audio.${id}`;
|
||||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Component ID '${id}' is not a valid identifier.`);
|
||||
if (!isObject(component)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Component must be an object.'); continue; }
|
||||
const exposed = new Set();
|
||||
if (component.parameters !== undefined) {
|
||||
if (!isObject(component.parameters)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.parameters`, 'parameters must be an object.');
|
||||
else for (const [name, rule] of Object.entries(component.parameters)) {
|
||||
const rulePath = `${path}.parameters.${name}`;
|
||||
if (!ID_PATTERN.test(name)) fail(errors, 'ERR_INVALID_ID', rulePath, `Exposed parameter '${name}' is not a valid identifier.`);
|
||||
if (!isObject(rule)) { fail(errors, 'ERR_SCHEMA_VALIDATION', rulePath, 'Exposed parameter must be an object.'); continue; }
|
||||
for (const field of Object.keys(rule)) if (!AUDIO_COMPONENT_PARAMETER_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${rulePath}.${field}`, `Unrecognized parameter field '${field}'.`);
|
||||
if (rule.type !== 'number') fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.type`, "Exposed audio parameters must declare type 'number' in 0.1.");
|
||||
for (const field of ['default', 'min', 'max']) {
|
||||
if (Object.hasOwn(rule, field) && !Number.isFinite(rule[field])) fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.${field}`, `${field} must be a finite number.`);
|
||||
}
|
||||
if (Number.isFinite(rule.min) && Number.isFinite(rule.max) && rule.min > rule.max) fail(errors, 'ERR_OUT_OF_BOUNDS', `${rulePath}.min`, 'min cannot exceed max.');
|
||||
exposed.add(name);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(component, 'input') && typeof component.input !== 'boolean') {
|
||||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.input`, 'input must be a boolean.');
|
||||
}
|
||||
validateGraphObject(document, component, path, errors, helpers, {
|
||||
allowedFields: AUDIO_COMPONENT_FIELDS,
|
||||
scope: { componentParameters: exposed }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sounds = document.sounds;
|
||||
if (sounds === undefined) return;
|
||||
if (!isObject(sounds)) return fail(errors, 'ERR_SCHEMA_VALIDATION', '$.sounds', 'sounds must be an object.');
|
||||
for (const [id, sound] of Object.entries(sounds)) {
|
||||
const path = `$.sounds.${id}`;
|
||||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Sound ID '${id}' is not a valid identifier.`);
|
||||
if (!isObject(sound)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Sound must be an object.'); continue; }
|
||||
for (const field of Object.keys(sound)) if (!AUDIO_SOUND_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized sound field '${field}'.`);
|
||||
if (typeof sound.name !== 'string' || sound.name.trim().length === 0 || sound.name.length > 128) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.name`, 'Sound name must be a non-empty string of at most 128 characters.');
|
||||
}
|
||||
if (sound.tags !== undefined && (!Array.isArray(sound.tags) || sound.tags.length > 16 || sound.tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) {
|
||||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.tags`, 'Tags must contain at most 16 strings of at most 32 characters.');
|
||||
}
|
||||
if (sound.usage !== undefined && (!Array.isArray(sound.usage) || sound.usage.length === 0 || sound.usage.some((entry) => !AUDIO_SOUND_USAGE.includes(entry)))) {
|
||||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.usage`, `usage entries must be among: ${AUDIO_SOUND_USAGE.join(', ')}.`);
|
||||
}
|
||||
if (sound.cadence !== undefined && !isObject(sound.cadence)) {
|
||||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.cadence`, 'cadence must be an object.');
|
||||
}
|
||||
if (sound.bus !== undefined && !isObject(document.audio?.buses?.[sound.bus])) {
|
||||
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.bus`, `Bus '${sound.bus}' is not declared.`);
|
||||
}
|
||||
if (isObject(sound.recipe) && !Object.hasOwn(sound.recipe, 'use')) {
|
||||
validateGraphObject(document, sound.recipe, `${path}.recipe`, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||||
if (Object.hasOwn(sound.recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(sound.recipe.mode)) {
|
||||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.recipe.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
const expansion = expandSoundGraph(document, id);
|
||||
errors.push(...expansion.errors);
|
||||
if (expansion.nodes.length > 0) checkGraphLegality(document, id, expansion, errors);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const XZBT_FORMAT_VERSION = '0.1';
|
||||
export const XZBT_RUNTIME_VERSION = '0.1.0-phase2';
|
||||
export const XZBT_RUNTIME_VERSION = '0.1.0-phase3b';
|
||||
export const UINT32_RANGE = 0x1_0000_0000;
|
||||
export const RNG_DOMAINS = Object.freeze([
|
||||
'cadence',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateAudioSubsystem } from './audio-graph.js';
|
||||
import {
|
||||
ALLOWED_META_FIELDS,
|
||||
ALLOWED_TOP_LEVEL_FIELDS,
|
||||
@@ -87,6 +88,7 @@ export function validateExhibit(document, filename = 'document.xzbt') {
|
||||
|
||||
validateDefinitions(document, errors);
|
||||
validateBindings(document, errors);
|
||||
validateAudioSubsystem(document, errors, { validateValueSpec, pushError });
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings: [], filename };
|
||||
}
|
||||
@@ -136,9 +138,10 @@ function validateDefinitionMap(definitions, namespace, allowedTypes, valueField,
|
||||
}
|
||||
}
|
||||
|
||||
function referenceType(document, path) {
|
||||
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;
|
||||
@@ -147,11 +150,11 @@ function referenceType(document, path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateReference(document, path, location, errors) {
|
||||
if (typeof path !== 'string' || !/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/.test(path) || !referenceType(document, path)) pushError(errors, 'ERR_INVALID_REFERENCE', location, `Reference '${path}' does not resolve.`);
|
||||
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) {
|
||||
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;
|
||||
@@ -168,7 +171,7 @@ function validateValueSpec(document, spec, path, errors) {
|
||||
}
|
||||
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);
|
||||
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.');
|
||||
@@ -183,7 +186,7 @@ function validateValueSpec(document, spec, path, errors) {
|
||||
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);
|
||||
else validateValueSpec(document, option.value, `${path}.choose[${index}].value`, errors, scope);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -191,7 +194,7 @@ function validateValueSpec(document, spec, path, errors) {
|
||||
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));
|
||||
spec.args.forEach((argument, index) => validateValueSpec(document, argument, `${path}.args[${index}]`, errors, scope));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,3 +77,37 @@ main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-heigh
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
AUDIO_LIMITS,
|
||||
AUDIO_NODE_TYPE_NAMES,
|
||||
AUDIO_STATIC_MAX_FREQUENCY,
|
||||
audioMaxFrequency
|
||||
} from '../src/runtime/audio-contract.js';
|
||||
import { expandSoundGraph } from '../src/runtime/audio-graph.js';
|
||||
import { instantiateSoundGraph, sampleHoldStreamKey } from '../src/runtime/audio-engine.js';
|
||||
import { SeededRNG } from '../src/runtime/rng.js';
|
||||
import { validateExhibit } from '../src/runtime/validator.js';
|
||||
|
||||
function exhibit(overrides = {}) {
|
||||
return {
|
||||
xzbt: '0.1',
|
||||
meta: { id: 'audio-study', name: 'Audio Study' },
|
||||
runtime: { seed: 42 },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// A minimal audible graph: `extra` nodes and routes are merged in around a working tone.
|
||||
function soundWith(nodes = {}, routes = [], soundOverrides = {}, documentOverrides = {}) {
|
||||
return exhibit({
|
||||
audio: { buses: { ambient: { gain: 1 } } },
|
||||
sounds: {
|
||||
probe: {
|
||||
name: 'Probe',
|
||||
bus: 'ambient',
|
||||
recipe: {
|
||||
nodes: { tone: { type: 'oscillator' }, ...nodes },
|
||||
routes: [{ from: 'tone', to: 'output' }, ...routes]
|
||||
},
|
||||
...soundOverrides
|
||||
}
|
||||
},
|
||||
...documentOverrides
|
||||
});
|
||||
}
|
||||
|
||||
const codes = (document) => validateExhibit(document).errors.map((error) => error.code);
|
||||
const clean = (document) => {
|
||||
const result = validateExhibit(document);
|
||||
assert.deepEqual(result.errors, [], JSON.stringify(result.errors, null, 2));
|
||||
};
|
||||
|
||||
/* --- 14.13 trace 1: every node type's minimal example validates --------------- */
|
||||
|
||||
test('every documented minimal node example validates inside a complete graph', () => {
|
||||
const minimal = {
|
||||
oscillator: { type: 'oscillator' },
|
||||
noise: { type: 'noise' },
|
||||
impulse: { type: 'impulse' },
|
||||
constant: { type: 'constant' },
|
||||
lfo: { type: 'lfo' },
|
||||
'sample-hold': { type: 'sample-hold' },
|
||||
gain: { type: 'gain' },
|
||||
filter: { type: 'filter' },
|
||||
compressor: { type: 'compressor' },
|
||||
waveshaper: { type: 'waveshaper' },
|
||||
delay: { type: 'delay' },
|
||||
reverb: { type: 'reverb' },
|
||||
'stereo-pan': { type: 'stereo-pan' },
|
||||
mixer: { type: 'mixer' },
|
||||
resonator: { type: 'resonator', modes: [{ ratio: 1 }] }
|
||||
};
|
||||
// `component` is exercised separately; every other type in the 0.1 set is covered here.
|
||||
assert.equal(Object.keys(minimal).length + 1, AUDIO_NODE_TYPE_NAMES.length);
|
||||
for (const [type, node] of Object.entries(minimal)) {
|
||||
const routes = ['constant', 'lfo', 'sample-hold'].includes(type) ? [] : [{ from: 'probe', to: 'output' }];
|
||||
clean(soundWith({ probe: node }, routes));
|
||||
}
|
||||
});
|
||||
|
||||
test('documented invalid cases emit exactly their documented code', () => {
|
||||
const cases = [
|
||||
[{ type: 'oscillator', waveform: 'sine', harmonics: [] }, 'ERR_UNKNOWN_FIELD'],
|
||||
[{ type: 'oscillator', waveform: 'custom' }, 'ERR_SCHEMA_VALIDATION'],
|
||||
[{ type: 'noise', color: 'grey' }, 'ERR_TYPE_MISMATCH'],
|
||||
[{ type: 'impulse', duration: '1s' }, 'ERR_OUT_OF_BOUNDS'],
|
||||
[{ type: 'constant', value: 5000 }, 'ERR_OUT_OF_BOUNDS'],
|
||||
[{ type: 'lfo', waveform: 'custom' }, 'ERR_TYPE_MISMATCH'],
|
||||
[{ type: 'sample-hold', min: 1, max: -1 }, 'ERR_INVALID_RANGE_ORDER'],
|
||||
[{ type: 'gain', gain: 8 }, 'ERR_OUT_OF_BOUNDS'],
|
||||
[{ type: 'filter', mode: 'comb' }, 'ERR_TYPE_MISMATCH'],
|
||||
[{ type: 'compressor', ratio: 40 }, 'ERR_OUT_OF_BOUNDS'],
|
||||
[{ type: 'waveshaper', oversample: '8x' }, 'ERR_TYPE_MISMATCH'],
|
||||
[{ type: 'delay', feedback: 1 }, 'ERR_OUT_OF_BOUNDS'],
|
||||
[{ type: 'reverb', decay: '60s' }, 'ERR_OUT_OF_BOUNDS'],
|
||||
[{ type: 'stereo-pan', pan: 2 }, 'ERR_OUT_OF_BOUNDS'],
|
||||
[{ type: 'resonator', modes: [{ ratio: 1, frequency: 200 }] }, 'ERR_SCHEMA_VALIDATION'],
|
||||
[{ type: 'chorus' }, 'ERR_INVALID_NODE_TYPE']
|
||||
];
|
||||
for (const [node, code] of cases) {
|
||||
assert.ok(codes(soundWith({ probe: node })).includes(code), `${node.type}: expected ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
/* --- 14.13 traces 2, 5: identity model and external targeting ---------------- */
|
||||
|
||||
test('a node carrying an id field is rejected and output is a reserved key', () => {
|
||||
assert.ok(codes(soundWith({ probe: { type: 'gain', id: 'probe' } })).includes('ERR_UNKNOWN_FIELD'));
|
||||
assert.ok(codes(soundWith({ output: { type: 'gain' } })).includes('ERR_INVALID_ID'));
|
||||
});
|
||||
|
||||
test('an external binding to a node field is unsupported while bus gain remains supported', () => {
|
||||
const unsupported = soundWith({}, [], {}, {
|
||||
parameters: { level: { type: 'number', default: 0.5, min: 0, max: 1 } },
|
||||
bindings: [{ source: 'parameters.level', target: 'sounds.probe.recipe.nodes.tone.frequency' }]
|
||||
});
|
||||
const reported = codes(unsupported);
|
||||
assert.ok(reported.includes('ERR_UNSUPPORTED_TARGET') || reported.includes('ERR_INVALID_REFERENCE'));
|
||||
clean(soundWith({}, [], {}, {
|
||||
parameters: { level: { type: 'number', default: 0.5, min: 0, max: 4 } },
|
||||
bindings: [{ source: 'parameters.level', target: 'audio.buses.ambient.gain' }]
|
||||
}));
|
||||
});
|
||||
|
||||
/* --- 14.13 trace 3 and 15.19 trace 7: frequency staging ---------------------- */
|
||||
|
||||
test('semantic validation uses the static ceiling and never needs a sample rate', () => {
|
||||
assert.equal(typeof globalThis.AudioContext, 'undefined');
|
||||
assert.ok(codes(soundWith({ probe: { type: 'oscillator', frequency: 30000 } })).includes('ERR_OUT_OF_BOUNDS'));
|
||||
clean(soundWith({ probe: { type: 'oscillator', frequency: AUDIO_STATIC_MAX_FREQUENCY } }, [{ from: 'probe', to: 'output' }]));
|
||||
});
|
||||
|
||||
test('instantiation clamps to the live device ceiling and warns instead of failing', () => {
|
||||
assert.equal(audioMaxFrequency(44100), 19845);
|
||||
assert.equal(audioMaxFrequency(96000), AUDIO_STATIC_MAX_FREQUENCY);
|
||||
const document = soundWith({}, []);
|
||||
document.sounds.probe.recipe.nodes.tone.frequency = 22000;
|
||||
const plan = instantiateSoundGraph(document, 'probe', { sampleRate: 44100, rng: new SeededRNG(42) });
|
||||
assert.deepEqual(plan.errors, []);
|
||||
assert.equal(plan.nodes.find((node) => node.path === 'tone').values.frequency, 19845);
|
||||
assert.equal(plan.warnings.length, 1);
|
||||
assert.equal(plan.warnings[0].code, 'WARN_AUDIO_RATE_CLAMP');
|
||||
const wide = instantiateSoundGraph(document, 'probe', { sampleRate: 96000, rng: new SeededRNG(42) });
|
||||
assert.equal(wide.warnings.length, 0);
|
||||
assert.equal(wide.nodes.find((node) => node.path === 'tone').values.frequency, 22000);
|
||||
});
|
||||
|
||||
test('custom partials above the device ceiling are omitted rather than aliased', () => {
|
||||
const document = soundWith({}, []);
|
||||
document.sounds.probe.recipe.nodes.tone = {
|
||||
type: 'oscillator', waveform: 'custom', frequency: 5000,
|
||||
harmonics: [{ ratio: 1, gain: 1 }, { ratio: 2, gain: 0.5 }, { ratio: 8, gain: 0.2 }]
|
||||
};
|
||||
clean(document);
|
||||
const plan = instantiateSoundGraph(document, 'probe', { sampleRate: 44100, rng: new SeededRNG(42) });
|
||||
assert.deepEqual(plan.nodes.find((node) => node.path === 'tone').values.harmonics.map((partial) => partial.ratio), [1, 2]);
|
||||
});
|
||||
|
||||
/* --- 14.13 trace 4: resolve-once semantics ----------------------------------- */
|
||||
|
||||
test('a node-field ValueSpec samples once and stays fixed for the node instance', () => {
|
||||
const document = soundWith({}, []);
|
||||
document.sounds.probe.recipe.nodes.tone.frequency = { random: { min: 220, max: 440 } };
|
||||
clean(document);
|
||||
const first = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42), ordinal: 0 });
|
||||
const again = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42), ordinal: 0 });
|
||||
const value = first.nodes.find((node) => node.path === 'tone').values.frequency;
|
||||
assert.equal(again.nodes.find((node) => node.path === 'tone').values.frequency, value);
|
||||
assert.ok(value >= 220 && value <= 440);
|
||||
const later = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42), ordinal: 1 });
|
||||
assert.notEqual(later.nodes.find((node) => node.path === 'tone').values.frequency, value);
|
||||
});
|
||||
|
||||
/* --- 14.13 trace 6: sample-hold stream derivation ---------------------------- */
|
||||
|
||||
test('sample-hold streams are seeded, keyed by node path, and reproducible', () => {
|
||||
const document = soundWith({ step: { type: 'sample-hold', rate: 4 } }, []);
|
||||
clean(document);
|
||||
const draw = (seed, ordinal) => {
|
||||
const plan = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(seed), ordinal });
|
||||
const node = plan.nodes.find((entry) => entry.path === 'step');
|
||||
assert.equal(node.values.streamKey, sampleHoldStreamKey(`probe#${ordinal}`, 'step'));
|
||||
return [node.values.stream.nextFloat(), node.values.stream.nextFloat(), node.values.stream.nextFloat()];
|
||||
};
|
||||
assert.deepEqual(draw(42, 0), draw(42, 0));
|
||||
assert.notDeepEqual(draw(42, 0), draw(42, 1));
|
||||
assert.notDeepEqual(draw(42, 0), draw(7, 0));
|
||||
|
||||
const renamed = soundWith({ tick: { type: 'sample-hold', rate: 4 } }, []);
|
||||
const renamedPlan = instantiateSoundGraph(renamed, 'probe', { rng: new SeededRNG(42), ordinal: 0 });
|
||||
const renamedNode = renamedPlan.nodes.find((entry) => entry.path === 'tick');
|
||||
assert.notDeepEqual([renamedNode.values.stream.nextFloat()], [draw(42, 0)[0]]);
|
||||
});
|
||||
|
||||
test('sample-hold slew is clamped to the tick period', () => {
|
||||
const document = soundWith({ step: { type: 'sample-hold', rate: 4, slew: '900ms' } }, []);
|
||||
clean(document);
|
||||
const plan = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42) });
|
||||
assert.equal(plan.nodes.find((node) => node.path === 'step').values.slew, 250);
|
||||
});
|
||||
|
||||
/* --- 15.19 trace 2: every legality rule ------------------------------------- */
|
||||
|
||||
test('graph legality rules each emit their documented diagnostic', () => {
|
||||
// Rule 4: output is sink-only.
|
||||
assert.ok(codes(soundWith({ level: { type: 'gain' } }, [{ from: 'output', to: 'level' }])).includes('ERR_INVALID_ROUTE'));
|
||||
// Rule 5: a control source cannot reach output.
|
||||
assert.ok(codes(soundWith({ wobble: { type: 'lfo' } }, [{ from: 'wobble', to: 'output' }])).includes('ERR_INVALID_ROUTE'));
|
||||
// Rule 6: a source cannot receive audio input.
|
||||
assert.ok(codes(soundWith({ hiss: { type: 'noise' } }, [{ from: 'tone', to: 'hiss' }])).includes('ERR_INVALID_ROUTE'));
|
||||
// Rule 3: endpoints must resolve.
|
||||
assert.ok(codes(soundWith({}, [{ from: 'tone', to: 'missing' }])).includes('ERR_INVALID_REFERENCE'));
|
||||
// Rule 7: the audio route graph is acyclic.
|
||||
const cyclic = soundWith({ a: { type: 'gain' }, b: { type: 'gain' } }, [
|
||||
{ from: 'a', to: 'b' }, { from: 'b', to: 'a' }
|
||||
]);
|
||||
assert.ok(codes(cyclic).includes('ERR_CYCLIC_DEPENDENCY'));
|
||||
// Rule 9: something audible must reach output.
|
||||
const silent = exhibit({
|
||||
sounds: { probe: { name: 'Probe', recipe: { nodes: { wobble: { type: 'lfo' }, level: { type: 'gain' } }, routes: [{ from: 'wobble', to: 'level.gain', depth: 0.5 }] } } }
|
||||
});
|
||||
assert.ok(codes(silent).includes('ERR_NO_AUDIBLE_PATH'));
|
||||
// A node routed to itself.
|
||||
assert.ok(codes(soundWith({ a: { type: 'gain' } }, [{ from: 'a', to: 'a' }])).includes('ERR_INVALID_ROUTE'));
|
||||
});
|
||||
|
||||
test('a control-only path fails while the same shape with an audible source passes', () => {
|
||||
const controlOnly = exhibit({
|
||||
sounds: { probe: { name: 'Probe', recipe: { nodes: { bias: { type: 'constant' }, level: { type: 'gain' } }, routes: [{ from: 'bias', to: 'level' }, { from: 'level', to: 'output' }] } } }
|
||||
});
|
||||
assert.ok(codes(controlOnly).includes('ERR_INVALID_ROUTE'));
|
||||
clean(exhibit({
|
||||
sounds: { probe: { name: 'Probe', recipe: { nodes: { tone: { type: 'oscillator' }, level: { type: 'gain' } }, routes: [{ from: 'tone', to: 'level' }, { from: 'level', to: 'output' }] } } }
|
||||
}));
|
||||
});
|
||||
|
||||
/* --- 15.19 traces 3, 5: modulation ------------------------------------------ */
|
||||
|
||||
test('modulation targets follow the registry and depth is required exactly there', () => {
|
||||
clean(soundWith({ wobble: { type: 'lfo', frequency: 3 } }, [{ from: 'wobble', to: 'tone.frequency', depth: 18 }]));
|
||||
assert.ok(codes(soundWith({ wobble: { type: 'lfo' }, verb: { type: 'reverb' } }, [
|
||||
{ from: 'tone', to: 'verb' }, { from: 'verb', to: 'output' }, { from: 'wobble', to: 'verb.mix', depth: 0.2 }
|
||||
])).includes('ERR_UNSUPPORTED_TARGET'));
|
||||
assert.ok(codes(soundWith({ wobble: { type: 'lfo' } }, [{ from: 'wobble', to: 'tone.frequency' }])).includes('ERR_SCHEMA_VALIDATION'));
|
||||
assert.ok(codes(soundWith({ level: { type: 'gain' } }, [{ from: 'tone', to: 'level', depth: 3 }])).includes('ERR_UNKNOWN_FIELD'));
|
||||
assert.ok(codes(soundWith({ shaper: { type: 'waveshaper' }, wobble: { type: 'lfo' } }, [
|
||||
{ from: 'shaper', to: 'tone.detune', depth: 5 }
|
||||
])).includes('ERR_INVALID_ROUTE'));
|
||||
});
|
||||
|
||||
test('two modulation routes onto one property are both retained for summation', () => {
|
||||
const document = soundWith({ slow: { type: 'lfo', frequency: 0.5 }, fast: { type: 'lfo', frequency: 6 } }, [
|
||||
{ from: 'slow', to: 'tone.frequency', depth: 20 },
|
||||
{ from: 'fast', to: 'tone.frequency', depth: 5 }
|
||||
]);
|
||||
clean(document);
|
||||
const plan = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42) });
|
||||
const onTone = plan.routes.filter((route) => route.kind === 'modulation' && route.property === 'frequency');
|
||||
assert.deepEqual(onTone.map((route) => route.depth), [20, 5]);
|
||||
});
|
||||
|
||||
/* --- 15.19 traces 4, 6: components and limits -------------------------------- */
|
||||
|
||||
const componentDocument = (overrides = {}) => exhibit({
|
||||
components: {
|
||||
audio: {
|
||||
voice: {
|
||||
parameters: { pitch: { type: 'number', default: 220, min: 20, max: 2000 } },
|
||||
input: false,
|
||||
nodes: { tone: { type: 'oscillator', frequency: { ref: 'inputs.pitch' } }, level: { type: 'gain', gain: 0.3 } },
|
||||
routes: [{ from: 'tone', to: 'level' }, { from: 'level', to: 'output' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
sounds: {
|
||||
probe: {
|
||||
name: 'Probe',
|
||||
recipe: {
|
||||
nodes: { a: { type: 'component', use: 'voice', values: { pitch: 330 } } },
|
||||
routes: [{ from: 'a', to: 'output' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
test('a component expands, resolves its inputs, and encapsulates its internals', () => {
|
||||
const document = componentDocument();
|
||||
clean(document);
|
||||
const expansion = expandSoundGraph(document, 'probe');
|
||||
assert.deepEqual(expansion.errors, []);
|
||||
const paths = expansion.nodes.map((node) => node.path).sort();
|
||||
assert.deepEqual(paths, ['a', 'a.level', 'a.output', 'a.tone'].sort());
|
||||
const plan = instantiateSoundGraph(document, 'probe', { rng: new SeededRNG(42) });
|
||||
assert.equal(plan.nodes.find((node) => node.path === 'a.tone').values.frequency, 330);
|
||||
|
||||
const defaulted = componentDocument();
|
||||
delete defaulted.sounds.probe.recipe.nodes.a.values;
|
||||
const defaultPlan = instantiateSoundGraph(defaulted, 'probe', { rng: new SeededRNG(42) });
|
||||
assert.equal(defaultPlan.nodes.find((node) => node.path === 'a.tone').values.frequency, 220);
|
||||
|
||||
const reachInside = componentDocument();
|
||||
reachInside.sounds.probe.recipe.routes = [{ from: 'a.tone', to: 'output' }];
|
||||
assert.ok(codes(reachInside).length > 0);
|
||||
|
||||
const unknownValue = componentDocument();
|
||||
unknownValue.sounds.probe.recipe.nodes.a.values = { volume: 1 };
|
||||
assert.ok(codes(unknownValue).includes('ERR_UNKNOWN_FIELD'));
|
||||
|
||||
const badInput = componentDocument();
|
||||
badInput.sounds.probe.recipe.nodes.b = { type: 'oscillator' };
|
||||
badInput.sounds.probe.recipe.routes.push({ from: 'b', to: 'a' });
|
||||
assert.ok(codes(badInput).includes('ERR_INVALID_ROUTE'));
|
||||
});
|
||||
|
||||
test('an exposed component parameter is a legal modulation target', () => {
|
||||
const document = componentDocument();
|
||||
document.sounds.probe.recipe.nodes.wobble = { type: 'lfo', frequency: 2 };
|
||||
document.sounds.probe.recipe.routes.push({ from: 'wobble', to: 'a.pitch', depth: 12 });
|
||||
clean(document);
|
||||
document.sounds.probe.recipe.routes.pop();
|
||||
document.sounds.probe.recipe.routes.push({ from: 'wobble', to: 'a.volume', depth: 12 });
|
||||
assert.ok(codes(document).includes('ERR_UNSUPPORTED_TARGET'));
|
||||
});
|
||||
|
||||
test('component recursion is rejected', () => {
|
||||
const document = componentDocument();
|
||||
document.components.audio.voice.nodes.inner = { type: 'component', use: 'voice' };
|
||||
assert.ok(codes(document).includes('ERR_COMPONENT_RECURSION'));
|
||||
});
|
||||
|
||||
test('inputs.* resolves only inside a component graph', () => {
|
||||
const document = soundWith({}, []);
|
||||
document.sounds.probe.recipe.nodes.tone.frequency = { ref: 'inputs.pitch' };
|
||||
assert.ok(codes(document).includes('ERR_INVALID_REFERENCE'));
|
||||
const undeclared = componentDocument();
|
||||
undeclared.components.audio.voice.nodes.tone.frequency = { ref: 'inputs.missing' };
|
||||
assert.ok(codes(undeclared).includes('ERR_INVALID_REFERENCE'));
|
||||
});
|
||||
|
||||
test('expanded node and route counts are enforced at their documented limits', () => {
|
||||
const build = (count) => {
|
||||
const nodes = {};
|
||||
const routes = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
nodes[`g-${index}`] = { type: 'gain' };
|
||||
routes.push({ from: 'tone', to: `g-${index}` }, { from: `g-${index}`, to: 'output' });
|
||||
}
|
||||
return soundWith(nodes, routes);
|
||||
};
|
||||
const atLimit = build(AUDIO_LIMITS.nodesPerSound - 1);
|
||||
assert.equal(expandSoundGraph(atLimit, 'probe').nodes.filter((node) => !node.implicit).length, AUDIO_LIMITS.nodesPerSound);
|
||||
clean(atLimit);
|
||||
assert.ok(codes(build(AUDIO_LIMITS.nodesPerSound)).includes('ERR_NODE_LIMIT_EXCEEDED'));
|
||||
});
|
||||
|
||||
test('per-node authoring limits are enforced', () => {
|
||||
const partials = Array.from({ length: 65 }, (unused, index) => ({ ratio: index + 1, gain: 0.1 }));
|
||||
assert.ok(codes(soundWith({ probe: { type: 'oscillator', waveform: 'custom', frequency: 40, harmonics: partials } })).includes('ERR_NODE_LIMIT_EXCEEDED'));
|
||||
const modes = Array.from({ length: 17 }, (unused, index) => ({ ratio: index + 1 }));
|
||||
assert.ok(codes(soundWith({ probe: { type: 'resonator', modes } })).includes('ERR_NODE_LIMIT_EXCEEDED'));
|
||||
});
|
||||
|
||||
/* --- buses, recipes, and sound definitions ---------------------------------- */
|
||||
|
||||
test('buses, shared recipes, and sound metadata validate against their contracts', () => {
|
||||
assert.ok(codes(exhibit({ audio: { buses: { master: { gain: 1 } } } })).includes('ERR_INVALID_ID'));
|
||||
assert.ok(codes(exhibit({ audio: { master: {} } })).includes('ERR_UNKNOWN_FIELD'));
|
||||
assert.ok(codes(exhibit({ audio: { buses: { ambient: { gain: 9 } } } })).includes('ERR_OUT_OF_BOUNDS'));
|
||||
assert.ok(codes(soundWith({}, [], { bus: 'missing' })).includes('ERR_INVALID_REFERENCE'));
|
||||
assert.ok(codes(soundWith({}, [], { usage: ['sideways'] })).includes('ERR_TYPE_MISMATCH'));
|
||||
|
||||
const shared = exhibit({
|
||||
audio: {
|
||||
buses: { ambient: { gain: 1 } },
|
||||
recipes: { drone: { mode: 'continuous', nodes: { tone: { type: 'oscillator' } }, routes: [{ from: 'tone', to: 'output' }] } }
|
||||
},
|
||||
sounds: { probe: { name: 'Probe', bus: 'ambient', recipe: { use: 'drone' } } }
|
||||
});
|
||||
clean(shared);
|
||||
assert.equal(expandSoundGraph(shared, 'probe').mode, 'continuous');
|
||||
|
||||
const mixedRecipe = structuredClone(shared);
|
||||
mixedRecipe.sounds.probe.recipe = { use: 'drone', mode: 'oneshot' };
|
||||
assert.ok(codes(mixedRecipe).includes('ERR_SCHEMA_VALIDATION'));
|
||||
|
||||
const missingRecipe = structuredClone(shared);
|
||||
missingRecipe.sounds.probe.recipe = { use: 'absent' };
|
||||
assert.ok(codes(missingRecipe).includes('ERR_INVALID_REFERENCE'));
|
||||
});
|
||||
|
||||
test('an exhibit with no audio section still validates', () => {
|
||||
clean(exhibit({ parameters: { level: { type: 'number', default: 0.5, min: 0, max: 1 } } }));
|
||||
});
|
||||
|
||||
/* --- realization smoke test against a recording stand-in ---------------------- */
|
||||
|
||||
function mockContext() {
|
||||
const log = { connections: [], created: [], started: 0, stopped: 0 };
|
||||
const param = (value = 0) => ({
|
||||
value,
|
||||
setValueAtTime() { return this; },
|
||||
linearRampToValueAtTime() { return this; },
|
||||
exponentialRampToValueAtTime() { return this; }
|
||||
});
|
||||
const base = (kind, extra = {}) => {
|
||||
log.created.push(kind);
|
||||
const node = {
|
||||
kind,
|
||||
connect(target) { log.connections.push([kind, target?.kind ?? 'param']); return target; },
|
||||
disconnect() {},
|
||||
...extra
|
||||
};
|
||||
return node;
|
||||
};
|
||||
return {
|
||||
log,
|
||||
sampleRate: 48000,
|
||||
currentTime: 0,
|
||||
state: 'running',
|
||||
destination: base('destination'),
|
||||
createGain: () => base('gain', { gain: param(1) }),
|
||||
createOscillator: () => base('oscillator', {
|
||||
frequency: param(440), detune: param(0), type: 'sine',
|
||||
setPeriodicWave() {}, start() { log.started += 1; }, stop() { log.stopped += 1; }
|
||||
}),
|
||||
createConstantSource: () => base('constant', { offset: param(0), start() { log.started += 1; }, stop() { log.stopped += 1; } }),
|
||||
createBufferSource: () => base('buffer-source', { buffer: null, loop: false, start() { log.started += 1; }, stop() { log.stopped += 1; } }),
|
||||
createBiquadFilter: () => base('filter', { type: 'lowpass', frequency: param(1000), Q: param(1), gain: param(0), detune: param(0) }),
|
||||
createDynamicsCompressor: () => base('compressor', { threshold: param(-24), knee: param(30), ratio: param(12), attack: param(0.003), release: param(0.25) }),
|
||||
createWaveShaper: () => base('waveshaper', { curve: null, oversample: 'none' }),
|
||||
createDelay: () => base('delay', { delayTime: param(0) }),
|
||||
createConvolver: () => base('convolver', { buffer: null }),
|
||||
createStereoPanner: () => base('panner', { pan: param(0) }),
|
||||
createPeriodicWave: () => ({ kind: 'periodic-wave' }),
|
||||
createBuffer: (channels, length) => ({
|
||||
length,
|
||||
getChannelData: () => new Float32Array(length)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
test('every node type realizes against an AudioContext stand-in and disposes cleanly', async () => {
|
||||
const { realizeSoundGraph, AudioSubsystem } = await import('../src/runtime/audio-engine.js');
|
||||
const document = soundWith({
|
||||
hiss: { type: 'noise', color: 'pink' },
|
||||
hit: { type: 'impulse' },
|
||||
bias: { type: 'constant' },
|
||||
wobble: { type: 'lfo', polarity: 'unipolar' },
|
||||
step: { type: 'sample-hold', rate: 2, slew: '50ms' },
|
||||
level: { type: 'gain', gain: 0.5 },
|
||||
shape: { type: 'filter' },
|
||||
squeeze: { type: 'compressor' },
|
||||
bend: { type: 'waveshaper', amount: 0.4 },
|
||||
echo: { type: 'delay' },
|
||||
space: { type: 'reverb' },
|
||||
place: { type: 'stereo-pan' },
|
||||
blend: { type: 'mixer' },
|
||||
body: { type: 'resonator', modes: [{ ratio: 1, decay: '200ms' }, { ratio: 3.1, gain: 0.3 }] }
|
||||
}, [
|
||||
{ from: 'hiss', to: 'blend' }, { from: 'hit', to: 'blend' }, { from: 'blend', to: 'body' },
|
||||
{ from: 'body', to: 'shape' }, { from: 'shape', to: 'squeeze' }, { from: 'squeeze', to: 'bend' },
|
||||
{ from: 'bend', to: 'echo' }, { from: 'echo', to: 'space' }, { from: 'space', to: 'place' },
|
||||
{ from: 'place', to: 'level' }, { from: 'level', to: 'output' },
|
||||
{ from: 'bias', to: 'level.gain', depth: 0.1 },
|
||||
{ from: 'wobble', to: 'shape.frequency', depth: 200 },
|
||||
{ from: 'step', to: 'echo.time', depth: 5 }
|
||||
]);
|
||||
clean(document);
|
||||
const context = mockContext();
|
||||
const plan = instantiateSoundGraph(document, 'probe', { sampleRate: context.sampleRate, rng: new SeededRNG(42) });
|
||||
assert.deepEqual(plan.errors, []);
|
||||
const voice = realizeSoundGraph(context, plan, context.destination);
|
||||
assert.ok(context.log.created.length > 20);
|
||||
assert.ok(context.log.started >= 5);
|
||||
assert.ok(context.log.connections.some(([, target]) => target === 'destination'));
|
||||
voice.dispose();
|
||||
assert.equal(context.log.stopped, context.log.started);
|
||||
|
||||
const subsystem = new AudioSubsystem({ document, rng: new SeededRNG(42), contextFactory: () => mockContext() });
|
||||
await subsystem.unlock();
|
||||
const handle = subsystem.play('probe');
|
||||
assert.equal(handle.soundId, 'probe');
|
||||
assert.equal(subsystem.voices.size, 1);
|
||||
assert.equal(subsystem.nextOrdinal('probe'), 1);
|
||||
subsystem.setMasterVolume(0.5);
|
||||
assert.equal(subsystem.master.gain.value, 0.5);
|
||||
subsystem.setBusGain('ambient', 2);
|
||||
assert.equal(subsystem.buses.get('ambient').gain.value, 2);
|
||||
subsystem.stopAll();
|
||||
assert.equal(subsystem.voices.size, 0);
|
||||
});
|
||||
@@ -11,6 +11,8 @@ const sourceFiles = Object.freeze([
|
||||
'src/runtime/rng.js',
|
||||
'src/runtime/types.js',
|
||||
'src/runtime/values.js',
|
||||
'src/runtime/audio-contract.js',
|
||||
'src/runtime/audio-graph.js',
|
||||
'src/runtime/validator.js',
|
||||
'src/runtime/persistence.js',
|
||||
'src/runtime/library.js',
|
||||
@@ -18,6 +20,7 @@ const sourceFiles = Object.freeze([
|
||||
'src/runtime/actions.js',
|
||||
'src/runtime/performance.js',
|
||||
'src/runtime/activation.js',
|
||||
'src/runtime/audio-engine.js',
|
||||
'src/runtime/app.js'
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* XZBT 0.1 Structural & Semantic Exhibit Validator
|
||||
* Zero external dependencies. Conforms to XZBT Format Specification 0.1 (Revision 0.3).
|
||||
* No external dependencies. Conforms to XZBT Format Specification 0.1 (Revision 0.4).
|
||||
* Audio subsystem checks (sections 14-15) delegate to the shared runtime module so the tool
|
||||
* and the runtime can never diverge.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { validateAudioSubsystem } from '../src/runtime/audio-graph.js';
|
||||
|
||||
const ID_REGEX = /^[a-z][a-z0-9_-]*$/;
|
||||
const REF_PATH_REGEX = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
|
||||
@@ -150,6 +153,12 @@ export class ExhibitValidator {
|
||||
// 6. Bindings & Cycle Detection
|
||||
this.validateBindings();
|
||||
|
||||
// 7. Audio subsystem (Format Specification sections 14-15)
|
||||
validateAudioSubsystem(this.doc, this.errors, {
|
||||
validateValueSpec: (unusedDocument, spec, path, unusedErrors, scope) => this.validateValueSpec(spec, path, scope),
|
||||
pushError: (errors, code, path, message) => errors.push({ code, path, message })
|
||||
});
|
||||
|
||||
return this.getResult();
|
||||
}
|
||||
|
||||
@@ -275,7 +284,7 @@ export class ExhibitValidator {
|
||||
}
|
||||
}
|
||||
|
||||
validateValueSpec(valueSpec, path) {
|
||||
validateValueSpec(valueSpec, path, scope = null) {
|
||||
if (typeof valueSpec === 'number') {
|
||||
if (!Number.isFinite(valueSpec)) {
|
||||
this.addError('ERR_TYPE_MISMATCH', path, `Number must be finite; got ${valueSpec}.`);
|
||||
@@ -294,6 +303,12 @@ export class ExhibitValidator {
|
||||
if ('ref' in valueSpec) {
|
||||
if (typeof valueSpec.ref !== 'string' || !REF_PATH_REGEX.test(valueSpec.ref)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', `${path}.ref`, `Invalid reference path: '${valueSpec.ref}'.`);
|
||||
} else if (valueSpec.ref.startsWith('inputs.')) {
|
||||
// Component-graph-scoped namespace (Format Specification 15.15).
|
||||
const name = valueSpec.ref.slice('inputs.'.length);
|
||||
if (!scope?.componentParameters?.has(name)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', `${path}.ref`, `Component input '${name}' is not declared here.`);
|
||||
}
|
||||
} else {
|
||||
this.resolveReference(valueSpec.ref, `${path}.ref`);
|
||||
}
|
||||
@@ -326,7 +341,7 @@ export class ExhibitValidator {
|
||||
if (typeof item !== 'object' || item === null || item.value === undefined || typeof item.weight !== 'number' || item.weight <= 0) {
|
||||
this.addError('ERR_SCHEMA_VALIDATION', `${path}.choose[${i}]`, 'choose item requires value and weight > 0.');
|
||||
} else {
|
||||
this.validateValueSpec(item.value, `${path}.choose[${i}].value`);
|
||||
this.validateValueSpec(item.value, `${path}.choose[${i}].value`, scope);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -352,7 +367,7 @@ export class ExhibitValidator {
|
||||
);
|
||||
}
|
||||
args.forEach((arg, index) => {
|
||||
this.validateValueSpec(arg, `${path}.args[${index}]`);
|
||||
this.validateValueSpec(arg, `${path}.args[${index}]`, scope);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user