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:
2026-09-05 22:03:18 +00:00
co-authored by Claude Opus 5
parent 12140055f7
commit f07fc777f3
20 changed files with 5736 additions and 49 deletions
+13 -3
View File
@@ -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; GC2GC5 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 GC2GC5 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.
+704 -3
View File
@@ -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.
+3 -1
View File
@@ -10,7 +10,7 @@
| --- | --- | --- |
| Phase 1 — Runtime skeleton | 814, 108118, 121127, 134135 | 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 | 1533, 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 | 3460, 118120, 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 | 3460, 118120, 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 3642), **3b** processing, routing, modulation, graph legality, authoring limits, components, sounds/recipes, and buses (PRD 4353, 55, 56, 59, 60), **3c** automation precedence, lifecycle, unlock behavior, voice ceilings, and master protection (PRD 54, 57, 58, 118120). |
| Phase 4 — Visual engine | 6989, 119120, 130, 138 | Complete visual subsystem contract and generic renderer features. Run visual challenge. Begin reference Exhibits AD visuals, then execute the early combined GC6 benchmark before fixing later optimization strategy. |
| Phase 5 — Events and cadence | 6168, 9091, 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 AD. |
| Phase 6 — Scenario director | 92102, 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.