Initial Commit
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
# Design: enterprise-audio-engine-browser
|
||||
|
||||
**Change ID**: enterprise-audio-engine-browser
|
||||
**Package**: `@suaveplan/audio-engine-browser`
|
||||
|
||||
`@suaveplan/audio-engine-browser` is the **browser rendering engine** of the audio family's
|
||||
three-layer model — the layer that turns universal timeline state into actual sound through the
|
||||
Web Audio API. It is the only browser-only package in the audio core: everything below it
|
||||
(`audio-core`, `audio-timeline`, `audio-transport`, `audio-plugins-fx`) is universal, and everything
|
||||
above it (`react-audio*`, `r3f-audio`) is application-tier. This document carries the
|
||||
**package-specific design**; the **cross-cutting architecture** for the whole `@suaveplan/audio-*`
|
||||
ecosystem lives in `enterprise-audio-core/design.md` §1–§4 (the three-layer separation, the six
|
||||
plugin slots, the genesis-package reuse table, the universal `Segment` shape, and the error
|
||||
hierarchy) and is referenced here, not duplicated.
|
||||
|
||||
---
|
||||
|
||||
## 1. Position in the three-layer model
|
||||
|
||||
Per `enterprise-audio-core/design.md` §1.2, the audio ecosystem separates the **data model**
|
||||
(`audio-core`) from the **timeline/transport** layer (`audio-timeline`, `audio-transport`) from the
|
||||
**rendering engines** (`audio-engine-*`). This package is one of those rendering engines — the
|
||||
browser one. Its sibling `audio-engine-server` renders the same timeline state to Node/Bun file I/O
|
||||
and speakers; both implement the same `TimelineAdapter` interface (plugin slot 5) so a
|
||||
`TimelineEngine` is engine-agnostic.
|
||||
|
||||
The design consequence: **this package contains no timeline logic.** Track ordering, clip
|
||||
placement, musical-time conversion, undo/redo, and tempo maps all live in `@suaveplan/audio-timeline`.
|
||||
`BrowserTimelineAdapter` is a pure translation layer — it receives already-resolved `Track[]` and
|
||||
playback commands and maps them onto Web Audio `AudioNode` graphs. This is what makes the legacy
|
||||
"browser-only" audio openspecs obsolete: they tangled timeline state into the rendering code; the
|
||||
adapter-based architecture keeps them apart.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why an injectable context seam
|
||||
|
||||
Every browser audio API the package touches — `AudioContext`, `OfflineAudioContext`,
|
||||
`AudioWorklet`, `AudioNode` — is reached through one of two seams:
|
||||
|
||||
1. The `context` option on `BrowserAdapterOptions` / the `WebAudioGraph` and `AudioWorkletManager`
|
||||
constructors. When supplied, the package uses it verbatim and never reads `globalThis`.
|
||||
2. `standardized-audio-context` when no context is injected — a cross-browser shim that smooths
|
||||
over Safari/Firefox/Chrome `AudioContext` differences and, critically, provides a real
|
||||
`OfflineAudioContext` implementation usable under `happy-dom`.
|
||||
|
||||
This is the same testability discipline `audio-core` applies to its adapters (`enterprise-audio-core`
|
||||
"Universal-tier purity" requirement), adapted for the browser tier. The package **may** name browser
|
||||
APIs — that is its job — but it must be drivable in CI without a real browser. Every test injects an
|
||||
`OfflineAudioContext`; `globalThis.AudioContext` is never read in the test path.
|
||||
|
||||
**Tier purity:** the package must not import Node-only APIs (`node:fs`, `node:path`, `process`,
|
||||
`Buffer`). There is no filesystem in this layer — file I/O belongs to `audio-engine-server`. The
|
||||
purity test greps the built `dist/` for those identifiers and asserts zero hits.
|
||||
|
||||
---
|
||||
|
||||
## 3. WebAudioGraph build strategy
|
||||
|
||||
`WebAudioGraph` is a thin, id-keyed registry over a `BaseAudioContext`. It exists so the adapter can
|
||||
address nodes by stable string id (e.g. `track:42:gain`) rather than juggling live `AudioNode`
|
||||
references, and so a graph can be **snapshotted** to a plain serializable object and **restored**.
|
||||
|
||||
```ts
|
||||
class WebAudioGraph {
|
||||
private readonly nodes = new Map<string, AudioNode>()
|
||||
private readonly edges: Connection[] = []
|
||||
|
||||
connect(sourceId: string, destinationId: string, outputIndex = 0, inputIndex = 0): void {
|
||||
const source = this.nodes.get(sourceId)
|
||||
const destination = this.nodes.get(destinationId)
|
||||
if (!source) throw new BrowserAudioError('AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND', { context: { id: sourceId } })
|
||||
if (!destination) throw new BrowserAudioError('AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND', { context: { id: destinationId } })
|
||||
source.connect(destination as AudioNode, outputIndex, inputIndex)
|
||||
this.edges.push({ source: sourceId, destination: destinationId, outputIndex, inputIndex })
|
||||
}
|
||||
|
||||
snapshot(): AudioGraphSnapshot {
|
||||
const nodes: Record<string, AudioNodeType> = {}
|
||||
for (const [id, node] of this.nodes) nodes[id] = classifyNode(node)
|
||||
return { nodes, connections: this.edges.map((e) => ({ ...e })) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`snapshot()` records **node types**, not node instances — an `AudioGraphSnapshot` is JSON-safe so it
|
||||
can be persisted in a session file or diffed for debugging. `restore()` rebuilds typed nodes against
|
||||
the graph's context and replays the edge list. The snapshot is Zod-validated on `restore` so a
|
||||
corrupt session file fails fast at the boundary rather than mid-render.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-track subgraph
|
||||
|
||||
Each `Track` becomes a fixed-shape subgraph. The chain is deliberately the same for every track so
|
||||
that mute/solo, volume, and pan map to predictable nodes:
|
||||
|
||||
```text
|
||||
AudioBufferSourceNode (per clip)
|
||||
→ GainNode (clip volume)
|
||||
→ GainNode (track volume) ← setTrackVolume / mute / solo write here
|
||||
→ StereoPannerNode (track pan) ← setTrackPan writes here
|
||||
→ DynamicsCompressorNode (limiter)
|
||||
→ GainNode (master mix) ← setMasterVolume writes here
|
||||
→ AnalyserNode (master analysis)
|
||||
→ context.destination
|
||||
```
|
||||
|
||||
**Solo semantics.** DAW solo is a whole-mix operation, not a per-track flag. When *any* track is
|
||||
soloed, every non-soloed track's track-volume `GainNode` is set to `0`; when the last solo is
|
||||
cleared, each track's gain reverts to its mute-governed value. The adapter keeps a `Set<soloed>` and
|
||||
recomputes effective gains on every `setTrackMute` / `setTrackSolo` call — this is the one piece of
|
||||
real state the adapter owns, and it is small and easy to test against an `OfflineAudioContext`.
|
||||
|
||||
`removeTrack` / `updateTrack` / the per-track setters throw `AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND`
|
||||
for an unknown id rather than silently no-op'ing — a missing track id is a caller bug, not a
|
||||
recoverable condition.
|
||||
|
||||
---
|
||||
|
||||
## 5. FX plugin hosting — three paths
|
||||
|
||||
An `AudioFxPlugin` from `@suaveplan/audio-fx-plugin-base` is dual-mode by design (plugin slot 6): it
|
||||
always exposes a universal `processBlock`, and *optionally* a browser-native `createAudioNode`. The
|
||||
host picks the best available path:
|
||||
|
||||
```ts
|
||||
function createFxNode(plugin: AudioFxPlugin, context: BaseAudioContext, worklets: AudioWorkletManager): AudioNode {
|
||||
// 1. AudioNode path — native, lowest latency, runs on the audio thread
|
||||
if (plugin.createAudioNode) return plugin.createAudioNode(context)
|
||||
|
||||
// 2. Worklet path — universal processBlock compiled into an AudioWorkletProcessor
|
||||
if (context.audioWorklet && plugin.getWorkletProcessor) {
|
||||
worklets.addModule(plugin.getWorkletProcessor())
|
||||
return worklets.createNode(plugin.name)
|
||||
}
|
||||
|
||||
// 3. Fallback path — ScriptProcessorNode on the main thread (deprecated, but universal)
|
||||
const sp = context.createScriptProcessor(BLOCK_SIZE, 2, 2)
|
||||
sp.onaudioprocess = (e) => {
|
||||
const input = [e.inputBuffer.getChannelData(0), e.inputBuffer.getChannelData(1)]
|
||||
const output = [e.outputBuffer.getChannelData(0), e.outputBuffer.getChannelData(1)]
|
||||
plugin.processBlock(input, output, { sampleRate: context.sampleRate, blockSize: BLOCK_SIZE })
|
||||
}
|
||||
return sp
|
||||
}
|
||||
```
|
||||
|
||||
Path 1 is preferred because a native chain runs entirely on the audio rendering thread. Path 2 keeps
|
||||
DSP off the main thread when the plugin only ships universal block code. Path 3 exists so *every*
|
||||
FX plugin works *somewhere* — `ScriptProcessorNode` is deprecated but universally available, and the
|
||||
fallback is honest about its main-thread cost. Whichever node is chosen, it is inserted into the
|
||||
track subgraph through `WebAudioGraph` so it sits in the signal path between the clip gain and the
|
||||
track gain.
|
||||
|
||||
---
|
||||
|
||||
## 6. Offline render
|
||||
|
||||
`renderOffline` is the bridge to non-realtime export. It wires a `BrowserTimelineAdapter` to an
|
||||
`OfflineAudioContext` and lets the Web Audio engine render faster than realtime:
|
||||
|
||||
```ts
|
||||
async function renderOffline(timeline: TimelineEngine, duration: number, sampleRate: number): Promise<AudioBuffer> {
|
||||
const offline = new OfflineAudioContext(2, Math.ceil(duration * sampleRate), sampleRate)
|
||||
const adapter = new BrowserTimelineAdapter({ context: offline })
|
||||
await adapter.init()
|
||||
timeline.setAdapter(adapter)
|
||||
timeline.play(0)
|
||||
return offline.startRendering()
|
||||
}
|
||||
```
|
||||
|
||||
Because the adapter takes an injected context, offline render reuses the *exact same* rendering code
|
||||
path as realtime playback — there is no separate offline renderer to keep in sync. This is also why
|
||||
the test suite can exercise the whole adapter against an `OfflineAudioContext`: the "tests" and the
|
||||
"export feature" are the same seam.
|
||||
|
||||
---
|
||||
|
||||
## 7. Html5AudioFallback
|
||||
|
||||
For environments with no Web Audio API at all (locked-down embedded webviews, very old browsers),
|
||||
`Html5AudioFallback` implements the same `TimelineAdapter` interface using a bare `HTMLAudioElement`.
|
||||
It supports only what an `<audio>` element can honour — `play` / `pause` / `stop` / `seek` /
|
||||
`getCurrentTime` / `setLoop` / `setMasterVolume`. Operations it cannot perform (per-track
|
||||
volume/mute/solo/pan, `setTempo`, FX hosting, sample-accurate scheduling) are defined no-ops or throw
|
||||
`BrowserAudioError`; they never silently corrupt state. The fallback never constructs an
|
||||
`AudioContext`. It is a graceful-degradation path, not a feature-equivalent engine, and the spec is
|
||||
explicit about that boundary.
|
||||
|
||||
---
|
||||
|
||||
## 8. File layout
|
||||
|
||||
```text
|
||||
packages/audio/audio-engine-browser/
|
||||
package.json, tsconfig.json, vitest.config.ts
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts (+ .test.ts)
|
||||
adapter/ browser-adapter.ts (+ .md .types.ts .test.ts) · fx-host.ts (+ .md .test.ts) · index.ts
|
||||
graph/ web-audio-graph.ts (+ .md .types.ts .test.ts) · index.ts
|
||||
worklet/ worklet-manager.ts (+ .md .types.ts .test.ts) · index.ts
|
||||
fallback/ html5-fallback.ts (+ .md .test.ts) · index.ts
|
||||
offline/ offline-render.ts (+ .md .test.ts) · index.ts
|
||||
__benchmarks__/ graph.bench.ts worklet.bench.ts
|
||||
tests/
|
||||
integration.test.ts purity.test.ts exports.test.ts
|
||||
```
|
||||
|
||||
Each public options object (`BrowserAdapterOptions`, `WorkletManagerOptions`, `WorkletModuleOptions`,
|
||||
`AudioGraphSnapshot`) gets a sibling `*.schemas.ts` with a Zod schema, validated at the boundary.
|
||||
|
||||
---
|
||||
|
||||
## 9. Integration notes
|
||||
|
||||
- **`@suaveplan/audio-timeline`** — supplies the `TimelineAdapter` interface (plugin slot 5),
|
||||
`TimelineEngine`, `Track`, and `Clip`. `BrowserTimelineAdapter` and `Html5AudioFallback` both
|
||||
implement `TimelineAdapter`; the adapter receives resolved `Track[]` and never re-derives timeline
|
||||
state.
|
||||
- **`@suaveplan/audio-transport`** — supplies the `Transport` musical clock; `lookAhead` and `ppqn`
|
||||
on the adapter feed the transport's lookahead scheduler.
|
||||
- **`@suaveplan/audio-fx-plugin-base`** — supplies the `AudioFxPlugin` interface (plugin slot 6) hosted
|
||||
by `createFxNode`.
|
||||
- **`@suaveplan/audio-core`** — supplies shared audio primitives (`BLOCK_SIZE`, the `BlockProcessor`
|
||||
contract that `processBlock` satisfies) used by the `ScriptProcessorNode` fallback path.
|
||||
- **`@suaveplan/events`** — backs the adapter's typed event surface.
|
||||
- **`standardized-audio-context`** — the cross-browser `AudioContext` / `OfflineAudioContext` shim;
|
||||
the sole non-`@suaveplan` runtime dependency beyond `@opentelemetry/api`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Benchmarks
|
||||
|
||||
- `graph.bench.ts` — add 100 nodes, connect 200 edges, then `snapshot()` + `restore()`.
|
||||
- `worklet.bench.ts` — load 10 worklet modules and create 50 `AudioWorkletNode`s.
|
||||
|
||||
---
|
||||
|
||||
## 11. Decisions and trade-offs
|
||||
|
||||
- **Adapter, not engine.** The package is a `TimelineAdapter` implementation with zero timeline
|
||||
logic. This makes it swappable with `audio-engine-server` and obsoletes the legacy browser-only
|
||||
audio openspecs that tangled timeline state into rendering code.
|
||||
- **One injectable context seam.** Every browser API goes through the `context` option or
|
||||
`standardized-audio-context`. The cost is one extra constructor parameter; the payoff is that the
|
||||
whole package — including offline render — is testable under `happy-dom` and that offline export
|
||||
and realtime playback share one code path.
|
||||
- **Fixed-shape per-track subgraph.** Every track gets the identical node chain. A uniform shape
|
||||
makes mute/solo/volume/pan map to predictable nodes and keeps `snapshot()`/`restore()` trivial,
|
||||
at the cost of a few unused nodes on a track with no pan or compression — acceptable, since
|
||||
`GainNode`/`StereoPannerNode` at unity are effectively free.
|
||||
- **Three FX hosting paths, preference-ordered.** Native `createAudioNode` first, worklet second,
|
||||
`ScriptProcessorNode` last. `ScriptProcessorNode` is deprecated but kept as the universal fallback
|
||||
so no FX plugin is ever unhostable; the spec is explicit that it runs on the main thread.
|
||||
- **Html5AudioFallback is degradation, not parity.** It implements `TimelineAdapter` so callers
|
||||
need no branching, but unsupported operations throw or no-op rather than pretending to work.
|
||||
- **No design.md for the React/r3f tier yet.** `react-audio*` and `r3f-audio` consume this package
|
||||
but are out of scope for this 13-change batch; they land in a later wave.
|
||||
@@ -0,0 +1,228 @@
|
||||
# OpenSpec Change: enterprise-audio-engine-browser
|
||||
|
||||
- **Change ID**: enterprise-audio-engine-browser
|
||||
- **Package**: `@suaveplan/audio-engine-browser`
|
||||
- **Category**: browser
|
||||
- **Target path**: `packages/audio/audio-engine-browser`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-14
|
||||
- **Design**: `./design.md`
|
||||
- **Spec delta**: `./specs/audio-engine-browser/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/audio-engine-browser` on the genesis branch from scratch at enterprise quality. The package is a Web Audio API rendering backend that implements `TimelineAdapter` to connect the universal `TimelineEngine` to the browser's audio output. It manages `AudioContext` lifecycle (via `standardized-audio-context`), `AudioWorklet` loading, and dual-mode FX plugin hosting. An `Html5AudioFallback` is provided for environments without Web Audio API. This is the only browser-only package in the audio core; it bridges universal data to browser-specific rendering and replaces old browser-only openspecs with a new adapter-based architecture.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
This is a new architecture replacing old browser-only openspecs. No prior source on genesis for this specific package; fresh build with an adapter-based design that delegates timeline logic to `@suaveplan/audio-timeline`.
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/audio-core` (`workspace:^`) | Shared audio primitives and FX plugin interfaces |
|
||||
| `@suaveplan/audio-timeline` (`workspace:^`) | `TimelineEngine`, `TimelineAdapter`, `Track`, `Clip` |
|
||||
| `@suaveplan/audio-transport` (`workspace:^`) | `Transport` for musical clock integration |
|
||||
| `@suaveplan/audio-fx-plugin-base` (`workspace:^`) | `AudioFxPlugin` interface for FX hosting |
|
||||
| `@suaveplan/error` (`workspace:^`) | Base class for `BrowserAudioError`; `AUDIO_ENGINE_BROWSER.*` codes |
|
||||
| `@suaveplan/events` (`workspace:^`) | Typed event emitter for adapter events |
|
||||
| `standardized-audio-context` | Cross-browser `AudioContext` / `OfflineAudioContext` shim |
|
||||
|
||||
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `happy-dom` (all `catalog:testing`).
|
||||
|
||||
---
|
||||
|
||||
## Public API
|
||||
|
||||
### Core types
|
||||
|
||||
```ts
|
||||
interface BrowserAdapterOptions {
|
||||
context?: BaseAudioContext
|
||||
latencyHint?: AudioContextLatencyCategory
|
||||
sampleRate?: number
|
||||
lookAhead?: number
|
||||
ppqn?: number
|
||||
}
|
||||
|
||||
interface WorkletManagerOptions {
|
||||
allowedUrls?: string[]
|
||||
}
|
||||
|
||||
interface WorkletModuleOptions {
|
||||
credentials?: RequestCredentials
|
||||
}
|
||||
|
||||
interface AudioGraphSnapshot {
|
||||
nodes: Record<string, AudioNodeType>
|
||||
connections: Array<{ source: string; destination: string; outputIndex?: number; inputIndex?: number }>
|
||||
}
|
||||
|
||||
class BrowserTimelineAdapter implements TimelineAdapter {
|
||||
constructor(options?: BrowserAdapterOptions)
|
||||
init(options?: { signal?: AbortSignal }): Promise<void>
|
||||
setTracks(tracks: Track[]): void
|
||||
addTrack(track: Track): void
|
||||
removeTrack(trackId: string): void
|
||||
updateTrack(trackId: string, track: Track): void
|
||||
play(time?: number): void
|
||||
pause(time?: number): void
|
||||
stop(time?: number): void
|
||||
seek(time: number): void
|
||||
getCurrentTime(): number
|
||||
setLoop(start: number, end: number, enabled: boolean): void
|
||||
setMasterVolume(volume: number): void
|
||||
setTrackVolume(trackId: string, volume: number): void
|
||||
setTrackMute(trackId: string, muted: boolean): void
|
||||
setTrackSolo(trackId: string, soloed: boolean): void
|
||||
setTrackPan(trackId: string, pan: number): void
|
||||
setTempo(bpm: number, atTick?: number): void
|
||||
ticksToSeconds(ticks: number): number
|
||||
secondsToTicks(seconds: number): number
|
||||
readonly lookAhead: number
|
||||
readonly ppqn: number
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
class WebAudioGraph {
|
||||
constructor(context: BaseAudioContext)
|
||||
addNode(id: string, node: AudioNode): void
|
||||
removeNode(id: string): void
|
||||
connect(sourceId: string, destinationId: string, outputIndex?: number, inputIndex?: number): void
|
||||
disconnect(sourceId: string, destinationId?: string): void
|
||||
getNode(id: string): AudioNode | undefined
|
||||
snapshot(): AudioGraphSnapshot
|
||||
restore(snapshot: AudioGraphSnapshot): void
|
||||
}
|
||||
|
||||
class AudioWorkletManager {
|
||||
constructor(context: BaseAudioContext, options?: WorkletManagerOptions)
|
||||
addModule(url: string, options?: WorkletModuleOptions): Promise<void>
|
||||
createNode(processorName: string, options?: AudioWorkletNodeOptions): AudioWorkletNode
|
||||
postMessage(processorName: string, message: unknown, transfer?: Transferable[]): void
|
||||
onMessage(processorName: string, handler: (event: MessageEvent) => void): void
|
||||
isLoaded(url: string): boolean
|
||||
}
|
||||
|
||||
class Html5AudioFallback implements TimelineAdapter {
|
||||
constructor(options?: { volume?: number; loop?: boolean })
|
||||
init(options?: { signal?: AbortSignal }): Promise<void>
|
||||
setTracks(tracks: Track[]): void
|
||||
addTrack(track: Track): void
|
||||
removeTrack(trackId: string): void
|
||||
updateTrack(trackId: string, track: Track): void
|
||||
play(time?: number): void
|
||||
pause(time?: number): void
|
||||
stop(time?: number): void
|
||||
seek(time: number): void
|
||||
getCurrentTime(): number
|
||||
setLoop(start: number, end: number, enabled: boolean): void
|
||||
setMasterVolume(volume: number): void
|
||||
setTrackVolume(trackId: string, volume: number): void
|
||||
setTrackMute(trackId: string, muted: boolean): void
|
||||
setTrackSolo(trackId: string, soloed: boolean): void
|
||||
setTrackPan(trackId: string, pan: number): void
|
||||
setTempo(bpm: number, atTick?: number): void
|
||||
readonly lookAhead: number
|
||||
readonly ppqn: number
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
async function renderOffline(
|
||||
timeline: TimelineEngine,
|
||||
duration: number,
|
||||
sampleRate: number
|
||||
): Promise<AudioBuffer>
|
||||
```
|
||||
|
||||
### Error codes
|
||||
|
||||
```ts
|
||||
type BrowserAudioErrorCode =
|
||||
| 'AUDIO_ENGINE_BROWSER.CONTEXT_FAILED'
|
||||
| 'AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED'
|
||||
| 'AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND'
|
||||
| 'AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND'
|
||||
| 'AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED'
|
||||
| 'AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED'
|
||||
|
||||
const AUDIO_ENGINE_BROWSER_ERROR_METADATA: Readonly<Record<BrowserAudioErrorCode, CodeMetadata>>
|
||||
function registerBrowserAudioErrorCodes(): void
|
||||
```
|
||||
|
||||
### Zod schemas
|
||||
|
||||
```ts
|
||||
BrowserAdapterOptionsSchema
|
||||
WorkletManagerOptionsSchema
|
||||
WorkletModuleOptionsSchema
|
||||
AudioGraphSnapshotSchema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/audio/audio-engine-browser/
|
||||
package.json
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts
|
||||
adapter/
|
||||
browser-adapter.ts
|
||||
browser-adapter.md
|
||||
browser-adapter.types.ts
|
||||
browser-adapter.test.ts
|
||||
index.ts
|
||||
graph/
|
||||
web-audio-graph.ts
|
||||
web-audio-graph.md
|
||||
web-audio-graph.types.ts
|
||||
web-audio-graph.test.ts
|
||||
index.ts
|
||||
worklet/
|
||||
worklet-manager.ts
|
||||
worklet-manager.md
|
||||
worklet-manager.types.ts
|
||||
worklet-manager.test.ts
|
||||
index.ts
|
||||
fallback/
|
||||
html5-fallback.ts
|
||||
html5-fallback.md
|
||||
html5-fallback.test.ts
|
||||
index.ts
|
||||
offline/
|
||||
offline-render.ts
|
||||
offline-render.md
|
||||
offline-render.test.ts
|
||||
index.ts
|
||||
__benchmarks__/
|
||||
graph.bench.ts
|
||||
worklet.bench.ts
|
||||
tests/
|
||||
integration.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Breaking changes**: none — fresh build replacing legacy browser-only openspecs; the package does not yet exist on genesis.
|
||||
|
||||
**Prerequisites**: `@suaveplan/audio-core`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/error`, `@suaveplan/events` published to Verdaccio.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
No dependents in this batch. Consumed by `@suaveplan/react-audio*` and `@suaveplan/r3f-audio`, which land in a later wave (verified by `bunx turbo ls --filter=...@suaveplan/audio-engine-browser`).
|
||||
|
||||
Once landed, any subsequent change to this package's public surface MUST run `bunx turbo test --filter=...@suaveplan/audio-engine-browser` per `openspec/AGENTS.md` §13 step 10.
|
||||
@@ -0,0 +1,317 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-engine-browser (audio-engine-browser)
|
||||
|
||||
**Package**: `@suaveplan/audio-engine-browser`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Browser-tier purity
|
||||
|
||||
The `@suaveplan/audio-engine-browser` package SHALL import zero Node-only APIs (`node:fs`, `node:path`, `node:crypto`, `process`, `Buffer`). It MAY reference browser audio APIs (`AudioContext`, `OfflineAudioContext`, `AudioWorklet`, `AudioNode`, `HTMLAudioElement`) — those are the package's reason to exist — but every such reference SHALL be reachable only through an injectable seam (the `context` option or `standardized-audio-context`) so the package is testable under `happy-dom` without a real browser. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/error`, `@suaveplan/events`, `standardized-audio-context`, `tslib`, and `zod`; `@opentelemetry/api` is the only permitted non-workspace runtime dependency beyond `standardized-audio-context`.
|
||||
|
||||
#### Scenario: Built dist references no Node globals
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd
|
||||
- **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:crypto`, `process`, or `Buffer`
|
||||
|
||||
#### Scenario: Tests pass under a happy-dom shim
|
||||
|
||||
- **WHEN** the suite runs under vitest with a `happy-dom` environment and an injected `OfflineAudioContext`
|
||||
- **THEN** every test passes without a real `AudioContext` ever being constructed from `globalThis`
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"dist references no Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace, standardized-audio-context, and zod"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BrowserTimelineAdapter implements the TimelineAdapter interface
|
||||
|
||||
The package SHALL export a `BrowserTimelineAdapter` class that implements the `TimelineAdapter` interface (plugin slot 5) defined by `@suaveplan/audio-timeline`. It SHALL implement every member of that interface — `init`, `setTracks`, `addTrack`, `removeTrack`, `updateTrack`, `play`, `pause`, `stop`, `seek`, `getCurrentTime`, `setLoop`, `setMasterVolume`, `setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`, `setTempo`, `dispose`, and the readonly `lookAhead` / `ppqn` properties — with the signatures declared on the interface. A `TimelineEngine` SHALL be able to drive the browser audio output by calling `timeline.setAdapter(new BrowserTimelineAdapter(...))` with no adapter-specific branching. `lookAhead` SHALL default to `0.1` seconds and `ppqn` to `960` when not supplied via `BrowserAdapterOptions`.
|
||||
|
||||
#### Scenario: adapter is structurally a TimelineAdapter
|
||||
|
||||
- **WHEN** a caller constructs `new BrowserTimelineAdapter({ context: offlineContext })`
|
||||
- **THEN** the instance satisfies the `TimelineAdapter` interface and every interface member is callable, and `adapter.lookAhead === 0.1` and `adapter.ppqn === 960` by default
|
||||
|
||||
#### Scenario: TimelineEngine drives the adapter without branching
|
||||
|
||||
- **WHEN** a caller attaches the adapter to a `TimelineEngine` and calls `timeline.play(0)`
|
||||
- **THEN** the adapter's `play` is invoked with the engine's start time and the browser graph begins scheduling
|
||||
|
||||
**Verified by:** `src/adapter/browser-adapter.test.ts::"implements every TimelineAdapter member"` AND `tests/integration.test.ts::"TimelineEngine drives BrowserTimelineAdapter end to end"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioContext lifecycle through an injectable seam
|
||||
|
||||
`BrowserTimelineAdapter.init()` SHALL obtain a `BaseAudioContext` either from the `context` option (when supplied — for tests and offline render) or by constructing one via `standardized-audio-context` with the `latencyHint` and `sampleRate` from `BrowserAdapterOptions`, then SHALL call `resume()` on it. The adapter SHALL track context state across the `suspended → running → closed` transitions. Constructing the context SHALL go through `standardized-audio-context` and never directly reference `globalThis.AudioContext`. If context creation or `resume()` rejects, `init()` SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED` carrying the underlying error as `cause`.
|
||||
|
||||
#### Scenario: injected context is used without touching globalThis
|
||||
|
||||
- **WHEN** `init()` is called on an adapter constructed with an injected `OfflineAudioContext`
|
||||
- **THEN** the injected context is used directly, `globalThis.AudioContext` is never read, and `init()` resolves
|
||||
|
||||
#### Scenario: context creation failure throws AUDIO_ENGINE_BROWSER.CONTEXT_FAILED
|
||||
|
||||
- **WHEN** `standardized-audio-context` construction or `resume()` rejects
|
||||
- **THEN** `init()` throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.CONTEXT_FAILED"` with the rejection in `cause`
|
||||
|
||||
**Verified by:** `src/adapter/browser-adapter.test.ts::"init uses the injected context without reading globalThis"` AND `src/adapter/browser-adapter.test.ts::"init throws AUDIO_ENGINE_BROWSER.CONTEXT_FAILED on context failure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: WebAudioGraph node-graph construction and connection
|
||||
|
||||
The package SHALL export a `WebAudioGraph` class constructed from a `BaseAudioContext`. `addNode(id, node)` SHALL register an `AudioNode` under a string id; `removeNode(id)` SHALL unregister it and disconnect it from the graph. `connect(sourceId, destinationId, outputIndex?, inputIndex?)` SHALL wrap `AudioNode.connect` and `disconnect(sourceId, destinationId?)` SHALL wrap `AudioNode.disconnect`. `getNode(id)` SHALL return the registered node or `undefined`. `connect` or `disconnect` referencing an id that is not registered SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND` carrying the missing id in `context`.
|
||||
|
||||
#### Scenario: connect wires two registered nodes
|
||||
|
||||
- **WHEN** a caller registers two nodes then calls `connect(sourceId, destinationId)`
|
||||
- **THEN** `source.connect(destination)` is invoked once with the resolved nodes and the edge is recorded in the graph
|
||||
|
||||
#### Scenario: connecting an unknown id throws AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND
|
||||
|
||||
- **WHEN** `connect` is called with a `sourceId` that was never registered
|
||||
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND"` with the offending id in `context`
|
||||
|
||||
**Verified by:** `src/graph/web-audio-graph.test.ts::"connect wires two registered nodes"` AND `src/graph/web-audio-graph.test.ts::"connect throws AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND for an unknown id"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: WebAudioGraph snapshot and restore
|
||||
|
||||
`WebAudioGraph.snapshot()` SHALL return a serializable `AudioGraphSnapshot` (`{ nodes: Record<string, AudioNodeType>; connections: Array<{ source; destination; outputIndex?; inputIndex? }> }`) describing the current node types and edges without holding references to live `AudioNode` instances. `restore(snapshot)` SHALL rebuild the node registry and re-issue every connection from the snapshot against the graph's context. A snapshot taken, then restored into a fresh `WebAudioGraph` over an equivalent context, SHALL produce a graph whose subsequent `snapshot()` is deep-equal to the original.
|
||||
|
||||
#### Scenario: snapshot then restore round-trips the graph shape
|
||||
|
||||
- **WHEN** a caller snapshots a populated graph and restores it into a fresh `WebAudioGraph`
|
||||
- **THEN** the restored graph's `snapshot()` is deep-equal to the original snapshot
|
||||
|
||||
**Verified by:** `src/graph/web-audio-graph.test.ts::"snapshot and restore round-trip the graph shape"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioWorkletManager module loading and worklet URL validation
|
||||
|
||||
The package SHALL export an `AudioWorkletManager` class constructed from a `BaseAudioContext` and optional `WorkletManagerOptions`. `addModule(url, options?)` SHALL validate `url` against the `allowedUrls` allow-list (when configured) before calling `context.audioWorklet.addModule(url, ...)`, applying the `credentials` from `WorkletModuleOptions`. A `url` that is not in a configured non-empty `allowedUrls` list SHALL be rejected before any network call with `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED`. An `addModule` whose underlying `addModule` call rejects SHALL throw `BrowserAudioError` with the same code carrying the rejection as `cause`. `createNode(processorName, options?)` SHALL return an `AudioWorkletNode`; `postMessage` / `onMessage` SHALL bridge the processor port; `isLoaded(url)` SHALL report whether a module URL has been successfully loaded.
|
||||
|
||||
#### Scenario: disallowed worklet URL is rejected before any network call
|
||||
|
||||
- **WHEN** `addModule` is called with a URL absent from a configured non-empty `allowedUrls` list
|
||||
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED"` before `context.audioWorklet.addModule` is invoked
|
||||
|
||||
#### Scenario: module load failure throws AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED
|
||||
|
||||
- **WHEN** the underlying `context.audioWorklet.addModule` call rejects
|
||||
- **THEN** `addModule` throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED"` with the rejection in `cause`, and `isLoaded(url)` returns `false`
|
||||
|
||||
**Verified by:** `src/worklet/worklet-manager.test.ts::"rejects a URL absent from the allow-list before any network call"` AND `src/worklet/worklet-manager.test.ts::"addModule throws AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED on load failure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Per-track Web Audio subgraph rendering
|
||||
|
||||
`BrowserTimelineAdapter.setTracks` / `addTrack` / `updateTrack` SHALL build, per `Track`, a Web Audio subgraph of the shape `AudioBufferSourceNode` (per clip) → `GainNode` (clip volume) → `GainNode` (track volume) → `StereoPannerNode` (track pan) → `DynamicsCompressorNode` (limiter) → `GainNode` (master mix) → `AnalyserNode` (master analysis) → `context.destination`. `removeTrack` SHALL disconnect and release that subgraph. `setTrackMute` / `setTrackSolo` SHALL implement DAW solo semantics: when any track is soloed, every non-soloed track's track-volume `GainNode` SHALL be set to `0`; when no track is soloed, mute state alone governs gain. `removeTrack` / `updateTrack` / `setTrackVolume` / `setTrackMute` / `setTrackSolo` / `setTrackPan` for an unknown `trackId` SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND`.
|
||||
|
||||
#### Scenario: a track is rendered as the documented subgraph
|
||||
|
||||
- **WHEN** `setTracks` is called with one track carrying one clip
|
||||
- **THEN** the graph contains the clip-source → clip-gain → track-gain → panner → compressor → master-gain → analyser → destination chain for that track
|
||||
|
||||
#### Scenario: soloing a track silences non-soloed tracks
|
||||
|
||||
- **WHEN** two tracks are loaded and `setTrackSolo(trackA, true)` is called
|
||||
- **THEN** track A's track-gain is non-zero and track B's track-gain is `0`; after `setTrackSolo(trackA, false)` both return to their mute-governed gains
|
||||
|
||||
#### Scenario: operating on an unknown track throws AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND
|
||||
|
||||
- **WHEN** `setTrackVolume` is called with a `trackId` that was never added
|
||||
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND"` with the id in `context`
|
||||
|
||||
**Verified by:** `src/adapter/browser-adapter.test.ts::"builds the documented per-track subgraph"` AND `src/adapter/browser-adapter.test.ts::"solo silences non-soloed tracks"` AND `src/adapter/browser-adapter.test.ts::"track ops throw AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND for unknown ids"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Each track hosts one PluginChain from @suaveplan/audio-fx-plugin-base
|
||||
|
||||
For each `Track` in the timeline, `BrowserTimelineAdapter` SHALL construct exactly one `PluginChain` instance imported from `@suaveplan/audio-fx-plugin-base/chain` and use it as the track's FX slot list. The chain SHALL be the sole owner of plugin instances, parameter automation, snapshot/restore, plugin delay compensation, chain-level bypass, and event emission for that track — the adapter SHALL NOT re-implement any of these. The adapter SHALL push ambient state into the chain via `chain.updateAmbient({ track, transport, listener? })` whenever track gain/pan/mute/solo, transport tempo/time/state, or listener position changes. The adapter SHALL construct one project-level `BusRegistry` instance at `init()` and pass the same registry into every chain so sidechain routing crosses tracks.
|
||||
|
||||
For each plugin in the chain the adapter SHALL select a render path via the plugin's `capabilities`, in this order:
|
||||
|
||||
1. **AudioNode path** — `capabilities.hasAudioNode === true`: call `plugin.createAudioNode(context, chainContext)` and insert the returned `AudioNode` chain into the track subgraph via `WebAudioGraph`.
|
||||
2. **Worklet path** — `capabilities.hasWorklet === true` and `context.audioWorklet` is available: register `plugin.getWorkletProcessor()` through `AudioWorkletManager` and insert an `AudioWorkletNode`.
|
||||
3. **BlockProcessor fallback** — wrap the plugin's `process()` in a `ScriptProcessorNode` running on the main thread.
|
||||
|
||||
The chain's effective per-track latency (`chain.getEffectiveLatencySamples()`) SHALL be used to apply per-track delay compensation across the project so tracks of different effective latencies stay phase-aligned. A `latency-changed` chain event SHALL trigger recomputation of compensation across all tracks.
|
||||
|
||||
#### Scenario: each track has exactly one PluginChain bound to the same BusRegistry
|
||||
|
||||
- **WHEN** `BrowserTimelineAdapter` is initialised and `setTracks([trackA, trackB])` is called
|
||||
- **THEN** two `PluginChain` instances exist (one per track) and both received the same `BusRegistry` instance via their `ChainContext.buses`
|
||||
|
||||
#### Scenario: ambient changes propagate to the chain on the next block
|
||||
|
||||
- **WHEN** the adapter receives `setTrackPan(trackA.id, 0.5)` and a subsequent block is processed
|
||||
- **THEN** the chain's `ChainContext.ambient.track.pan === 0.5` is observed by every plugin in track A's chain
|
||||
|
||||
#### Scenario: capability flag selects the render path
|
||||
|
||||
- **WHEN** track A contains plugins `[eqWithAudioNode, limiterBlockOnly, pitchShifterWorklet]` and the adapter wires them
|
||||
- **THEN** `eq` is wired as a native `BiquadFilterNode` chain, `limiter` via `ScriptProcessorNode` fallback, and `pitchShifter` via `AudioWorkletNode`
|
||||
|
||||
#### Scenario: dynamic chain latency triggers per-track PDC recompute
|
||||
|
||||
- **WHEN** any chain emits `latency-changed`
|
||||
- **THEN** the adapter recomputes per-track compensation so the maximum effective latency across tracks equals the longest chain's effective latency
|
||||
|
||||
**Verified by:** `src/adapter/browser-adapter.test.ts::"each track hosts one PluginChain bound to a shared BusRegistry"` AND `src/adapter/browser-adapter.test.ts::"ambient changes propagate into ChainContext on the next block"` AND `src/adapter/fx-host.test.ts::"capability flag selects the correct render path per plugin"` AND `src/adapter/browser-adapter.test.ts::"chain latency-changed triggers per-track PDC recompute"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Project-level BusRegistry owned by the adapter
|
||||
|
||||
The adapter SHALL construct and own one `BusRegistry` instance (the `DefaultBusRegistry` from `@suaveplan/audio-fx-plugin-base/chain` or an engine-specific implementation) for the entire `TimelineAdapter` lifetime. It SHALL detect bus cycles (chain A reads a bus written by chain B which reads a bus written by chain A) and reject the offending publisher with `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED`. The adapter SHALL order per-block chain processing so publishers write before subscribers read in the same block. `dispose()` SHALL close every active `SidechainPublisher` and release the registry.
|
||||
|
||||
#### Scenario: bus cycle is rejected
|
||||
|
||||
- **WHEN** track A's chain publishes to `bus-x` and reads from `bus-y`, and track B's chain publishes to `bus-y` and reads from `bus-x`
|
||||
- **THEN** the second declared edge is rejected with `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED"`
|
||||
|
||||
#### Scenario: publishers process before subscribers in the same block
|
||||
|
||||
- **WHEN** track A publishes to `kick` and track B's compressor reads `kick`
|
||||
- **THEN** for any block, A's `chain.process()` completes before B's `chain.process()` begins
|
||||
|
||||
**Verified by:** `src/adapter/browser-adapter.test.ts::"bus cycle is rejected with AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED"` AND `src/adapter/browser-adapter.test.ts::"publishers process before subscribers in the same block"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Html5AudioFallback for environments without Web Audio API
|
||||
|
||||
The package SHALL export an `Html5AudioFallback` class implementing the `TimelineAdapter` interface using an `HTMLAudioElement` for environments without the Web Audio API. It SHALL support `play`, `pause`, `stop`, `seek`, `getCurrentTime`, `setLoop`, and `setMasterVolume`. Adapter methods that the HTML5 element cannot honour — per-track volume / mute / solo / pan, `setTempo`, FX hosting, and precise scheduling — SHALL be defined no-ops or SHALL throw `BrowserAudioError` for explicitly unsupported operations, never silently corrupting state. The fallback SHALL never construct an `AudioContext`.
|
||||
|
||||
#### Scenario: basic playback works through the HTML5 element
|
||||
|
||||
- **WHEN** a caller constructs `Html5AudioFallback`, calls `init()` then `play()`
|
||||
- **THEN** the underlying `HTMLAudioElement` begins playback and `getCurrentTime()` advances, with no `AudioContext` constructed
|
||||
|
||||
#### Scenario: unsupported per-track operations do not corrupt state
|
||||
|
||||
- **WHEN** a caller calls `setTrackPan` on the fallback
|
||||
- **THEN** the call is a defined no-op or throws `BrowserAudioError`, and subsequent `play()` / `getCurrentTime()` still behave correctly
|
||||
|
||||
**Verified by:** `src/fallback/html5-fallback.test.ts::"plays through the HTML5 element without an AudioContext"` AND `src/fallback/html5-fallback.test.ts::"unsupported per-track operations do not corrupt playback state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Offline render through an OfflineAudioContext
|
||||
|
||||
The package SHALL export `renderOffline(timeline, duration, sampleRate)` returning `Promise<AudioBuffer>`. It SHALL construct an `OfflineAudioContext` (2 channels, `ceil(duration * sampleRate)` frames, `sampleRate`) via `standardized-audio-context`, instantiate a `BrowserTimelineAdapter` bound to that offline context, attach it to the supplied `TimelineEngine`, play from time `0`, and resolve with the buffer returned by `startRendering()`. A rendering failure SHALL reject with `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED` carrying the underlying error as `cause`.
|
||||
|
||||
#### Scenario: offline render returns a buffer of the expected length
|
||||
|
||||
- **WHEN** a caller calls `renderOffline(timeline, 2, 48000)` on a timeline with one track
|
||||
- **THEN** the resolved `AudioBuffer` has `length === Math.ceil(2 * 48000)` and `sampleRate === 48000`
|
||||
|
||||
#### Scenario: render failure rejects with AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED
|
||||
|
||||
- **WHEN** `startRendering()` rejects
|
||||
- **THEN** `renderOffline` rejects with `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED"` with the rejection in `cause`
|
||||
|
||||
**Verified by:** `src/offline/offline-render.test.ts::"renderOffline returns a buffer of the expected length"` AND `src/offline/offline-render.test.ts::"renderOffline rejects with AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED on failure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Dispose releases the context and all nodes
|
||||
|
||||
`BrowserTimelineAdapter.dispose()` SHALL disconnect and release every node in every track subgraph, close the owned `AudioContext` (unless the context was injected, in which case it SHALL be left open for the injector to manage), and mark the adapter disposed. Any adapter method called after `dispose()` — other than `dispose()` itself, which SHALL be idempotent — SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED`.
|
||||
|
||||
#### Scenario: methods after dispose throw AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED
|
||||
|
||||
- **WHEN** a caller calls `play(0)` after `dispose()`
|
||||
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED"`
|
||||
|
||||
#### Scenario: dispose is idempotent
|
||||
|
||||
- **WHEN** a caller calls `dispose()` twice
|
||||
- **THEN** the second call is a no-op and does not throw
|
||||
|
||||
**Verified by:** `src/adapter/browser-adapter.test.ts::"methods after dispose throw AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED"` AND `src/adapter/browser-adapter.test.ts::"dispose is idempotent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation through async entry points
|
||||
|
||||
`BrowserTimelineAdapter.init()`, `AudioWorkletManager.addModule()`, and `renderOffline()` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to reject with `BrowserAudioError` code `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED` (for `init`), `AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED` (for `addModule`), or `AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED` (for `renderOffline`) before any context, network, or render work is performed. A signal aborted mid-operation SHALL abort the underlying work and reject with the same code.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits addModule
|
||||
|
||||
- **WHEN** `addModule` is called with an already-aborted signal
|
||||
- **THEN** it rejects with `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED"` before `context.audioWorklet.addModule` is invoked
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits renderOffline
|
||||
|
||||
- **WHEN** `renderOffline` is called with an already-aborted signal
|
||||
- **THEN** it rejects with `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED"` before `startRendering()` is invoked
|
||||
|
||||
**Verified by:** `src/worklet/worklet-manager.test.ts::"addModule honours a pre-aborted signal"` AND `src/offline/offline-render.test.ts::"renderOffline honours a pre-aborted signal"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
Every public options object — `BrowserAdapterOptions`, `WorkletManagerOptions`, `WorkletModuleOptions`, `AudioGraphSnapshot` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point that accepts one SHALL validate its argument against the schema before use. `WebAudioGraph.restore` SHALL validate its `AudioGraphSnapshot` argument before rebuilding the graph. A validation failure SHALL throw `BrowserAudioError` with code `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED` (for adapter/worklet construction) or `AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND` (for an invalid snapshot) carrying the Zod issue list in `context`.
|
||||
|
||||
#### Scenario: invalid adapter options are rejected at the boundary
|
||||
|
||||
- **WHEN** `BrowserTimelineAdapter` is constructed with a negative `sampleRate`
|
||||
- **THEN** it throws `BrowserAudioError` with the Zod issues in `context`
|
||||
|
||||
#### Scenario: invalid snapshot is rejected by restore
|
||||
|
||||
- **WHEN** `WebAudioGraph.restore` is called with a snapshot whose `connections` reference an absent node id
|
||||
- **THEN** it throws `BrowserAudioError` whose `code === "AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND"` before any node is created
|
||||
|
||||
**Verified by:** `src/adapter/browser-adapter.test.ts::"constructor rejects invalid options via Zod"` AND `src/graph/web-audio-graph.test.ts::"restore rejects an invalid snapshot"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes
|
||||
|
||||
The package SHALL declare its codes via `defineCodes("AUDIO_ENGINE_BROWSER", [...])` producing the dotted codes `AUDIO_ENGINE_BROWSER.CONTEXT_FAILED`, `AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED`, `AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND`, `AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND`, `AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED`, `AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED`, and `AUDIO_ENGINE_BROWSER.BUS_CYCLE_DETECTED`. The package SHALL export `BrowserAudioErrorCode`, `AUDIO_ENGINE_BROWSER_ERROR_METADATA`, `registerBrowserAudioErrorCodes()`, and a `BrowserAudioError` class extending `AudioError` from `@suaveplan/audio-core`. Every error thrown by the package SHALL be a `BrowserAudioError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
|
||||
|
||||
#### Scenario: every registered code is thrown by a call site
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** each code in `AUDIO_ENGINE_BROWSER_ERROR_METADATA` is asserted thrown by at least one test
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every AUDIO_ENGINE_BROWSER code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `engine_browser.init`, `engine_browser.set_tracks`, `engine_browser.worklet_load`, and `engine_browser.offline_render`; a counter `audio_engine_browser_nodes_created_total`; and a histogram `audio_engine_browser_offline_render_duration_ms`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
|
||||
|
||||
#### Scenario: instrumentation is inert without an exporter
|
||||
|
||||
- **WHEN** the package is imported and used with no OTel exporter registered
|
||||
- **THEN** no error is thrown and no global state is mutated at import time
|
||||
|
||||
**Verified by:** `src/offline/offline-render.test.ts::"renderOffline records a span and histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per feature module
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per feature module (`./adapter/browser-adapter`, `./graph/web-audio-graph`, `./worklet/worklet-manager`, `./fallback/html5-fallback`, `./offline/offline-render`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
|
||||
|
||||
#### Scenario: each feature module is independently importable
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/audio-engine-browser/graph/web-audio-graph`
|
||||
- **THEN** the import resolves to the `WebAudioGraph` module without pulling the whole package barrel
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
|
||||
@@ -0,0 +1,121 @@
|
||||
# Tasks: enterprise-audio-engine-browser
|
||||
|
||||
**Change ID**: enterprise-audio-engine-browser
|
||||
**Package**: `@suaveplan/audio-engine-browser`
|
||||
**Target path**: `packages/audio/audio-engine-browser`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/error`, `@suaveplan/events`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] Create directory `packages/audio/audio-engine-browser/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
|
||||
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/audio-timeline workspace:^`, `@suaveplan/audio-transport workspace:^`, `@suaveplan/audio-fx-plugin-base workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/events workspace:^`, `standardized-audio-context`.
|
||||
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` with `happy-dom` environment.
|
||||
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types + errors
|
||||
|
||||
- [ ] Write `src/adapter/browser-adapter.types.ts` — `BrowserAdapterOptions`.
|
||||
- [ ] Write `src/graph/web-audio-graph.types.ts` — `AudioGraphSnapshot`, `AudioNodeType`.
|
||||
- [ ] Write `src/worklet/worklet-manager.types.ts` — `WorkletManagerOptions`, `WorkletModuleOptions`.
|
||||
- [ ] Write co-located `.md` files for each types module (≥200 words).
|
||||
- [ ] Write `src/error-codes.ts` — `defineCodes("AUDIO_ENGINE_BROWSER", [...])` producing `AUDIO_ENGINE_BROWSER.*` code strings; export `BrowserAudioErrorCode`, `AUDIO_ENGINE_BROWSER_ERROR_METADATA`, `registerBrowserAudioErrorCodes()`, and a `BrowserAudioError` class extending `AudioError` from `@suaveplan/audio-core`.
|
||||
- [ ] Write Zod schemas: `BrowserAdapterOptionsSchema`, `WorkletManagerOptionsSchema`, `WorkletModuleOptionsSchema`, `AudioGraphSnapshotSchema`.
|
||||
|
||||
---
|
||||
|
||||
## 3. WebAudioGraph
|
||||
|
||||
- [ ] Implement `WebAudioGraph` in `src/graph/web-audio-graph.ts`:
|
||||
- `addNode`, `removeNode` with ID-based registry.
|
||||
- `connect` / `disconnect` wrapping `AudioNode.connect` / `disconnect`.
|
||||
- `snapshot()` returns serializable `AudioGraphSnapshot`.
|
||||
- `restore(snapshot)` recreates nodes and connections from a snapshot.
|
||||
- [ ] Write `web-audio-graph.test.ts` and `web-audio-graph.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. AudioWorkletManager
|
||||
|
||||
- [ ] Implement `AudioWorkletManager` in `src/worklet/worklet-manager.ts`:
|
||||
- `addModule` with URL allow-list check.
|
||||
- `createNode` returns `AudioWorkletNode`.
|
||||
- `postMessage` / `onMessage` for processor communication.
|
||||
- `isLoaded` tracks loaded module URLs.
|
||||
- [ ] Write `worklet-manager.test.ts` and `worklet-manager.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Html5AudioFallback
|
||||
|
||||
- [ ] Implement `Html5AudioFallback` in `src/fallback/html5-fallback.ts`:
|
||||
- Implements `TimelineAdapter` using `HTMLAudioElement`.
|
||||
- Supports: play, pause, seek, volume, loop.
|
||||
- No FX, no analysis, no precise scheduling.
|
||||
- All other adapter methods are no-ops or throw `BrowserAudioError` for unsupported operations.
|
||||
- [ ] Write `html5-fallback.test.ts` and `html5-fallback.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. BrowserTimelineAdapter
|
||||
|
||||
- [ ] Implement `BrowserTimelineAdapter` in `src/adapter/browser-adapter.ts`:
|
||||
- `init()` creates `AudioContext` via `standardized-audio-context` (or uses injected `context`) and resumes it.
|
||||
- `setTracks`, `addTrack`, `removeTrack`, `updateTrack` build per-track subgraphs:
|
||||
`AudioBufferSourceNode` → `GainNode` (clip) → `GainNode` (track) → `StereoPannerNode` → `DynamicsCompressorNode` → `GainNode` (master) → `AnalyserNode` → destination.
|
||||
- `play`, `pause`, `stop`, `seek` drive `AudioBufferSourceNode` scheduling.
|
||||
- Mute/solo logic: when any track is soloed, all non-soloed tracks have gain set to 0.
|
||||
- `setLoop`, `setMasterVolume`, `setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`, `setTempo` update graph parameters.
|
||||
- `lookAhead` and `ppqn` exposed as readonly properties.
|
||||
- `dispose()` closes context and releases all nodes.
|
||||
- [ ] Write `browser-adapter.test.ts` (≤500 lines): init with injected `OfflineAudioContext`, set tracks, play/stop, track volume/pan, mute/solo logic, loop, dispose.
|
||||
- [ ] Write `browser-adapter.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Offline render
|
||||
|
||||
- [ ] Implement `renderOffline` in `src/offline/offline-render.ts`:
|
||||
- Creates `OfflineAudioContext` via `standardized-audio-context`.
|
||||
- Instantiates `BrowserTimelineAdapter` with injected offline context.
|
||||
- Attaches adapter to `TimelineEngine`, plays from 0, and returns rendered `AudioBuffer`.
|
||||
- [ ] Write `offline-render.test.ts` and `offline-render.md`.
|
||||
|
||||
---
|
||||
|
||||
## 8. FX plugin hosting
|
||||
|
||||
- [ ] Implement `createFxNode` utility in `src/adapter/browser-adapter.ts` (or separate `fx-host.ts` if needed):
|
||||
- **AudioNode path**: `plugin.createAudioNode(context)` returns native `AudioNode` chain.
|
||||
- **Worklet path**: `plugin.createWorkletProcessor()` returns processor code; host wraps in `AudioWorkletNode` via `AudioWorkletManager`.
|
||||
- **Fallback path**: `ScriptProcessorNode` wrapping block processor for main-thread fallback.
|
||||
- [ ] Write unit tests for all three FX hosting paths.
|
||||
|
||||
---
|
||||
|
||||
## 9. Benchmarks
|
||||
|
||||
- [ ] Write `src/__benchmarks__/graph.bench.ts` — add 100 nodes, connect 200 edges, snapshot + restore.
|
||||
- [ ] Write `src/__benchmarks__/worklet.bench.ts` — load 10 worklet modules, create 50 nodes.
|
||||
|
||||
---
|
||||
|
||||
## 10. Integration tests
|
||||
|
||||
- [ ] Write `tests/integration.test.ts` — full browser adapter lifecycle with injected `OfflineAudioContext`: init, set tracks with clips, play, verify graph structure, mute/solo behavior, offline render returns `AudioBuffer` with expected length.
|
||||
|
||||
---
|
||||
|
||||
## 11. Docs + verify + publish
|
||||
|
||||
- [ ] All co-located `.md` files (≥200 words each).
|
||||
- [ ] Write `README.md` at package root.
|
||||
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-engine-browser` — green.
|
||||
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-engine-browser` — 100%.
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-engine-browser`.
|
||||
- [ ] Fold `specs/audio-engine-browser/spec.md` into `openspec/specs/audio-engine-browser/spec.md`.
|
||||
- [ ] Archive to `openspec/archive/2026-05-14-enterprise-audio-engine-browser/`.
|
||||
Reference in New Issue
Block a user