# 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; 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`. 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"`