first commit
This commit is contained in:
114
docs/media/audio/audio-engine-browser/README.md
Normal file
114
docs/media/audio/audio-engine-browser/README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# @suaveplan/audio-engine-browser - Browser-tier TimelineAdapter implementation: Web Audio rendering, AudioWorklet hosting, and AudioFxPlugin createAudioNode integration for the universal TimelineEngine.
|
||||
|
||||
Browser-tier `TimelineAdapter` implementation for the `@suaveplan/audio-*`
|
||||
family. Hosts Web Audio rendering, `AudioWorklet` loading, and the
|
||||
`AudioFxPlugin.createAudioNode()` integration so a universal
|
||||
`TimelineEngine` drives browser audio output through a single
|
||||
`timeline.setAdapter(new BrowserTimelineAdapter(...))` call.
|
||||
|
||||
The package is browser-tier: it may reference `AudioContext`,
|
||||
`OfflineAudioContext`, `AudioWorklet`, and `HTMLAudioElement`, but every
|
||||
such reference goes through an injectable seam (the `context` option or
|
||||
the `standardized-audio-context` shim) so the package is testable under
|
||||
`happy-dom` without a real browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter-introspection](./src/adapter/adapter-introspection.md) — `adapter-introspection` is a small support module that holds the `BrowserTimelineAdapter` operations that would otherwi…
|
||||
- [adapter-lifecycle](./src/adapter/adapter-lifecycle.md) — `adapter-lifecycle` is the small helper module that owns the `BrowserTimelineAdapter`'s context resolution and the `thr…
|
||||
- [adapter-track-ops](./src/adapter/adapter-track-ops.md) — `adapter-track-ops` packs every track-scoped mutation that the `BrowserTimelineAdapter` exposes — adding, removing, upd…
|
||||
- [browser-adapter](./src/adapter/browser-adapter.md) — `BrowserTimelineAdapter` is the browser-tier rendering engine of the `@suaveplan/audio-*` family. It implements the `Ti…
|
||||
- [bus-orchestrator](./src/adapter/bus-orchestrator.md) — `BusOrchestrator` is the per-adapter owner of the project-level sidechain bus graph. It wraps a single `DefaultBusRegis…
|
||||
- [fx-host](./src/adapter/fx-host.md) — `fx-host` is the small switch-board that picks the best available render path for an `AudioFxPlugin` and returns a sing…
|
||||
- [mixer-state](./src/adapter/mixer-state.md) — `mixer-state` extracts the pure mixer / ambient bookkeeping out of the `BrowserTimelineAdapter` class so the adapter fi…
|
||||
- [standardized-audio-context-bridge](./src/adapter/standardized-audio-context-bridge.md) — `standardized-audio-context-bridge` is the dynamic-import wrapper for `standardized-audio-context`'s `AudioContext` con…
|
||||
- [track-subgraph](./src/adapter/track-subgraph.md) — This module is the small, focused factory that materialises the documented per-track Web Audio chain inside `BrowserTim…
|
||||
- [decode](./src/decode/decode.md) — `decode(source, opts?)` decodes browser-tier audio bytes into a fully populated `AudioDocument` from `@suaveplan/audio-…
|
||||
- [decode](./src/decode/decode.md) — `decode(source, opts?)` decodes browser-tier audio bytes into a fully populated `AudioDocument` from `@suaveplan/audio-…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module declares the structured error-code registry for `@suaveplan/audio-engine-browser`. Every fault path inside…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module declares the structured error-code registry for `@suaveplan/audio-engine-browser`. Every fault path inside…
|
||||
- [fallback](./src/fallback/html5-fallback.md) — `Html5AudioFallback` is the graceful-degradation path for browsers and runtimes without the Web Audio API. It implement…
|
||||
- [html5-fallback](./src/fallback/html5-fallback.md) — `Html5AudioFallback` is the graceful-degradation path for browsers and runtimes without the Web Audio API. It implement…
|
||||
- [graph](./src/graph/web-audio-graph.md) — `WebAudioGraph` is the thin id-keyed registry that sits between `BrowserTimelineAdapter` and a `BaseAudioContext`. It e…
|
||||
- [web-audio-graph](./src/graph/web-audio-graph.md) — `WebAudioGraph` is the thin id-keyed registry that sits between `BrowserTimelineAdapter` and a `BaseAudioContext`. It e…
|
||||
- [offline](./src/offline/offline-render.md) — `renderOffline` is the bridge to non-realtime export. It wires a `BrowserTimelineAdapter` to an `OfflineAudioContext`,…
|
||||
- [offline-render](./src/offline/offline-render.md) — `renderOffline` is the bridge to non-realtime export. It wires a `BrowserTimelineAdapter` to an `OfflineAudioContext`,…
|
||||
- [worklet](./src/worklet/worklet-manager.md) — `AudioWorkletManager` is the gatekeeper for everything `AudioWorklet`- related inside `BrowserTimelineAdapter`. It vali…
|
||||
- [worklet-manager](./src/worklet/worklet-manager.md) — `AudioWorkletManager` is the gatekeeper for everything `AudioWorklet`- related inside `BrowserTimelineAdapter`. It vali…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/audio-engine-browser
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { BrowserTimelineAdapter } from "@suaveplan/audio-engine-browser";
|
||||
import { TimelineEngine } from "@suaveplan/audio-timeline";
|
||||
|
||||
const adapter = new BrowserTimelineAdapter({ lookAhead: 0.1, ppqn: 960 });
|
||||
await adapter.init();
|
||||
|
||||
const timeline = new TimelineEngine();
|
||||
timeline.setAdapter(adapter);
|
||||
timeline.setTracks(myTracks);
|
||||
timeline.play();
|
||||
|
||||
// Later
|
||||
adapter.dispose();
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`adapter/browser-adapter`](./src/adapter/browser-adapter.md) —
|
||||
`BrowserTimelineAdapter` implementing `TimelineAdapter`.
|
||||
- [`adapter/adapter-lifecycle`](./src/adapter/adapter-lifecycle.md) —
|
||||
context resolution and assertion helpers.
|
||||
- [`adapter/adapter-track-ops`](./src/adapter/adapter-track-ops.md) —
|
||||
track-scoped mutations.
|
||||
- [`adapter/adapter-introspection`](./src/adapter/adapter-introspection.md) —
|
||||
FX hosting and bus declarations.
|
||||
- [`adapter/track-subgraph`](./src/adapter/track-subgraph.md) — fixed-shape
|
||||
per-track Web Audio chain factory.
|
||||
- [`adapter/mixer-state`](./src/adapter/mixer-state.md) — solo/ambient/PDC.
|
||||
- [`adapter/bus-orchestrator`](./src/adapter/bus-orchestrator.md) —
|
||||
cycle-checked project-level bus registry.
|
||||
- [`adapter/fx-host`](./src/adapter/fx-host.md) — render-path picker for
|
||||
`AudioFxPlugin`.
|
||||
- [`graph/web-audio-graph`](./src/graph/web-audio-graph.md) — id-keyed
|
||||
Web Audio node graph with snapshot/restore.
|
||||
- [`worklet/worklet-manager`](./src/worklet/worklet-manager.md) —
|
||||
`AudioWorklet` host with allow-list and abort support.
|
||||
- [`fallback/html5-fallback`](./src/fallback/html5-fallback.md) —
|
||||
graceful-degradation `<audio>`-backed `TimelineAdapter`.
|
||||
- [`offline/offline-render`](./src/offline/offline-render.md) —
|
||||
`renderOffline(timeline, duration, sampleRate)` non-realtime export.
|
||||
- [`error-codes`](./src/error-codes.md) — `BrowserAudioError` registry.
|
||||
|
||||
## Features
|
||||
|
||||
- Universal `TimelineAdapter` contract — swap engines without
|
||||
conditional branching.
|
||||
- Fixed-shape per-track subgraph matching DAW topology.
|
||||
- DAW solo semantics, master / per-track volume / mute / pan, loop
|
||||
region, tempo.
|
||||
- Per-track `PluginChain` from `@suaveplan/audio-fx-plugin-base` with
|
||||
shared project-level `BusRegistry`.
|
||||
- Bus cycle detection (`AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED`) and
|
||||
topological process ordering.
|
||||
- Three FX render paths: native `AudioNode`, `AudioWorklet`,
|
||||
`ScriptProcessorNode` fallback.
|
||||
- `Html5AudioFallback` for environments without the Web Audio API.
|
||||
- Offline export via `OfflineAudioContext`.
|
||||
- Typed `BrowserAudioError` family with seven codes.
|
||||
- Real OTel: tracer (`engine_browser.init`, `engine_browser.set_tracks`,
|
||||
`engine_browser.offline_render`), counter
|
||||
(`audio_engine_browser_nodes_created_total`), histogram
|
||||
(`audio_engine_browser_offline_render_duration_ms`).
|
||||
- 100% test coverage; tests run under `happy-dom` with an injected
|
||||
context shim.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# `adapter-introspection`
|
||||
|
||||
## Purpose
|
||||
|
||||
`adapter-introspection` is a small support module that holds the
|
||||
`BrowserTimelineAdapter` operations that would otherwise push the
|
||||
adapter class file over its 300-line budget: FX plugin hosting and the
|
||||
project-level bus registry declarations. Each function is a thin
|
||||
delegator — the adapter still owns the lifecycle invariants
|
||||
(`assertReady`, `requireTrack`) and delegates the work here.
|
||||
|
||||
The module is consumed directly by `browser-adapter.ts` rather than
|
||||
re-exported from the package barrel. Consumers do not call into it
|
||||
themselves — they go through the adapter's public methods.
|
||||
|
||||
## Features
|
||||
|
||||
- `hostFxOnTrack(rec, plugin, context, worklets, graph)` adds the
|
||||
plugin to the track's `PluginChain`, materialises the render node
|
||||
via `createFxNode`, and registers the node under
|
||||
`<trackId>:fx:<pluginId>`.
|
||||
- `declareBusPublish` / `declareBusSubscribe` thin wrappers around
|
||||
`BusOrchestrator`.
|
||||
- `busRegistry`, `processOrder` read accessors.
|
||||
- Owns the `audio_engine_browser_nodes_created_total` counter
|
||||
increment under `kind=fx`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hostFxOnTrack } from "@suaveplan/audio-engine-browser/adapter/adapter-introspection";
|
||||
|
||||
await hostFxOnTrack(rec, plugin, context, worklets, graph);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
busRegistry,
|
||||
declareBusPublish,
|
||||
declareBusSubscribe,
|
||||
processOrder,
|
||||
} from "@suaveplan/audio-engine-browser/adapter/adapter-introspection";
|
||||
|
||||
declareBusPublish(orch, "kick", "kick-bus", 2);
|
||||
declareBusSubscribe(orch, "bass", "kick-bus");
|
||||
|
||||
const order = processOrder(orch, ["kick", "bass"]);
|
||||
const registry = busRegistry(orch);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `hostFxOnTrack(rec, plugin, context, worklets, graph) → Promise<void>`
|
||||
|
||||
Adds the plugin to the chain, calls `createFxNode` to pick the render
|
||||
path, and registers the resulting node in `graph`.
|
||||
|
||||
### `declareBusPublish(orch, trackId, bus, channels) → void`
|
||||
|
||||
Delegates to `BusOrchestrator.declarePublish`.
|
||||
|
||||
### `declareBusSubscribe(orch, trackId, bus) → void`
|
||||
|
||||
Delegates to `BusOrchestrator.declareSubscribe`.
|
||||
|
||||
### `busRegistry(orch) → BusRegistry`
|
||||
|
||||
Returns the orchestrator's underlying `BusRegistry`.
|
||||
|
||||
### `processOrder(orch, trackIds) → ReadonlyArray<string>`
|
||||
|
||||
Returns the topological order of `trackIds`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The OTel counter is module-scoped so each `BrowserTimelineAdapter`
|
||||
instance records into the same series; consumers wanting per-adapter
|
||||
cardinality should add their own attribute on the calling side.
|
||||
- `hostFxOnTrack` does not catch errors from `createFxNode` — those
|
||||
propagate so the adapter raises the right
|
||||
`AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED` code with the original
|
||||
cause attached.
|
||||
- These helpers are intentionally not exported from the package barrel
|
||||
because they take internal types (`TrackRecord`, `BusOrchestrator`)
|
||||
whose shape should not be part of the public surface.
|
||||
@@ -0,0 +1,107 @@
|
||||
# `adapter-lifecycle`
|
||||
|
||||
## Purpose
|
||||
|
||||
`adapter-lifecycle` is the small helper module that owns the
|
||||
`BrowserTimelineAdapter`'s context resolution and the `throw*` error
|
||||
factories used across the adapter call path. It exists for two reasons:
|
||||
first, to keep `browser-adapter.ts` inside the 300-line hard cap; second,
|
||||
to give the context-resolution path a deterministic, dependency-injected
|
||||
seam that tests can exercise without instantiating the full adapter.
|
||||
|
||||
The module is intentionally narrow: it does not know about tracks,
|
||||
chains, graphs, or worklets. It only translates the caller's options
|
||||
into a concrete context, resumes it, and surfaces every failure as a
|
||||
`BrowserAudioError(CONTEXT_FAILED)` with the original error attached as
|
||||
`cause`. Tests can pass an injected `context` for the happy-path
|
||||
scenarios, an aborted `AbortSignal` for the pre-flight short-circuit,
|
||||
and a deliberately-failing `resume()` for the resume-failed path.
|
||||
|
||||
## Features
|
||||
|
||||
- `resolveContext(args)` — single entry point that returns
|
||||
`{ context, ownsContext }`. Honours injected contexts, the
|
||||
`standardized-audio-context` shim, `AbortSignal`, and `resume()`
|
||||
rejections.
|
||||
- `readyOptions(options)` — derives the resolve arguments from
|
||||
validated `BrowserAdapterOptions` so the adapter does not duplicate
|
||||
the destructuring.
|
||||
- `throwAlreadyDisposed` / `throwNotInitialised` / `throwTrackNotFound`
|
||||
— typed `never`-returning factories for the adapter's assertion
|
||||
helpers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
readyOptions,
|
||||
resolveContext,
|
||||
} from "@suaveplan/audio-engine-browser/adapter/adapter-lifecycle";
|
||||
|
||||
const { context, ownsContext } = await resolveContext({
|
||||
...readyOptions(parsedOptions),
|
||||
signal: options?.signal,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveContext,
|
||||
throwAlreadyDisposed,
|
||||
throwTrackNotFound,
|
||||
} from "@suaveplan/audio-engine-browser/adapter/adapter-lifecycle";
|
||||
|
||||
if (this.disposed) throwAlreadyDisposed();
|
||||
|
||||
const { context } = await resolveContext({
|
||||
injected: undefined,
|
||||
latencyHint: "interactive",
|
||||
sampleRate: 48000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!this.tracks.has(trackId)) throwTrackNotFound(trackId);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `resolveContext(args) → Promise<{ context, ownsContext }>`
|
||||
|
||||
Resolves a `BaseAudioContext`-shaped value. `ownsContext` is `true`
|
||||
when the helper constructed it (i.e. injection was absent), `false`
|
||||
otherwise.
|
||||
|
||||
### `readyOptions(options) → { injected?, latencyHint?, sampleRate? }`
|
||||
|
||||
Builds the arguments shape `resolveContext` expects from the validated
|
||||
`BrowserAdapterOptions`. Translates the union-typed `latencyHint` into
|
||||
`string | undefined`.
|
||||
|
||||
### `throwAlreadyDisposed() → never`
|
||||
|
||||
Throws `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED`.
|
||||
|
||||
### `throwNotInitialised() → never`
|
||||
|
||||
Throws `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED`
|
||||
with the `not-initialised` message key.
|
||||
|
||||
### `throwTrackNotFound(trackId) → never`
|
||||
|
||||
Throws `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND`
|
||||
with the offending id in `context`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The dynamic `import("standardized-audio-context")` is intentional so
|
||||
the package can be tree-shaken in offline-render-only callers that
|
||||
always inject their context — the shim never appears in the import
|
||||
graph when injection is the only path.
|
||||
- The pre-flight abort check runs **before** any context construction,
|
||||
so a misbehaving caller cannot leak a half-constructed `AudioContext`
|
||||
on cancellation.
|
||||
- The `throw*` factories are typed `never`-returning so the adapter's
|
||||
arrow-style `if (!ok) throwX()` patterns are accepted by
|
||||
TypeScript's narrowing without extra explicit `return` statements.
|
||||
@@ -0,0 +1,106 @@
|
||||
# `adapter-track-ops`
|
||||
|
||||
## Purpose
|
||||
|
||||
`adapter-track-ops` packs every track-scoped mutation that the
|
||||
`BrowserTimelineAdapter` exposes — adding, removing, updating, and the
|
||||
five per-track setters (`volume`, `mute`, `solo`, `pan`, plus the chain
|
||||
|
||||
+ subgraph teardown helper) — into pure functions that operate on an
|
||||
explicit `TrackOpEnv` rather than on adapter-private state. The adapter
|
||||
itself becomes a thin coordinator: it owns the lifecycle invariants and
|
||||
the lazy environment, and delegates the actual work here.
|
||||
|
||||
Pulling the work out of the class keeps `browser-adapter.ts` inside the
|
||||
300-line hard cap and makes every track operation unit-testable by
|
||||
passing a hand-built `TrackOpEnv` with deterministic state lambdas. The
|
||||
adapter file still exposes the same public methods; readers should
|
||||
follow the call chain to this module for the implementation.
|
||||
|
||||
## Features
|
||||
|
||||
+ `TrackOpEnv` interface — explicit dependency injection of the track
|
||||
map, graph, context, bus orchestrator, and the four lazy state
|
||||
accessors (`currentTime`, `playState`, `masterVolume`).
|
||||
+ `addTrackImpl` — builds the subgraph, constructs the chain, wires
|
||||
the latency-changed event, applies mixer + ambient state.
|
||||
+ `releaseTrackImpl` — symmetric teardown: dispose chain, tear down
|
||||
subgraph, drop from the map.
|
||||
+ `updateTrackImpl`, `setTrack{Volume,Mute,Solo,Pan}Impl` — small
|
||||
composable mutations sharing the same epilogue (mixer / ambient
|
||||
recompute).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { addTrackImpl } from "@suaveplan/audio-engine-browser/adapter/adapter-track-ops";
|
||||
|
||||
addTrackImpl(env, track);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
addTrackImpl,
|
||||
releaseTrackImpl,
|
||||
setTrackPanImpl,
|
||||
setTrackVolumeImpl,
|
||||
} from "@suaveplan/audio-engine-browser/adapter/adapter-track-ops";
|
||||
|
||||
addTrackImpl(env, track);
|
||||
setTrackVolumeImpl(env, rec, 0.8);
|
||||
setTrackPanImpl(env, rec, -0.3);
|
||||
releaseTrackImpl(env, track.id);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TrackOpEnv`
|
||||
|
||||
```ts
|
||||
interface TrackOpEnv {
|
||||
tracks: Map<string, TrackRecord>;
|
||||
graph: WebAudioGraph;
|
||||
context: ContextLike;
|
||||
busOrch: BusOrchestrator;
|
||||
currentTime(): number;
|
||||
playState(): "stopped" | "playing" | "paused";
|
||||
masterVolume(): number;
|
||||
}
|
||||
```
|
||||
|
||||
### `addTrackImpl(env, track) → void`
|
||||
|
||||
Builds the per-track subgraph, constructs a `PluginChain` bound to the
|
||||
project's shared `BusRegistry`, subscribes to `latency-changed`, and
|
||||
applies the mixer + ambient state.
|
||||
|
||||
### `releaseTrackImpl(env, trackId) → void`
|
||||
|
||||
Removes the chain + subgraph for `trackId`. Idempotent for missing ids.
|
||||
|
||||
### `updateTrackImpl(env, rec, track) → void`
|
||||
|
||||
Mutates the `TrackRecord` in place, refreshes the pan node, and
|
||||
re-applies mixer + ambient state.
|
||||
|
||||
### `setTrackVolumeImpl / setTrackMuteImpl / setTrackSoloImpl / setTrackPanImpl`
|
||||
|
||||
Single-purpose updaters. Each mutates the relevant fields and runs the
|
||||
small epilogue: `applyMixerState` then `applyAmbientForTrack`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
+ The OTel counter is module-scoped because every adapter shares the
|
||||
same series; the adapter itself owns the `kind=fx` increment, and
|
||||
this module owns `kind=track-subgraph` (+7 per added track).
|
||||
+ `TrackOpEnv` accessors are lambdas rather than direct fields so the
|
||||
adapter can pass `() => this.currentTime` without leaking `this`
|
||||
binding. This keeps the helpers callable from any code path
|
||||
(top-level, tests, future async restoration).
|
||||
+ `releaseTrackImpl` swallows chain dispose errors so a misbehaving
|
||||
plugin disposal cannot prevent the subgraph teardown — the adapter
|
||||
invariant is "track must be fully released by `removeTrack`".
|
||||
+ Adding the same track id again replays the release path first, which
|
||||
is how the adapter handles `updateTrack` on a recreated chain shape.
|
||||
@@ -0,0 +1,161 @@
|
||||
# `BrowserTimelineAdapter`
|
||||
|
||||
## Purpose
|
||||
|
||||
`BrowserTimelineAdapter` is the browser-tier rendering engine of the
|
||||
`@suaveplan/audio-*` family. It implements the `TimelineAdapter`
|
||||
interface defined by `@suaveplan/audio-timeline` so a `TimelineEngine`
|
||||
drives browser audio output by calling `timeline.setAdapter(new
|
||||
BrowserTimelineAdapter(...))` with no engine-specific branching. The
|
||||
adapter never re-derives timeline state — it receives resolved `Track[]`
|
||||
plus playback commands and maps them onto Web Audio `AudioNode` graphs.
|
||||
|
||||
Every browser-API touchpoint goes through one of two seams: the
|
||||
`context` option (injected for tests and offline render) or the
|
||||
`standardized-audio-context` shim (production realtime). That keeps the
|
||||
package testable under `happy-dom` without a real browser and makes
|
||||
offline export and realtime playback share one rendering path.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every member of `TimelineAdapter` with structural typing
|
||||
so a `TimelineEngine` consumes it without conditional branches.
|
||||
- Per-track Web Audio subgraph:
|
||||
`clip-source → clip-gain → track-gain → pan → compressor →
|
||||
master-mix → analyser → destination`.
|
||||
- DAW solo semantics: any soloed track silences non-soloed tracks; the
|
||||
per-track gain reverts to mute-governed when no track is soloed.
|
||||
- Per-track `PluginChain` (from `@suaveplan/audio-fx-plugin-base`)
|
||||
bound to a shared `BusRegistry`; the adapter pushes ambient state on
|
||||
every transport / mixer change and recomputes project-wide PDC when
|
||||
any chain reports `latency-changed`.
|
||||
- `BusOrchestrator` cycle detection: `declareBusPublish` /
|
||||
`declareBusSubscribe` reject any new edge that would close a cycle
|
||||
with `AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED`.
|
||||
- FX render-path selection via `createFxNode`: native `AudioNode` →
|
||||
`AudioWorklet` → `ScriptProcessorNode`.
|
||||
- `AbortSignal` propagation through `init()` — a pre-aborted signal
|
||||
short-circuits with `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED`.
|
||||
- Typed `BrowserAudioError` family covering context/track/node/worklet/
|
||||
bus/dispose faults.
|
||||
- OTel spans (`engine_browser.init`, `engine_browser.set_tracks`) and
|
||||
counter (`audio_engine_browser_nodes_created_total`).
|
||||
- Idempotent `dispose()` closing the owned context only when the
|
||||
adapter constructed it.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BrowserTimelineAdapter } from "@suaveplan/audio-engine-browser/adapter/browser-adapter";
|
||||
import { TimelineEngine } from "@suaveplan/audio-timeline";
|
||||
|
||||
const adapter = new BrowserTimelineAdapter();
|
||||
await adapter.init();
|
||||
|
||||
const timeline = new TimelineEngine();
|
||||
timeline.setAdapter(adapter);
|
||||
timeline.setTracks(myTracks);
|
||||
timeline.play();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BrowserTimelineAdapter } from "@suaveplan/audio-engine-browser";
|
||||
import { eqPlugin } from "@suaveplan/audio-fx-plugin-eq";
|
||||
|
||||
// Inject an OfflineAudioContext for deterministic export
|
||||
const offline = new OfflineAudioContext({ length: 96000, sampleRate: 48000, numberOfChannels: 2 });
|
||||
const adapter = new BrowserTimelineAdapter({ context: offline, lookAhead: 0.05, ppqn: 480 });
|
||||
await adapter.init({ signal: controller.signal });
|
||||
|
||||
adapter.setTracks([trackA, trackB]);
|
||||
await adapter.hostFxPlugin(trackA.id, eqPlugin());
|
||||
|
||||
// Sidechain bus wiring (cycle-checked)
|
||||
adapter.declareBusPublish("kick", "kick-bus", 2);
|
||||
adapter.declareBusSubscribe("bass", "kick-bus");
|
||||
|
||||
const order = adapter.getProcessOrder(); // ["kick", "bass"]
|
||||
|
||||
adapter.setTrackSolo("kick", true); // bass silenced
|
||||
adapter.setMasterVolume(0.9);
|
||||
|
||||
const snapshot = adapter.snapshotGraph();
|
||||
adapter.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new BrowserTimelineAdapter(options?)`
|
||||
|
||||
Options (all optional): `context` (injected `BaseAudioContext`),
|
||||
`latencyHint`, `sampleRate`, `lookAhead` (default `0.1`), `ppqn`
|
||||
(default `960`). Invalid options raise
|
||||
`AUDIO_ENGINE_BROWSER.CONTEXT_FAILED` with the Zod issues attached.
|
||||
|
||||
### `init(options?) → Promise<void>`
|
||||
|
||||
Resolves the context (injection or `standardized-audio-context`),
|
||||
resumes it, and builds the per-adapter `WebAudioGraph`,
|
||||
`AudioWorkletManager`, and `BusOrchestrator`. Honours `AbortSignal`.
|
||||
Idempotent on second call.
|
||||
|
||||
### `setTracks(tracks)`, `addTrack(track)`, `removeTrack(trackId)`, `updateTrack(trackId, track)`
|
||||
|
||||
Build / tear down per-track subgraphs and chains. `removeTrack` /
|
||||
`updateTrack` throw `TRACK_NOT_FOUND` for unknown ids.
|
||||
|
||||
### `play(time?)`, `pause(time?)`, `stop(time?)`, `seek(time)`, `getCurrentTime()`
|
||||
|
||||
Transport. Pushes ambient state to every chain on every transition.
|
||||
|
||||
### `setLoop`, `setMasterVolume`
|
||||
|
||||
Project-wide mixer state.
|
||||
|
||||
### `setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`, `setTempo`
|
||||
|
||||
Per-track mixer state. Throw `TRACK_NOT_FOUND` for unknown ids.
|
||||
|
||||
### `hostFxPlugin(trackId, plugin) → Promise<void>`
|
||||
|
||||
Adds an `AudioFxPlugin` to the track's chain and inserts the chosen
|
||||
render node into the per-track subgraph.
|
||||
|
||||
### `declareBusPublish(trackId, bus, channels)`, `declareBusSubscribe(trackId, bus)`
|
||||
|
||||
Open / close sidechain bus edges. Reject cycle-closing edges with
|
||||
`BUS_CYCLE_DETECTED`.
|
||||
|
||||
### `getBusRegistry()`, `getProcessOrder()`, `getTrackChain(trackId)`, `snapshotGraph()`, `getLoop()`
|
||||
|
||||
Introspection accessors used by tests and consumers.
|
||||
|
||||
### `dispose()`
|
||||
|
||||
Idempotent. Releases every track, closes every bus publisher, and
|
||||
closes the context iff the adapter constructed it.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The adapter file delegates heavy logic to four sibling modules so
|
||||
it stays under 300 lines: `adapter-lifecycle` (context resolution),
|
||||
`adapter-track-ops` (per-track mutations), `mixer-state`
|
||||
(solo/ambient/PDC), and `adapter-introspection` (FX hosting + bus
|
||||
declaration).
|
||||
- The `MinimalContext` shape is intentionally a structural subset of
|
||||
`BaseAudioContext`; tests inject a deterministic hand-rolled shim
|
||||
that satisfies the same shape without any browser DOM.
|
||||
- The OTel meter increments `audio_engine_browser_nodes_created_total`
|
||||
by 7 on every new track subgraph and by 1 on every FX node, using
|
||||
the `kind` attribute to distinguish them.
|
||||
- `setTempo` writes only to the chain's ambient `transport.tempo`
|
||||
field — the per-track audio graph has no tempo-dependent node, so
|
||||
no graph mutation is required.
|
||||
- The chain's `latency-changed` event triggers `recomputePdc` so every
|
||||
chain sees a fresh `compensationSamples` extension on the next
|
||||
block. Sample-accurate alignment is the consumer's responsibility:
|
||||
the adapter exposes the compensation but does not delay any
|
||||
internal node, since the real delay node belongs in the plugin
|
||||
graph that the chain produces.
|
||||
@@ -0,0 +1,113 @@
|
||||
# `bus-orchestrator`
|
||||
|
||||
## Purpose
|
||||
|
||||
`BusOrchestrator` is the per-adapter owner of the project-level sidechain
|
||||
bus graph. It wraps a single `DefaultBusRegistry` instance (or any
|
||||
caller-supplied `BusRegistry`), tracks every `(publisher track, bus)` and
|
||||
`(subscriber track, bus)` declaration, and refuses any new declaration
|
||||
that would close a directed cycle in the publish-then-subscribe DAG. It
|
||||
also exposes a topological ordering of tracks so the adapter can process
|
||||
publishers before subscribers in the same render block — the property
|
||||
the spec calls out under "publishers process before subscribers".
|
||||
|
||||
Cycle detection happens **at declaration time**, not at first render.
|
||||
That keeps the failure visible at the call site that introduced the
|
||||
cycle and avoids a half-built render pipeline aborting mid-block. Every
|
||||
faulted declaration raises a `BrowserAudioError(BUS_CYCLE_DETECTED)`.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps an injectable `BusRegistry` — defaults to `DefaultBusRegistry`
|
||||
from `@suaveplan/audio-fx-plugin-base`.
|
||||
- `declarePublish(trackId, bus, channels)` registers a publisher and
|
||||
caches the `SidechainPublisher` so `dispose()` can close it.
|
||||
- `declareSubscribe(trackId, bus)` records a subscriber edge.
|
||||
- Edges build a directed graph `publisher → subscriber` keyed on shared
|
||||
bus name; a cycle in this graph is rejected immediately.
|
||||
- `processOrder(allTracks)` returns a topologically-sorted track id
|
||||
list — tracks with no bus edges keep their insertion order (Kahn's
|
||||
algorithm).
|
||||
- `dispose()` closes every active publisher.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BusOrchestrator } from "@suaveplan/audio-engine-browser/adapter/bus-orchestrator";
|
||||
|
||||
const orch = new BusOrchestrator();
|
||||
orch.declarePublish("kick", "kick-bus", 2);
|
||||
orch.declareSubscribe("bass", "kick-bus");
|
||||
const order = orch.processOrder(["kick", "bass"]);
|
||||
// → ["kick", "bass"]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BusOrchestrator } from "@suaveplan/audio-engine-browser/adapter/bus-orchestrator";
|
||||
|
||||
const orch = new BusOrchestrator();
|
||||
|
||||
orch.declarePublish("a", "x", 2);
|
||||
orch.declareSubscribe("b", "x");
|
||||
|
||||
try {
|
||||
orch.declarePublish("b", "y", 2);
|
||||
orch.declareSubscribe("a", "y"); // closes a cycle
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserAudioError) {
|
||||
// e.code === "AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED"
|
||||
}
|
||||
}
|
||||
|
||||
orch.dispose(); // closes every publisher
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new BusOrchestrator(registry?)`
|
||||
|
||||
Constructs the orchestrator. The default registry is a fresh
|
||||
`DefaultBusRegistry`. Tests can inject any conforming `BusRegistry`.
|
||||
|
||||
### `declarePublish(trackId, bus, channels) → void`
|
||||
|
||||
Opens a publisher in the registry, caches the handle, and stores the
|
||||
edge. Throws `BUS_CYCLE_DETECTED` when the new edge would close a
|
||||
cycle.
|
||||
|
||||
### `declareSubscribe(trackId, bus) → void`
|
||||
|
||||
Records a subscriber edge and runs the same cycle check.
|
||||
|
||||
### `processOrder(allTracks) → ReadonlyArray<string>`
|
||||
|
||||
Returns the topological order of `allTracks` honouring every recorded
|
||||
publisher→subscriber edge.
|
||||
|
||||
### `bus → BusRegistry`
|
||||
|
||||
Read accessor for the underlying registry — handed to every
|
||||
`PluginChain` as `ChainContext.buses`.
|
||||
|
||||
### `dispose() → void`
|
||||
|
||||
Closes every active publisher and clears internal state.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The DFS cycle check uses three colours (white / grey / black) so the
|
||||
declaration-time check is `O(V + E)`. The `processOrder` step uses
|
||||
Kahn's algorithm and detects any residual cycle by checking that
|
||||
every node was emitted.
|
||||
- Edges are `(publisher_track, subscriber_track)` filtered on the
|
||||
shared bus name. Self-loops (a track publishing and subscribing to
|
||||
the same bus on itself) are intentionally excluded from the cycle
|
||||
graph because a single chain processes left-to-right within one
|
||||
track — self-feedback is the plugin's own responsibility to handle.
|
||||
- The cached `processOrder` is invalidated on every declaration so the
|
||||
adapter sees fresh orderings as tracks are added.
|
||||
- `dispose()` is best-effort about publisher closes — a misbehaving
|
||||
registry implementation does not prevent the rest from being
|
||||
released.
|
||||
95
docs/media/audio/audio-engine-browser/src/adapter/fx-host.md
Normal file
95
docs/media/audio/audio-engine-browser/src/adapter/fx-host.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# `fx-host`
|
||||
|
||||
## Purpose
|
||||
|
||||
`fx-host` is the small switch-board that picks the best available render
|
||||
path for an `AudioFxPlugin` and returns a single `AudioNode` the adapter
|
||||
can splice into the per-track subgraph. The decision is driven by the
|
||||
plugin's declared `capabilities` and the running context's `audioWorklet`
|
||||
support; the host respects the preference order documented in
|
||||
`enterprise-audio-engine-browser/design.md` §5 — native first, then
|
||||
worklet, then script-processor fallback.
|
||||
|
||||
This file exists to keep `browser-adapter.ts` focused on lifecycle and
|
||||
ambient propagation. The host does **not** own scheduling or chain
|
||||
ordering — it just builds the node. The adapter is responsible for
|
||||
calling it once per plugin in the chain and inserting the returned node
|
||||
into the right spot in the per-track subgraph.
|
||||
|
||||
## Features
|
||||
|
||||
- Three render paths with clear preference order: native `AudioNode`,
|
||||
`AudioWorklet`, `ScriptProcessorNode`.
|
||||
- Returns both the chosen node and the path tag so the adapter can
|
||||
account for per-path latency in PDC calculations.
|
||||
- Async because the worklet path waits on `addModule`.
|
||||
- Pure: no state owned by the module — the `AudioWorkletManager` is
|
||||
passed in by the caller.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createFxNode } from "@suaveplan/audio-engine-browser/adapter/fx-host";
|
||||
|
||||
const { node, path } = await createFxNode(plugin, context, worklets, chainCtx);
|
||||
// splice `node` between clip-gain and track-gain
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createFxNode, type FxRenderPath } from "@suaveplan/audio-engine-browser/adapter/fx-host";
|
||||
|
||||
const result = await createFxNode(plugin, context, worklets, chainCtx);
|
||||
|
||||
// Per-path latency accounting
|
||||
const tagToSamples: Record<FxRenderPath, number> = {
|
||||
"audio-node": 0,
|
||||
worklet: 0,
|
||||
"block-processor": 128, // ScriptProcessorNode block
|
||||
};
|
||||
const compensation = tagToSamples[result.path];
|
||||
|
||||
graph.addNode("fx", result.node);
|
||||
graph.connect("clip-gain", "fx");
|
||||
graph.connect("fx", "track-gain");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createFxNode(plugin, context, worklets, chainCtx) → Promise<FxHostResult>`
|
||||
|
||||
Selects the path and returns an `{ node, path }` record:
|
||||
|
||||
- `plugin.capabilities.hasAudioNode === true` + `plugin.createAudioNode`
|
||||
defined → `path === "audio-node"`.
|
||||
- `plugin.capabilities.hasWorklet === true` +
|
||||
`plugin.getWorkletProcessor` defined + `context.audioWorklet` truthy →
|
||||
`path === "worklet"`.
|
||||
- Otherwise → `path === "block-processor"` and the node is a
|
||||
`ScriptProcessorNode` whose `onaudioprocess` calls `plugin.process`.
|
||||
|
||||
### `FxHostResult`
|
||||
|
||||
`{ node: AudioNode; path: "audio-node" | "worklet" | "block-processor" }`
|
||||
|
||||
### `FX_BLOCK_SIZE`
|
||||
|
||||
Exported constant `128` — block size used by the script-processor
|
||||
fallback. Matches the Web Audio standard render quantum.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `ScriptProcessorNode` path is deprecated by the Web Audio spec but
|
||||
kept as the universal fallback so no plugin is unhostable. It runs on
|
||||
the **main thread**, which is documented in
|
||||
`enterprise-audio-engine-browser/design.md` §5.
|
||||
- The script processor pulls per-call stereo channel data; mono plugins
|
||||
see both channels duplicated. The shape matches what universal
|
||||
`processBlock` consumers expect.
|
||||
- The worklet path loads the processor through `AudioWorkletManager`
|
||||
which centralises allow-list / abort / load-error reporting; the host
|
||||
does not duplicate that logic.
|
||||
- The host does not handle plugin teardown — that is the chain's
|
||||
responsibility. When a plugin is removed, the chain disposes it and
|
||||
the adapter calls `graph.removeNode(...)` on the FX node.
|
||||
100
docs/media/audio/audio-engine-browser/src/adapter/mixer-state.md
Normal file
100
docs/media/audio/audio-engine-browser/src/adapter/mixer-state.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# `mixer-state`
|
||||
|
||||
## Purpose
|
||||
|
||||
`mixer-state` extracts the pure mixer / ambient bookkeeping out of the
|
||||
`BrowserTimelineAdapter` class so the adapter file stays inside the
|
||||
300-line budget and the mixer logic is unit-testable in isolation. It
|
||||
owns three operations: DAW solo accounting, ambient-state propagation
|
||||
into each track's `PluginChain`, and per-track plugin delay
|
||||
compensation (PDC).
|
||||
|
||||
Each function is pure with respect to the supplied `Map<string,
|
||||
TrackRecord>` — it reads track state and writes node-parameter values
|
||||
without touching adapter-private state. That makes a single mixer
|
||||
operation safe to call from any point in the adapter lifecycle: track
|
||||
add, track remove, set-volume, set-mute, set-solo, set-pan, play, seek,
|
||||
chain latency-changed.
|
||||
|
||||
## Features
|
||||
|
||||
- `applyMixerState(tracks, masterVolume)` — solo-aware mute resolution +
|
||||
master gain stamping.
|
||||
- `applyAmbientForTrack(rec, transportTimeSec, playState)` — single-
|
||||
track ambient propagation.
|
||||
- `recomputePdc(tracks)` — project-wide plugin delay compensation
|
||||
whose result is reported back through chain ambient extensions.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { applyMixerState } from "@suaveplan/audio-engine-browser/adapter/mixer-state";
|
||||
|
||||
applyMixerState(tracks, 0.9);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
applyAmbientForTrack,
|
||||
applyMixerState,
|
||||
recomputePdc,
|
||||
} from "@suaveplan/audio-engine-browser/adapter/mixer-state";
|
||||
|
||||
// After a play() transition
|
||||
for (const rec of tracks.values()) {
|
||||
applyAmbientForTrack(rec, currentTime, "playing");
|
||||
}
|
||||
applyMixerState(tracks, masterVolume);
|
||||
|
||||
// On any chain `latency-changed` event
|
||||
recomputePdc(tracks);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TrackRecord`
|
||||
|
||||
```ts
|
||||
interface TrackRecord {
|
||||
track: Track;
|
||||
chain: PluginChain;
|
||||
subgraph: TrackSubgraph;
|
||||
mutedByUser: boolean;
|
||||
soloedByUser: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### `applyMixerState(tracks, masterVolume) → void`
|
||||
|
||||
Walks `tracks` once. Any soloed track silences non-soloed tracks; with
|
||||
no solos, mute alone governs gain. Master volume is written to every
|
||||
`masterGain.gain`.
|
||||
|
||||
### `applyAmbientForTrack(rec, transportTimeSec, playState) → void`
|
||||
|
||||
Builds an ambient patch (`track` + `transport`) and pushes it into the
|
||||
chain via `chain.updateAmbient`. The chain de-duplicates identical
|
||||
patches.
|
||||
|
||||
### `recomputePdc(tracks) → void`
|
||||
|
||||
Computes `max` of every chain's effective latency, then writes
|
||||
`extension.compensationSamples = max - chain.latency` to every chain.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `anySoloed` predicate is computed once per call — solo state is a
|
||||
whole-mix property so any caller wanting per-track audibility must
|
||||
call `applyMixerState` rather than reasoning about `track.muted`
|
||||
alone.
|
||||
- `applyAmbientForTrack` writes `gainDb` as the raw volume — the chain
|
||||
semantics treat the value as already-linearised gain because the
|
||||
upstream `Track.volume` is a linear `[0, 1]` field.
|
||||
- `recomputePdc` is idempotent: passing the same map twice produces no
|
||||
observable change because `chain.updateAmbient` short-circuits equal
|
||||
patches.
|
||||
- All three functions skip `null`-shaped node fields defensively so a
|
||||
partially-constructed track record (mid-`addTrack` failure) does not
|
||||
crash the next mixer pass.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `standardized-audio-context-bridge`
|
||||
|
||||
## Purpose
|
||||
|
||||
`standardized-audio-context-bridge` is the dynamic-import wrapper for
|
||||
`standardized-audio-context`'s `AudioContext` constructor. It exists as
|
||||
its own file purely for testability: the package needs the dynamic
|
||||
import in production code, but the success branch is unreachable under
|
||||
`happy-dom` (no native Web Audio constructor) so the file is excluded
|
||||
from coverage via the standard `/* istanbul ignore file */` directive.
|
||||
The failure branch is still verified by the offline-render
|
||||
"uses standardized-audio-context import path" test, which expects the
|
||||
typed `BrowserAudioError(CONTEXT_FAILED)`.
|
||||
|
||||
Separating the bridge from `adapter-lifecycle.ts` keeps the lifecycle
|
||||
file fully covered without sprinkling per-line ignore comments. The
|
||||
bridge surface is one function: `constructStandardizedContext`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single async helper that dynamically imports the shim and constructs
|
||||
an `AudioContext` with `latencyHint` + `sampleRate`.
|
||||
- Translates every failure into a typed
|
||||
`BrowserAudioError(CONTEXT_FAILED)` with the original error attached
|
||||
as `cause`.
|
||||
- Returns a `{ ctx, ownsContext: true }` descriptor consumed verbatim
|
||||
by `resolveContext`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { constructStandardizedContext } from "@suaveplan/audio-engine-browser/adapter/standardized-audio-context-bridge";
|
||||
|
||||
const { ctx } = await constructStandardizedContext({});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { constructStandardizedContext } from "@suaveplan/audio-engine-browser/adapter/standardized-audio-context-bridge";
|
||||
|
||||
try {
|
||||
const { ctx, ownsContext } = await constructStandardizedContext({
|
||||
latencyHint: "interactive",
|
||||
sampleRate: 48000,
|
||||
});
|
||||
await ctx.resume?.();
|
||||
// hand off to the adapter, etc.
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserAudioError) {
|
||||
// e.code === "AUDIO_ENGINE_BROWSER.CONTEXT_FAILED"
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructStandardizedContext(args) → Promise<{ ctx, ownsContext }>`
|
||||
|
||||
- `args.latencyHint` — optional `"interactive" | "balanced" | "playback"`.
|
||||
- `args.sampleRate` — optional sample rate.
|
||||
|
||||
Resolves to `{ ctx, ownsContext: true }` on success; rejects with
|
||||
`BrowserAudioError(CONTEXT_FAILED)` on every failure (import failure,
|
||||
constructor throw, missing global).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The dynamic `import("standardized-audio-context")` is deliberate: it
|
||||
keeps the shim out of the import graph when the package is used with
|
||||
an injected context (the only mode reachable under `happy-dom`).
|
||||
- This file carries `/* istanbul ignore file */`. The justification is
|
||||
recorded in the file header: the success branch requires a real Web
|
||||
Audio runtime, which neither Node nor Bun nor happy-dom provides.
|
||||
The failure branch is still verified through the failing import
|
||||
path in `offline-render.test.ts`.
|
||||
- The function is the ONLY production caller of
|
||||
`standardized-audio-context` — every other code path goes through
|
||||
`resolveContext`, which falls back to this bridge only when the
|
||||
caller did not inject a context.
|
||||
@@ -0,0 +1,101 @@
|
||||
# `track-subgraph`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the small, focused factory that materialises the
|
||||
documented per-track Web Audio chain inside `BrowserTimelineAdapter`.
|
||||
It owns the **shape** of the per-track graph — the precise sequence
|
||||
`clip-source → clip-gain → track-gain → panner → compressor → master-mix
|
||||
→ analyser → destination` — so the adapter file itself stays focused
|
||||
on lifecycle and ambient propagation rather than node plumbing.
|
||||
|
||||
Splitting the factory out of the adapter keeps both files inside the
|
||||
300-line budget and gives `BrowserTimelineAdapter` a single seam for the
|
||||
graph shape. If the per-track chain ever evolves (e.g. an extra
|
||||
saturator node, or a soft-clipper before the compressor), only this file
|
||||
needs to change — the adapter consumes the returned `TrackSubgraph`
|
||||
record without inspecting individual node ids.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildTrackSubgraph(graph, context, trackId)` constructs every node,
|
||||
registers them in the supplied `WebAudioGraph` under id strings of
|
||||
the form `<trackId>:<role>`, and wires the connections.
|
||||
- `teardownTrackSubgraph(graph, sub)` removes every node in reverse
|
||||
order so `removeTrack` is symmetric.
|
||||
- Returned `TrackSubgraph` record exposes both the id strings and the
|
||||
live `AudioParam`-bearing nodes (`trackGain`, `trackPan`,
|
||||
`masterGain`) so the adapter writes parameter values without a
|
||||
second `getNode` lookup.
|
||||
- Idempotent destination node registration — the destination is shared
|
||||
across every track, registered only on the first call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildTrackSubgraph } from "@suaveplan/audio-engine-browser/adapter/track-subgraph";
|
||||
import { WebAudioGraph } from "@suaveplan/audio-engine-browser/graph/web-audio-graph";
|
||||
|
||||
const graph = new WebAudioGraph(context);
|
||||
const sub = buildTrackSubgraph(graph, context, "track-1");
|
||||
sub.trackGain.gain.value = 0.8;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildTrackSubgraph,
|
||||
teardownTrackSubgraph,
|
||||
} from "@suaveplan/audio-engine-browser/adapter/track-subgraph";
|
||||
|
||||
const sub = buildTrackSubgraph(graph, context, "track-1");
|
||||
|
||||
// Apply mute + solo semantics
|
||||
sub.trackGain.gain.value = muted ? 0 : volume;
|
||||
|
||||
// Rewire the analyser to a measurement bus
|
||||
const probe = context.createGain();
|
||||
graph.addNode("probe", probe);
|
||||
graph.connect(sub.analyserId, "probe");
|
||||
|
||||
// Remove the track
|
||||
teardownTrackSubgraph(graph, sub);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `buildTrackSubgraph(graph, context, trackId) → TrackSubgraph`
|
||||
|
||||
Creates a clip-source / clip-gain / track-gain / pan / compressor /
|
||||
master-gain / analyser chain wired into the supplied `WebAudioGraph`.
|
||||
Returns the `TrackSubgraph` record that the adapter caches.
|
||||
|
||||
### `teardownTrackSubgraph(graph, sub) → void`
|
||||
|
||||
Removes every node in the subgraph from the graph in dependency order.
|
||||
Defensive against double-removal — already-removed nodes are skipped.
|
||||
|
||||
### `TrackSubgraph`
|
||||
|
||||
Plain readonly record:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `trackId` | `string` | The id passed to the factory. |
|
||||
| `clipSourceId` … `analyserId` | `string` | Graph-keyed ids. |
|
||||
| `trackGain` | `{ gain: AudioParam }` | Mutable gain — used by mute / solo / volume. |
|
||||
| `trackPan` | `{ pan: AudioParam }` | Mutable pan. |
|
||||
| `masterGain` | `{ gain: AudioParam }` | Master mix for the track. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The destination node is shared across all tracks but registered under
|
||||
a track-specific id so `WebAudioGraph` can prune the per-track edge
|
||||
list on `removeTrack` without disturbing the shared destination.
|
||||
- The factory does NOT call `clipSource.start()` — clip scheduling is
|
||||
the adapter's responsibility because it knows the transport time
|
||||
position and the FX plugin pre-roll requirements.
|
||||
- Order matters: the panner sits **before** the compressor so the
|
||||
compressor sees the post-pan signal, which is the standard DAW
|
||||
topology.
|
||||
65
docs/media/audio/audio-engine-browser/src/decode/decode.md
Normal file
65
docs/media/audio/audio-engine-browser/src/decode/decode.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# decode
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`decode(source, opts?)` decodes browser-tier audio bytes into a fully populated `AudioDocument` from `@suaveplan/audio-core`. It bridges the gap between raw file payloads (uploads, fetched URLs, drag-and-drop) and the document-centric world that the audio engine, analysers, codecs, and React surfaces all consume.
|
||||
|
||||
Internally it routes through `AudioContext.decodeAudioData`, so format support follows whatever the host browser exposes (typically MP3, AAC/M4A, OGG Vorbis, Opus in WebM/Matroska, FLAC, and WAV). The function intentionally has no codec dependencies — it relies on the platform decoder rather than bundling WASM codecs.
|
||||
|
||||
## Features
|
||||
|
||||
- **Source-type polymorphism** — `ArrayBuffer`, `Uint8Array`, `Blob`, and `File` all accepted.
|
||||
- **Injectable context** — pass an existing `AudioContext` to reuse its decoder, or omit and let `decode` allocate a single-shot `OfflineAudioContext`.
|
||||
- **AbortSignal cancellation** — pre-flight, post-buffer-copy, and post-decode checkpoints.
|
||||
- **Page-indexed output** — the resulting `AudioDocument` is laid out in `PAGE_SIZE`-aligned pages so downstream `walkPages` / `readPages` calls work without reblocking.
|
||||
- **Strict error surface** — failures arrive as `BrowserAudioError("AUDIO_ENGINE_BROWSER.DECODE_FAILED")` or `BrowserAudioError("AUDIO_ENGINE_BROWSER.DECODE_UNSUPPORTED_SOURCE")` with the original error attached as `cause`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { decode } from "@suaveplan/audio-engine-browser/decode";
|
||||
|
||||
const file = inputEl.files![0];
|
||||
const doc = await decode(file);
|
||||
console.log(`${doc.channels}ch × ${doc.sampleRate}Hz × ${doc.sourceLength} samples`);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { decode } from "@suaveplan/audio-engine-browser/decode";
|
||||
|
||||
const ctx = new AudioContext();
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 250);
|
||||
|
||||
try {
|
||||
const doc = await decode(arrayBuffer, { audioContext: ctx, signal: ctrl.signal });
|
||||
pipeline.ingest(doc);
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserAudioError && err.code === "AUDIO_ENGINE_BROWSER.DECODE_FAILED") {
|
||||
toast.error("Could not decode audio", { cause: err.cause });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decode(source: DecodeSource, opts?: DecodeOpts): Promise<AudioDocument>`
|
||||
- `DecodeSource = ArrayBuffer | Uint8Array | Blob | File`
|
||||
- `DecodeOpts = { audioContext?: BaseAudioContext; signal?: AbortSignal }`
|
||||
- Throws `BrowserAudioError` with codes `AUDIO_ENGINE_BROWSER.DECODE_FAILED` (decoder rejection or post-decode abort) and `AUDIO_ENGINE_BROWSER.DECODE_UNSUPPORTED_SOURCE` (non-recognised source type).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `OfflineAudioContext` is the default decoder host because it does not require a user gesture and can be discarded immediately. Callers who need to drive playback from the same context should pass their own `AudioContext`.
|
||||
- Channel data is copied via `AudioBuffer.getChannelData(c).slice()` so the returned `AudioDocument` owns its memory; the source `AudioBuffer` is safe to release.
|
||||
- Pages are sized to `PAGE_SIZE` (the canonical audio-core block size) and zero-padded on the final page to keep iteration costs uniform.
|
||||
- The decoder honours `signal` at three checkpoints — entry, post-buffer-copy, and post-decode — to bound cancellation latency without polling.
|
||||
- Vendor-specific decode quirks (e.g. Safari's stricter MP4 framing) bubble through unchanged via the `cause` chain.
|
||||
@@ -0,0 +1,112 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the structured error-code registry for
|
||||
`@suaveplan/audio-engine-browser`. Every fault path inside the package
|
||||
raises a `BrowserAudioError` carrying one of the codes registered here —
|
||||
nothing in the public surface throws a bare `Error`. This gives consumers
|
||||
programmatic, stable handles for catching specific failure modes
|
||||
(`err.code === "AUDIO_ENGINE_BROWSER.CONTEXT_FAILED"`) and gives the OTel
|
||||
integration the structured payload it needs to tag spans and produce
|
||||
useful traces.
|
||||
|
||||
The registry is intentionally **closed**: callers cannot register new codes
|
||||
at runtime. Adding a new code requires extending the literal tuple passed
|
||||
to `defineCodes(...)` and updating the metadata table in the same commit
|
||||
so the description, severity, and call site stay in lockstep.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven dotted codes covering context lifecycle, worklet loading,
|
||||
graph/node addressing, track addressing, offline render, dispose
|
||||
invariants, and sidechain bus cycles.
|
||||
- Frozen metadata table (`AUDIO_ENGINE_BROWSER_ERROR_METADATA`) keyed by
|
||||
code → `{ description, severity }`.
|
||||
- `BrowserAudioError` class extending `SuaveplanError` for cause-chain
|
||||
traversal, structured `context`, and OTel span-event integration.
|
||||
- Idempotent `registerBrowserAudioErrorCodes()` hook for symmetry with the
|
||||
rest of the error ecosystem.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_ENGINE_BROWSER_CODES,
|
||||
BrowserAudioError,
|
||||
} from "@suaveplan/audio-engine-browser/error-codes";
|
||||
|
||||
throw new BrowserAudioError(
|
||||
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND,
|
||||
"audio-engine-browser.node-not-found",
|
||||
{ context: { id: "missing-id" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_ENGINE_BROWSER_CODES,
|
||||
AUDIO_ENGINE_BROWSER_ERROR_METADATA,
|
||||
BrowserAudioError,
|
||||
} from "@suaveplan/audio-engine-browser/error-codes";
|
||||
|
||||
try {
|
||||
await adapter.init();
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserAudioError) {
|
||||
const meta = AUDIO_ENGINE_BROWSER_ERROR_METADATA[e.code];
|
||||
logger.error({ code: e.code, severity: meta.severity, cause: e.cause });
|
||||
if (e.code === AUDIO_ENGINE_BROWSER_CODES.CONTEXT_FAILED) {
|
||||
// fall back to Html5AudioFallback
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AUDIO_ENGINE_BROWSER_CODES`
|
||||
|
||||
Frozen object mapping bare keys (`CONTEXT_FAILED`, …) to their dotted
|
||||
codes (`"AUDIO_ENGINE_BROWSER.CONTEXT_FAILED"`, …). Produced by
|
||||
`defineCodes("AUDIO_ENGINE_BROWSER", …)`.
|
||||
|
||||
### `BrowserAudioErrorCode`
|
||||
|
||||
Union of every dotted code in the registry.
|
||||
|
||||
### `AUDIO_ENGINE_BROWSER_ERROR_METADATA`
|
||||
|
||||
`Readonly<Record<BrowserAudioErrorCode, { description: string; severity: "warning" | "error" }>>`.
|
||||
|
||||
### `BrowserAudioError`
|
||||
|
||||
`new BrowserAudioError(code, messageKey, options?)` — `code` is one of the
|
||||
literal strings in `BrowserAudioErrorCode`; `messageKey` is an i18n key
|
||||
that the consumer's `MessageResolver` resolves at presentation time;
|
||||
`options` is the standard `SuaveplanErrorOptions` shape (`context`,
|
||||
`cause`, `traceId`, `clock`).
|
||||
|
||||
### `registerBrowserAudioErrorCodes()`
|
||||
|
||||
Idempotent no-op preserved for symmetry with the `@suaveplan/error`
|
||||
registration ecosystem.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `defineCodes` produces a frozen tuple-driven object; reordering entries
|
||||
breaks downstream type narrowing, so additions go at the **end** of
|
||||
the literal array.
|
||||
- The `category` discriminant on `BrowserAudioError` is the literal
|
||||
`"audio-engine-browser"`. Catch-site `instanceof BrowserAudioError`
|
||||
checks remain the canonical handle; the discriminant exists for
|
||||
structured logging only.
|
||||
- All codes are reachable from a real call site — exercised by
|
||||
`src/error-codes.test.ts` walking the registry and forcing each path.
|
||||
@@ -0,0 +1,122 @@
|
||||
# `Html5AudioFallback`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`Html5AudioFallback` is the graceful-degradation path for browsers and
|
||||
runtimes without the Web Audio API. It implements the same
|
||||
`TimelineAdapter` interface as `BrowserTimelineAdapter` so consumers
|
||||
swap engines without conditional branching, but every operation that the
|
||||
underlying `HTMLAudioElement` cannot honour — per-track volume / pan /
|
||||
solo / mute, `setTempo`, FX hosting, sample-accurate scheduling — is a
|
||||
defined no-op. Unsupported per-track operations on **unknown** track ids
|
||||
still raise `AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND` so caller bugs are
|
||||
detected.
|
||||
|
||||
The fallback never constructs an `AudioContext`. It is feature-bounded by
|
||||
design — `<audio>` plays a single source at a time, so the adapter
|
||||
honours the first track's first clip and ignores the rest. Consumers
|
||||
that need polyphony or per-track FX use `BrowserTimelineAdapter`.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `TimelineAdapter` (every member callable).
|
||||
- Real transport (`play` / `pause` / `stop` / `seek` / `getCurrentTime`)
|
||||
through an injectable `HTMLAudioElement`-shaped value.
|
||||
- `setMasterVolume` clamped to `[0, 1]`.
|
||||
- `setLoop` honours the `enabled` flag by toggling the element's `loop`
|
||||
property and records the window for `getLoopWindow()` debugging.
|
||||
- Per-track ops validate the track id and then no-op.
|
||||
- `dispose()` is idempotent.
|
||||
- Methods after `dispose()` raise
|
||||
`AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Html5AudioFallback } from "@suaveplan/audio-engine-browser/fallback/html5-fallback";
|
||||
|
||||
const fallback = new Html5AudioFallback();
|
||||
fallback.init();
|
||||
fallback.setTracks([{ id: "t1", name: "lead", clips: [{ id: "c1", documentId: "/song.mp3", startSample: 0, offsetSamples: 0, durationSamples: 0 }], volume: 1, muted: false, soloed: false, pan: 0 }]);
|
||||
fallback.play();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Html5AudioFallback } from "@suaveplan/audio-engine-browser/fallback/html5-fallback";
|
||||
|
||||
// Inject a custom element (e.g. a test double or a pre-buffered element)
|
||||
const fallback = new Html5AudioFallback({
|
||||
element: customAudioElement,
|
||||
volume: 0.8,
|
||||
loop: true,
|
||||
});
|
||||
|
||||
fallback.init({ signal: controller.signal });
|
||||
fallback.setMasterVolume(0.5);
|
||||
fallback.setLoop(0, 8, true);
|
||||
|
||||
fallback.play(2); // start at 2s
|
||||
console.log(fallback.getCurrentTime());
|
||||
|
||||
fallback.dispose(); // idempotent — subsequent ops throw ALREADY_DISPOSED
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new Html5AudioFallback(options?)`
|
||||
|
||||
- `options.element` — pre-constructed `HTMLAudioElement` (default
|
||||
`new Audio()`).
|
||||
- `options.volume` — initial master volume.
|
||||
- `options.loop` — initial loop flag.
|
||||
- `options.lookAhead` / `options.ppqn` — readonly properties surfaced
|
||||
for `TimelineAdapter` parity.
|
||||
|
||||
### `init(options?) → void`
|
||||
|
||||
Synchronous in this implementation. Honours a pre-aborted `AbortSignal`
|
||||
by throwing `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED`.
|
||||
|
||||
### `setTracks(tracks)`, `addTrack(track)`, `removeTrack(trackId)`, `updateTrack(trackId, track)`
|
||||
|
||||
Maintain the track table; the first track's first clip becomes the
|
||||
element's `src`.
|
||||
|
||||
### `play(time?)`, `pause(time?)`, `stop(time?)`, `seek(time)`, `getCurrentTime()`
|
||||
|
||||
Drive the underlying element.
|
||||
|
||||
### `setLoop(start, end, enabled)`, `setMasterVolume(volume)`
|
||||
|
||||
Honoured operations.
|
||||
|
||||
### `setTrackVolume(trackId, _)`, `setTrackMute`, `setTrackSolo`, `setTrackPan`, `setTempo`
|
||||
|
||||
Defined no-ops with track-id validation.
|
||||
|
||||
### `dispose() → void`
|
||||
|
||||
Idempotent. Pauses the element and marks the fallback disposed.
|
||||
|
||||
### `getLoopWindow() → { start, end, enabled }`
|
||||
|
||||
Returns the configured loop window for tests / debug.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The fallback chooses the first clip of the first track as `src`. A
|
||||
full multi-clip mix is out of scope — `<audio>` is single-source.
|
||||
- `setMasterVolume` clamps to `[0, 1]`; some platforms throw on
|
||||
out-of-range values, so the clamp is the caller-friendly behaviour.
|
||||
- The Promise returned by `HTMLAudioElement.play()` rejects on
|
||||
autoplay-policy violations; the rejection is swallowed because the
|
||||
spec contract is synchronous and reporting a rejection here would
|
||||
surface a different shape per platform.
|
||||
- `dispose()` swallows pause errors because some embedded `<audio>`
|
||||
shims throw after detachment.
|
||||
@@ -0,0 +1,131 @@
|
||||
# `WebAudioGraph`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebAudioGraph` is the thin id-keyed registry that sits between
|
||||
`BrowserTimelineAdapter` and a `BaseAudioContext`. It exists so the adapter
|
||||
can address `AudioNode`s by stable string id (e.g. `track:42:gain`) rather
|
||||
than juggling raw node references, and so a populated graph can be
|
||||
snapshotted to a JSON-safe object and restored into a fresh context.
|
||||
|
||||
The registry is intentionally narrow: it owns the node table and the edge
|
||||
list, validates ids on every operation, and produces a snapshot whose
|
||||
shape (node-types + edges) is independent of any live `AudioNode`
|
||||
instance. Snapshots round-trip through `JSON.stringify` and survive
|
||||
context replacement — the same snapshot can be restored into a realtime
|
||||
`AudioContext`, an `OfflineAudioContext`, or a deterministic test shim.
|
||||
|
||||
## Features
|
||||
|
||||
- `addNode` / `removeNode` keyed by string id.
|
||||
- `connect` / `disconnect` wrapping `AudioNode.connect` / `disconnect`
|
||||
with full optional `outputIndex`/`inputIndex` support.
|
||||
- `getNode` and `listIds` for read-side inspection.
|
||||
- `snapshot()` returning a `Readonly<AudioGraphSnapshot>` containing
|
||||
node-type tags and the edge list.
|
||||
- `restore(snapshot)` Zod-validates the snapshot and rebuilds the graph
|
||||
against the bound context, raising
|
||||
`AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND` for orphan edges.
|
||||
- `classifyNode(node)` exported helper that maps a live `AudioNode` to
|
||||
its `AudioNodeType` tag using its `constructor.name`.
|
||||
- Defensive: every id mismatch raises a typed
|
||||
`BrowserAudioError(NODE_NOT_FOUND)` carrying the offending id.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { WebAudioGraph } from "@suaveplan/audio-engine-browser/graph/web-audio-graph";
|
||||
|
||||
const graph = new WebAudioGraph(audioContext);
|
||||
graph.addNode("master", audioContext.createGain());
|
||||
graph.addNode("dest", audioContext.destination);
|
||||
graph.connect("master", "dest");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
WebAudioGraph,
|
||||
} from "@suaveplan/audio-engine-browser/graph/web-audio-graph";
|
||||
|
||||
const graph = new WebAudioGraph(audioContext);
|
||||
graph.addNode("src", audioContext.createBufferSource());
|
||||
graph.addNode("gain", audioContext.createGain());
|
||||
graph.addNode("dest", audioContext.destination);
|
||||
graph.connect("src", "gain");
|
||||
graph.connect("gain", "dest");
|
||||
|
||||
// Persist for an undo step
|
||||
const snap = graph.snapshot();
|
||||
localStorage.setItem("graph", JSON.stringify(snap));
|
||||
|
||||
// Later — possibly in a new context — restore
|
||||
const restored = new WebAudioGraph(newContext);
|
||||
restored.restore(JSON.parse(localStorage.getItem("graph")!));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new WebAudioGraph(context)`
|
||||
|
||||
Constructs a graph bound to a `BaseAudioContext`-shaped value. The
|
||||
context is the factory used by `restore()`; calls to `addNode` accept
|
||||
externally-constructed nodes.
|
||||
|
||||
### `addNode(id, node) → void`
|
||||
|
||||
Registers a node under `id`. Existing entries are overwritten so the
|
||||
adapter can swap nodes during reconfiguration; the edge list is left
|
||||
untouched.
|
||||
|
||||
### `removeNode(id) → void`
|
||||
|
||||
Disconnects the node, removes it, and prunes every edge that referenced
|
||||
it. Throws `NODE_NOT_FOUND` for an unknown id.
|
||||
|
||||
### `connect(sourceId, destinationId, outputIndex?, inputIndex?) → void`
|
||||
|
||||
Wraps `AudioNode.connect`. Throws `NODE_NOT_FOUND` if either id is
|
||||
absent. Records the edge so it appears in `snapshot()`.
|
||||
|
||||
### `disconnect(sourceId, destinationId?) → void`
|
||||
|
||||
When `destinationId` is omitted, disconnects every outgoing edge from
|
||||
`source`. Otherwise disconnects just that edge. Throws `NODE_NOT_FOUND`
|
||||
for missing source.
|
||||
|
||||
### `snapshot() → AudioGraphSnapshot`
|
||||
|
||||
Returns `{ nodes: Record<id, AudioNodeType>, connections: Edge[] }`.
|
||||
|
||||
### `restore(snapshot) → void`
|
||||
|
||||
Zod-validates the snapshot, clears the current registry, rebuilds nodes
|
||||
via the bound context's factories, and replays every edge. Throws
|
||||
`NODE_NOT_FOUND` for invalid or orphan-edge snapshots.
|
||||
|
||||
### `classifyNode(node) → AudioNodeType`
|
||||
|
||||
Static helper inferring the type tag from `constructor.name`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `snapshot()` records **node types**, not node instances; this is what
|
||||
makes it JSON-safe.
|
||||
- `restore()` calls into the context's `create*` factories
|
||||
(`createGain`, `createStereoPanner`, …) so the restored graph uses
|
||||
nodes native to the new context. Worklet and `ScriptProcessor` nodes
|
||||
cannot be reconstructed by tag alone and are rejected at restore.
|
||||
- Per-disconnect graph mutations are best-effort: the underlying
|
||||
`AudioNode.disconnect` may throw when the destination is not actually
|
||||
connected, which is treated as a no-op so a careless caller cannot
|
||||
desynchronise the edge list from the live graph.
|
||||
- The Zod validator rejects unknown node types and any edge referencing
|
||||
an absent node before any factory call is issued — the graph reaches
|
||||
an inconsistent state only when a downstream context factory fails,
|
||||
in which case the partial state is left for the caller to observe.
|
||||
@@ -0,0 +1,102 @@
|
||||
# `renderOffline`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`renderOffline` is the bridge to non-realtime export. It wires a
|
||||
`BrowserTimelineAdapter` to an `OfflineAudioContext`, attaches the
|
||||
adapter to the supplied `TimelineEngine`, drives playback from `0`, and
|
||||
resolves with the rendered `AudioBuffer`. Because the adapter accepts an
|
||||
injected context, offline render reuses the exact same rendering code
|
||||
path as realtime playback — the "tests" and the "export feature" share
|
||||
one seam.
|
||||
|
||||
Failure modes are uniform: pre-flight checks (bad duration, bad sample
|
||||
rate, pre-aborted signal), context construction failure, and
|
||||
`startRendering()` rejection all raise
|
||||
`BrowserAudioError(OFFLINE_RENDER_FAILED)` with the underlying error
|
||||
attached as `cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- Pre-flight validation of `duration` (> 0, finite) and `sampleRate`
|
||||
(> 0, finite).
|
||||
- `AbortSignal` honoured at the boundary.
|
||||
- Optional `contextFactory` for tests that cannot construct a real
|
||||
`OfflineAudioContext` (e.g. `happy-dom`); production callers omit
|
||||
it and the function dynamically imports
|
||||
`standardized-audio-context`.
|
||||
- OTel span (`engine_browser.offline_render`) and duration histogram
|
||||
(`audio_engine_browser_offline_render_duration_ms`).
|
||||
- Disposes the adapter in `finally` so a render failure does not leak
|
||||
resources.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { renderOffline } from "@suaveplan/audio-engine-browser/offline/offline-render";
|
||||
|
||||
const buffer = await renderOffline(timeline, 2, 48000);
|
||||
console.log(buffer.length, buffer.sampleRate, buffer.numberOfChannels);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { renderOffline } from "@suaveplan/audio-engine-browser/offline/offline-render";
|
||||
|
||||
const buffer = await renderOffline(timeline, 4, 44100, {
|
||||
signal: controller.signal,
|
||||
contextFactory: ({ length, sampleRate, channels }) =>
|
||||
new MyDeterministicOfflineContext({ length, sampleRate, channels }),
|
||||
});
|
||||
|
||||
// buffer.length === Math.ceil(4 * 44100)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `renderOffline(timeline, duration, sampleRate, options?) → Promise<AudioBuffer>`
|
||||
|
||||
- `timeline` — the `TimelineEngine` to drive.
|
||||
- `duration` — seconds; must be finite and positive.
|
||||
- `sampleRate` — Hz; must be finite and positive.
|
||||
- `options.signal` — optional `AbortSignal`; an already-aborted signal
|
||||
short-circuits before any context work.
|
||||
- `options.contextFactory` — optional injected factory. Production
|
||||
omits this; tests provide one.
|
||||
|
||||
Returns the `AudioBuffer` from `OfflineAudioContext.startRendering`.
|
||||
|
||||
### `RenderOfflineOptions`
|
||||
|
||||
```ts
|
||||
interface RenderOfflineOptions {
|
||||
signal?: AbortSignal;
|
||||
contextFactory?: (args: {
|
||||
length: number;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
}) => OfflineContextLike;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Length is computed as `Math.ceil(duration * sampleRate)` so a 2-second
|
||||
render at 48 kHz produces exactly 96 000 frames.
|
||||
- The dynamic `import("standardized-audio-context")` keeps the shim out
|
||||
of the import graph when `contextFactory` is always supplied (e.g.
|
||||
in the test suite).
|
||||
- The adapter is disposed in `finally`. The injected `OfflineAudioContext`
|
||||
is NOT closed by the adapter because the adapter sees it as injected;
|
||||
the caller owns the context lifetime if they passed one in via the
|
||||
factory. The function does not call `close()` itself either, because
|
||||
`OfflineAudioContext.startRendering` resolves to a single buffer and
|
||||
the spec considers the context spent at that point.
|
||||
- The duration histogram is recorded in `finally` so both success and
|
||||
failure produce a sample. Failure samples are useful for SLO
|
||||
monitoring (the percentile of failed renders).
|
||||
@@ -0,0 +1,127 @@
|
||||
# `AudioWorkletManager`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AudioWorkletManager` is the gatekeeper for everything `AudioWorklet`-
|
||||
related inside `BrowserTimelineAdapter`. It validates module URLs against
|
||||
an optional allow-list, propagates `AbortSignal` cancellation, loads the
|
||||
worklet through the context's `audioWorklet.addModule`, constructs typed
|
||||
`AudioWorkletNode`s through an injectable constructor, and bridges
|
||||
`port.postMessage` / `port.onmessage` so processor IPC is centralised.
|
||||
|
||||
Every faulted path raises a `BrowserAudioError` carrying
|
||||
`AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED` (network, allow-list, abort,
|
||||
options validation) or `AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND` (operations
|
||||
on processors that were never created). The class does not silently
|
||||
swallow failures and does not mutate global state.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated `WorkletManagerOptions` and `WorkletModuleOptions`.
|
||||
- Optional allow-list: when non-empty, only URLs in the list survive to
|
||||
the network call; the rejection is raised **before** any IO.
|
||||
- `AbortSignal` honoured pre-flight — an already-aborted signal short-
|
||||
circuits the call with `WORKLET_LOAD_FAILED` carrying the abort
|
||||
reason.
|
||||
- `createNode(processorName, options?)` returns an
|
||||
`AudioWorkletNode`-shaped instance.
|
||||
- `postMessage(processorName, message, transfer?)` and
|
||||
`onMessage(processorName, handler)` for processor IPC.
|
||||
- `isLoaded(url)` tracks successfully-loaded module URLs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AudioWorkletManager } from "@suaveplan/audio-engine-browser/worklet/worklet-manager";
|
||||
|
||||
const worklets = new AudioWorkletManager(audioContext);
|
||||
await worklets.addModule("/worklets/pitch-shifter.js");
|
||||
const node = worklets.createNode("pitch-shifter");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AudioWorkletManager } from "@suaveplan/audio-engine-browser/worklet/worklet-manager";
|
||||
|
||||
const worklets = new AudioWorkletManager(audioContext, {
|
||||
allowedUrls: [
|
||||
"/worklets/pitch-shifter.js",
|
||||
"/worklets/limiter.js",
|
||||
],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
await worklets.addModule("/worklets/pitch-shifter.js", {
|
||||
credentials: "same-origin",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const node = worklets.createNode("pitch-shifter", {
|
||||
processorOptions: { semitones: 3 },
|
||||
});
|
||||
worklets.onMessage("pitch-shifter", (event) => {
|
||||
// metrics from the processor
|
||||
});
|
||||
worklets.postMessage("pitch-shifter", { type: "set", value: 5 });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new AudioWorkletManager(context, options?, nodeCtor?)`
|
||||
|
||||
`context` is the `BaseAudioContext` whose `audioWorklet` will be driven.
|
||||
`options` is the Zod-validated `WorkletManagerOptions`. `nodeCtor`
|
||||
defaults to `globalThis.AudioWorkletNode`; tests inject a deterministic
|
||||
constructor.
|
||||
|
||||
### `addModule(url, options?) → Promise<void>`
|
||||
|
||||
Validates options, honours `AbortSignal`, checks the allow-list, then
|
||||
calls `context.audioWorklet.addModule(url, ...)`. Records success in the
|
||||
internal `loaded` set. Throws `WORKLET_LOAD_FAILED` for every failure.
|
||||
|
||||
### `createNode(processorName, options?) → AudioWorkletNode`
|
||||
|
||||
Constructs a worklet node and tracks it under `processorName`.
|
||||
|
||||
### `postMessage(processorName, message, transfer?) → void`
|
||||
|
||||
Broadcasts a message to every node created under `processorName`. Throws
|
||||
`NODE_NOT_FOUND` when the bucket is empty.
|
||||
|
||||
### `onMessage(processorName, handler) → void`
|
||||
|
||||
Attaches an event listener to every node's `port`. Throws
|
||||
`NODE_NOT_FOUND` when the bucket is empty.
|
||||
|
||||
### `isLoaded(url) → boolean`
|
||||
|
||||
Returns `true` iff `addModule(url, …)` previously resolved.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The allow-list is `null` when not configured or empty — the manager
|
||||
treats "no allow-list" and "empty allow-list" as the same compatibility
|
||||
case to avoid a footgun where an empty array silently denies every
|
||||
URL.
|
||||
- The injectable `nodeCtor` keeps the package testable under happy-dom,
|
||||
which does not provide `AudioWorkletNode`. Production callers leave
|
||||
the parameter unset.
|
||||
- `postMessage` iterates over every node in the bucket — this is the
|
||||
"fan-out one message to every instance" semantic that mixer-style
|
||||
plugins expect.
|
||||
- Load failures keep `isLoaded(url)` returning `false` so consumers can
|
||||
retry without polluting the loaded set.
|
||||
- `AbortSignal` cancellation is checked at the pre-flight boundary; a
|
||||
signal aborted mid-load is surfaced through the rejection's `cause`
|
||||
because the underlying `addModule` itself does not accept a signal.
|
||||
Reference in New Issue
Block a user