Files
suaveplan/openspec/specs/audio-engine-browser/spec.md
2026-05-27 23:29:53 +01:00

318 lines
26 KiB
Markdown

<!-- 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"`