Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
# 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.

View File

@@ -0,0 +1,228 @@
# OpenSpec Change: enterprise-audio-engine-browser
- **Change ID**: enterprise-audio-engine-browser
- **Package**: `@suaveplan/audio-engine-browser`
- **Category**: browser
- **Target path**: `packages/audio/audio-engine-browser`
- **Status**: PROPOSED
- **Date**: 2026-05-14
- **Design**: `./design.md`
- **Spec delta**: `./specs/audio-engine-browser/spec.md`
---
## Summary
Deliver `@suaveplan/audio-engine-browser` on the genesis branch from scratch at enterprise quality. The package is a Web Audio API rendering backend that implements `TimelineAdapter` to connect the universal `TimelineEngine` to the browser's audio output. It manages `AudioContext` lifecycle (via `standardized-audio-context`), `AudioWorklet` loading, and dual-mode FX plugin hosting. An `Html5AudioFallback` is provided for environments without Web Audio API. This is the only browser-only package in the audio core; it bridges universal data to browser-specific rendering and replaces old browser-only openspecs with a new adapter-based architecture.
---
## Current-state audit (genesis)
This is a new architecture replacing old browser-only openspecs. No prior source on genesis for this specific package; fresh build with an adapter-based design that delegates timeline logic to `@suaveplan/audio-timeline`.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/audio-core` (`workspace:^`) | Shared audio primitives and FX plugin interfaces |
| `@suaveplan/audio-timeline` (`workspace:^`) | `TimelineEngine`, `TimelineAdapter`, `Track`, `Clip` |
| `@suaveplan/audio-transport` (`workspace:^`) | `Transport` for musical clock integration |
| `@suaveplan/audio-fx-plugin-base` (`workspace:^`) | `AudioFxPlugin` interface for FX hosting |
| `@suaveplan/error` (`workspace:^`) | Base class for `BrowserAudioError`; `AUDIO_ENGINE_BROWSER.*` codes |
| `@suaveplan/events` (`workspace:^`) | Typed event emitter for adapter events |
| `standardized-audio-context` | Cross-browser `AudioContext` / `OfflineAudioContext` shim |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `happy-dom` (all `catalog:testing`).
---
## Public API
### Core types
```ts
interface BrowserAdapterOptions {
context?: BaseAudioContext
latencyHint?: AudioContextLatencyCategory
sampleRate?: number
lookAhead?: number
ppqn?: number
}
interface WorkletManagerOptions {
allowedUrls?: string[]
}
interface WorkletModuleOptions {
credentials?: RequestCredentials
}
interface AudioGraphSnapshot {
nodes: Record<string, AudioNodeType>
connections: Array<{ source: string; destination: string; outputIndex?: number; inputIndex?: number }>
}
class BrowserTimelineAdapter implements TimelineAdapter {
constructor(options?: BrowserAdapterOptions)
init(options?: { signal?: AbortSignal }): Promise<void>
setTracks(tracks: Track[]): void
addTrack(track: Track): void
removeTrack(trackId: string): void
updateTrack(trackId: string, track: Track): void
play(time?: number): void
pause(time?: number): void
stop(time?: number): void
seek(time: number): void
getCurrentTime(): number
setLoop(start: number, end: number, enabled: boolean): void
setMasterVolume(volume: number): void
setTrackVolume(trackId: string, volume: number): void
setTrackMute(trackId: string, muted: boolean): void
setTrackSolo(trackId: string, soloed: boolean): void
setTrackPan(trackId: string, pan: number): void
setTempo(bpm: number, atTick?: number): void
ticksToSeconds(ticks: number): number
secondsToTicks(seconds: number): number
readonly lookAhead: number
readonly ppqn: number
dispose(): void
}
class WebAudioGraph {
constructor(context: BaseAudioContext)
addNode(id: string, node: AudioNode): void
removeNode(id: string): void
connect(sourceId: string, destinationId: string, outputIndex?: number, inputIndex?: number): void
disconnect(sourceId: string, destinationId?: string): void
getNode(id: string): AudioNode | undefined
snapshot(): AudioGraphSnapshot
restore(snapshot: AudioGraphSnapshot): void
}
class AudioWorkletManager {
constructor(context: BaseAudioContext, options?: WorkletManagerOptions)
addModule(url: string, options?: WorkletModuleOptions): Promise<void>
createNode(processorName: string, options?: AudioWorkletNodeOptions): AudioWorkletNode
postMessage(processorName: string, message: unknown, transfer?: Transferable[]): void
onMessage(processorName: string, handler: (event: MessageEvent) => void): void
isLoaded(url: string): boolean
}
class Html5AudioFallback implements TimelineAdapter {
constructor(options?: { volume?: number; loop?: boolean })
init(options?: { signal?: AbortSignal }): Promise<void>
setTracks(tracks: Track[]): void
addTrack(track: Track): void
removeTrack(trackId: string): void
updateTrack(trackId: string, track: Track): void
play(time?: number): void
pause(time?: number): void
stop(time?: number): void
seek(time: number): void
getCurrentTime(): number
setLoop(start: number, end: number, enabled: boolean): void
setMasterVolume(volume: number): void
setTrackVolume(trackId: string, volume: number): void
setTrackMute(trackId: string, muted: boolean): void
setTrackSolo(trackId: string, soloed: boolean): void
setTrackPan(trackId: string, pan: number): void
setTempo(bpm: number, atTick?: number): void
readonly lookAhead: number
readonly ppqn: number
dispose(): void
}
async function renderOffline(
timeline: TimelineEngine,
duration: number,
sampleRate: number
): Promise<AudioBuffer>
```
### Error codes
```ts
type BrowserAudioErrorCode =
| 'AUDIO_ENGINE_BROWSER.CONTEXT_FAILED'
| 'AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED'
| 'AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND'
| 'AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND'
| 'AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED'
| 'AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED'
const AUDIO_ENGINE_BROWSER_ERROR_METADATA: Readonly<Record<BrowserAudioErrorCode, CodeMetadata>>
function registerBrowserAudioErrorCodes(): void
```
### Zod schemas
```ts
BrowserAdapterOptionsSchema
WorkletManagerOptionsSchema
WorkletModuleOptionsSchema
AudioGraphSnapshotSchema
```
---
## File layout
```text
packages/audio/audio-engine-browser/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts
adapter/
browser-adapter.ts
browser-adapter.md
browser-adapter.types.ts
browser-adapter.test.ts
index.ts
graph/
web-audio-graph.ts
web-audio-graph.md
web-audio-graph.types.ts
web-audio-graph.test.ts
index.ts
worklet/
worklet-manager.ts
worklet-manager.md
worklet-manager.types.ts
worklet-manager.test.ts
index.ts
fallback/
html5-fallback.ts
html5-fallback.md
html5-fallback.test.ts
index.ts
offline/
offline-render.ts
offline-render.md
offline-render.test.ts
index.ts
__benchmarks__/
graph.bench.ts
worklet.bench.ts
tests/
integration.test.ts
```
---
## Impact
**Breaking changes**: none — fresh build replacing legacy browser-only openspecs; the package does not yet exist on genesis.
**Prerequisites**: `@suaveplan/audio-core`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/error`, `@suaveplan/events` published to Verdaccio.
### Affected dependents
No dependents in this batch. Consumed by `@suaveplan/react-audio*` and `@suaveplan/r3f-audio`, which land in a later wave (verified by `bunx turbo ls --filter=...@suaveplan/audio-engine-browser`).
Once landed, any subsequent change to this package's public surface MUST run `bunx turbo test --filter=...@suaveplan/audio-engine-browser` per `openspec/AGENTS.md` §13 step 10.

