Files
2026-05-27 23:29:53 +01:00

262 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design: enterprise-audio-engine-browser
**Change ID**: enterprise-audio-engine-browser
**Package**: `@suaveplan/audio-engine-browser`
`@suaveplan/audio-engine-browser` is the **browser rendering engine** of the audio family's
three-layer model — the layer that turns universal timeline state into actual sound through the
Web Audio API. It is the only browser-only package in the audio core: everything below it
(`audio-core`, `audio-timeline`, `audio-transport`, `audio-plugins-fx`) is universal, and everything
above it (`react-audio*`, `r3f-audio`) is application-tier. This document carries the
**package-specific design**; the **cross-cutting architecture** for the whole `@suaveplan/audio-*`
ecosystem lives in `enterprise-audio-core/design.md` §1–§4 (the three-layer separation, the six
plugin slots, the genesis-package reuse table, the universal `Segment` shape, and the error
hierarchy) and is referenced here, not duplicated.
---
## 1. Position in the three-layer model
Per `enterprise-audio-core/design.md` §1.2, the audio ecosystem separates the **data model**
(`audio-core`) from the **timeline/transport** layer (`audio-timeline`, `audio-transport`) from the
**rendering engines** (`audio-engine-*`). This package is one of those rendering engines — the
browser one. Its sibling `audio-engine-server` renders the same timeline state to Node/Bun file I/O
and speakers; both implement the same `TimelineAdapter` interface (plugin slot 5) so a
`TimelineEngine` is engine-agnostic.
The design consequence: **this package contains no timeline logic.** Track ordering, clip
placement, musical-time conversion, undo/redo, and tempo maps all live in `@suaveplan/audio-timeline`.
`BrowserTimelineAdapter` is a pure translation layer — it receives already-resolved `Track[]` and
playback commands and maps them onto Web Audio `AudioNode` graphs. This is what makes the legacy
"browser-only" audio openspecs obsolete: they tangled timeline state into the rendering code; the
adapter-based architecture keeps them apart.
---
## 2. Why an injectable context seam
Every browser audio API the package touches — `AudioContext`, `OfflineAudioContext`,
`AudioWorklet`, `AudioNode` — is reached through one of two seams:
1. The `context` option on `BrowserAdapterOptions` / the `WebAudioGraph` and `AudioWorkletManager`
constructors. When supplied, the package uses it verbatim and never reads `globalThis`.
2. `standardized-audio-context` when no context is injected — a cross-browser shim that smooths
over Safari/Firefox/Chrome `AudioContext` differences and, critically, provides a real
`OfflineAudioContext` implementation usable under `happy-dom`.
This is the same testability discipline `audio-core` applies to its adapters (`enterprise-audio-core`
"Universal-tier purity" requirement), adapted for the browser tier. The package **may** name browser
APIs — that is its job — but it must be drivable in CI without a real browser. Every test injects an
`OfflineAudioContext`; `globalThis.AudioContext` is never read in the test path.
**Tier purity:** the package must not import Node-only APIs (`node:fs`, `node:path`, `process`,
`Buffer`). There is no filesystem in this layer — file I/O belongs to `audio-engine-server`. The
purity test greps the built `dist/` for those identifiers and asserts zero hits.
---
## 3. WebAudioGraph build strategy
`WebAudioGraph` is a thin, id-keyed registry over a `BaseAudioContext`. It exists so the adapter can
address nodes by stable string id (e.g. `track:42:gain`) rather than juggling live `AudioNode`
references, and so a graph can be **snapshotted** to a plain serializable object and **restored**.
```ts
class WebAudioGraph {
private readonly nodes = new Map<string, AudioNode>()
private readonly edges: Connection[] = []
connect(sourceId: string, destinationId: string, outputIndex = 0, inputIndex = 0): void {
const source = this.nodes.get(sourceId)
const destination = this.nodes.get(destinationId)
if (!source) throw new BrowserAudioError('AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND', { context: { id: sourceId } })
if (!destination) throw new BrowserAudioError('AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND', { context: { id: destinationId } })
source.connect(destination as AudioNode, outputIndex, inputIndex)
this.edges.push({ source: sourceId, destination: destinationId, outputIndex, inputIndex })
}
snapshot(): AudioGraphSnapshot {
const nodes: Record<string, AudioNodeType> = {}
for (const [id, node] of this.nodes) nodes[id] = classifyNode(node)
return { nodes, connections: this.edges.map((e) => ({ ...e })) }
}
}
```
`snapshot()` records **node types**, not node instances — an `AudioGraphSnapshot` is JSON-safe so it
can be persisted in a session file or diffed for debugging. `restore()` rebuilds typed nodes against
the graph's context and replays the edge list. The snapshot is Zod-validated on `restore` so a
corrupt session file fails fast at the boundary rather than mid-render.
---
## 4. Per-track subgraph
Each `Track` becomes a fixed-shape subgraph. The chain is deliberately the same for every track so
that mute/solo, volume, and pan map to predictable nodes:
```text
AudioBufferSourceNode (per clip)
→ GainNode (clip volume)
→ GainNode (track volume) ← setTrackVolume / mute / solo write here
→ StereoPannerNode (track pan) ← setTrackPan writes here
→ DynamicsCompressorNode (limiter)
→ GainNode (master mix) ← setMasterVolume writes here
→ AnalyserNode (master analysis)
→ context.destination
```
**Solo semantics.** DAW solo is a whole-mix operation, not a per-track flag. When *any* track is
soloed, every non-soloed track's track-volume `GainNode` is set to `0`; when the last solo is
cleared, each track's gain reverts to its mute-governed value. The adapter keeps a `Set<soloed>` and
recomputes effective gains on every `setTrackMute` / `setTrackSolo` call — this is the one piece of
real state the adapter owns, and it is small and easy to test against an `OfflineAudioContext`.
`removeTrack` / `updateTrack` / the per-track setters throw `AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND`
for an unknown id rather than silently no-op'ing — a missing track id is a caller bug, not a
recoverable condition.
---
## 5. FX plugin hosting — three paths
An `AudioFxPlugin` from `@suaveplan/audio-fx-plugin-base` is dual-mode by design (plugin slot 6): it
always exposes a universal `processBlock`, and *optionally* a browser-native `createAudioNode`. The
host picks the best available path:
```ts
function createFxNode(plugin: AudioFxPlugin, context: BaseAudioContext, worklets: AudioWorkletManager): AudioNode {
// 1. AudioNode path — native, lowest latency, runs on the audio thread
if (plugin.createAudioNode) return plugin.createAudioNode(context)
// 2. Worklet path — universal processBlock compiled into an AudioWorkletProcessor
if (context.audioWorklet && plugin.getWorkletProcessor) {
worklets.addModule(plugin.getWorkletProcessor())
return worklets.createNode(plugin.name)
}
// 3. Fallback path — ScriptProcessorNode on the main thread (deprecated, but universal)
const sp = context.createScriptProcessor(BLOCK_SIZE, 2, 2)
sp.onaudioprocess = (e) => {
const input = [e.inputBuffer.getChannelData(0), e.inputBuffer.getChannelData(1)]
const output = [e.outputBuffer.getChannelData(0), e.outputBuffer.getChannelData(1)]
plugin.processBlock(input, output, { sampleRate: context.sampleRate, blockSize: BLOCK_SIZE })
}
return sp
}
```
Path 1 is preferred because a native chain runs entirely on the audio rendering thread. Path 2 keeps
DSP off the main thread when the plugin only ships universal block code. Path 3 exists so *every*
FX plugin works *somewhere* — `ScriptProcessorNode` is deprecated but universally available, and the
fallback is honest about its main-thread cost. Whichever node is chosen, it is inserted into the
track subgraph through `WebAudioGraph` so it sits in the signal path between the clip gain and the
track gain.
---
## 6. Offline render
`renderOffline` is the bridge to non-realtime export. It wires a `BrowserTimelineAdapter` to an
`OfflineAudioContext` and lets the Web Audio engine render faster than realtime:
```ts
async function renderOffline(timeline: TimelineEngine, duration: number, sampleRate: number): Promise<AudioBuffer> {
const offline = new OfflineAudioContext(2, Math.ceil(duration * sampleRate), sampleRate)
const adapter = new BrowserTimelineAdapter({ context: offline })
await adapter.init()
timeline.setAdapter(adapter)
timeline.play(0)
return offline.startRendering()
}
```
Because the adapter takes an injected context, offline render reuses the *exact same* rendering code
path as realtime playback — there is no separate offline renderer to keep in sync. This is also why
the test suite can exercise the whole adapter against an `OfflineAudioContext`: the "tests" and the
"export feature" are the same seam.
---
## 7. Html5AudioFallback
For environments with no Web Audio API at all (locked-down embedded webviews, very old browsers),
`Html5AudioFallback` implements the same `TimelineAdapter` interface using a bare `HTMLAudioElement`.
It supports only what an `<audio>` element can honour — `play` / `pause` / `stop` / `seek` /
`getCurrentTime` / `setLoop` / `setMasterVolume`. Operations it cannot perform (per-track
volume/mute/solo/pan, `setTempo`, FX hosting, sample-accurate scheduling) are defined no-ops or throw
`BrowserAudioError`; they never silently corrupt state. The fallback never constructs an
`AudioContext`. It is a graceful-degradation path, not a feature-equivalent engine, and the spec is
explicit about that boundary.
---
## 8. File layout
```text
packages/audio/audio-engine-browser/
package.json, tsconfig.json, vitest.config.ts
src/
index.ts
error-codes.ts (+ .test.ts)
adapter/ browser-adapter.ts (+ .md .types.ts .test.ts) · fx-host.ts (+ .md .test.ts) · index.ts
graph/ web-audio-graph.ts (+ .md .types.ts .test.ts) · index.ts
worklet/ worklet-manager.ts (+ .md .types.ts .test.ts) · index.ts
fallback/ html5-fallback.ts (+ .md .test.ts) · index.ts
offline/ offline-render.ts (+ .md .test.ts) · index.ts
__benchmarks__/ graph.bench.ts worklet.bench.ts
tests/
integration.test.ts purity.test.ts exports.test.ts
```
Each public options object (`BrowserAdapterOptions`, `WorkletManagerOptions`, `WorkletModuleOptions`,
`AudioGraphSnapshot`) gets a sibling `*.schemas.ts` with a Zod schema, validated at the boundary.
---
## 9. Integration notes
- **`@suaveplan/audio-timeline`** — supplies the `TimelineAdapter` interface (plugin slot 5),
`TimelineEngine`, `Track`, and `Clip`. `BrowserTimelineAdapter` and `Html5AudioFallback` both
implement `TimelineAdapter`; the adapter receives resolved `Track[]` and never re-derives timeline
state.
- **`@suaveplan/audio-transport`** — supplies the `Transport` musical clock; `lookAhead` and `ppqn`
on the adapter feed the transport's lookahead scheduler.
- **`@suaveplan/audio-fx-plugin-base`** — supplies the `AudioFxPlugin` interface (plugin slot 6) hosted
by `createFxNode`.
- **`@suaveplan/audio-core`** — supplies shared audio primitives (`BLOCK_SIZE`, the `BlockProcessor`
contract that `processBlock` satisfies) used by the `ScriptProcessorNode` fallback path.
- **`@suaveplan/events`** — backs the adapter's typed event surface.
- **`standardized-audio-context`** — the cross-browser `AudioContext` / `OfflineAudioContext` shim;
the sole non-`@suaveplan` runtime dependency beyond `@opentelemetry/api`.
---
## 10. Benchmarks
- `graph.bench.ts` — add 100 nodes, connect 200 edges, then `snapshot()` + `restore()`.
- `worklet.bench.ts` — load 10 worklet modules and create 50 `AudioWorkletNode`s.
---
## 11. Decisions and trade-offs
- **Adapter, not engine.** The package is a `TimelineAdapter` implementation with zero timeline
logic. This makes it swappable with `audio-engine-server` and obsoletes the legacy browser-only
audio openspecs that tangled timeline state into rendering code.
- **One injectable context seam.** Every browser API goes through the `context` option or
`standardized-audio-context`. The cost is one extra constructor parameter; the payoff is that the
whole package — including offline render — is testable under `happy-dom` and that offline export
and realtime playback share one code path.
- **Fixed-shape per-track subgraph.** Every track gets the identical node chain. A uniform shape
makes mute/solo/volume/pan map to predictable nodes and keeps `snapshot()`/`restore()` trivial,
at the cost of a few unused nodes on a track with no pan or compression — acceptable, since
`GainNode`/`StereoPannerNode` at unity are effectively free.
- **Three FX hosting paths, preference-ordered.** Native `createAudioNode` first, worklet second,
`ScriptProcessorNode` last. `ScriptProcessorNode` is deprecated but kept as the universal fallback
so no FX plugin is ever unhostable; the spec is explicit that it runs on the main thread.
- **Html5AudioFallback is degradation, not parity.** It implements `TimelineAdapter` so callers
need no branching, but unsupported operations throw or no-op rather than pretending to work.
- **No design.md for the React/r3f tier yet.** `react-audio*` and `r3f-audio` consume this package
but are out of scope for this 13-change batch; they land in a later wave.