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

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, 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"