View File

@@ -0,0 +1,317 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-engine-browser (audio-engine-browser)
**Package**: `@suaveplan/audio-engine-browser`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Browser-tier purity
The `@suaveplan/audio-engine-browser` package SHALL import zero Node-only APIs (`node:fs`, `node:path`, `node:crypto`, `process`, `Buffer`). It MAY reference browser audio APIs (`AudioContext`, `OfflineAudioContext`, `AudioWorklet`, `AudioNode`, `HTMLAudioElement`) — those are the package's reason to exist — but every such reference SHALL be reachable only through an injectable seam (the `context` option or `standardized-audio-context`) so the package is testable under `happy-dom` without a real browser. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/error`, `@suaveplan/events`, `standardized-audio-context`, `tslib`, and `zod`; `@opentelemetry/api` is the only permitted non-workspace runtime dependency beyond `standardized-audio-context`.
#### Scenario: Built dist references no Node globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:crypto`, `process`, or `Buffer`
#### Scenario: Tests pass under a happy-dom shim
- **WHEN** the suite runs under vitest with a `happy-dom` environment and an injected `OfflineAudioContext`
- **THEN** every test passes without a real `AudioContext` ever being constructed from `globalThis`
**Verified by:** `tests/purity.test.ts::"dist references no Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace, standardized-audio-context, and zod"`
---
### Requirement: BrowserTimelineAdapter implements the TimelineAdapter interface
The package SHALL export a `BrowserTimelineAdapter` class that implements the `TimelineAdapter` interface (plugin slot 5) defined by `@suaveplan/audio-timeline`. It SHALL implement every member of that interface — `init`, `setTracks`, `addTrack`, `removeTrack`, `updateTrack`, `play`, `pause`, `stop`, `seek`, `getCurrentTime`, `setLoop`, `setMasterVolume`, `setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`, `setTempo`, `dispose`, and the readonly `lookAhead` / `ppqn` properties — with the signatures declared on the interface. A `TimelineEngine` SHALL be able to drive the browser audio output by calling `timeline.setAdapter(new BrowserTimelineAdapter(...))` with no adapter-specific branching. `lookAhead` SHALL default to `0.1` seconds and `ppqn` to `960` when not supplied via `BrowserAdapterOptions`.
#### Scenario: adapter is structurally a TimelineAdapter
- **WHEN** a caller constructs `new BrowserTimelineAdapter({ context: offlineContext })`
- **THEN** the instance satisfies the `TimelineAdapter` interface and every interface member is callable, and `adapter.lookAhead === 0.1` and `adapter.ppqn === 960` by default
#### Scenario: TimelineEngine drives the adapter without branching
- **WHEN** a caller attaches the adapter to a `TimelineEngine` and calls `timeline.play(0)`
- **THEN** the adapter's `play` is invoked with the engine's start time and the browser graph begins scheduling
**Verified by:** `src/adapter/browser-adapter.test.ts::"implements every TimelineAdapter member"` AND `tests/integration.test.ts::"TimelineEngine drives BrowserTimelineAdapter end to end"`
---
### Requirement: AudioContext lifecycle through an injectable seam
`BrowserTimelineAdapter.init()` SHALL obtain a `BaseAudioContext` either from the `context` option (when supplied — for tests and offline render) or by constructing one via `standardized-audio-context` with the `latencyHint` and `sampleRate` from `BrowserAdapterOptions`, then SHALL call `resume()` on it. The adapter SHALL track context state across the `suspended → running → closed` transitions. Constructing the context SHALL go through `standardized-audio-context` and never directly reference `globalThis.AudioContext`. If context creation or `resume()` rejects, `init()` SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED` carrying the underlying error as `cause`.
#### Scenario: injected context is used without touching globalThis
- **WHEN** `init()` is called on an adapter constructed with an injected `OfflineAudioContext`
- **THEN** the injected context is used directly, `globalThis.AudioContext` is never read, and `init()` resolves
#### Scenario: context creation failure throws AUDIO_ENGINE_BROWSER.CONTEXT_FAILED
- **WHEN** `standardized-audio-context` construction or `resume()` rejects
- **THEN** `init()` throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.CONTEXT_FAILED"` with the rejection in `cause`
**Verified by:** `src/adapter/browser-adapter.test.ts::"init uses the injected context without reading globalThis"` AND `src/adapter/browser-adapter.test.ts::"init throws AUDIO_ENGINE_BROWSER.CONTEXT_FAILED on context failure"`
---
### Requirement: WebAudioGraph node-graph construction and connection
The package SHALL export a `WebAudioGraph` class constructed from a `BaseAudioContext`. `addNode(id, node)` SHALL register an `AudioNode` under a string id; `removeNode(id)` SHALL unregister it and disconnect it from the graph. `connect(sourceId, destinationId, outputIndex?, inputIndex?)` SHALL wrap `AudioNode.connect` and `disconnect(sourceId, destinationId?)` SHALL wrap `AudioNode.disconnect`. `getNode(id)` SHALL return the registered node or `undefined`. `connect` or `disconnect` referencing an id that is not registered SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND` carrying the missing id in `context`.
#### Scenario: connect wires two registered nodes
- **WHEN** a caller registers two nodes then calls `connect(sourceId, destinationId)`
- **THEN** `source.connect(destination)` is invoked once with the resolved nodes and the edge is recorded in the graph
#### Scenario: connecting an unknown id throws AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND
- **WHEN** `connect` is called with a `sourceId` that was never registered
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND"` with the offending id in `context`
**Verified by:** `src/graph/web-audio-graph.test.ts::"connect wires two registered nodes"` AND `src/graph/web-audio-graph.test.ts::"connect throws AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND for an unknown id"`
---
### Requirement: WebAudioGraph snapshot and restore
`WebAudioGraph.snapshot()` SHALL return a serializable `AudioGraphSnapshot` (`{ nodes: Record<string, AudioNodeType>; connections: Array<{ source; destination; outputIndex?; inputIndex? }> }`) describing the current node types and edges without holding references to live `AudioNode` instances. `restore(snapshot)` SHALL rebuild the node registry and re-issue every connection from the snapshot against the graph's context. A snapshot taken, then restored into a fresh `WebAudioGraph` over an equivalent context, SHALL produce a graph whose subsequent `snapshot()` is deep-equal to the original.
#### Scenario: snapshot then restore round-trips the graph shape
- **WHEN** a caller snapshots a populated graph and restores it into a fresh `WebAudioGraph`
- **THEN** the restored graph's `snapshot()` is deep-equal to the original snapshot
**Verified by:** `src/graph/web-audio-graph.test.ts::"snapshot and restore round-trip the graph shape"`
---
### Requirement: AudioWorkletManager module loading and worklet URL validation
The package SHALL export an `AudioWorkletManager` class constructed from a `BaseAudioContext` and optional `WorkletManagerOptions`. `addModule(url, options?)` SHALL validate `url` against the `allowedUrls` allow-list (when configured) before calling `context.audioWorklet.addModule(url, ...)`, applying the `credentials` from `WorkletModuleOptions`. A `url` that is not in a configured non-empty `allowedUrls` list SHALL be rejected before any network call with `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED`. An `addModule` whose underlying `addModule` call rejects SHALL throw `BrowserAudioError` with the same code carrying the rejection as `cause`. `createNode(processorName, options?)` SHALL return an `AudioWorkletNode`; `postMessage` / `onMessage` SHALL bridge the processor port; `isLoaded(url)` SHALL report whether a module URL has been successfully loaded.
#### Scenario: disallowed worklet URL is rejected before any network call
- **WHEN** `addModule` is called with a URL absent from a configured non-empty `allowedUrls` list
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED"` before `context.audioWorklet.addModule` is invoked
#### Scenario: module load failure throws AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED
- **WHEN** the underlying `context.audioWorklet.addModule` call rejects
- **THEN** `addModule` throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED"` with the rejection in `cause`, and `isLoaded(url)` returns `false`
**Verified by:** `src/worklet/worklet-manager.test.ts::"rejects a URL absent from the allow-list before any network call"` AND `src/worklet/worklet-manager.test.ts::"addModule throws AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED on load failure"`
---
### Requirement: Per-track Web Audio subgraph rendering
`BrowserTimelineAdapter.setTracks` / `addTrack` / `updateTrack` SHALL build, per `Track`, a Web Audio subgraph of the shape `AudioBufferSourceNode` (per clip) → `GainNode` (clip volume) → `GainNode` (track volume) → `StereoPannerNode` (track pan) → `DynamicsCompressorNode` (limiter) → `GainNode` (master mix) → `AnalyserNode` (master analysis) → `context.destination`. `removeTrack` SHALL disconnect and release that subgraph. `setTrackMute` / `setTrackSolo` SHALL implement DAW solo semantics: when any track is soloed, every non-soloed track's track-volume `GainNode` SHALL be set to `0`; when no track is soloed, mute state alone governs gain. `removeTrack` / `updateTrack` / `setTrackVolume` / `setTrackMute` / `setTrackSolo` / `setTrackPan` for an unknown `trackId` SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND`.
#### Scenario: a track is rendered as the documented subgraph
- **WHEN** `setTracks` is called with one track carrying one clip
- **THEN** the graph contains the clip-source → clip-gain → track-gain → panner → compressor → master-gain → analyser → destination chain for that track
#### Scenario: soloing a track silences non-soloed tracks
- **WHEN** two tracks are loaded and `setTrackSolo(trackA, true)` is called
- **THEN** track A's track-gain is non-zero and track B's track-gain is `0`; after `setTrackSolo(trackA, false)` both return to their mute-governed gains
#### Scenario: operating on an unknown track throws AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND
- **WHEN** `setTrackVolume` is called with a `trackId` that was never added
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND"` with the id in `context`
**Verified by:** `src/adapter/browser-adapter.test.ts::"builds the documented per-track subgraph"` AND `src/adapter/browser-adapter.test.ts::"solo silences non-soloed tracks"` AND `src/adapter/browser-adapter.test.ts::"track ops throw AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND for unknown ids"`
---
### Requirement: Each track hosts one PluginChain from @suaveplan/audio-fx-plugin-base
For each `Track` in the timeline, `BrowserTimelineAdapter` SHALL construct exactly one `PluginChain` instance imported from `@suaveplan/audio-fx-plugin-base/chain` and use it as the track's FX slot list. The chain SHALL be the sole owner of plugin instances, parameter automation, snapshot/restore, plugin delay compensation, chain-level bypass, and event emission for that track — the adapter SHALL NOT re-implement any of these. The adapter SHALL push ambient state into the chain via `chain.updateAmbient({ track, transport, listener? })` whenever track gain/pan/mute/solo, transport tempo/time/state, or listener position changes. The adapter SHALL construct one project-level `BusRegistry` instance at `init()` and pass the same registry into every chain so sidechain routing crosses tracks.
For each plugin in the chain the adapter SHALL select a render path via the plugin's `capabilities`, in this order:
1. **AudioNode path** — `capabilities.hasAudioNode === true`: call `plugin.createAudioNode(context, chainContext)` and insert the returned `AudioNode` chain into the track subgraph via `WebAudioGraph`.
2. **Worklet path** — `capabilities.hasWorklet === true` and `context.audioWorklet` is available: register `plugin.getWorkletProcessor()` through `AudioWorkletManager` and insert an `AudioWorkletNode`.
3. **BlockProcessor fallback** — wrap the plugin's `process()` in a `ScriptProcessorNode` running on the main thread.
The chain's effective per-track latency (`chain.getEffectiveLatencySamples()`) SHALL be used to apply per-track delay compensation across the project so tracks of different effective latencies stay phase-aligned. A `latency-changed` chain event SHALL trigger recomputation of compensation across all tracks.
#### Scenario: each track has exactly one PluginChain bound to the same BusRegistry
- **WHEN** `BrowserTimelineAdapter` is initialised and `setTracks([trackA, trackB])` is called
- **THEN** two `PluginChain` instances exist (one per track) and both received the same `BusRegistry` instance via their `ChainContext.buses`
#### Scenario: ambient changes propagate to the chain on the next block
- **WHEN** the adapter receives `setTrackPan(trackA.id, 0.5)` and a subsequent block is processed
- **THEN** the chain's `ChainContext.ambient.track.pan === 0.5` is observed by every plugin in track A's chain
#### Scenario: capability flag selects the render path
- **WHEN** track A contains plugins `[eqWithAudioNode, limiterBlockOnly, pitchShifterWorklet]` and the adapter wires them
- **THEN** `eq` is wired as a native `BiquadFilterNode` chain, `limiter` via `ScriptProcessorNode` fallback, and `pitchShifter` via `AudioWorkletNode`
#### Scenario: dynamic chain latency triggers per-track PDC recompute
- **WHEN** any chain emits `latency-changed`
- **THEN** the adapter recomputes per-track compensation so the maximum effective latency across tracks equals the longest chain's effective latency
**Verified by:** `src/adapter/browser-adapter.test.ts::"each track hosts one PluginChain bound to a shared BusRegistry"` AND `src/adapter/browser-adapter.test.ts::"ambient changes propagate into ChainContext on the next block"` AND `src/adapter/fx-host.test.ts::"capability flag selects the correct render path per plugin"` AND `src/adapter/browser-adapter.test.ts::"chain latency-changed triggers per-track PDC recompute"`
---
### Requirement: Project-level BusRegistry owned by the adapter
The adapter SHALL construct and own one `BusRegistry` instance (the `DefaultBusRegistry` from `@suaveplan/audio-fx-plugin-base/chain` or an engine-specific implementation) for the entire `TimelineAdapter` lifetime. It SHALL detect bus cycles (chain A reads a bus written by chain B which reads a bus written by chain A) and reject the offending publisher with `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED`. The adapter SHALL order per-block chain processing so publishers write before subscribers read in the same block. `dispose()` SHALL close every active `SidechainPublisher` and release the registry.
#### Scenario: bus cycle is rejected
- **WHEN** track A's chain publishes to `bus-x` and reads from `bus-y`, and track B's chain publishes to `bus-y` and reads from `bus-x`
- **THEN** the second declared edge is rejected with `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED"`
#### Scenario: publishers process before subscribers in the same block
- **WHEN** track A publishes to `kick` and track B's compressor reads `kick`
- **THEN** for any block, A's `chain.process()` completes before B's `chain.process()` begins
**Verified by:** `src/adapter/browser-adapter.test.ts::"bus cycle is rejected with AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED"` AND `src/adapter/browser-adapter.test.ts::"publishers process before subscribers in the same block"`
---
### Requirement: Html5AudioFallback for environments without Web Audio API
The package SHALL export an `Html5AudioFallback` class implementing the `TimelineAdapter` interface using an `HTMLAudioElement` for environments without the Web Audio API. It SHALL support `play`, `pause`, `stop`, `seek`, `getCurrentTime`, `setLoop`, and `setMasterVolume`. Adapter methods that the HTML5 element cannot honour — per-track volume / mute / solo / pan, `setTempo`, FX hosting, and precise scheduling — SHALL be defined no-ops or SHALL throw `BrowserAudioError` for explicitly unsupported operations, never silently corrupting state. The fallback SHALL never construct an `AudioContext`.
#### Scenario: basic playback works through the HTML5 element
- **WHEN** a caller constructs `Html5AudioFallback`, calls `init()` then `play()`
- **THEN** the underlying `HTMLAudioElement` begins playback and `getCurrentTime()` advances, with no `AudioContext` constructed
#### Scenario: unsupported per-track operations do not corrupt state
- **WHEN** a caller calls `setTrackPan` on the fallback
- **THEN** the call is a defined no-op or throws `BrowserAudioError`, and subsequent `play()` / `getCurrentTime()` still behave correctly
**Verified by:** `src/fallback/html5-fallback.test.ts::"plays through the HTML5 element without an AudioContext"` AND `src/fallback/html5-fallback.test.ts::"unsupported per-track operations do not corrupt playback state"`
---
### Requirement: Offline render through an OfflineAudioContext
The package SHALL export `renderOffline(timeline, duration, sampleRate)` returning `Promise<AudioBuffer>`. It SHALL construct an `OfflineAudioContext` (2 channels, `ceil(duration * sampleRate)` frames, `sampleRate`) via `standardized-audio-context`, instantiate a `BrowserTimelineAdapter` bound to that offline context, attach it to the supplied `TimelineEngine`, play from time `0`, and resolve with the buffer returned by `startRendering()`. A rendering failure SHALL reject with `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED` carrying the underlying error as `cause`.
#### Scenario: offline render returns a buffer of the expected length
- **WHEN** a caller calls `renderOffline(timeline, 2, 48000)` on a timeline with one track
- **THEN** the resolved `AudioBuffer` has `length === Math.ceil(2 * 48000)` and `sampleRate === 48000`
#### Scenario: render failure rejects with AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED
- **WHEN** `startRendering()` rejects
- **THEN** `renderOffline` rejects with `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED"` with the rejection in `cause`
**Verified by:** `src/offline/offline-render.test.ts::"renderOffline returns a buffer of the expected length"` AND `src/offline/offline-render.test.ts::"renderOffline rejects with AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED on failure"`
---
### Requirement: Dispose releases the context and all nodes
`BrowserTimelineAdapter.dispose()` SHALL disconnect and release every node in every track subgraph, close the owned `AudioContext` (unless the context was injected, in which case it SHALL be left open for the injector to manage), and mark the adapter disposed. Any adapter method called after `dispose()` — other than `dispose()` itself, which SHALL be idempotent — SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED`.
#### Scenario: methods after dispose throw AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED
- **WHEN** a caller calls `play(0)` after `dispose()`
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED"`
#### Scenario: dispose is idempotent
- **WHEN** a caller calls `dispose()` twice
- **THEN** the second call is a no-op and does not throw
**Verified by:** `src/adapter/browser-adapter.test.ts::"methods after dispose throw AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED"` AND `src/adapter/browser-adapter.test.ts::"dispose is idempotent"`
---
### Requirement: AbortSignal propagation through async entry points
`BrowserTimelineAdapter.init()`, `AudioWorkletManager.addModule()`, and `renderOffline()` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to reject with `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED` (for `init`), `AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED` (for `addModule`), or `AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED` (for `renderOffline`) before any context, network, or render work is performed. A signal aborted mid-operation SHALL abort the underlying work and reject with the same code.
#### Scenario: pre-aborted signal short-circuits addModule
- **WHEN** `addModule` is called with an already-aborted signal
- **THEN** it rejects with `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED"` before `context.audioWorklet.addModule` is invoked
#### Scenario: pre-aborted signal short-circuits renderOffline
- **WHEN** `renderOffline` is called with an already-aborted signal
- **THEN** it rejects with `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED"` before `startRendering()` is invoked
**Verified by:** `src/worklet/worklet-manager.test.ts::"addModule honours a pre-aborted signal"` AND `src/offline/offline-render.test.ts::"renderOffline honours a pre-aborted signal"`
---
### Requirement: Zod boundary validation
Every public options object — `BrowserAdapterOptions`, `WorkletManagerOptions`, `WorkletModuleOptions`, `AudioGraphSnapshot` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point that accepts one SHALL validate its argument against the schema before use. `WebAudioGraph.restore` SHALL validate its `AudioGraphSnapshot` argument before rebuilding the graph. A validation failure SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED` (for adapter/worklet construction) or `AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND` (for an invalid snapshot) carrying the Zod issue list in `context`.
#### Scenario: invalid adapter options are rejected at the boundary
- **WHEN** `BrowserTimelineAdapter` is constructed with a negative `sampleRate`
- **THEN** it throws `BrowserAudioError` with the Zod issues in `context`
#### Scenario: invalid snapshot is rejected by restore
- **WHEN** `WebAudioGraph.restore` is called with a snapshot whose `connections` reference an absent node id
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND"` before any node is created
**Verified by:** `src/adapter/browser-adapter.test.ts::"constructor rejects invalid options via Zod"` AND `src/graph/web-audio-graph.test.ts::"restore rejects an invalid snapshot"`
---
### Requirement: Structured error codes
The package SHALL declare its codes via `defineCodes("AUDIO_ENGINE_BROWSER", [...])` producing the dotted codes `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED`, `AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED`, `AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND`, `AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND`, `AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED`, `AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED`, and `AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED`. The package SHALL export `BrowserAudioErrorCode`, `AUDIO_ENGINE_BROWSER_ERROR_METADATA`, `registerBrowserAudioErrorCodes()`, and a `BrowserAudioError` class extending `AudioError` from `@suaveplan/audio-core`. Every error thrown by the package SHALL be a `BrowserAudioError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_ENGINE_BROWSER_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/error-codes.test.ts::"every AUDIO_ENGINE_BROWSER code is reachable from a call site"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `engine_browser.init`, `engine_browser.set_tracks`, `engine_browser.worklet_load`, and `engine_browser.offline_render`; a counter `audio_engine_browser_nodes_created_total`; and a histogram `audio_engine_browser_offline_render_duration_ms`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
**Verified by:** `src/offline/offline-render.test.ts::"renderOffline records a span and histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./adapter/browser-adapter`, `./graph/web-audio-graph`, `./worklet/worklet-manager`, `./fallback/html5-fallback`, `./offline/offline-render`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-engine-browser/graph/web-audio-graph`
- **THEN** the import resolves to the `WebAudioGraph` module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`

View File

@@ -0,0 +1,121 @@
# Tasks: enterprise-audio-engine-browser
**Change ID**: enterprise-audio-engine-browser
**Package**: `@suaveplan/audio-engine-browser`
**Target path**: `packages/audio/audio-engine-browser`
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/error`, `@suaveplan/events`
---
## 1. Scaffold
- [ ] Create directory `packages/audio/audio-engine-browser/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/audio-timeline workspace:^`, `@suaveplan/audio-transport workspace:^`, `@suaveplan/audio-fx-plugin-base workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/events workspace:^`, `standardized-audio-context`.
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` with `happy-dom` environment.
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
---
## 2. Types + errors
- [ ] Write `src/adapter/browser-adapter.types.ts` — `BrowserAdapterOptions`.
- [ ] Write `src/graph/web-audio-graph.types.ts` — `AudioGraphSnapshot`, `AudioNodeType`.
- [ ] Write `src/worklet/worklet-manager.types.ts` — `WorkletManagerOptions`, `WorkletModuleOptions`.
- [ ] Write co-located `.md` files for each types module (≥200 words).
- [ ] Write `src/error-codes.ts` — `defineCodes("AUDIO_ENGINE_BROWSER", [...])` producing `AUDIO_ENGINE_BROWSER.*` code strings; export `BrowserAudioErrorCode`, `AUDIO_ENGINE_BROWSER_ERROR_METADATA`, `registerBrowserAudioErrorCodes()`, and a `BrowserAudioError` class extending `AudioError` from `@suaveplan/audio-core`.
- [ ] Write Zod schemas: `BrowserAdapterOptionsSchema`, `WorkletManagerOptionsSchema`, `WorkletModuleOptionsSchema`, `AudioGraphSnapshotSchema`.
---
## 3. WebAudioGraph
- [ ] Implement `WebAudioGraph` in `src/graph/web-audio-graph.ts`:
- `addNode`, `removeNode` with ID-based registry.
- `connect` / `disconnect` wrapping `AudioNode.connect` / `disconnect`.
- `snapshot()` returns serializable `AudioGraphSnapshot`.
- `restore(snapshot)` recreates nodes and connections from a snapshot.
- [ ] Write `web-audio-graph.test.ts` and `web-audio-graph.md`.
---
## 4. AudioWorkletManager
- [ ] Implement `AudioWorkletManager` in `src/worklet/worklet-manager.ts`:
- `addModule` with URL allow-list check.
- `createNode` returns `AudioWorkletNode`.
- `postMessage` / `onMessage` for processor communication.
- `isLoaded` tracks loaded module URLs.
- [ ] Write `worklet-manager.test.ts` and `worklet-manager.md`.
---
## 5. Html5AudioFallback
- [ ] Implement `Html5AudioFallback` in `src/fallback/html5-fallback.ts`:
- Implements `TimelineAdapter` using `HTMLAudioElement`.
- Supports: play, pause, seek, volume, loop.
- No FX, no analysis, no precise scheduling.
- All other adapter methods are no-ops or throw `BrowserAudioError` for unsupported operations.
- [ ] Write `html5-fallback.test.ts` and `html5-fallback.md`.
---
## 6. BrowserTimelineAdapter
- [ ] Implement `BrowserTimelineAdapter` in `src/adapter/browser-adapter.ts`:
- `init()` creates `AudioContext` via `standardized-audio-context` (or uses injected `context`) and resumes it.
- `setTracks`, `addTrack`, `removeTrack`, `updateTrack` build per-track subgraphs:
`AudioBufferSourceNode` → `GainNode` (clip) → `GainNode` (track) → `StereoPannerNode` → `DynamicsCompressorNode` → `GainNode` (master) → `AnalyserNode` → destination.
- `play`, `pause`, `stop`, `seek` drive `AudioBufferSourceNode` scheduling.
- Mute/solo logic: when any track is soloed, all non-soloed tracks have gain set to 0.
- `setLoop`, `setMasterVolume`, `setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`, `setTempo` update graph parameters.
- `lookAhead` and `ppqn` exposed as readonly properties.
- `dispose()` closes context and releases all nodes.
- [ ] Write `browser-adapter.test.ts` (≤500 lines): init with injected `OfflineAudioContext`, set tracks, play/stop, track volume/pan, mute/solo logic, loop, dispose.
- [ ] Write `browser-adapter.md`.
---
## 7. Offline render
- [ ] Implement `renderOffline` in `src/offline/offline-render.ts`:
- Creates `OfflineAudioContext` via `standardized-audio-context`.
- Instantiates `BrowserTimelineAdapter` with injected offline context.
- Attaches adapter to `TimelineEngine`, plays from 0, and returns rendered `AudioBuffer`.
- [ ] Write `offline-render.test.ts` and `offline-render.md`.
---
## 8. FX plugin hosting
- [ ] Implement `createFxNode` utility in `src/adapter/browser-adapter.ts` (or separate `fx-host.ts` if needed):
- **AudioNode path**: `plugin.createAudioNode(context)` returns native `AudioNode` chain.
- **Worklet path**: `plugin.createWorkletProcessor()` returns processor code; host wraps in `AudioWorkletNode` via `AudioWorkletManager`.
- **Fallback path**: `ScriptProcessorNode` wrapping block processor for main-thread fallback.
- [ ] Write unit tests for all three FX hosting paths.
---
## 9. Benchmarks
- [ ] Write `src/__benchmarks__/graph.bench.ts` — add 100 nodes, connect 200 edges, snapshot + restore.
- [ ] Write `src/__benchmarks__/worklet.bench.ts` — load 10 worklet modules, create 50 nodes.
---
## 10. Integration tests
- [ ] Write `tests/integration.test.ts` — full browser adapter lifecycle with injected `OfflineAudioContext`: init, set tracks with clips, play, verify graph structure, mute/solo behavior, offline render returns `AudioBuffer` with expected length.
---
## 11. Docs + verify + publish
- [ ] All co-located `.md` files (≥200 words each).
- [ ] Write `README.md` at package root.
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-engine-browser` — green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-engine-browser` — 100%.
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-engine-browser`.
- [ ] Fold `specs/audio-engine-browser/spec.md` into `openspec/specs/audio-engine-browser/spec.md`.
- [ ] Archive to `openspec/archive/2026-05-14-enterprise-audio-engine-browser/`.