26 KiB
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, orBuffer
Scenario: Tests pass under a happy-dom shim
- WHEN the suite runs under vitest with a
happy-domenvironment and an injectedOfflineAudioContext - THEN every test passes without a real
AudioContextever being constructed fromglobalThis
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
TimelineAdapterinterface and every interface member is callable, andadapter.lookAhead === 0.1andadapter.ppqn === 960by default
Scenario: TimelineEngine drives the adapter without branching
- WHEN a caller attaches the adapter to a
TimelineEngineand callstimeline.play(0) - THEN the adapter's
playis 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 injectedOfflineAudioContext - THEN the injected context is used directly,
globalThis.AudioContextis never read, andinit()resolves
Scenario: context creation failure throws AUDIO_ENGINE_BROWSER.CONTEXT_FAILED
- WHEN
standardized-audio-contextconstruction orresume()rejects - THEN
init()throwsBrowserAudioErrorwhosecode === "AUDIO_ENGINE_BROWSER.CONTEXT_FAILED"with the rejection incause
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
connectis called with asourceIdthat was never registered - THEN it throws
BrowserAudioErrorwhosecode === "AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND"with the offending id incontext
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
addModuleis called with a URL absent from a configured non-emptyallowedUrlslist - THEN it throws
BrowserAudioErrorwhosecode === "AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED"beforecontext.audioWorklet.addModuleis invoked
Scenario: module load failure throws AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED
- WHEN the underlying
context.audioWorklet.addModulecall rejects - THEN
addModulethrowsBrowserAudioErrorwhosecode === "AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED"with the rejection incause, andisLoaded(url)returnsfalse
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
setTracksis 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; aftersetTrackSolo(trackA, false)both return to their mute-governed gains
Scenario: operating on an unknown track throws AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND
- WHEN
setTrackVolumeis called with atrackIdthat was never added - THEN it throws
BrowserAudioErrorwhosecode === "AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND"with the id incontext
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:
- AudioNode path —
capabilities.hasAudioNode === true: callplugin.createAudioNode(context, chainContext)and insert the returnedAudioNodechain into the track subgraph viaWebAudioGraph. - Worklet path —
capabilities.hasWorklet === trueandcontext.audioWorkletis available: registerplugin.getWorkletProcessor()throughAudioWorkletManagerand insert anAudioWorkletNode. - BlockProcessor fallback — wrap the plugin's
process()in aScriptProcessorNoderunning 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
BrowserTimelineAdapteris initialised andsetTracks([trackA, trackB])is called - THEN two
PluginChaininstances exist (one per track) and both received the sameBusRegistryinstance via theirChainContext.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.5is 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
eqis wired as a nativeBiquadFilterNodechain,limiterviaScriptProcessorNodefallback, andpitchShifterviaAudioWorkletNode
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-xand reads frombus-y, and track B's chain publishes tobus-yand reads frombus-x - THEN the second declared edge is rejected with
BrowserAudioErrorwhosecode === "AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED"
Scenario: publishers process before subscribers in the same block
- WHEN track A publishes to
kickand track B's compressor readskick - THEN for any block, A's
chain.process()completes before B'schain.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, callsinit()thenplay() - THEN the underlying
HTMLAudioElementbegins playback andgetCurrentTime()advances, with noAudioContextconstructed
Scenario: unsupported per-track operations do not corrupt state
- WHEN a caller calls
setTrackPanon the fallback - THEN the call is a defined no-op or throws
BrowserAudioError, and subsequentplay()/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
AudioBufferhaslength === Math.ceil(2 * 48000)andsampleRate === 48000
Scenario: render failure rejects with AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED
- WHEN
startRendering()rejects - THEN
renderOfflinerejects withBrowserAudioErrorwhosecode === "AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED"with the rejection incause
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)afterdispose() - THEN it throws
BrowserAudioErrorwhosecode === "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
addModuleis called with an already-aborted signal - THEN it rejects with
BrowserAudioErrorwhosecode === "AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED"beforecontext.audioWorklet.addModuleis invoked
Scenario: pre-aborted signal short-circuits renderOffline
- WHEN
renderOfflineis called with an already-aborted signal - THEN it rejects with
BrowserAudioErrorwhosecode === "AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED"beforestartRendering()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
BrowserTimelineAdapteris constructed with a negativesampleRate - THEN it throws
BrowserAudioErrorwith the Zod issues incontext
Scenario: invalid snapshot is rejected by restore
- WHEN
WebAudioGraph.restoreis called with a snapshot whoseconnectionsreference an absent node id - THEN it throws
BrowserAudioErrorwhosecode === "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_METADATAis 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
WebAudioGraphmodule without pulling the whole package barrel
Verified by: tests/exports.test.ts::"every feature module has a subpath export entry"