first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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.

View 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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.