feat(audio): complete phase 3c slice 3 automation
This commit is contained in:
@@ -12,7 +12,7 @@ XZBT is a self-contained browser runtime for declarative procedural audiovisual
|
||||
| [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 complete Phase 3a-3c audio subsystem contract, 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 | Audio contract complete through Phase 3c; Phase 3c implementation, Phase 1 direct-file, and Phase 3 audible observations pending |
|
||||
| [Implementation status](docs/IMPLEMENTATION_STATUS.md) | Current phase, stop reason, saved work, and resume prerequisites | Implemented through Phase 3c slice 3; stopped before slice 4. 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, the production runtime is implemented through Phase 3b, and the audio subsystem contract is written through Phase 3c. Continue with Phase 3c implementation slice 2 (lifecycle and voices) 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.
|
||||
Phase 0 is complete, the production runtime is implemented through Phase 3c slice 3 (automation), and the audio subsystem contract is written through Phase 3c. Work is stopped at the user-requested slice-3 checkpoint. The next slice is 3c-4 (measured master protection), which requires user-observed hardware measurements and listening; it has not been started. The Phase 1 direct-file two-fixture restart and Phase 3 audible acceptance remain open — no sound has been heard from a production 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.
|
||||
|
||||
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.
|
||||
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 graph contract and engine; Phase 3c slices 1–3 add the remaining audio contract, lifecycle/voice management, and automation. Master protection, visual, cadence, scenario, UI, library-hardening, benchmark, and soak gates remain explicit in the implementation plan.
|
||||
|
||||
## Repository configuration
|
||||
|
||||
@@ -65,9 +65,13 @@ npm run test:phase3
|
||||
|
||||
Run every suite with `npm test`.
|
||||
|
||||
Run just the Phase 3c slice-3 traces with `npm run test:phase3c3`. The [slice-3 evidence](docs/evidence/phase3/2026-09-05-phase3c-automation.md) records coverage and the remaining manual gates.
|
||||
|
||||
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.
|
||||
|
||||
The Phase 3c contract (section 16) specifies automation, the lifecycle state machine, determinable one-shot endings, voice ceilings, master protection, and unlock behavior, but the runtime does not implement it yet: `automation` and `release` are still rejected as unknown fields. No sound has yet been heard from a build, and the audio acceptance challenge, peak and finite-sample capture, and listening observations remain open Phase 3 gates. Visuals, cadence, events, scenarios, and the final schema-driven UI remain assigned to later phases.
|
||||
The runtime implements recipe `release`, the seven-state lifecycle, determinable one-shot endings, voice ceilings, and graph-local `automation`. Tracks support `absolute`, `offset`, and `scale`, with `step`, `linear`, `exponential`, and `smooth` interpolation. Values are sampled once from the owning sound's seeded stream; duplicate targets and expanded limits are validated. Exposed component parameters participate without exposing component internals. Bus gains now use the shared binding → automation → override → modulation → clamp resolver. Bus automation/modulation registration is internal: the contract does not add document fields to buses or permit external node bindings/overrides.
|
||||
|
||||
Master protection is still the placeholder chain. No sound has yet been heard from a production build; measured protection, the audio acceptance challenge, real GC4 synchronization, peak/finite-sample capture, and listening observations remain open Phase 3 gates. 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:** The audio subsystem contract is complete through Phase 3c; Phase 3c slices 1 and 2 implemented; automation (slice 3) and master protection (slice 4), the Phase 3 audible gates, and the Phase 1 direct-file import/restart observation remain pending
|
||||
**State:** Phase 3c slices 1–3 complete; stopped at the user-requested slice-3 checkpoint. Master protection (slice 4), 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, the Phase 2 common grammar, and the Phase 3a/3b/3c-2 audio engine 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.
|
||||
**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, Phase 2 common grammar, and audio engine through Phase 3c slice 3 pass automated checks. Phase 1's direct-file two-fixture restart observation, Phase 3's audible acceptance (no sound has been heard from a production build), and measured master protection remain open. 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 | Slices 3c-1 (contract) and 3c-2 (lifecycle/voices) complete; slices 3c-3 and 3c-4 pending | [Automated evidence](evidence/phase3/2026-09-05-audio-authoring-contract.md) covers sections 14-15; [contract evidence](evidence/phase3/2026-09-05-phase3c-contract.md) adds section 16. [Slice 3c-2 evidence](evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md) implements the seven-state lifecycle, engine-owned release gain, determinable one-shot endings, voice ceilings (64/16), 4-step eviction, and disposal. Slices 3c-3 (automation) and 3c-4 (master protection measurement) and the PRD 129 audio acceptance challenge remain. |
|
||||
| 3 — Audio engine | Slices 3c-1 (contract), 3c-2 (lifecycle/voices), and 3c-3 (automation) complete; stopped before slice 4 | [Audio authoring evidence](evidence/phase3/2026-09-05-audio-authoring-contract.md), [contract evidence](evidence/phase3/2026-09-05-phase3c-contract.md), [lifecycle/voice evidence](evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md), and [automation evidence](evidence/phase3/2026-09-05-phase3c-automation.md). Slice 4 requires measured master protection and user listening; PRD 129 and real GC4 audio acceptance remain open. |
|
||||
| 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 |
|
||||
@@ -59,11 +59,11 @@ Implementation lives in `src/runtime/audio-contract.js` (declarative tables), `s
|
||||
|
||||
`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.
|
||||
At the Phase 3a/3b checkpoint, automation, lifecycle/voices, and measured protection were still future work. The subsequent slice records below supersede that implementation status. PRD 129, real GC4 synchronization, peak/finite-sample capture, and listening observations remain open.
|
||||
|
||||
## Phase 3c contract (slice 1)
|
||||
|
||||
Format Specification section 16 completes the audio subsystem contract. Phase 3c is delivered in four slices, recorded in the implementation plan: the contract (done), lifecycle and voices (done), automation, and measured master protection. Each slice ends green and committable, because slice 2 replaces the working one-shot timer scaffolding and must not be left half-applied.
|
||||
Format Specification section 16 completes the audio subsystem contract. Phase 3c is delivered in four slices, recorded in the implementation plan: the contract (done), lifecycle and voices (done), automation (done), and measured master protection (pending). Each slice ends green and committable.
|
||||
|
||||
Section 16 was contract only at slice 1.
|
||||
|
||||
@@ -73,4 +73,12 @@ Slice 3c-2 implements the seven-state lifecycle state machine (`CREATED`, `SCHED
|
||||
|
||||
`npm test` runs 67 tests with zero failures. Two clean builds produce byte-identical artifacts with digest `d7d16a89f9b1a9e21262232382d7ae53a0a84abafce191a873327e66dc4103a6`.
|
||||
|
||||
Phase 3 is not accepted. Slices 3c-3 (automation) and 3c-4 (measured master protection) remain. No sound has been heard from any build.
|
||||
At the slice-2 checkpoint, slices 3 and 4 remained. No sound had been heard from a production build.
|
||||
|
||||
## Phase 3c automation (slice 3)
|
||||
|
||||
Slice 3c-3 implements section 16.1 graph-local tracks, all three modes and four curves, strict literal time ordering, numeric ValueSpec validation, component expansion/encapsulation, conflict detection, and the 64-track / 256-total-point expanded limits. Procedural values are sampled once in depth-first document order. Native audio-clock scheduling holds curve endpoints, sums modulation before the safety clamp, and propagates exposed component controls. Section 16.2 is integrated into the shared Phase 2 resolver and the application's live bus gains; masked automation advances and override release follows its current value.
|
||||
|
||||
`npm test` passes **88 tests**, including 21 new slice-3 tests. The self-contained build is deterministic; the artifact digest is `6aa1b659e1f06ca3175ac62a4544cc3342e8731f6d6f186c68fcd667cf683d97`. See the [slice-3 evidence](evidence/phase3/2026-09-05-phase3c-automation.md) for exact coverage, scheduling precision, and the test boundary.
|
||||
|
||||
**Stop:** Slice 3 is complete; wait for the user before starting slice 4. The master chain is unchanged. Measured protection, audible acceptance, and real GC4 synchronization are not claimed by the automated tests.
|
||||
|
||||
@@ -1260,7 +1260,7 @@ This section closes the audio subsystem. It covers automation tracks and their p
|
||||
|
||||
Sections 14 and 15 define what an exhibit may *declare*. This section defines what the runtime *does* with it over time. Where an earlier section deferred a rule to "Phase 3c", this section is the referent.
|
||||
|
||||
**Implementation status.** This section is the Phase 3c *contract*; the runtime does not yet implement it. Until slices 3c-2 and 3c-3 land, a document declaring `automation` or `release` is rejected with `ERR_UNKNOWN_FIELD` by the current validator, sound instances have no lifecycle state machine, and no voice ceiling is enforced. The implementation plan records the slice order.
|
||||
**Implementation status.** Phase 3c slices 1–3 are implemented: the runtime accepts graph-local `automation` and recipe `release`, implements lifecycle/voice management, and resolves automation before override and modulation. Slice 4 (measured master protection) and the user-observed audio acceptance gates remain open. See the implementation status and slice evidence; the current master chain is still a placeholder, not proof of the protection contract.
|
||||
|
||||
### 16.1 Automation tracks
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Phase 3c slice 3 — Automation
|
||||
|
||||
Date: September 5, 2026. Contract: PRD 54 and Format Specification revision 0.4, sections 8.1, 14.4, 15.13–15.17, and 16.1–16.2. Scope: implementation-plan slice 3c-3 only. The requested stop is preserved; slice 4 has not started.
|
||||
|
||||
## Delivered
|
||||
|
||||
- Graph-local tracks on inline/shared recipes and reusable components. Both validators share structural/semantic checks; JSON Schema exposes the same authoring fields.
|
||||
- Defaults, three modes, four curves, first/last holds, and one exponential fallback warning per sampled track. Times are duration literals in strictly increasing order; values are numeric ValueSpecs sampled once from the owning sound stream.
|
||||
- Depth-first, property-document sampling, including nested components, route depths, and automation after the owning graph's nodes/routes. Internal expressions retain only exposed `inputs.*` references; random choices and external references stay sampled.
|
||||
- Expanded-target conflicts and limits of 64 tracks / 256 total points. Component internals remain encapsulated. Automation does not enable external node bindings or overrides.
|
||||
- Audio-clock scheduling, exposed component controls, modulation summation before property safety clamps, millisecond-to-second delay conversion, and resonator-ratio control. Automated delay tails conservatively use their maximum resolved time for disposal bounds.
|
||||
- Shared numeric resolution: base → binding → automation → override → modulation → clamp. Bus gains use this path in the actual application, with current-value override release and preserved masked base edits. Zero-smoothing bindings react to same-tick edits. Bus automation/modulation registration is an internal runtime surface; no unspecified bus document fields were invented.
|
||||
- Disposal cancels scheduled controls, stops/disconnects sources, clears control caches, releases the voice plan, and removes bus subscriptions. Injected scheduling failures clean partial graphs without violating the terminal `FAILED` lifecycle state. Successful exhibit switching detaches the preceding audio owner.
|
||||
- The minimal audio exhibit demonstrates all modes and curves; the standalone artifact includes the new modules with no external runtime dependency.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm test`: **88 passed, 0 failed** (67 existing regression tests plus 21 new tests in `test/phase3-automation.test.mjs`). Node's test workers require the existing sandbox escalation on this machine; no audio device was opened. `npm run test:phase3c3` runs the new suite directly. `npm run test:phase3` includes both audio test files.
|
||||
|
||||
| Required section 16.11 trace | Automated evidence |
|
||||
| --- | --- |
|
||||
| 1–2: modes, curves, slopes, holds | Every mode/curve combination at start, midpoint, and end; quarter points and endpoint slopes for smooth; delayed first point and long-time final hold |
|
||||
| 3: conflict versus modulation sum | Duplicate expanded targets rejected; distinct component instances remain distinct; duplicate modulation routes sum |
|
||||
| 4: exponential fallback | Zero, sign crossing, and negative endpoints; one warning per track despite repeated evaluations; native linear-ramp fallback |
|
||||
| 5: target capabilities | Every registry property, unknown nodes, unsupported fields, component encapsulation, external binding/override restrictions, and absence of stages for parameters/state |
|
||||
| 6: limits | 64 expanded component tracks / 256 points accepted; 65 tracks and 257 points rejected |
|
||||
| 8: masked automation | Production bus resolver and connected bus AudioParam follow a live binding, masked track, mid-release destination, additive modulation, and final clamp |
|
||||
| 11: resource cleanup | Voice disposal, duplicate disposal, cleared scheduled events, disconnected native-control graph, released plan/subscription, and injected scheduling failure |
|
||||
|
||||
Additional traces cover seeded sampling order/repeatability, component expressions with frozen procedural choices, bus ValueSpec sampling, same-tick upstream parameter overrides, delay units/tails, device frequency ceilings, and resonator ratios. Existing lifecycle/voice and Phase 0–2 tests continue to pass.
|
||||
|
||||
The connected Web Audio stand-in records scheduled events and evaluates the constructed scalar control graph; it does not merely test a separate analytic formula. Step/linear/exponential tracks schedule native AudioParam events. Smooth tracks schedule linear progress and evaluate `3t² - 2t³` with native a-rate gain/summing nodes, including segment resets and zero endpoint slopes; allocation is independent of segment duration. General nonlinear component-expression shapers (reciprocal and rounding) use sampled lookup curves; this is not a claim of bit-identical rendered samples. The final property clamp uses three collinear WaveShaper points with saturation, after summation.
|
||||
|
||||
`npm run build` and the reproducible-build regression produce byte-identical, self-contained HTML. Checked-in `XZBT.html`: **204,205 bytes**, SHA-256 **`6aa1b659e1f06ca3175ac62a4544cc3342e8731f6d6f186c68fcd667cf683d97`**. `git diff --check` passes.
|
||||
|
||||
## Acceptance boundary and stop
|
||||
|
||||
This is automated slice-3 evidence, not a browser listening or hardware measurement record. No production-build audio has been heard. The placeholder master chain is unchanged; measured peak ceiling, tolerance/release, finite-sample protection, PRD 129 listening, and real GC4 synchronization remain open. Phase 1's direct-file two-fixture restart observation also remains open. Do not start slice 4 or later phases until the user resumes work.
|
||||
@@ -3,9 +3,9 @@
|
||||
"meta": {
|
||||
"id": "minimal-audio",
|
||||
"name": "Minimal Audio",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "XZBT",
|
||||
"description": "A minimal generic exhibit exercising the Phase 3 audio graph, component, routing, and bus contracts.",
|
||||
"description": "A minimal generic exhibit exercising audio graphs, components, routing, buses, and the Phase 3c automation modes and curves.",
|
||||
"license": "CC0-1.0",
|
||||
"tags": ["minimal", "audio"]
|
||||
},
|
||||
@@ -45,6 +45,11 @@
|
||||
{ "from": "tone", "to": "shape" },
|
||||
{ "from": "shape", "to": "level" },
|
||||
{ "from": "level", "to": "output" }
|
||||
],
|
||||
"automation": [
|
||||
{ "target": "shape.frequency", "interpolation": "exponential", "points": [
|
||||
{ "at": "0s", "value": 1800 }, { "at": "12s", "value": 800 }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -74,6 +79,14 @@
|
||||
{ "from": "trim", "to": "output" },
|
||||
{ "from": "wobble", "to": "low.pitch", "depth": 1.5 },
|
||||
{ "from": "wander", "to": "high.pitch", "depth": 1 }
|
||||
],
|
||||
"automation": [
|
||||
{ "target": "low.pitch", "mode": "offset", "interpolation": "smooth", "points": [
|
||||
{ "at": "0s", "value": 0 }, { "at": "6s", "value": 12 }, { "at": "18s", "value": -4 }
|
||||
] },
|
||||
{ "target": "high.level", "mode": "scale", "points": [
|
||||
{ "at": "0s", "value": 0.4 }, { "at": "3s", "value": 1 }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -113,6 +126,11 @@
|
||||
{ "from": "body", "to": "place" },
|
||||
{ "from": "place", "to": "trim" },
|
||||
{ "from": "trim", "to": "output" }
|
||||
],
|
||||
"automation": [
|
||||
{ "target": "place.pan", "interpolation": "step", "points": [
|
||||
{ "at": "0ms", "value": -0.2 }, { "at": "120ms", "value": 0.2 }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"build": "node tools/build-xzbt.mjs",
|
||||
"test:phase1": "node test/phase1-runtime.test.mjs",
|
||||
"test:phase2": "node test/phase2-common-grammar.test.mjs",
|
||||
"test:phase3": "node test/phase3-audio.test.mjs",
|
||||
"test:phase3": "node --test test/phase3-*.test.mjs",
|
||||
"test:phase3c3": "node test/phase3-automation.test.mjs",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1594,6 +1594,41 @@
|
||||
"$ref": "#/definitions/AudioRoute"
|
||||
}
|
||||
},
|
||||
"AudioAutomationList": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"description": "Graph-local tracks (16.1); expanded totals and target capabilities are semantic checks.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["target", "points"],
|
||||
"properties": {
|
||||
"target": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*\\.[a-z][a-z0-9_-]*$" },
|
||||
"mode": { "enum": ["absolute", "offset", "scale"], "default": "absolute" },
|
||||
"interpolation": { "enum": ["step", "linear", "exponential", "smooth"], "default": "linear" },
|
||||
"points": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["at", "value"],
|
||||
"properties": {
|
||||
"at": { "$ref": "#/definitions/DurationSpec" },
|
||||
"value": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/definitions/ValueSpec" },
|
||||
{ "type": ["number", "object"] }
|
||||
],
|
||||
"description": "ValueSpec<number>; numeric references and expression leaves are checked semantically."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AudioRecipe": {
|
||||
"type": "object",
|
||||
"description": "A recipe graph, or a reference to a shared recipe (15.16).",
|
||||
@@ -1610,6 +1645,9 @@
|
||||
"routes": {
|
||||
"$ref": "#/definitions/AudioRouteList"
|
||||
},
|
||||
"automation": {
|
||||
"$ref": "#/definitions/AudioAutomationList"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1650,6 +1688,9 @@
|
||||
"routes": {
|
||||
"$ref": "#/definitions/AudioRouteList"
|
||||
},
|
||||
"automation": {
|
||||
"$ref": "#/definitions/AudioAutomationList"
|
||||
},
|
||||
"input": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
+3
-1
@@ -125,6 +125,7 @@ export class XZBTApplication {
|
||||
this.setBusy(true, `Preparing ${record.document.meta.name}…`);
|
||||
try {
|
||||
await this.activation.activate(record);
|
||||
await this.disposeAudio();
|
||||
this.renderLibrary();
|
||||
this.renderStage();
|
||||
} finally {
|
||||
@@ -196,6 +197,7 @@ export class XZBTApplication {
|
||||
this.audio = new AudioSubsystem({
|
||||
document: current.record.document,
|
||||
rng: current.performance.rng,
|
||||
resolutionEngine: current.performance.engine,
|
||||
diagnostics: this.diagnostics
|
||||
});
|
||||
this.audio.setMasterVolume(Number(element('master-volume').value));
|
||||
@@ -251,7 +253,7 @@ export class XZBTApplication {
|
||||
input.min = '0';
|
||||
input.max = '4';
|
||||
input.step = '0.01';
|
||||
input.value = String(typeof bus.gain === 'number' ? bus.gain : 1);
|
||||
input.value = String(current.performance.engine.base(`audio.buses.${id}.gain`));
|
||||
input.disabled = !unlocked;
|
||||
input.addEventListener('input', (event) => this.audio?.setBusGain(id, Number(event.target.value)));
|
||||
row.append(label, input);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Shared numeric stages and immutable, once-sampled automation (spec 8.1 / 16.1).
|
||||
import { RuntimeFault, clamp, parseDuration } from './types.js';
|
||||
import { AUDIO_AUTOMATION_CURVES, AUDIO_AUTOMATION_MODES, AUDIO_LIMITS } from './audio-contract.js';
|
||||
|
||||
export function sampleAutomationTrack(definition, evaluate, warn = () => {}) {
|
||||
const mode = definition.mode ?? 'absolute';
|
||||
const interpolation = definition.interpolation ?? 'linear';
|
||||
if (!AUDIO_AUTOMATION_MODES.includes(mode) || !AUDIO_AUTOMATION_CURVES.includes(interpolation)) {
|
||||
throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Invalid automation mode or interpolation.', definition.path);
|
||||
}
|
||||
if (!Array.isArray(definition.points) || definition.points.length < 2) {
|
||||
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Automation requires at least two points.', definition.path);
|
||||
}
|
||||
if (definition.points.length > AUDIO_LIMITS.automationPoints) {
|
||||
throw new RuntimeFault('ERR_NODE_LIMIT_EXCEEDED', 'Automation exceeds the point limit.', definition.path);
|
||||
}
|
||||
let previous = -1;
|
||||
const points = definition.points.map((point, index) => {
|
||||
const path = `${definition.path ?? definition.target}.points[${index}]`;
|
||||
if (typeof point.at !== 'string') throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Automation time must be a duration literal.', path);
|
||||
const at = parseDuration(point.at, path);
|
||||
if (at <= previous) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'Automation times must strictly increase.', path);
|
||||
previous = at;
|
||||
const value = evaluate(point.value, `${path}.value`);
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Automation values must resolve to finite numbers.', path);
|
||||
return Object.freeze({ at, value });
|
||||
});
|
||||
if (interpolation === 'exponential' && points.some((point) => point.value <= 0)) {
|
||||
warn({ code: 'WARN_AUTOMATION_FALLBACK', path: definition.path ?? definition.target, message: 'Nonpositive exponential endpoints use linear interpolation (once per track).' });
|
||||
}
|
||||
return Object.freeze({ ...definition, mode, interpolation, points: Object.freeze(points) });
|
||||
}
|
||||
|
||||
export function interpolateAutomation(curve, v0, v1, t) {
|
||||
if (curve === 'step') return v0;
|
||||
if (curve === 'exponential' && v0 > 0 && v1 > 0) return Math.exp(Math.log(v0) * (1 - t) + Math.log(v1) * t);
|
||||
const progress = curve === 'smooth' ? t * t * (3 - 2 * t) : t;
|
||||
return v0 * (1 - progress) + v1 * progress;
|
||||
}
|
||||
|
||||
export function automationValueAt(track, milliseconds) {
|
||||
const points = track.points;
|
||||
if (milliseconds <= points[0].at) return points[0].value;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const left = points[index - 1], right = points[index];
|
||||
if (milliseconds < right.at) return interpolateAutomation(track.interpolation, left.value, right.value, (milliseconds - left.at) / (right.at - left.at));
|
||||
}
|
||||
return points[points.length - 1].value;
|
||||
}
|
||||
|
||||
export function applyAutomationMode(base, value, mode) {
|
||||
return mode === 'offset' ? base + value : mode === 'scale' ? base * value : value;
|
||||
}
|
||||
|
||||
// Callers supply only stages their target exposes; the override sees the current
|
||||
// lower value on every evaluation, even while it masks automation.
|
||||
export function resolveNumericStages(base, { binding, automation, override, modulation = 0, min = -Infinity, max = Infinity, round } = {}) {
|
||||
let value = binding ? binding(base) : base;
|
||||
if (automation) value = automation(value);
|
||||
if (override) value = override(value);
|
||||
value += modulation;
|
||||
if (!Number.isFinite(value)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Numeric resolution produced a non-finite value.');
|
||||
if (round) value = round(value);
|
||||
return clamp(value, min, max);
|
||||
}
|
||||
@@ -9,7 +9,9 @@ export const AUDIO_LIMITS = Object.freeze({
|
||||
routesPerSound: 256,
|
||||
componentDepth: 8,
|
||||
resonatorModes: 16,
|
||||
oscillatorPartials: 64
|
||||
oscillatorPartials: 64,
|
||||
automationTracks: 64,
|
||||
automationPoints: 256
|
||||
});
|
||||
|
||||
export const AUDIO_NOISE_COLORS = Object.freeze(['white', 'pink', 'brown']);
|
||||
@@ -179,9 +181,12 @@ export const AUDIO_MODULATION_SOURCE_TYPES = Object.freeze(['constant', 'lfo', '
|
||||
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', 'release']);
|
||||
export const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'parameters', 'input']);
|
||||
export const AUDIO_GRAPH_FIELDS = Object.freeze(['nodes', 'routes', 'automation']);
|
||||
export const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'automation', 'mode', 'release']);
|
||||
export const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'automation', 'parameters', 'input']);
|
||||
export const AUDIO_AUTOMATION_FIELDS = Object.freeze(['target', 'mode', 'interpolation', 'points']);
|
||||
export const AUDIO_AUTOMATION_MODES = Object.freeze(['absolute', 'offset', 'scale']);
|
||||
export const AUDIO_AUTOMATION_CURVES = Object.freeze(['step', 'linear', 'exponential', 'smooth']);
|
||||
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 });
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Native control-signal graph. Automation is scheduled once on the audio clock;
|
||||
// modulation sums before the final clamp, not directly on an unclamped AudioParam.
|
||||
import { AUDIO_MODULATABLE, AUDIO_NODE_TYPES } from './audio-contract.js';
|
||||
import { applyAutomationMode, automationValueAt, resolveNumericStages } from './audio-automation.js';
|
||||
import { RuntimeFault, clamp } from './types.js';
|
||||
import { ValueResolver } from './values.js';
|
||||
|
||||
export function audioPropertyRange(node, property, ceiling) {
|
||||
const rule = node.type === 'component' ? node.exposes[property] : AUDIO_NODE_TYPES[node.type].fields[property];
|
||||
return { min: rule.min ?? -Infinity, max: rule.ceiling === 'audio' ? Math.min(rule.max, ceiling) : rule.max ?? Infinity };
|
||||
}
|
||||
|
||||
// Exact reference evaluator used by traces and by callers inspecting a voice.
|
||||
// Source samples are supplied in their own units; sampling this function draws no RNG.
|
||||
export function audioPropertyAt(plan, target, property, milliseconds, sourceValues = {}) {
|
||||
const visiting = new Set();
|
||||
const cache = new Map();
|
||||
const resolve = (path, field) => {
|
||||
const key = `${path}::${field}`;
|
||||
if (cache.has(key)) return cache.get(key);
|
||||
if (visiting.has(key)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Audio control cycle at '${key}'.`);
|
||||
const node = plan.nodes.find((item) => item.path === path);
|
||||
if (!node || !Object.hasOwn(node.type === 'component' ? node.exposes : AUDIO_MODULATABLE[node.type] ?? {}, field)) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Unsupported audio property '${key}'.`);
|
||||
visiting.add(key);
|
||||
const resolver = new ValueResolver((ref) => resolve(node.scope, ref.slice('inputs.'.length)));
|
||||
const base = resolver.evaluate(node.expressions?.[field] ?? node.values[field], null);
|
||||
const track = plan.automation?.find((item) => item.target === path && item.property === field);
|
||||
const modulation = plan.routes.filter((route) => route.kind === 'modulation' && route.to === path && route.property === field)
|
||||
.reduce((sum, route) => sum + (sourceValues[route.from] ?? 0) * route.depth, 0);
|
||||
const value = resolveNumericStages(base, {
|
||||
automation: track ? (lower) => applyAutomationMode(lower, automationValueAt(track, milliseconds), track.mode) : undefined,
|
||||
modulation, ...audioPropertyRange(node, field, plan.ceiling)
|
||||
});
|
||||
visiting.delete(key);
|
||||
cache.set(key, value);
|
||||
return value;
|
||||
};
|
||||
return resolve(target, property);
|
||||
}
|
||||
|
||||
export function scheduleNativeAutomation(param, track, startTime) {
|
||||
if (track.interpolation === 'smooth') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Smooth automation requires the polynomial control graph.');
|
||||
const points = track.points;
|
||||
param.setValueAtTime(points[0].value, startTime);
|
||||
param.setValueAtTime(points[0].value, startTime + points[0].at / 1000);
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const left = points[i - 1], right = points[i];
|
||||
const end = startTime + right.at / 1000;
|
||||
if (track.interpolation === 'step') param.setValueAtTime(right.value, end);
|
||||
else if (track.interpolation === 'exponential' && left.value > 0 && right.value > 0) param.exponentialRampToValueAtTime(right.value, end);
|
||||
else param.linearRampToValueAtTime(right.value, end);
|
||||
}
|
||||
}
|
||||
|
||||
export function createAudioControls(context, plan, entries, disposeWith, startTime) {
|
||||
const nodes = new Map(plan.nodes.map((node) => [node.path, node]));
|
||||
const cache = new Map();
|
||||
const building = new Set();
|
||||
const constants = new Map();
|
||||
const literal = (value) => ({ value, min: value, max: value });
|
||||
const own = (node) => { disposeWith(() => node.disconnect()); return node; };
|
||||
const scheduledSource = (value, min, max, schedule) => {
|
||||
const source = own(context.createConstantSource());
|
||||
source.offset.value = value;
|
||||
disposeWith(() => { source.offset.cancelScheduledValues?.(context.currentTime); try { source.stop(); } catch { /* not started */ } });
|
||||
schedule?.(source.offset);
|
||||
source.start(startTime);
|
||||
return { output: source, min, max };
|
||||
};
|
||||
const constant = (value, track) => {
|
||||
if (track?.interpolation === 'smooth') return smoothSignal(track);
|
||||
if (!track && constants.has(value)) return constants.get(value);
|
||||
const values = track ? track.points.map((point) => point.value) : [value];
|
||||
const signal = scheduledSource(value, Math.min(...values), Math.max(...values), track ? (param) => scheduleNativeAutomation(param, track, startTime) : undefined);
|
||||
if (!track) constants.set(value, signal);
|
||||
return signal;
|
||||
};
|
||||
const materialize = (signal) => signal.output ? signal : constant(signal.value);
|
||||
const scale = (signal, factor) => {
|
||||
if (signal.value !== undefined) return literal(signal.value * factor);
|
||||
if (factor === 0) return literal(0);
|
||||
if (factor === 1) return signal;
|
||||
const gain = own(context.createGain());
|
||||
gain.gain.value = factor;
|
||||
signal.output.connect(gain);
|
||||
return { output: gain, min: Math.min(signal.min * factor, signal.max * factor), max: Math.max(signal.min * factor, signal.max * factor) };
|
||||
};
|
||||
const add = (a, b) => {
|
||||
if (a.value !== undefined && b.value !== undefined) return literal(a.value + b.value);
|
||||
if (a.value === 0) return b;
|
||||
if (b.value === 0) return a;
|
||||
const sum = own(context.createGain());
|
||||
materialize(a).output.connect(sum);
|
||||
materialize(b).output.connect(sum);
|
||||
return { output: sum, min: a.min + b.min, max: a.max + b.max };
|
||||
};
|
||||
const multiply = (a, b) => {
|
||||
if (a.value !== undefined) return scale(b, a.value);
|
||||
if (b.value !== undefined) return scale(a, b.value);
|
||||
const gain = own(context.createGain());
|
||||
gain.gain.value = 0;
|
||||
a.output.connect(gain);
|
||||
b.output.connect(gain.gain);
|
||||
const bounds = [a.min * b.min, a.min * b.max, a.max * b.min, a.max * b.max];
|
||||
return { output: gain, min: Math.min(...bounds), max: Math.max(...bounds) };
|
||||
};
|
||||
const smoothSignal = (track) => {
|
||||
const points = track.points;
|
||||
const spans = points.slice(1).map((point, i) => point.value - points[i].value);
|
||||
const values = points.map((point) => point.value);
|
||||
const low = Math.min(...values), high = Math.max(...values);
|
||||
const progress = scheduledSource(0, 0, 1, (param) => {
|
||||
param.setValueAtTime(0, startTime);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
param.setValueAtTime(0, startTime + points[i - 1].at / 1000);
|
||||
param.linearRampToValueAtTime(1, startTime + points[i].at / 1000);
|
||||
}
|
||||
});
|
||||
const steps = (initial, min, max, valueAt) => scheduledSource(initial, min, max, (param) => {
|
||||
param.setValueAtTime(initial, startTime);
|
||||
for (let i = 0; i < points.length - 1; i++) param.setValueAtTime(valueAt(i), startTime + points[i].at / 1000);
|
||||
});
|
||||
const origin = steps(points[0].value, low, high, (i) => points[i].value);
|
||||
const excursion = steps(spans[0], Math.min(...spans), Math.max(...spans), (i) => spans[i]);
|
||||
// a-rate polynomial 3t² - 2t³, not a sampled approximation of the curve.
|
||||
const smooth = multiply(multiply(progress, progress), add(literal(3), scale(progress, -2)));
|
||||
return { ...add(origin, multiply(excursion, smooth)), min: low, max: high };
|
||||
};
|
||||
const shape = (signal, fn, lower = signal.min, upper = signal.max, samples = 4097) => {
|
||||
if (signal.value !== undefined || lower === upper) return literal(fn(signal.value ?? lower));
|
||||
if (!Number.isFinite(lower) || !Number.isFinite(upper)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Audio control bounds must be finite.');
|
||||
const half = (upper - lower) / 2;
|
||||
const normalized = add(scale(signal, 1 / half), literal(-lower / half - 1));
|
||||
const shaper = own(context.createWaveShaper());
|
||||
shaper.curve = Float32Array.from({ length: samples }, (_, i) => fn(lower + (upper - lower) * i / (samples - 1)));
|
||||
const bounds = [...shaper.curve];
|
||||
materialize(normalized).output.connect(shaper);
|
||||
disposeWith(() => { shaper.curve = null; });
|
||||
return { output: shaper, min: Math.min(...bounds), max: Math.max(...bounds) };
|
||||
};
|
||||
const bounded = (signal, min, max) => {
|
||||
const lower = Math.max(signal.min, min), upper = Math.min(signal.max, max);
|
||||
if (signal.max <= min) return literal(min);
|
||||
if (signal.min >= max) return literal(max);
|
||||
if (signal.min >= min && signal.max <= max) return signal;
|
||||
// WaveShaper saturates outside [-1,1]; these three collinear points make
|
||||
// the property safety clamp exact, including after an arbitrary route sum.
|
||||
return shape(signal, (value) => value, lower, upper, 3);
|
||||
};
|
||||
const abs = (signal) => signal.min >= 0 ? signal : signal.max <= 0 ? scale(signal, -1) : shape(signal, Math.abs, -Math.max(-signal.min, signal.max), Math.max(-signal.min, signal.max), 3);
|
||||
const minimum = (a, b) => scale(add(add(a, b), scale(abs(add(a, scale(b, -1))), -1)), 0.5);
|
||||
const maximum = (a, b) => scale(add(add(a, b), abs(add(a, scale(b, -1)))), 0.5);
|
||||
|
||||
const expression = (spec, scope) => {
|
||||
if (typeof spec === 'number') return literal(spec);
|
||||
if (spec.ref) return property(scope, spec.ref.slice('inputs.'.length));
|
||||
const args = spec.args.map((arg) => expression(arg, scope));
|
||||
if (args.every((arg) => arg.value !== undefined)) return literal(new ValueResolver(() => {}).evaluate({ op: spec.op, args: args.map((arg) => arg.value) }, null));
|
||||
const [a, b, c] = args;
|
||||
switch (spec.op) {
|
||||
case 'add': return add(a, b);
|
||||
case 'subtract': return add(a, scale(b, -1));
|
||||
case 'negate': return scale(a, -1);
|
||||
case 'multiply': return multiply(a, b);
|
||||
case 'divide': return multiply(a, shape(b, (value) => value === 0 ? 0 : 1 / value));
|
||||
case 'abs': return abs(a);
|
||||
case 'min': return minimum(a, b);
|
||||
case 'max': return maximum(a, b);
|
||||
case 'clamp': return minimum(maximum(a, b), c);
|
||||
case 'lerp': return add(a, multiply(add(b, scale(a, -1)), c));
|
||||
case 'round': case 'floor': case 'ceil': return shape(a, Math[spec.op]);
|
||||
default: throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported audio expression '${spec.op}'.`);
|
||||
}
|
||||
};
|
||||
const modulationSource = (route) => {
|
||||
const node = nodes.get(route.from), output = entries.get(route.from)?.output;
|
||||
if (!output) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Missing modulation source '${route.from}'.`);
|
||||
const v = node.values;
|
||||
const range = node.type === 'constant' ? [v.value, v.value]
|
||||
: node.type === 'sample-hold' ? [Math.min(0, v.min), Math.max(0, v.max)]
|
||||
: node.type === 'lfo' ? [v.polarity === 'unipolar' ? 0 : -v.amplitude, v.amplitude] : [-1, 1];
|
||||
return { output, min: range[0], max: range[1] };
|
||||
};
|
||||
const property = (path, field) => {
|
||||
const key = `${path}::${field}`;
|
||||
if (cache.has(key)) return cache.get(key);
|
||||
if (building.has(key)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Audio control cycle at '${key}'.`);
|
||||
building.add(key);
|
||||
const node = nodes.get(path);
|
||||
let signal = expression(node.expressions?.[field] ?? node.values[field], node.scope);
|
||||
const track = plan.automation?.find((item) => item.target === path && item.property === field);
|
||||
if (track) {
|
||||
const curve = constant(track.points[0].value, track);
|
||||
signal = track.mode === 'offset' ? add(signal, curve) : track.mode === 'scale' ? multiply(signal, curve) : curve;
|
||||
}
|
||||
for (const route of plan.routes) if (route.kind === 'modulation' && route.to === path && route.property === field) signal = add(signal, scale(modulationSource(route), route.depth));
|
||||
const { min, max } = audioPropertyRange(node, field, plan.ceiling);
|
||||
signal = bounded(signal, min, max);
|
||||
cache.set(key, signal);
|
||||
building.delete(key);
|
||||
return signal;
|
||||
};
|
||||
const connect = (signal, param, factor = 1) => {
|
||||
if (signal.value !== undefined) param.value = signal.value * factor;
|
||||
else {
|
||||
param.value = 0;
|
||||
scale(signal, factor).output.connect(param);
|
||||
}
|
||||
disposeWith(() => param.cancelScheduledValues?.(context.currentTime));
|
||||
};
|
||||
return {
|
||||
apply() {
|
||||
for (const node of plan.nodes) {
|
||||
if (node.implicit || node.type === 'component') continue;
|
||||
const entry = entries.get(node.path);
|
||||
for (const field of Object.keys(AUDIO_MODULATABLE[node.type] ?? {})) {
|
||||
const signal = property(node.path, field);
|
||||
if (field === 'fundamental') {
|
||||
for (const band of entry.bands ?? []) if (band.ratio !== undefined) {
|
||||
const frequency = bounded(scale(signal, band.ratio), 0.1, plan.ceiling);
|
||||
connect(frequency, band.frequency);
|
||||
connect(maximum(literal(1), scale(frequency, band.decay / 3000)), band.q);
|
||||
}
|
||||
} else connect(signal, entry.params[field], field === 'time' ? 0.001 : 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
dispose() { cache.clear(); constants.clear(); nodes.clear(); building.clear(); }
|
||||
};
|
||||
}
|
||||
+314
-271
@@ -6,6 +6,7 @@ import {
|
||||
AUDIO_LIFECYCLE_STATES,
|
||||
AUDIO_LIFECYCLE_TRANSITIONS,
|
||||
AUDIO_MODE_FIELDS,
|
||||
AUDIO_MODULATABLE,
|
||||
AUDIO_NODE_TYPES,
|
||||
AUDIO_PARTIAL_FIELDS,
|
||||
AUDIO_VOICE_LIMITS,
|
||||
@@ -16,7 +17,11 @@ import {
|
||||
durationMilliseconds,
|
||||
expandSoundGraph
|
||||
} from './audio-graph.js';
|
||||
import { applyAutomationMode, sampleAutomationTrack } from './audio-automation.js';
|
||||
import { createAudioControls } from './audio-controls.js';
|
||||
import { ValueResolver } from './values.js';
|
||||
import { ResolutionEngine } from './resolution.js';
|
||||
import { SeededRNG } from './rng.js';
|
||||
import { RuntimeFault, clamp } from './types.js';
|
||||
|
||||
export function soundInstanceKey(soundId, ordinal) {
|
||||
@@ -71,6 +76,10 @@ export function instantiateSoundGraph(document, soundId, options = {}) {
|
||||
resolver.currentScope = scope;
|
||||
return resolver.evaluate(spec, stream, path);
|
||||
};
|
||||
const sample = (spec, scope, path) => {
|
||||
resolver.currentScope = scope;
|
||||
return resolver.sample(spec, stream, path, (ref) => scope && ref.startsWith('inputs.'));
|
||||
};
|
||||
|
||||
const clampField = (value, spec, nodePath, field) => {
|
||||
let limitMax = spec.max;
|
||||
@@ -82,69 +91,78 @@ export function instantiateSoundGraph(document, soundId, options = {}) {
|
||||
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 } });
|
||||
const nodes = expansion.nodes.filter((node) => node.implicit).map((node) => ({ path: node.path, type: 'gain', implicit: true, values: { gain: 1 } }));
|
||||
const routes = [];
|
||||
const automation = [];
|
||||
const expandedNodes = new Map(expansion.nodes.map((node) => [node.path, node]));
|
||||
for (const item of expansion.samplingOrder) {
|
||||
if (item.kind === 'route') {
|
||||
const route = expansion.routes[item.index];
|
||||
routes.push(route.kind === 'audio' ? { ...route } : { ...route, depth: evaluate(route.depth, route.scope, `${route.path}.depth`) });
|
||||
continue;
|
||||
}
|
||||
if (item.kind === 'automation') {
|
||||
const track = expansion.automation[item.index];
|
||||
automation.push(sampleAutomationTrack(track, (value, path) => evaluate(value, track.scope, path), (warning) => warnings.push(warning)));
|
||||
continue;
|
||||
}
|
||||
const node = expandedNodes.get(item.path);
|
||||
if (!node) continue;
|
||||
const contract = AUDIO_NODE_TYPES[node.type];
|
||||
if (!contract) continue;
|
||||
const scope = node.scope;
|
||||
const values = {};
|
||||
const expressions = {};
|
||||
|
||||
if (node.type === 'component') {
|
||||
const declared = node.exposes ?? {};
|
||||
const supplied = node.spec.values ?? {};
|
||||
const resolved = {};
|
||||
for (const [name, rule] of Object.entries(declared)) {
|
||||
const names = [...Object.keys(supplied), ...Object.keys(declared).filter((name) => !Object.hasOwn(supplied, name))];
|
||||
for (const name of names) {
|
||||
const rule = declared[name];
|
||||
const raw = Object.hasOwn(supplied, name) ? supplied[name] : rule.default;
|
||||
const value = evaluate(raw, scope, `${node.path}.values.${name}`);
|
||||
expressions[name] = sample(raw, scope, `${node.path}.values.${name}`);
|
||||
const value = evaluate(expressions[name], scope, `${node.path}.values.${name}`);
|
||||
resolved[name] = clamp(value, rule.min ?? -Infinity, rule.max ?? Infinity);
|
||||
}
|
||||
componentValues.set(node.path, resolved);
|
||||
nodes.push({ path: node.path, type: 'component', component: node.component, values: resolved, passthrough: true });
|
||||
nodes.push({ path: node.path, type: 'component', component: node.component, values: resolved, expressions, exposes: declared, scope, passthrough: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [field, spec] of Object.entries(contract.fields)) {
|
||||
if (spec.kind === 'partials' || spec.kind === 'modes') continue;
|
||||
const fields = [...Object.keys(node.spec).filter((field) => Object.hasOwn(contract.fields, field)), ...Object.keys(contract.fields).filter((field) => !Object.hasOwn(node.spec, field))];
|
||||
for (const field of fields) {
|
||||
const spec = contract.fields[field];
|
||||
if (spec.kind === 'partials' || spec.kind === 'modes') {
|
||||
const table = spec.kind === 'partials' ? AUDIO_PARTIAL_FIELDS : AUDIO_MODE_FIELDS;
|
||||
values[field] = (node.spec[field] ?? []).map((entry, index) => {
|
||||
const sampled = {};
|
||||
const names = [...Object.keys(entry), ...Object.keys(table).filter((name) => !Object.hasOwn(entry, name) && table[name].default !== undefined)];
|
||||
for (const name of names) {
|
||||
const value = Object.hasOwn(entry, name) ? entry[name] : table[name].default;
|
||||
sampled[name] = name === 'decay' ? durationMilliseconds(value) : evaluate(value, scope, `${node.path}.${field}[${index}].${name}`);
|
||||
}
|
||||
return sampled;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const authored = Object.hasOwn(node.spec, field) ? node.spec[field] : spec.default;
|
||||
if (authored === undefined) continue;
|
||||
if (spec.kind === 'enum') { values[field] = authored; continue; }
|
||||
if (spec.kind === 'duration') { values[field] = durationMilliseconds(authored); continue; }
|
||||
const raw = evaluate(authored, scope, `${node.path}.${field}`);
|
||||
const frozen = sample(authored, scope, `${node.path}.${field}`);
|
||||
const raw = evaluate(frozen, scope, `${node.path}.${field}`);
|
||||
if (Object.hasOwn(AUDIO_MODULATABLE[node.type] ?? {}, field)) expressions[field] = frozen;
|
||||
values[field] = clampField(raw, spec, node.path, field);
|
||||
}
|
||||
|
||||
if (node.type === 'oscillator' && values.waveform === 'custom') {
|
||||
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;
|
||||
values.harmonics = values.harmonics.filter((partial) => partial.ratio * values.frequency <= ceiling);
|
||||
}
|
||||
|
||||
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;
|
||||
values.modes = values.modes.map((mode) => ({ ...mode, frequency: mode.frequency ?? mode.ratio * values.fundamental })).filter((mode) => mode.frequency <= ceiling);
|
||||
}
|
||||
|
||||
if (node.type === 'sample-hold') {
|
||||
@@ -154,23 +172,26 @@ export function instantiateSoundGraph(document, soundId, options = {}) {
|
||||
values.stream = rng ? rng.stream('sound', values.streamKey) : null;
|
||||
}
|
||||
|
||||
nodes.push({ path: node.path, type: node.type, values, scope });
|
||||
nodes.push({ path: node.path, type: node.type, values, expressions, scope });
|
||||
}
|
||||
|
||||
const routes = expansion.routes.map((route) => {
|
||||
if (route.kind !== 'modulation') return { ...route };
|
||||
const owner = expansion.nodes.find((node) => node.path === route.to);
|
||||
return { ...route, depth: evaluate(route.depth, owner?.scope ?? null, `${route.path}.depth`) };
|
||||
});
|
||||
|
||||
let endingBoundMs = null;
|
||||
if (expansion.mode === 'oneshot') {
|
||||
endingBoundMs = computeDeterminableEndingBound(expansion, nodes);
|
||||
const boundedNodes = nodes.map((node) => {
|
||||
if (node.type !== 'delay') return node;
|
||||
const track = automation.find((item) => item.target === node.path && item.property === 'time');
|
||||
const modulated = routes.some((route) => route.kind === 'modulation' && route.to === node.path && route.property === 'time');
|
||||
// A varying delay must not dispose its voice at the shorter base-time tail.
|
||||
const maximumTime = modulated ? 10000 : track ? clamp(Math.max(...track.points.map((point) => applyAutomationMode(node.values.time, point.value, track.mode))), 0, 10000) : node.values.time;
|
||||
return { ...node, values: { ...node.values, time: maximumTime } };
|
||||
});
|
||||
endingBoundMs = computeDeterminableEndingBound(expansion, boundedNodes);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
routes,
|
||||
automation,
|
||||
warnings,
|
||||
errors: [],
|
||||
mode: expansion.mode,
|
||||
@@ -266,8 +287,8 @@ function periodicWave(context, partials) {
|
||||
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.
|
||||
// Builds one voice and its audio-clock automation; partial construction is also
|
||||
// disposable, so a failed scheduler cannot leave connected or running sources.
|
||||
export function realizeSoundGraph(context, plan, destination) {
|
||||
const created = new Map();
|
||||
const disposers = [];
|
||||
@@ -281,227 +302,236 @@ export function realizeSoundGraph(context, plan, 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));
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
schedule(0);
|
||||
entry = { input: null, output: source, params: {} };
|
||||
starters.push(() => source.start());
|
||||
disposers.push(() => { clearInterval(timer); try { source.stop(); } catch { /* already stopped */ } source.disconnect(); void tick; });
|
||||
} else if (type === 'gain') {
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = values.gain ?? 1;
|
||||
entry = { input: gain, output: gain, params: { gain: gain.gain } };
|
||||
disposers.push(() => gain.disconnect());
|
||||
} else if (type === 'filter') {
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = values.mode;
|
||||
filter.frequency.value = values.frequency;
|
||||
filter.Q.value = values.q;
|
||||
filter.gain.value = values.gain;
|
||||
filter.detune.value = values.detune;
|
||||
entry = { input: filter, output: filter, params: { frequency: filter.frequency, q: filter.Q, gain: filter.gain, detune: filter.detune } };
|
||||
disposers.push(() => filter.disconnect());
|
||||
} else if (type === 'compressor') {
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
compressor.threshold.value = values.threshold;
|
||||
compressor.knee.value = values.knee;
|
||||
compressor.ratio.value = values.ratio;
|
||||
compressor.attack.value = values.attack / 1000;
|
||||
compressor.release.value = values.release / 1000;
|
||||
entry = { input: compressor, output: compressor, params: {} };
|
||||
disposers.push(() => compressor.disconnect());
|
||||
} else if (type === 'waveshaper') {
|
||||
const shaper = context.createWaveShaper();
|
||||
shaper.curve = shaperCurve(values.shape, values.amount);
|
||||
shaper.oversample = values.oversample;
|
||||
entry = { input: shaper, output: shaper, params: {} };
|
||||
disposers.push(() => shaper.disconnect());
|
||||
} else if (type === 'delay') {
|
||||
const input = context.createGain();
|
||||
const delay = context.createDelay(10);
|
||||
delay.delayTime.value = values.time / 1000;
|
||||
const feedback = context.createGain();
|
||||
feedback.gain.value = values.feedback;
|
||||
const dry = context.createGain();
|
||||
const wet = context.createGain();
|
||||
const output = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
wet.gain.value = values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
input.connect(delay);
|
||||
delay.connect(feedback).connect(delay);
|
||||
delay.connect(wet).connect(output);
|
||||
entry = { input, output, params: { time: delay.delayTime } };
|
||||
disposers.push(() => [input, delay, feedback, dry, wet, output].forEach((item) => item.disconnect()));
|
||||
} else if (type === 'reverb') {
|
||||
const input = context.createGain();
|
||||
const convolver = context.createConvolver();
|
||||
convolver.buffer = reverbBuffer(context, values);
|
||||
const predelay = context.createDelay(1);
|
||||
predelay.delayTime.value = values.predelay / 1000;
|
||||
const dry = context.createGain();
|
||||
const wet = context.createGain();
|
||||
const output = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
wet.gain.value = values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
input.connect(predelay).connect(convolver).connect(wet).connect(output);
|
||||
entry = { input, output, params: {} };
|
||||
disposers.push(() => [input, convolver, predelay, dry, wet, output].forEach((item) => item.disconnect()));
|
||||
} else if (type === 'stereo-pan') {
|
||||
const panner = context.createStereoPanner();
|
||||
panner.pan.value = values.pan;
|
||||
entry = { input: panner, output: panner, params: { pan: panner.pan } };
|
||||
disposers.push(() => panner.disconnect());
|
||||
} else if (type === 'mixer') {
|
||||
const mixer = context.createGain();
|
||||
mixer.gain.value = 1;
|
||||
entry = { input: mixer, output: mixer, params: {} };
|
||||
disposers.push(() => mixer.disconnect());
|
||||
} else if (type === 'resonator') {
|
||||
const input = context.createGain();
|
||||
const output = context.createGain();
|
||||
const dry = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
const wet = context.createGain();
|
||||
wet.gain.value = values.mix;
|
||||
wet.connect(output);
|
||||
const bands = [];
|
||||
for (const mode of values.modes ?? []) {
|
||||
const band = context.createBiquadFilter();
|
||||
band.type = 'bandpass';
|
||||
band.frequency.value = mode.frequency;
|
||||
band.Q.value = Math.max(1, (mode.frequency * (mode.decay / 1000)) / 3);
|
||||
const level = context.createGain();
|
||||
level.gain.value = mode.gain;
|
||||
input.connect(band).connect(level).connect(wet);
|
||||
bands.push(band, level);
|
||||
}
|
||||
entry = { input, output, params: {} };
|
||||
disposers.push(() => [input, output, dry, wet, ...bands].forEach((item) => item.disconnect()));
|
||||
}
|
||||
|
||||
if (entry) created.set(path, entry);
|
||||
}
|
||||
|
||||
for (const route of plan.routes) {
|
||||
const source = created.get(route.from);
|
||||
if (!source?.output) continue;
|
||||
if (route.kind === 'audio') {
|
||||
const target = created.get(route.to);
|
||||
if (target?.input) source.output.connect(target.input);
|
||||
continue;
|
||||
}
|
||||
const target = created.get(route.to);
|
||||
const param = target?.params?.[route.property];
|
||||
if (!param) continue;
|
||||
const depth = context.createGain();
|
||||
depth.gain.value = route.property === 'time' ? route.depth / 1000 : route.depth;
|
||||
source.output.connect(depth).connect(param);
|
||||
disposers.push(() => depth.disconnect());
|
||||
}
|
||||
|
||||
for (const start of starters) start();
|
||||
|
||||
return {
|
||||
sink,
|
||||
releaseGain,
|
||||
dispose() {
|
||||
for (const release of disposers.reverse()) {
|
||||
try { release(); } catch { /* disposal is best effort */ }
|
||||
}
|
||||
sink.disconnect();
|
||||
releaseGain.disconnect();
|
||||
created.clear();
|
||||
const startTime = now();
|
||||
const dispose = () => {
|
||||
for (const release of disposers.splice(0).reverse()) {
|
||||
try { release(); } catch { /* disposal is best effort */ }
|
||||
}
|
||||
sink.disconnect();
|
||||
releaseGain.disconnect();
|
||||
created.clear();
|
||||
starters.length = 0;
|
||||
};
|
||||
|
||||
try {
|
||||
for (const node of plan.nodes) {
|
||||
const { path, type, values } = node;
|
||||
if (type === 'component') continue;
|
||||
let entry = null;
|
||||
|
||||
if (type === 'oscillator') {
|
||||
const oscillator = context.createOscillator();
|
||||
if (values.waveform === 'custom') oscillator.setPeriodicWave(periodicWave(context, values.harmonics ?? []));
|
||||
else oscillator.type = values.waveform;
|
||||
oscillator.frequency.value = values.frequency;
|
||||
oscillator.detune.value = values.detune;
|
||||
entry = { input: null, output: oscillator, params: { frequency: oscillator.frequency, detune: oscillator.detune } };
|
||||
starters.push(() => oscillator.start(startTime));
|
||||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); });
|
||||
} else if (type === 'noise') {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = noiseBuffer(context, values.color);
|
||||
source.loop = true;
|
||||
entry = { input: null, output: source, params: {} };
|
||||
starters.push(() => source.start(startTime));
|
||||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||||
} else if (type === 'impulse') {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = noiseBuffer(context, values.color);
|
||||
const envelope = context.createGain();
|
||||
const seconds = values.duration / 1000;
|
||||
const fade = Math.min(0.001, seconds * 0.1);
|
||||
const start = startTime;
|
||||
envelope.gain.setValueAtTime(values.amplitude, start);
|
||||
if (values.decay === 'linear') envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||||
else if (values.decay === 'exponential') {
|
||||
envelope.gain.exponentialRampToValueAtTime(Math.max(1e-4, values.amplitude * 0.001), start + seconds - fade);
|
||||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||||
} else {
|
||||
envelope.gain.setValueAtTime(values.amplitude, start + seconds - fade);
|
||||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||||
}
|
||||
source.connect(envelope);
|
||||
entry = { input: null, output: envelope, params: {} };
|
||||
starters.push(() => source.start(startTime, 0, seconds));
|
||||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); envelope.disconnect(); });
|
||||
} else if (type === 'constant') {
|
||||
const source = context.createConstantSource();
|
||||
source.offset.value = values.value;
|
||||
entry = { input: null, output: source, params: { value: source.offset } };
|
||||
starters.push(() => source.start(startTime));
|
||||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||||
} else if (type === 'lfo') {
|
||||
const oscillator = context.createOscillator();
|
||||
oscillator.type = values.waveform;
|
||||
oscillator.frequency.value = values.frequency;
|
||||
const depth = context.createGain();
|
||||
depth.gain.value = values.polarity === 'unipolar' ? values.amplitude / 2 : values.amplitude;
|
||||
oscillator.connect(depth);
|
||||
let output = depth;
|
||||
if (values.polarity === 'unipolar') {
|
||||
const offset = context.createConstantSource();
|
||||
offset.offset.value = values.amplitude / 2;
|
||||
const sum = context.createGain();
|
||||
depth.connect(sum);
|
||||
offset.connect(sum);
|
||||
output = sum;
|
||||
starters.push(() => offset.start(startTime));
|
||||
disposers.push(() => { try { offset.stop(); } catch { /* already stopped */ } offset.disconnect(); sum.disconnect(); });
|
||||
}
|
||||
entry = { input: null, output, params: {} };
|
||||
starters.push(() => oscillator.start(startTime + ((values.phase / 360) / Math.max(values.frequency, 1e-6))));
|
||||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); depth.disconnect(); });
|
||||
} else if (type === 'sample-hold') {
|
||||
const source = context.createConstantSource();
|
||||
const period = 1 / values.rate;
|
||||
const slew = values.slew / 1000;
|
||||
let held = 0;
|
||||
source.offset.value = 0;
|
||||
const schedule = (index) => {
|
||||
const target = values.stream
|
||||
? values.min + (values.stream.nextFloat() * (values.max - values.min))
|
||||
: values.min;
|
||||
const at = now() + (index * period);
|
||||
if (slew > 0) source.offset.linearRampToValueAtTime(target, at + slew);
|
||||
else source.offset.setValueAtTime(target, at);
|
||||
held = target;
|
||||
return held;
|
||||
};
|
||||
let tick = 0;
|
||||
const timer = setInterval(() => { schedule(0); tick += 1; }, Math.max(10, period * 1000));
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
schedule(0);
|
||||
entry = { input: null, output: source, params: {} };
|
||||
starters.push(() => source.start(startTime));
|
||||
disposers.push(() => { clearInterval(timer); try { source.stop(); } catch { /* already stopped */ } source.disconnect(); void tick; });
|
||||
} else if (type === 'gain') {
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = values.gain ?? 1;
|
||||
entry = { input: gain, output: gain, params: { gain: gain.gain } };
|
||||
disposers.push(() => gain.disconnect());
|
||||
} else if (type === 'filter') {
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = values.mode;
|
||||
filter.frequency.value = values.frequency;
|
||||
filter.Q.value = values.q;
|
||||
filter.gain.value = values.gain;
|
||||
filter.detune.value = values.detune;
|
||||
entry = { input: filter, output: filter, params: { frequency: filter.frequency, q: filter.Q, gain: filter.gain, detune: filter.detune } };
|
||||
disposers.push(() => filter.disconnect());
|
||||
} else if (type === 'compressor') {
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
compressor.threshold.value = values.threshold;
|
||||
compressor.knee.value = values.knee;
|
||||
compressor.ratio.value = values.ratio;
|
||||
compressor.attack.value = values.attack / 1000;
|
||||
compressor.release.value = values.release / 1000;
|
||||
entry = { input: compressor, output: compressor, params: {} };
|
||||
disposers.push(() => compressor.disconnect());
|
||||
} else if (type === 'waveshaper') {
|
||||
const shaper = context.createWaveShaper();
|
||||
shaper.curve = shaperCurve(values.shape, values.amount);
|
||||
shaper.oversample = values.oversample;
|
||||
entry = { input: shaper, output: shaper, params: {} };
|
||||
disposers.push(() => shaper.disconnect());
|
||||
} else if (type === 'delay') {
|
||||
const input = context.createGain();
|
||||
const delay = context.createDelay(10);
|
||||
delay.delayTime.value = values.time / 1000;
|
||||
const feedback = context.createGain();
|
||||
feedback.gain.value = values.feedback;
|
||||
const dry = context.createGain();
|
||||
const wet = context.createGain();
|
||||
const output = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
wet.gain.value = values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
input.connect(delay);
|
||||
delay.connect(feedback).connect(delay);
|
||||
delay.connect(wet).connect(output);
|
||||
entry = { input, output, params: { time: delay.delayTime } };
|
||||
disposers.push(() => [input, delay, feedback, dry, wet, output].forEach((item) => item.disconnect()));
|
||||
} else if (type === 'reverb') {
|
||||
const input = context.createGain();
|
||||
const convolver = context.createConvolver();
|
||||
convolver.buffer = reverbBuffer(context, values);
|
||||
const predelay = context.createDelay(1);
|
||||
predelay.delayTime.value = values.predelay / 1000;
|
||||
const dry = context.createGain();
|
||||
const wet = context.createGain();
|
||||
const output = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
wet.gain.value = values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
input.connect(predelay).connect(convolver).connect(wet).connect(output);
|
||||
entry = { input, output, params: {} };
|
||||
disposers.push(() => [input, convolver, predelay, dry, wet, output].forEach((item) => item.disconnect()));
|
||||
} else if (type === 'stereo-pan') {
|
||||
const panner = context.createStereoPanner();
|
||||
panner.pan.value = values.pan;
|
||||
entry = { input: panner, output: panner, params: { pan: panner.pan } };
|
||||
disposers.push(() => panner.disconnect());
|
||||
} else if (type === 'mixer') {
|
||||
const mixer = context.createGain();
|
||||
mixer.gain.value = 1;
|
||||
entry = { input: mixer, output: mixer, params: {} };
|
||||
disposers.push(() => mixer.disconnect());
|
||||
} else if (type === 'resonator') {
|
||||
const input = context.createGain();
|
||||
const output = context.createGain();
|
||||
const dry = context.createGain();
|
||||
dry.gain.value = 1 - values.mix;
|
||||
input.connect(dry).connect(output);
|
||||
const wet = context.createGain();
|
||||
wet.gain.value = values.mix;
|
||||
wet.connect(output);
|
||||
const bands = [];
|
||||
const controls = [];
|
||||
for (const mode of values.modes ?? []) {
|
||||
const band = context.createBiquadFilter();
|
||||
band.type = 'bandpass';
|
||||
band.frequency.value = mode.frequency;
|
||||
band.Q.value = Math.max(1, (mode.frequency * (mode.decay / 1000)) / 3);
|
||||
const level = context.createGain();
|
||||
level.gain.value = mode.gain;
|
||||
input.connect(band).connect(level).connect(wet);
|
||||
bands.push(band, level);
|
||||
controls.push({ ratio: mode.ratio, decay: mode.decay, frequency: band.frequency, q: band.Q });
|
||||
}
|
||||
entry = { input, output, params: {}, bands: controls };
|
||||
disposers.push(() => [input, output, dry, wet, ...bands].forEach((item) => item.disconnect()));
|
||||
}
|
||||
|
||||
if (entry) created.set(path, entry);
|
||||
}
|
||||
|
||||
for (const route of plan.routes) {
|
||||
const source = created.get(route.from);
|
||||
if (!source?.output) continue;
|
||||
if (route.kind === 'audio') {
|
||||
const target = created.get(route.to);
|
||||
if (target?.input) source.output.connect(target.input);
|
||||
continue;
|
||||
}
|
||||
// Modulation routes are connected by the final, clamped control pipeline.
|
||||
}
|
||||
|
||||
const controls = createAudioControls(context, plan, created, (release) => disposers.push(release), startTime);
|
||||
disposers.push(() => controls.dispose());
|
||||
controls.apply();
|
||||
|
||||
for (const start of starters) start();
|
||||
|
||||
return {
|
||||
sink,
|
||||
releaseGain,
|
||||
startTime,
|
||||
dispose
|
||||
};
|
||||
} catch (error) {
|
||||
dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class SoundInstance {
|
||||
@@ -648,11 +678,15 @@ export class SoundInstance {
|
||||
this.transition('FINISHED');
|
||||
}
|
||||
}
|
||||
this.transition('DISPOSED');
|
||||
// FAILED is terminal, but still owns the same resource cleanup obligation.
|
||||
if (this.state !== 'FAILED') this.transition('DISPOSED');
|
||||
if (this.realized) {
|
||||
try { this.realized.dispose(); } catch { /* best effort */ }
|
||||
this.realized = null;
|
||||
}
|
||||
this.plan = null;
|
||||
this.bus = null;
|
||||
this.context = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,10 +699,13 @@ export class SoundInstance {
|
||||
// ceilings (16.6), and the measured master-protection contract (PRD 58) are Phase 3c;
|
||||
// the master chain built here is engine-owned and unbypassable.
|
||||
export class AudioSubsystem {
|
||||
constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null } = {}) {
|
||||
constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null, resolutionEngine = null } = {}) {
|
||||
this.document = document;
|
||||
this.rng = rng;
|
||||
this.diagnostics = diagnostics;
|
||||
this.ownsResolution = !resolutionEngine;
|
||||
this.resolution = resolutionEngine ?? new ResolutionEngine(document ?? {}, rng ?? new SeededRNG(0), { diagnostics });
|
||||
this.unsubscribeResolution = this.resolution.subscribe(() => this.updateBusGains());
|
||||
this.hasCustomContext = Boolean(contextFactory);
|
||||
this.contextFactory = contextFactory ?? (() => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)());
|
||||
this.context = null;
|
||||
@@ -722,9 +759,9 @@ export class AudioSubsystem {
|
||||
|
||||
buildBuses() {
|
||||
const declared = this.document?.audio?.buses ?? {};
|
||||
for (const [id, bus] of Object.entries(declared)) {
|
||||
for (const id of Object.keys(declared)) {
|
||||
const gain = this.context.createGain();
|
||||
gain.gain.value = typeof bus.gain === 'number' ? bus.gain : 1;
|
||||
gain.gain.value = this.resolution.get(`audio.buses.${id}.gain`);
|
||||
gain.connect(this.protection);
|
||||
this.buses.set(id, gain);
|
||||
}
|
||||
@@ -736,8 +773,11 @@ export class AudioSubsystem {
|
||||
}
|
||||
|
||||
setBusGain(id, value) {
|
||||
const bus = this.buses.get(id);
|
||||
if (bus) bus.gain.value = Math.min(4, Math.max(0, value));
|
||||
if (this.document?.audio?.buses?.[id]) this.resolution.setBusBase(id, Math.min(4, Math.max(0, value)));
|
||||
}
|
||||
|
||||
updateBusGains() {
|
||||
for (const [id, bus] of this.buses) bus.gain.value = this.resolution.get(`audio.buses.${id}.gain`);
|
||||
}
|
||||
|
||||
setMasterVolume(value) {
|
||||
@@ -751,7 +791,7 @@ export class AudioSubsystem {
|
||||
return next;
|
||||
}
|
||||
|
||||
play(soundId, { resolveReference } = {}) {
|
||||
play(soundId, { resolveReference = (path) => this.resolution.get(path) } = {}) {
|
||||
if (!this.unlocked) return null;
|
||||
const sound = this.document?.sounds?.[soundId];
|
||||
if (!sound) return null;
|
||||
@@ -831,7 +871,7 @@ export class AudioSubsystem {
|
||||
instance.transition('SCHEDULED');
|
||||
instance.realize(this.context, this.busFor(soundId));
|
||||
instance.transition('ACTIVE');
|
||||
instance.startTime = this.context.currentTime;
|
||||
instance.startTime = instance.realized.startTime;
|
||||
this.voices.add(instance);
|
||||
} catch (error) {
|
||||
this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
|
||||
@@ -858,6 +898,8 @@ export class AudioSubsystem {
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.unsubscribeResolution?.();
|
||||
this.unsubscribeResolution = null;
|
||||
this.stopAll();
|
||||
for (const instance of [...this.oneshotVoices]) instance.dispose();
|
||||
for (const instance of [...this.continuousVoices]) instance.dispose();
|
||||
@@ -868,5 +910,6 @@ export class AudioSubsystem {
|
||||
this.context = null;
|
||||
this.master = null;
|
||||
this.protection = null;
|
||||
if (this.ownsResolution) this.resolution.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// instantiation. Pure: never touches an AudioContext (Format Specification 14.5).
|
||||
|
||||
import {
|
||||
AUDIO_AUTOMATION_FIELDS,
|
||||
AUDIO_AUTOMATION_MODES,
|
||||
AUDIO_AUTOMATION_CURVES,
|
||||
AUDIO_BUS_FIELDS,
|
||||
AUDIO_BUS_GAIN_RANGE,
|
||||
AUDIO_COMPONENT_FIELDS,
|
||||
@@ -208,6 +211,77 @@ function validateRoute(document, route, path, errors, helpers, scope) {
|
||||
}
|
||||
}
|
||||
|
||||
function automationTarget(document, graph, name, path, errors) {
|
||||
if (typeof name !== 'string') { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Automation requires a target string.'); return null; }
|
||||
const [key, property, extra] = name.split('.');
|
||||
const node = graph.nodes?.[key];
|
||||
if (!isObject(node)) { fail(errors, 'ERR_INVALID_REFERENCE', path, `Automation node '${key}' is not declared in this graph.`); return null; }
|
||||
if (node.type === 'component' && extra !== undefined) {
|
||||
fail(errors, 'ERR_INVALID_REFERENCE', path, `Component internals are encapsulated; '${name}' is not reachable.`);
|
||||
return null;
|
||||
}
|
||||
const registry = node.type === 'component' ? document.components?.audio?.[node.use]?.parameters : AUDIO_MODULATABLE[node.type];
|
||||
if (extra !== undefined || !Object.hasOwn(registry ?? {}, property)) {
|
||||
fail(errors, 'ERR_UNSUPPORTED_TARGET', path, `'${name}' is not an automatable property.`);
|
||||
return null;
|
||||
}
|
||||
return { key, property };
|
||||
}
|
||||
|
||||
// Structural ValueSpec checks are shared with each validator. This additional
|
||||
// expected-number walk rejects string/boolean leaves and references, including
|
||||
// branches of a choice which might not be sampled in a particular run.
|
||||
function validateAutomationNumber(document, value, path, errors) {
|
||||
if (typeof value === 'string' || typeof value === 'boolean') return fail(errors, 'ERR_TYPE_MISMATCH', path, 'Automation requires a numeric ValueSpec.');
|
||||
if (!isObject(value)) return;
|
||||
if (typeof value.ref === 'string') {
|
||||
const [namespace, id] = value.ref.split('.');
|
||||
const type = document[namespace]?.[id]?.type;
|
||||
if (type && !['number', 'integer'].includes(type) || value.ref === 'signals.scenario.active') fail(errors, 'ERR_TYPE_MISMATCH', path, 'Automation reference must be numeric.');
|
||||
}
|
||||
if (Array.isArray(value.args)) value.args.forEach((child, i) => validateAutomationNumber(document, child, `${path}.args[${i}]`, errors));
|
||||
if (Array.isArray(value.choose)) value.choose.forEach((child, i) => validateAutomationNumber(document, child?.value, `${path}.choose[${i}].value`, errors));
|
||||
}
|
||||
|
||||
function validateAutomation(document, graph, path, errors, helpers, scope) {
|
||||
if (graph.automation === undefined) return;
|
||||
if (!Array.isArray(graph.automation)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'automation must be an array.');
|
||||
const targets = new Set();
|
||||
graph.automation.forEach((track, index) => {
|
||||
const location = `${path}[${index}]`;
|
||||
if (!isObject(track)) return fail(errors, 'ERR_SCHEMA_VALIDATION', location, 'Automation track must be an object.');
|
||||
for (const key of Object.keys(track)) if (!AUDIO_AUTOMATION_FIELDS.includes(key)) fail(errors, 'ERR_UNKNOWN_FIELD', `${location}.${key}`, `Unknown automation field '${key}'.`);
|
||||
if (automationTarget(document, graph, track.target, `${location}.target`, errors)) {
|
||||
if (targets.has(track.target)) fail(errors, 'ERR_AUTOMATION_CONFLICT', location, `More than one track controls '${track.target}'.`);
|
||||
targets.add(track.target);
|
||||
}
|
||||
if (track.mode !== undefined && !AUDIO_AUTOMATION_MODES.includes(track.mode)) fail(errors, 'ERR_TYPE_MISMATCH', `${location}.mode`, 'Unsupported automation mode.');
|
||||
if (track.interpolation !== undefined && !AUDIO_AUTOMATION_CURVES.includes(track.interpolation)) fail(errors, 'ERR_TYPE_MISMATCH', `${location}.interpolation`, 'Unsupported automation interpolation.');
|
||||
if (!Array.isArray(track.points) || track.points.length < 2) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${location}.points`, 'Automation requires at least two points.');
|
||||
let previous = -1;
|
||||
track.points.forEach((point, i) => {
|
||||
const pointPath = `${location}.points[${i}]`;
|
||||
if (!isObject(point)) return fail(errors, 'ERR_SCHEMA_VALIDATION', pointPath, 'Automation point must be an object.');
|
||||
for (const key of Object.keys(point)) if (!['at', 'value'].includes(key)) fail(errors, 'ERR_UNKNOWN_FIELD', `${pointPath}.${key}`, `Unknown point field '${key}'.`);
|
||||
const at = durationMilliseconds(point.at);
|
||||
if (typeof point.at !== 'string') fail(errors, 'ERR_TYPE_MISMATCH', `${pointPath}.at`, 'Automation times must be duration literals, not TimeSpecs.');
|
||||
else if (at === null) fail(errors, 'ERR_INVALID_DURATION', `${pointPath}.at`, 'Invalid automation duration.');
|
||||
else {
|
||||
if (at <= previous) fail(errors, 'ERR_INVALID_RANGE_ORDER', `${pointPath}.at`, 'Automation times must strictly increase.');
|
||||
previous = at;
|
||||
}
|
||||
helpers.validateValueSpec(document, point.value, `${pointPath}.value`, errors, scope);
|
||||
validateAutomationNumber(document, point.value, `${pointPath}.value`, errors);
|
||||
});
|
||||
});
|
||||
checkAutomationLimits(graph.automation, path, errors);
|
||||
}
|
||||
|
||||
function checkAutomationLimits(tracks, path, errors) {
|
||||
const count = tracks.reduce((sum, track) => sum + (Array.isArray(track?.points) ? track.points.length : 0), 0);
|
||||
if (tracks.length > AUDIO_LIMITS.automationTracks || count > AUDIO_LIMITS.automationPoints) fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', path, `Automation exceeds ${AUDIO_LIMITS.automationTracks} tracks or ${AUDIO_LIMITS.automationPoints} total points per expanded sound.`);
|
||||
}
|
||||
|
||||
function validateGraphObject(document, graph, path, errors, helpers, { allowedFields, scope }) {
|
||||
if (!isObject(graph)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Audio graph must be an object.');
|
||||
for (const field of Object.keys(graph)) {
|
||||
@@ -222,6 +296,7 @@ function validateGraphObject(document, graph, path, errors, helpers, { allowedFi
|
||||
if (!Array.isArray(graph.routes)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.routes`, 'routes must be an array.');
|
||||
else graph.routes.forEach((route, index) => validateRoute(document, route, `${path}.routes[${index}]`, errors, helpers, scope));
|
||||
}
|
||||
validateAutomation(document, graph, `${path}.automation`, errors, helpers, scope);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
@@ -254,10 +329,12 @@ export function expandSoundGraph(document, soundId) {
|
||||
const sound = document.sounds?.[soundId];
|
||||
const soundPath = `$.sounds.${soundId}`;
|
||||
const located = recipeGraphFor(document, sound, soundPath, errors);
|
||||
if (!located) return { nodes: [], routes: [], errors, mode: 'oneshot', release: AUDIO_DEFAULT_RELEASE_MS };
|
||||
if (!located) return { nodes: [], routes: [], automation: [], samplingOrder: [], errors, mode: 'oneshot', release: AUDIO_DEFAULT_RELEASE_MS };
|
||||
|
||||
const nodes = new Map();
|
||||
const routes = [];
|
||||
const automation = [];
|
||||
const samplingOrder = [];
|
||||
const components = document.components?.audio ?? {};
|
||||
|
||||
const join = (prefix, key) => (prefix ? `${prefix}.${key}` : key);
|
||||
@@ -271,6 +348,7 @@ export function expandSoundGraph(document, soundId) {
|
||||
for (const [key, node] of Object.entries(declared)) {
|
||||
if (!isObject(node)) continue;
|
||||
const path = join(prefix, key);
|
||||
samplingOrder.push({ kind: 'node', path });
|
||||
if (node.type === 'component') {
|
||||
const component = components[node.use];
|
||||
if (!isObject(component)) continue;
|
||||
@@ -353,7 +431,7 @@ export function expandSoundGraph(document, soundId) {
|
||||
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 });
|
||||
routes.push({ kind: 'modulation', from, to: target, property, depth: route.depth, scope: prefix || null, path: routePath });
|
||||
} else {
|
||||
if (to.includes('::')) {
|
||||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Audio route target '${route.to}' names a property.`);
|
||||
@@ -361,7 +439,16 @@ export function expandSoundGraph(document, soundId) {
|
||||
}
|
||||
routes.push({ kind: 'audio', from, to, path: routePath });
|
||||
}
|
||||
samplingOrder.push({ kind: 'route', index: routes.length - 1 });
|
||||
});
|
||||
for (const [index, track] of (Array.isArray(graph.automation) ? graph.automation : []).entries()) {
|
||||
if (!isObject(track)) continue;
|
||||
const trackPath = `${graphPath}.automation[${index}]`;
|
||||
const target = automationTarget(document, graph, track.target, `${trackPath}.target`, errors);
|
||||
if (!target) continue;
|
||||
automation.push({ ...track, target: join(prefix, target.key), property: target.property, scope: prefix || null, path: trackPath });
|
||||
samplingOrder.push({ kind: 'automation', index: automation.length - 1 });
|
||||
}
|
||||
}
|
||||
|
||||
expand(located.graph, '', 1, [], located.path);
|
||||
@@ -369,7 +456,14 @@ export function expandSoundGraph(document, soundId) {
|
||||
const release = Object.hasOwn(located.graph, 'release')
|
||||
? (durationMilliseconds(located.graph.release) ?? AUDIO_DEFAULT_RELEASE_MS)
|
||||
: AUDIO_DEFAULT_RELEASE_MS;
|
||||
return { nodes: [...nodes.values()], routes, errors, mode, release, graphPath: located.path };
|
||||
checkAutomationLimits(automation, located.path, errors);
|
||||
const seen = new Set();
|
||||
for (const track of automation) {
|
||||
const target = `${track.target}::${track.property}`;
|
||||
if (seen.has(target)) fail(errors, 'ERR_AUTOMATION_CONFLICT', track.path, `More than one track controls '${target}'.`);
|
||||
seen.add(target);
|
||||
}
|
||||
return { nodes: [...nodes.values()], routes, automation, samplingOrder, errors, mode, release, graphPath: located.path };
|
||||
}
|
||||
|
||||
function detectCycle(adjacency) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
valueMatchesType
|
||||
} from './types.js';
|
||||
import { ConditionEvaluator, ValueResolver } from './values.js';
|
||||
import { applyAutomationMode, automationValueAt, resolveNumericStages, sampleAutomationTrack } from './audio-automation.js';
|
||||
|
||||
const STEP_SECONDS = 1 / 60;
|
||||
|
||||
@@ -76,7 +77,7 @@ export class OverrideStack {
|
||||
priority,
|
||||
activationSequence: sequence,
|
||||
sampledValue: normalized,
|
||||
attackOrigin: this.engine.get(action.target),
|
||||
attackOrigin: this.engine.preModulationValue(action.target),
|
||||
attackMs,
|
||||
releaseMs,
|
||||
easing,
|
||||
@@ -153,6 +154,11 @@ export class ResolutionEngine {
|
||||
this.onParameterChange = onParameterChange;
|
||||
this.parameters = new Map();
|
||||
this.state = new Map();
|
||||
this.busValues = new Map();
|
||||
this.automation = new Map();
|
||||
this.modulation = new Map();
|
||||
this.preModulation = new Map();
|
||||
this.listeners = new Set();
|
||||
this.signals = new SignalProvider();
|
||||
this.bindings = (document.bindings ?? []).map((binding, index) => ({ definition: binding, index, smoother: undefined, enabled: false, lastTick: -1 }));
|
||||
this.transitions = new Map();
|
||||
@@ -168,10 +174,16 @@ export class ResolutionEngine {
|
||||
this.parameters.set(id, valueMatchesType(spec.type, candidate, spec) ? normalizeForSpec(spec, candidate, { clampNumeric: true }) : spec.default);
|
||||
}
|
||||
for (const [id, spec] of Object.entries(document.state ?? {})) this.state.set(id, spec.initial);
|
||||
// Bus ValueSpecs are sampled once, lazily, so references may resolve through
|
||||
// the same dependency graph as bindings (and cycles are diagnosed there).
|
||||
this.resolveAll();
|
||||
}
|
||||
|
||||
target(path) {
|
||||
const parts = path.split('.');
|
||||
if (parts.length === 4 && parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain' && this.document.audio?.buses?.[parts[2]]) {
|
||||
return { namespace: 'buses', id: parts[2], spec: { type: 'number', min: 0, max: 4 } };
|
||||
}
|
||||
const [namespace, id, extra] = path.split('.');
|
||||
if (extra !== undefined) return null;
|
||||
if (namespace === 'parameters' && this.document.parameters?.[id]) return { namespace, id, spec: this.document.parameters[id] };
|
||||
@@ -182,6 +194,10 @@ export class ResolutionEngine {
|
||||
base(path) {
|
||||
const target = this.target(path);
|
||||
if (!target) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown value reference '${path}'.`, path);
|
||||
if (target.namespace === 'buses') {
|
||||
if (!this.busValues.has(target.id)) this.busValues.set(target.id, this.valueResolver.evaluate(this.document.audio.buses[target.id].gain ?? 1, this.rng.stream('sound', `bus:${target.id}`), path));
|
||||
return this.busValues.get(target.id);
|
||||
}
|
||||
return target.namespace === 'parameters' ? this.parameters.get(target.id) : this.state.get(target.id);
|
||||
}
|
||||
|
||||
@@ -195,6 +211,8 @@ export class ResolutionEngine {
|
||||
this.resolved.clear();
|
||||
for (const id of Object.keys(this.document.parameters ?? {})) this.resolveTarget(`parameters.${id}`);
|
||||
for (const id of Object.keys(this.document.state ?? {})) this.resolveTarget(`state.${id}`);
|
||||
for (const id of Object.keys(this.document.audio?.buses ?? {})) this.resolveTarget(`audio.buses.${id}.gain`);
|
||||
for (const listener of this.listeners) listener(this);
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
@@ -207,12 +225,26 @@ export class ResolutionEngine {
|
||||
try {
|
||||
let lower = this.base(path);
|
||||
const binding = this.bindings.find((item) => item.definition.target === path);
|
||||
if (binding) lower = this.bindingValue(binding, target, lower);
|
||||
const winner = this.overrides.select(path);
|
||||
let value = winner ? this.overrides.value(winner, lower) : lower;
|
||||
let value;
|
||||
if (isNumericType(target.spec.type)) {
|
||||
if (target.spec.type === 'integer') value = roundHalfAwayFromZero(value);
|
||||
value = clamp(value, target.spec.min ?? -Infinity, target.spec.max ?? Infinity);
|
||||
const track = target.namespace === 'buses' ? this.automation.get(path) : null;
|
||||
value = resolveNumericStages(lower, {
|
||||
binding: binding ? (base) => this.bindingValue(binding, target, base) : undefined,
|
||||
automation: track ? (base) => applyAutomationMode(base, automationValueAt(track, this.logicalMilliseconds - track.startedAt), track.mode) : undefined,
|
||||
override: (currentLower) => {
|
||||
const preModulation = winner ? this.overrides.value(winner, currentLower) : currentLower;
|
||||
this.preModulation.set(path, preModulation);
|
||||
return preModulation;
|
||||
},
|
||||
modulation: target.namespace === 'buses' ? [...(this.modulation.get(path)?.values() ?? [])].reduce((sum, contribution) => sum + contribution, 0) : 0,
|
||||
min: target.spec.min, max: target.spec.max,
|
||||
round: target.spec.type === 'integer' ? roundHalfAwayFromZero : undefined
|
||||
});
|
||||
} else {
|
||||
if (binding) lower = this.bindingValue(binding, target, lower);
|
||||
value = winner ? this.overrides.value(winner, lower) : lower;
|
||||
this.preModulation.set(path, value);
|
||||
}
|
||||
this.resolved.set(path, value);
|
||||
return value;
|
||||
@@ -237,20 +269,69 @@ export class ResolutionEngine {
|
||||
value = (source * (definition.scale ?? 1)) + (definition.offset ?? 0);
|
||||
if (definition.clamp) value = clamp(value, definition.clamp[0], definition.clamp[1]);
|
||||
const tau = parseDuration(definition.smoothing ?? '0ms') / 1000;
|
||||
if (!binding.enabled || binding.smoother === undefined) {
|
||||
if (tau === 0 || !binding.enabled || binding.smoother === undefined) {
|
||||
binding.smoother = value;
|
||||
binding.lastTick = this.tickIndex;
|
||||
} else if (binding.lastTick !== this.tickIndex) {
|
||||
if (tau === 0) binding.smoother = value;
|
||||
else binding.smoother += (1 - Math.exp(-STEP_SECONDS / tau)) * (value - binding.smoother);
|
||||
binding.smoother += (1 - Math.exp(-STEP_SECONDS / tau)) * (value - binding.smoother);
|
||||
binding.lastTick = this.tickIndex;
|
||||
}
|
||||
value = binding.smoother;
|
||||
}
|
||||
binding.enabled = true;
|
||||
if (numeric) return value;
|
||||
return normalizeForSpec(target.spec, value, { clampNumeric: true });
|
||||
}
|
||||
|
||||
preModulationValue(path) { this.get(path); return this.preModulation.get(path); }
|
||||
|
||||
requireAudioStage(path) {
|
||||
if (this.target(path)?.namespace !== 'buses') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Automation/modulation is not exposed for '${path}'.`, path);
|
||||
}
|
||||
|
||||
// Internal engine registration surface. The graph-only authoring syntax does
|
||||
// not introduce new bus document fields, or external node targets.
|
||||
addAutomation(path, definition, { startedAt = this.logicalMilliseconds, stream = this.rng.stream('sound', `bus:${path}:automation`) } = {}) {
|
||||
this.requireAudioStage(path);
|
||||
if (!Number.isFinite(startedAt)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Automation start time must be finite.', path);
|
||||
if (this.automation.has(path)) throw new RuntimeFault('ERR_AUTOMATION_CONFLICT', `More than one track controls '${path}'.`, path);
|
||||
const track = sampleAutomationTrack({ ...definition, target: path }, (value, location) => this.evaluateValue(value, stream, location), (warning) => this.diagnostics?.warn(warning.code, warning.message, { path: warning.path, section: 'audio' }));
|
||||
const registration = { ...track, startedAt };
|
||||
this.automation.set(path, registration);
|
||||
this.invalidate();
|
||||
this.resolveAll();
|
||||
return () => {
|
||||
if (this.automation.get(path) !== registration) return;
|
||||
this.automation.delete(path); this.invalidate(); this.resolveAll();
|
||||
};
|
||||
}
|
||||
|
||||
setModulation(path, id, value) {
|
||||
this.requireAudioStage(path);
|
||||
if (!Number.isFinite(value)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Modulation contribution must be finite.', path);
|
||||
if (!this.modulation.has(path)) this.modulation.set(path, new Map());
|
||||
this.modulation.get(path).set(id, value);
|
||||
this.invalidate();
|
||||
return this.resolveAll();
|
||||
}
|
||||
|
||||
removeModulation(path, id) {
|
||||
this.requireAudioStage(path);
|
||||
this.modulation.get(path)?.delete(id);
|
||||
this.invalidate();
|
||||
return this.resolveAll();
|
||||
}
|
||||
|
||||
setBusBase(id, value) {
|
||||
const path = `audio.buses.${id}.gain`;
|
||||
this.requireAudioStage(path);
|
||||
this.busValues.set(id, normalizeForSpec(this.target(path).spec, value));
|
||||
this.invalidate();
|
||||
return this.resolveAll();
|
||||
}
|
||||
|
||||
subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
||||
|
||||
setParameter(id, value) {
|
||||
const spec = this.document.parameters?.[id];
|
||||
if (!spec) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown parameter '${id}'.`, `parameters.${id}`);
|
||||
@@ -301,5 +382,8 @@ export class ResolutionEngine {
|
||||
parameterSnapshot() { return Object.fromEntries(this.parameters); }
|
||||
stateSnapshot() { return Object.fromEntries(this.state); }
|
||||
snapshot() { return Object.fromEntries(this.resolved); }
|
||||
dispose() { this.overrides.clear(); this.transitions.clear(); this.resolved.clear(); }
|
||||
dispose() {
|
||||
this.listeners.clear(); this.automation.clear(); this.modulation.clear(); this.busValues.clear(); this.preModulation.clear();
|
||||
this.overrides.clear(); this.transitions.clear(); this.resolved.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,20 @@ export class ValueResolver {
|
||||
this.resolveReference = resolveReference;
|
||||
}
|
||||
|
||||
// Freeze procedural choices once, retaining only explicitly live component
|
||||
// input references. Re-evaluating this tree never draws from an RNG.
|
||||
sample(spec, stream, path = '$', retainReference = () => false) {
|
||||
if (!isRecord(spec)) return this.evaluate(spec, stream, path);
|
||||
if (Object.hasOwn(spec, 'ref')) return retainReference(spec.ref) ? { ref: spec.ref } : this.evaluate(spec, stream, path);
|
||||
if (Object.hasOwn(spec, 'random')) return this.evaluate(spec, stream, path);
|
||||
if (Object.hasOwn(spec, 'choose')) {
|
||||
const index = this.evaluate({ choose: spec.choose.map((option, i) => ({ weight: option.weight, value: i })) }, stream, path);
|
||||
return this.sample(spec.choose[index].value, stream, `${path}.choose[${index}].value`, retainReference);
|
||||
}
|
||||
if (Object.hasOwn(spec, 'op')) return { op: spec.op, args: spec.args.map((arg, i) => this.sample(arg, stream, `${path}.args[${i}]`, retainReference)) };
|
||||
return this.evaluate(spec, stream, path);
|
||||
}
|
||||
|
||||
evaluate(spec, stream, path = '$') {
|
||||
if (typeof spec === 'number') {
|
||||
if (!Number.isFinite(spec)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Numeric literals must be finite.', path);
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { AUDIO_MODULATABLE } from '../src/runtime/audio-contract.js';
|
||||
import { applyAutomationMode, automationValueAt, sampleAutomationTrack } from '../src/runtime/audio-automation.js';
|
||||
import { audioPropertyAt, scheduleNativeAutomation } from '../src/runtime/audio-controls.js';
|
||||
import { expandSoundGraph } from '../src/runtime/audio-graph.js';
|
||||
import { AudioSubsystem, instantiateSoundGraph, realizeSoundGraph } from '../src/runtime/audio-engine.js';
|
||||
import { ResolutionEngine } from '../src/runtime/resolution.js';
|
||||
import { SeededRNG } from '../src/runtime/rng.js';
|
||||
import { validateExhibit } from '../src/runtime/validator.js';
|
||||
import { ExhibitValidator } from '../tools/validate-exhibit.mjs';
|
||||
|
||||
const close = (actual, expected, epsilon = 1e-9) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
||||
const track = (target = 'tone.frequency', values = [100, 400], options = {}) => ({ target, points: values.map((value, i) => ({ at: `${i}s`, value })), ...options });
|
||||
const fixture = (automation = [], nodes = {}, routes = []) => ({
|
||||
xzbt: '0.1', meta: { id: 'automation-study', name: 'Automation Study' },
|
||||
audio: { buses: { main: { gain: 1 } } },
|
||||
sounds: { probe: { name: 'Probe', bus: 'main', recipe: { mode: 'continuous', nodes: { tone: { type: 'oscillator', frequency: 200 }, ...nodes }, routes: [{ from: 'tone', to: 'output' }, ...routes], automation } } }
|
||||
});
|
||||
const recipe = (doc) => doc.sounds.probe.recipe;
|
||||
const planFor = (doc, options = {}) => instantiateSoundGraph(doc, 'probe', { rng: new SeededRNG(42), ...options });
|
||||
const results = (doc) => [validateExhibit(doc), new ExhibitValidator(doc).validate()];
|
||||
const valid = (doc) => { for (const result of results(doc)) assert.deepEqual(result.errors, []); };
|
||||
const invalid = (doc, code) => { for (const result of results(doc)) assert.ok(result.errors.some((error) => error.code === code), `${code}: ${JSON.stringify(result.errors)}`); };
|
||||
|
||||
test('trace 1: all three modes and four curves have correct starts, midpoints, ends, and smooth slopes', () => {
|
||||
for (const mode of ['absolute', 'offset', 'scale']) for (const interpolation of ['step', 'linear', 'exponential', 'smooth']) {
|
||||
const sampled = sampleAutomationTrack(track('tone.frequency', [2, 8], { mode, interpolation }), (value) => value);
|
||||
const expectedMid = interpolation === 'step' ? 2 : interpolation === 'exponential' ? 4 : 5;
|
||||
for (const [at, value] of [[0, 2], [500, expectedMid], [1000, 8]]) close(applyAutomationMode(3, automationValueAt(sampled, at), mode), mode === 'absolute' ? value : mode === 'offset' ? 3 + value : 3 * value);
|
||||
}
|
||||
const smooth = sampleAutomationTrack(track('tone.frequency', [0, 1], { interpolation: 'smooth' }), (value) => value);
|
||||
close(automationValueAt(smooth, 250), 0.15625);
|
||||
close(automationValueAt(smooth, 0.001) / 0.000001, 0, 0.000004);
|
||||
close((1 - automationValueAt(smooth, 999.999)) / 0.000001, 0, 0.000004);
|
||||
});
|
||||
|
||||
test('trace 2: delayed first point holds before start and final value holds without looping', () => {
|
||||
const sampled = sampleAutomationTrack({ target: 'tone.frequency', points: [{ at: '2s', value: 30 }, { at: '4s', value: 50 }] }, (value) => value);
|
||||
for (const at of [-100, 0, 500, 2000]) assert.equal(automationValueAt(sampled, at), 30);
|
||||
assert.equal(automationValueAt(sampled, 3000), 40);
|
||||
for (const at of [4000, 8000, 1e12]) assert.equal(automationValueAt(sampled, at), 50);
|
||||
});
|
||||
|
||||
test('trace 3: duplicate expanded tracks conflict while duplicate modulation routes sum', () => {
|
||||
invalid(fixture([track(), track()]), 'ERR_AUTOMATION_CONFLICT');
|
||||
const doc = fixture([track('level.gain', [1, 2])], { level: { type: 'gain' }, bias: { type: 'constant', value: 1 } }, [
|
||||
{ from: 'tone', to: 'level' }, { from: 'level', to: 'output' },
|
||||
{ from: 'bias', to: 'level.gain', depth: 0.2 }, { from: 'bias', to: 'level.gain', depth: 0.2 }
|
||||
]);
|
||||
valid(doc);
|
||||
close(audioPropertyAt(planFor(doc), 'level', 'gain', 500, { bias: 1 }), 1.9);
|
||||
});
|
||||
|
||||
test('trace 4: nonpositive exponential segments fall back once per sampled track, not per evaluation', () => {
|
||||
for (const values of [[0, 4, 0], [-2, 2, -4], [-2, -8, -16]]) {
|
||||
const warnings = [];
|
||||
const sampled = sampleAutomationTrack(track('tone.detune', values, { interpolation: 'exponential' }), (value) => value, (warning) => warnings.push(warning));
|
||||
for (let i = 0; i < 100; i++) close(automationValueAt(sampled, 500), (values[0] + values[1]) / 2);
|
||||
assert.deepEqual(warnings.map((warning) => warning.code), ['WARN_AUTOMATION_FALLBACK']);
|
||||
}
|
||||
});
|
||||
|
||||
test('trace 5: target registry, unknown nodes, encapsulation, and node-level rejection match both validators', () => {
|
||||
for (const [type, properties] of Object.entries(AUDIO_MODULATABLE)) for (const property of Object.keys(properties)) {
|
||||
const node = type === 'resonator' ? { type, modes: [{ ratio: 1 }] } : { type };
|
||||
valid(fixture([track(`subject.${property}`)], { subject: node }));
|
||||
}
|
||||
invalid(fixture([track('missing.frequency')]), 'ERR_INVALID_REFERENCE');
|
||||
invalid(fixture([track('tone.waveform')]), 'ERR_UNSUPPORTED_TARGET');
|
||||
invalid(fixture([track('tone')]), 'ERR_UNSUPPORTED_TARGET');
|
||||
const doc = componentFixture();
|
||||
recipe(doc).automation = [track('voice.tone.frequency')];
|
||||
invalid(doc, 'ERR_INVALID_REFERENCE');
|
||||
recipe(doc).automation = [track('voice.private')];
|
||||
invalid(doc, 'ERR_UNSUPPORTED_TARGET');
|
||||
recipe(doc).automation = [];
|
||||
recipe(doc).nodes.voice.automation = [];
|
||||
invalid(doc, 'ERR_UNKNOWN_FIELD');
|
||||
const external = fixture();
|
||||
external.parameters = { level: { type: 'number', default: 1 } };
|
||||
external.bindings = [{ source: 'parameters.level', target: 'sounds.probe.recipe.nodes.tone.frequency' }];
|
||||
invalid(external, 'ERR_UNSUPPORTED_TARGET');
|
||||
});
|
||||
|
||||
test('point shapes, literal times, strict order, numeric ValueSpecs, and unknown fields are checked at import', () => {
|
||||
const cases = [
|
||||
[{ ...track(), points: [] }, 'ERR_SCHEMA_VALIDATION'],
|
||||
[{ ...track(), points: [{ at: '0s', value: 1 }] }, 'ERR_SCHEMA_VALIDATION'],
|
||||
[track(undefined, [1, 2], { mode: 'multiply' }), 'ERR_TYPE_MISMATCH'],
|
||||
[track(undefined, [1, 2], { interpolation: 'bezier' }), 'ERR_TYPE_MISMATCH'],
|
||||
[track(undefined, [1, 2], { extra: true }), 'ERR_UNKNOWN_FIELD'],
|
||||
[track(undefined, ['100', 200]), 'ERR_TYPE_MISMATCH'],
|
||||
[track(undefined, [{ choose: [{ weight: 1, value: false }] }, 200]), 'ERR_TYPE_MISMATCH'],
|
||||
[track(undefined, [{ op: 'add', args: [true, 2] }, 200]), 'ERR_TYPE_MISMATCH'],
|
||||
[track(undefined, [1, 2], { points: [{ at: { random: { min: '0s', max: '1s' } }, value: 1 }, { at: '2s', value: 2 }] }), 'ERR_TYPE_MISMATCH'],
|
||||
[track(undefined, [1, 2], { points: [{ at: '1s', value: 1 }, { at: '1000ms', value: 2 }] }), 'ERR_INVALID_RANGE_ORDER'],
|
||||
[track(undefined, [1, 2], { points: [{ at: '1s', value: 1 }, { at: '0s', value: 2 }] }), 'ERR_INVALID_RANGE_ORDER'],
|
||||
[track(undefined, [1, 2], { points: [{ at: '-1s', value: 1 }, { at: '1s', value: 2 }] }), 'ERR_INVALID_DURATION']
|
||||
];
|
||||
for (const [definition, code] of cases) invalid(fixture([definition]), code);
|
||||
const doc = fixture([track(undefined, [{ ref: 'parameters.flag' }, 200])]);
|
||||
doc.parameters = { flag: { type: 'boolean', default: true } };
|
||||
invalid(doc, 'ERR_TYPE_MISMATCH');
|
||||
recipe(doc).automation = {};
|
||||
invalid(doc, 'ERR_SCHEMA_VALIDATION');
|
||||
});
|
||||
|
||||
function componentFixture() {
|
||||
const doc = fixture([track('voice.pitch', [100, 400])], { voice: { type: 'component', use: 'voice' } }, [{ from: 'voice', to: 'output' }]);
|
||||
doc.components = { audio: { voice: {
|
||||
parameters: { pitch: { type: 'number', min: 10, max: 1000, default: 100 } },
|
||||
nodes: { tone: { type: 'oscillator', frequency: { op: 'multiply', args: [{ ref: 'inputs.pitch' }, 2] } } },
|
||||
routes: [{ from: 'tone', to: 'output' }]
|
||||
} } };
|
||||
return doc;
|
||||
}
|
||||
|
||||
test('trace 6: expanded limits admit exactly 64 tracks / 256 points and reject 65 / 257', () => {
|
||||
const doc = fixture();
|
||||
doc.components = { audio: { unit: { nodes: { level: { type: 'gain' } }, automation: [track('level.gain', [0, 1, 2, 3])] } } };
|
||||
for (let i = 0; i < 64; i++) recipe(doc).nodes[`unit${i}`] = { type: 'component', use: 'unit' };
|
||||
valid(doc);
|
||||
const expanded = expandSoundGraph(doc, 'probe');
|
||||
assert.equal(expanded.automation.length, 64);
|
||||
assert.equal(new Set(expanded.automation.map((item) => item.target)).size, 64);
|
||||
recipe(doc).automation.push(track('tone.frequency'));
|
||||
invalid(doc, 'ERR_NODE_LIMIT_EXCEEDED');
|
||||
const points = fixture([track('tone.frequency', Array(256).fill(200))]);
|
||||
valid(points);
|
||||
recipe(points).automation[0].points.push({ at: '256s', value: 100 });
|
||||
invalid(points, 'ERR_NODE_LIMIT_EXCEEDED');
|
||||
});
|
||||
|
||||
test('track sampling is depth-first after each graph nodes/routes and does not redraw when evaluated', () => {
|
||||
const random = { random: { min: 0, max: 1 } };
|
||||
const doc = componentFixture();
|
||||
recipe(doc).nodes = { voice: recipe(doc).nodes.voice, tone: { type: 'oscillator', detune: random, frequency: random }, bias: { type: 'constant' } };
|
||||
doc.components.audio.voice.nodes.tone.frequency = random;
|
||||
doc.components.audio.voice.automation = [track('tone.detune', [random, random])];
|
||||
recipe(doc).routes.push({ from: 'bias', to: 'tone.detune', depth: random });
|
||||
recipe(doc).automation = [track('voice.pitch', [random, random])];
|
||||
let draws = 0;
|
||||
const rng = { stream: () => ({ nextFloat: () => ++draws / 20 }) };
|
||||
const plan = planFor(doc, { rng });
|
||||
assert.equal(draws, 8);
|
||||
close(plan.nodes.find((node) => node.path === 'voice.tone').expressions.frequency, 0.05);
|
||||
assert.deepEqual(plan.automation[0].points.map((point) => point.value), [0.1, 0.15]);
|
||||
const tone = plan.nodes.find((node) => node.path === 'tone');
|
||||
close(tone.values.detune, 0.2);
|
||||
close(tone.values.frequency, 0.25);
|
||||
close(plan.routes.find((route) => route.kind === 'modulation').depth, 0.3);
|
||||
assert.deepEqual(plan.automation[1].points.map((point) => point.value), [0.35, 0.4]);
|
||||
for (let i = 0; i < 100; i++) audioPropertyAt(plan, 'voice.tone', 'detune', i * 100);
|
||||
assert.equal(draws, 8);
|
||||
const seeded = fixture([track('tone.frequency', [random, random])]);
|
||||
assert.deepEqual(planFor(seeded).automation, planFor(seeded).automation);
|
||||
assert.notDeepEqual(planFor(seeded).automation, planFor(seeded, { ordinal: 1 }).automation);
|
||||
});
|
||||
|
||||
test('component expressions retain only inputs; procedural choices are frozen, nested automation composes', () => {
|
||||
const doc = componentFixture();
|
||||
doc.components.audio.voice.nodes.tone.frequency.args[1] = { choose: [{ weight: 1, value: { random: { min: 2, max: 2 } } }] };
|
||||
doc.components.audio.voice.automation = [track('tone.frequency', [10, 30], { mode: 'offset' })];
|
||||
valid(doc);
|
||||
const plan = planFor(doc);
|
||||
close(audioPropertyAt(plan, 'voice.tone', 'frequency', 500), 520);
|
||||
assert.deepEqual(plan.nodes.find((node) => node.path === 'voice.tone').expressions.frequency, { op: 'multiply', args: [{ ref: 'inputs.pitch' }, 2] });
|
||||
});
|
||||
|
||||
test('trace 8: bus binding -> automation -> override -> modulation -> clamp, including live release', async () => {
|
||||
const doc = fixture();
|
||||
doc.parameters = { amount: { type: 'number', default: 1, min: 0, max: 10 } };
|
||||
doc.bindings = [{ source: 'parameters.amount', target: 'audio.buses.main.gain', scale: 2 }];
|
||||
const engine = new ResolutionEngine(doc, new SeededRNG(42));
|
||||
const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), resolutionEngine: engine, contextFactory: recordingContext });
|
||||
await audio.unlock();
|
||||
const path = 'audio.buses.main.gain';
|
||||
const remove = engine.addAutomation(path, track(path, [0.5, 1.5], { mode: 'scale' }));
|
||||
engine.setModulation(path, 'one', 0.1);
|
||||
engine.setModulation(path, 'two', 0.2);
|
||||
close(engine.get(path), 1.3);
|
||||
const override = engine.overrides.add({ target: path, scope: 'scenario', transition: { out: '1s' } }, 3, { owner: 'test' });
|
||||
engine.advance(500);
|
||||
close(engine.get(path), 3.3);
|
||||
engine.setParameter('amount', 0.5);
|
||||
engine.overrides.beginRelease(override);
|
||||
engine.advance(500); // lower is now 1 * 1.5; midpoint release = (3 + 1.5) / 2
|
||||
close(engine.get(path), 2.55);
|
||||
close(audio.buses.get('main').gain.value, 2.55);
|
||||
engine.advance(500);
|
||||
close(engine.get(path), 1.8);
|
||||
engine.setParameter('amount', 10);
|
||||
close(engine.get(path), 4);
|
||||
engine.setModulation(path, 'one', -30);
|
||||
close(engine.get(path), 0.2); // binding 20 wasn't prematurely clamped to 4
|
||||
remove();
|
||||
await audio.dispose();
|
||||
assert.equal(engine.listeners.size, 0);
|
||||
engine.dispose();
|
||||
assert.equal(engine.automation.size + engine.modulation.size, 0);
|
||||
});
|
||||
|
||||
test('only bus targets gain shared automation/modulation stages; duplicate registrations fail', () => {
|
||||
const doc = fixture();
|
||||
doc.parameters = { amount: { type: 'number', default: 1 } };
|
||||
const engine = new ResolutionEngine(doc, new SeededRNG(42));
|
||||
for (const path of ['parameters.amount', 'state.missing', 'sounds.probe.recipe.nodes.tone.frequency']) {
|
||||
assert.throws(() => engine.addAutomation(path, track()), { code: 'ERR_UNSUPPORTED_TARGET' });
|
||||
assert.throws(() => engine.setModulation(path, 'one', 1), { code: 'ERR_UNSUPPORTED_TARGET' });
|
||||
}
|
||||
const path = 'audio.buses.main.gain';
|
||||
const remove = engine.addAutomation(path, track(path, [0, 1]));
|
||||
assert.throws(() => engine.addAutomation(path, track()), { code: 'ERR_AUTOMATION_CONFLICT' });
|
||||
remove();
|
||||
engine.addAutomation(path, track(path, [1, 2]));
|
||||
remove(); // a stale disposer cannot remove the replacement registration
|
||||
assert.equal(engine.automation.get(path).points[0].value, 1);
|
||||
assert.throws(() => engine.overrides.add({ target: 'sounds.probe.recipe.nodes.tone.frequency', scope: 'scenario' }, 1), { code: 'ERR_UNSUPPORTED_TARGET' });
|
||||
});
|
||||
|
||||
// Recording Web Audio stand-in with a scalar DSP evaluator. This verifies the
|
||||
// connected native graph and scheduled values, not a disconnected logical model.
|
||||
function recordingContext() {
|
||||
const nodes = [];
|
||||
const parameter = (value = 0) => ({
|
||||
value, inputs: [], events: [],
|
||||
setValueAtTime(value, at) { this.events.push({ kind: 'set', value, at }); },
|
||||
linearRampToValueAtTime(value, at) { this.events.push({ kind: 'linear', value, at }); },
|
||||
exponentialRampToValueAtTime(value, at) { this.events.push({ kind: 'exponential', value, at }); },
|
||||
setValueCurveAtTime(values, at, duration) { this.events.push({ kind: 'curve', values, at, duration }); },
|
||||
cancelScheduledValues() { this.events.length = 0; }
|
||||
});
|
||||
const make = (kind, properties = {}) => {
|
||||
const node = { kind, inputs: [], connections: [], disconnected: false, ...properties,
|
||||
connect(target) { this.connections.push(target); target.inputs.push(this); return target; },
|
||||
disconnect() { this.disconnected = true; for (const target of this.connections) target.inputs = target.inputs.filter((input) => input !== this); this.connections.length = 0; }
|
||||
};
|
||||
nodes.push(node);
|
||||
return node;
|
||||
};
|
||||
const source = { start() { this.started = true; }, stop() { this.stopped = true; } };
|
||||
const context = {
|
||||
nodes, sampleRate: 48000, currentTime: 7, state: 'running',
|
||||
destination: make('destination'),
|
||||
createGain: () => make('gain', { gain: parameter(1) }),
|
||||
createConstantSource: () => make('constant', { offset: parameter(1), ...source }),
|
||||
createOscillator: () => make('oscillator', { frequency: parameter(440), detune: parameter(0), ...source }),
|
||||
createWaveShaper: () => make('shaper', { curve: null }),
|
||||
createBiquadFilter: () => make('filter', { frequency: parameter(350), Q: parameter(1), gain: parameter(0), detune: parameter(0) }),
|
||||
createStereoPanner: () => make('panner', { pan: parameter(0) }),
|
||||
createDelay: () => make('delay', { delayTime: parameter(0) }),
|
||||
createDynamicsCompressor: () => make('compressor', Object.fromEntries(['threshold', 'knee', 'ratio', 'attack', 'release'].map((name) => [name, parameter()]))),
|
||||
close() { this.state = 'closed'; }
|
||||
};
|
||||
const curveAt = (values, t) => { const at = Math.max(0, Math.min(1, t)) * (values.length - 1), i = Math.floor(at); return values[i] + ((values[i + 1] ?? values[i]) - values[i]) * (at - i); };
|
||||
const paramAt = (param, time) => {
|
||||
let value = param.value, at = 0;
|
||||
for (const event of param.events) {
|
||||
if (event.kind === 'curve') {
|
||||
if (time < event.at) break;
|
||||
value = curveAt(event.values, (time - event.at) / event.duration);
|
||||
at = event.at + event.duration;
|
||||
if (time < at) break;
|
||||
} else if (time < event.at) {
|
||||
if (event.kind === 'linear') value += (event.value - value) * (time - at) / (event.at - at);
|
||||
else if (event.kind === 'exponential') value *= (event.value / value) ** ((time - at) / (event.at - at));
|
||||
break;
|
||||
} else { value = event.value; at = event.at; }
|
||||
}
|
||||
return value + param.inputs.reduce((sum, node) => sum + signalAt(node, time), 0);
|
||||
};
|
||||
const signalAt = (node, time) => {
|
||||
if (node.kind === 'constant') return paramAt(node.offset, time);
|
||||
if (node.kind === 'oscillator') return Math.sin(2 * Math.PI * time * paramAt(node.frequency, time));
|
||||
const input = node.inputs.reduce((sum, child) => sum + signalAt(child, time), 0);
|
||||
if (node.kind === 'gain') return input * paramAt(node.gain, time);
|
||||
if (node.kind === 'shaper') return curveAt(node.curve, (input + 1) / 2);
|
||||
return input;
|
||||
};
|
||||
context.paramAt = paramAt;
|
||||
return context;
|
||||
}
|
||||
|
||||
test('all modes/curves reach the scheduled AudioParam path on the owning clock and retain end values', () => {
|
||||
for (const mode of ['absolute', 'offset', 'scale']) for (const interpolation of ['step', 'linear', 'exponential', 'smooth']) {
|
||||
const definition = track('tone.frequency', mode === 'scale' ? [0.5, 2] : [100, 400], { mode, interpolation });
|
||||
const plan = planFor(fixture([definition]));
|
||||
const context = recordingContext();
|
||||
const voice = realizeSoundGraph(context, plan, context.destination);
|
||||
const param = context.nodes.find((node) => node.kind === 'oscillator').frequency;
|
||||
for (const elapsed of [0, 0.25, 0.5, 0.75, 1, 4]) close(context.paramAt(param, 7 + elapsed), audioPropertyAt(plan, 'tone', 'frequency', elapsed * 1000), 0.0001);
|
||||
voice.dispose();
|
||||
assert.ok(context.nodes.filter((node) => node.started).every((node) => node.stopped));
|
||||
assert.ok(context.nodes.filter((node) => node.kind !== 'destination').every((node) => node.disconnected));
|
||||
assert.ok(context.nodes.filter((node) => node.offset).every((node) => node.offset.events.length === 0));
|
||||
}
|
||||
const definition = track('tone.frequency', [100, 400, 50], { interpolation: 'smooth' });
|
||||
definition.points.forEach((point, i) => { point.at = `${i + 1}s`; });
|
||||
const plan = planFor(fixture([definition])), context = recordingContext();
|
||||
const voice = realizeSoundGraph(context, plan, context.destination);
|
||||
const param = context.nodes.find((node) => node.kind === 'oscillator').frequency;
|
||||
for (const elapsed of [0, 1, 1.001, 1.234567, 1.999, 2, 2.001, 2.345678, 3, 30]) close(context.paramAt(param, 7 + elapsed), audioPropertyAt(plan, 'tone', 'frequency', elapsed * 1000), 1e-8);
|
||||
close((context.paramAt(param, 8.000001) - 100) / 0.000001, 0, 0.001);
|
||||
close((50 - context.paramAt(param, 9.999999)) / 0.000001, 0, 0.002);
|
||||
voice.dispose();
|
||||
});
|
||||
|
||||
test('native modulation sums after automation and before safety clamp, including signed/offset values', () => {
|
||||
const doc = fixture([track('level.gain', [5, -2])], { level: { type: 'gain' }, bias: { type: 'constant', value: 1 } }, [
|
||||
{ from: 'tone', to: 'level' }, { from: 'level', to: 'output' },
|
||||
{ from: 'bias', to: 'level.gain', depth: 1 }, { from: 'bias', to: 'level.gain', depth: -2 }
|
||||
]);
|
||||
valid(doc);
|
||||
const context = recordingContext();
|
||||
const voice = realizeSoundGraph(context, planFor(doc), context.destination);
|
||||
const level = context.nodes.filter((node) => node.kind === 'gain')[2];
|
||||
for (const [seconds, expected] of [[0, 4], [0.25, 2.25], [0.5, 0.5], [1, 0]]) close(context.paramAt(level.gain, 7 + seconds), expected, 1e-5);
|
||||
voice.dispose();
|
||||
});
|
||||
|
||||
test('native component parameter automation and modulation propagate into internal expressions', () => {
|
||||
const doc = componentFixture();
|
||||
recipe(doc).nodes.bias = { type: 'constant', value: 1 };
|
||||
recipe(doc).routes.push({ from: 'bias', to: 'voice.pitch', depth: 50 });
|
||||
doc.components.audio.voice.automation = [track('tone.frequency', [10, 30], { mode: 'offset' })];
|
||||
valid(doc);
|
||||
const plan = planFor(doc), context = recordingContext();
|
||||
const voice = realizeSoundGraph(context, plan, context.destination);
|
||||
const internal = context.nodes.filter((node) => node.kind === 'oscillator')[1];
|
||||
close(context.paramAt(internal.frequency, 7.5), 620);
|
||||
close(audioPropertyAt(plan, 'voice.tone', 'frequency', 500, { bias: 1 }), 620);
|
||||
voice.dispose();
|
||||
});
|
||||
|
||||
test('native delay uses milliseconds, live frequency ceiling clamps, and resonator ratios follow fundamental', () => {
|
||||
const doc = fixture([track('echo.time', [100, 400]), track('body.fundamental', [100, 400]), track('tone.frequency', [100, 10000])], {
|
||||
echo: { type: 'delay' }, body: { type: 'resonator', modes: [{ ratio: 2 }, { frequency: 700 }] }
|
||||
}, [{ from: 'tone', to: 'body' }, { from: 'body', to: 'echo' }, { from: 'echo', to: 'output' }]);
|
||||
valid(doc);
|
||||
const context = recordingContext();
|
||||
context.sampleRate = 8000;
|
||||
const voice = realizeSoundGraph(context, planFor(doc, { sampleRate: 8000 }), context.destination);
|
||||
close(context.paramAt(context.nodes.find((node) => node.kind === 'delay').delayTime, 7.5), 0.25);
|
||||
close(context.paramAt(context.nodes.find((node) => node.kind === 'oscillator').frequency, 8), 3600, 0.001);
|
||||
const bands = context.nodes.filter((node) => node.kind === 'filter');
|
||||
close(context.paramAt(bands[0].frequency, 7.5), 500);
|
||||
close(context.paramAt(bands[1].frequency, 7.5), 700);
|
||||
voice.dispose();
|
||||
});
|
||||
|
||||
test('automation extends determinable delay tails without mutating sampled base values', () => {
|
||||
const doc = fixture([track('echo.time', [100, 800])], { hit: { type: 'impulse', duration: '10ms' }, echo: { type: 'delay', time: '100ms', feedback: 0 } });
|
||||
recipe(doc).mode = 'oneshot';
|
||||
recipe(doc).routes = [{ from: 'hit', to: 'echo' }, { from: 'echo', to: 'output' }];
|
||||
valid(doc);
|
||||
const plan = planFor(doc);
|
||||
assert.equal(plan.endingBoundMs, 860);
|
||||
assert.equal(plan.nodes.find((node) => node.path === 'echo').values.time, 100);
|
||||
});
|
||||
|
||||
test('trace 11: disposal and scheduler failure cancel automation and release all voice resources', async () => {
|
||||
const doc = fixture([track(undefined, [100, 400], { interpolation: 'smooth' })]);
|
||||
const context = recordingContext();
|
||||
const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), contextFactory: () => context });
|
||||
await audio.unlock();
|
||||
const voice = audio.play('probe');
|
||||
voice.dispose();
|
||||
voice.dispose();
|
||||
assert.equal(voice.state, 'DISPOSED');
|
||||
assert.equal(voice.plan, null);
|
||||
assert.equal(voice.realized, null);
|
||||
assert.equal(audio.continuousVoices.size, 0);
|
||||
assert.ok(context.nodes.filter((node) => node.started).every((node) => node.stopped));
|
||||
const makeSource = context.createConstantSource;
|
||||
context.createConstantSource = () => {
|
||||
const source = makeSource();
|
||||
source.offset.linearRampToValueAtTime = () => { throw new Error('injected scheduler failure'); };
|
||||
return source;
|
||||
};
|
||||
assert.equal(audio.play('probe'), null);
|
||||
assert.equal(audio.voices.size + audio.continuousVoices.size, 0);
|
||||
assert.ok(context.nodes.filter((node) => ['constant', 'oscillator'].includes(node.kind)).every((node) => node.disconnected));
|
||||
await audio.dispose();
|
||||
});
|
||||
|
||||
test('native exponential fallback schedules linear ramps and schema exposes only graph-local automation', () => {
|
||||
const context = recordingContext(), param = context.createGain().gain;
|
||||
const sampled = sampleAutomationTrack(track('tone.detune', [-1, 0, 2, 8], { interpolation: 'exponential' }), (value) => value);
|
||||
scheduleNativeAutomation(param, sampled, 7);
|
||||
assert.deepEqual(param.events.slice(2).map((event) => event.kind), ['linear', 'linear', 'exponential']);
|
||||
const definitions = JSON.parse(readFileSync(new URL('../schema/xzbt-0.1.schema.json', import.meta.url))).definitions;
|
||||
assert.equal(definitions.AudioRecipe.oneOf[0].properties.automation.$ref, '#/definitions/AudioAutomationList');
|
||||
assert.equal(definitions.AudioComponent.properties.automation.$ref, '#/definitions/AudioAutomationList');
|
||||
assert.equal(definitions.AudioBus.properties.automation, undefined);
|
||||
});
|
||||
|
||||
test('the minimal audio exhibit exercises all modes/curves and compiles with both validators', () => {
|
||||
const doc = JSON.parse(readFileSync(new URL('../exhibits/minimal-audio.xzbt', import.meta.url)));
|
||||
valid(doc);
|
||||
const tracks = [];
|
||||
for (const soundId of Object.keys(doc.sounds)) {
|
||||
const plan = instantiateSoundGraph(doc, soundId, { rng: new SeededRNG(42), resolveReference: () => 0.6 });
|
||||
assert.deepEqual(plan.errors, []);
|
||||
tracks.push(...plan.automation);
|
||||
}
|
||||
assert.deepEqual([...new Set(tracks.map((item) => item.mode))].sort(), ['absolute', 'offset', 'scale']);
|
||||
assert.deepEqual([...new Set(tracks.map((item) => item.interpolation))].sort(), ['exponential', 'linear', 'smooth', 'step']);
|
||||
});
|
||||
|
||||
test('bus base ValueSpecs sample once and parameter overrides feed downstream buses in the same tick', () => {
|
||||
const doc = fixture();
|
||||
doc.audio.buses.main.gain = { random: { min: 0.5, max: 0.5 } };
|
||||
doc.parameters = { amount: { type: 'number', default: 1 } };
|
||||
doc.bindings = [{ source: 'parameters.amount', target: 'audio.buses.main.gain' }];
|
||||
valid(doc);
|
||||
let draws = 0;
|
||||
const rng = { stream: () => ({ nextFloat: () => { draws++; return 0.5; } }) };
|
||||
const engine = new ResolutionEngine(doc, rng);
|
||||
const path = 'audio.buses.main.gain';
|
||||
assert.equal(engine.base(path), 0.5);
|
||||
const id = engine.overrides.add({ target: 'parameters.amount', scope: 'scenario' }, 2);
|
||||
assert.equal(engine.get(path), 2);
|
||||
engine.setParameter('amount', 0.75);
|
||||
assert.equal(engine.get(path), 2);
|
||||
engine.overrides.beginRelease(id);
|
||||
assert.equal(engine.get(path), 0.75);
|
||||
engine.advance(1000);
|
||||
assert.equal(draws, 1);
|
||||
});
|
||||
|
||||
test('partial and resonator ValueSpecs preserve nested document order before automation draws', () => {
|
||||
const random = { random: { min: 0, max: 1 } };
|
||||
const doc = fixture([track('tone.detune', [random, random])]);
|
||||
recipe(doc).nodes = {
|
||||
tone: { type: 'oscillator', waveform: 'custom', harmonics: [{ phase: random, gain: random, ratio: random }], frequency: random },
|
||||
body: { type: 'resonator', modes: [{ gain: random, ratio: random }], fundamental: random }
|
||||
};
|
||||
let draws = 0;
|
||||
const plan = planFor(doc, { rng: { stream: () => ({ nextFloat: () => ++draws / 10 }) } });
|
||||
const tone = plan.nodes.find((node) => node.path === 'tone');
|
||||
assert.deepEqual(tone.values.harmonics, [{ phase: 0.1, gain: 0.2, ratio: 0.3 }]);
|
||||
assert.equal(tone.values.frequency, 0.4);
|
||||
const body = plan.nodes.find((node) => node.path === 'body');
|
||||
assert.equal(body.values.modes[0].gain, 0.5);
|
||||
assert.equal(body.values.modes[0].ratio, 0.6);
|
||||
assert.equal(body.values.fundamental, 0.7);
|
||||
assert.deepEqual(plan.automation[0].points.map((point) => point.value), [0.8, 0.9]);
|
||||
assert.equal(draws, 9);
|
||||
});
|
||||
@@ -12,7 +12,9 @@ const sourceFiles = Object.freeze([
|
||||
'src/runtime/types.js',
|
||||
'src/runtime/values.js',
|
||||
'src/runtime/audio-contract.js',
|
||||
'src/runtime/audio-automation.js',
|
||||
'src/runtime/audio-graph.js',
|
||||
'src/runtime/audio-controls.js',
|
||||
'src/runtime/validator.js',
|
||||
'src/runtime/persistence.js',
|
||||
'src/runtime/library.js',
|
||||
|
||||
Reference in New Issue
Block a user