Initial Commit
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Superseded — not folded into `openspec/specs/`
|
||||
|
||||
**Date superseded**: 2026-05-15
|
||||
**Superseded by**:
|
||||
- `openspec/changes/enterprise-audio-engine-browser/` (browser-tier `TimelineAdapter` implementation)
|
||||
- `openspec/changes/enterprise-audio-engine-server/` (server-tier `TimelineAdapter` implementation)
|
||||
|
||||
## Why
|
||||
|
||||
This proposal was authored as a port of the upstream monolithic browser-only `@suaveplan/audio-engine`. Since it was drafted, the genesis audio suite settled on a different architecture:
|
||||
|
||||
- Universal core: `@suaveplan/audio-core` (`AudioDocument`, `PlanEngine`, `ProcessPipeline`, `OpRegistry`) — already shipped.
|
||||
- Universal timeline: `@suaveplan/audio-timeline` (`TimelineEngine`, `Track`, `Clip`, and the `TimelineAdapter` port) — already shipped.
|
||||
- Universal transport: `@suaveplan/audio-transport` (musical clock, PPQ, BPM) — already shipped.
|
||||
- Render adapters: `@suaveplan/audio-engine-browser` (Web Audio) and `@suaveplan/audio-engine-server` (file I/O) — thin packages that implement `TimelineAdapter`.
|
||||
- Plugin surface lives in `@suaveplan/audio-plugins-fx` (dual-mode `BlockProcessor` + optional `createAudioNode`).
|
||||
|
||||
The monolithic `@suaveplan/audio-engine` re-introduced its own `AudioContext` lifecycle, its own `AudioPlugin` base class, its own `PluginRegistry`, and its own queue engine — none of which compose with the universal core. Keeping it would have produced two parallel engines, two plugin interfaces, and two scheduling models.
|
||||
|
||||
No code from this proposal was implemented (`packages/audio/audio-engine/` contained only a 3-file stub which has been deleted).
|
||||
|
||||
## What to read instead
|
||||
|
||||
- `openspec/changes/enterprise-audio-engine-browser/` — adapter
|
||||
- `openspec/changes/enterprise-audio-engine-server/` — adapter
|
||||
- `openspec/archive/2026-05-14-enterprise-audio-core/` — universal core
|
||||
- `openspec/archive/2026-05-15-enterprise-audio-timeline/` — universal timeline + `TimelineAdapter`
|
||||
- `openspec/archive/2026-05-15-enterprise-audio-transport/` — universal clock
|
||||
- `openspec/changes/enterprise-audio-plugins-fx/` — plugin surface
|
||||
@@ -0,0 +1,149 @@
|
||||
# Design — @suaveplan/audio-engine
|
||||
|
||||
## Architecture overview
|
||||
|
||||
The package is a layered audio engine:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AudioQueueEngine (extends AudioEngine + queue logic) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ AudioEngine (context lifecycle, plugin map, metrics) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ AudioGraph (plugin node connections, DAG algorithms) │
|
||||
│ PluginRegistry (constructor + descriptor registration) │
|
||||
│ AudioWorkletManager (module load, node lifecycle) │
|
||||
│ EventBus (typed wrapper over @suaveplan/events) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ AudioPlugin (abstract base: state, metadata, params) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
All layers depend downward. `AudioQueueEngine` is the only class that extends another; everything else uses composition.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # explicit named re-exports only
|
||||
error-codes.ts # defineCodes + AudioEngineError
|
||||
error-codes.md
|
||||
types/
|
||||
common.ts # all public types + Zod schemas
|
||||
common.md
|
||||
engine/
|
||||
engine-state.ts # AudioEngineState enum (~20 lines)
|
||||
engine-metrics.ts # OTel meter instruments (~40 lines)
|
||||
engine.ts # AudioEngine class (~280 lines)
|
||||
engine.test.ts
|
||||
engine.spec-conformance.test.ts
|
||||
engine.md
|
||||
graph/
|
||||
graph-connect.ts # connect/disconnect + cycle detection (~100 lines)
|
||||
graph-traverse.ts # topological sort, BFS, source/sink (~100 lines)
|
||||
graph.ts # AudioGraph class facade (~120 lines)
|
||||
graph.test.ts
|
||||
graph.spec-conformance.test.ts
|
||||
graph.md
|
||||
plugin/
|
||||
plugin-base.ts # AudioPlugin abstract class + definePlugin (~180 lines)
|
||||
plugin-base.test.ts
|
||||
plugin-base.md
|
||||
registry/
|
||||
plugin-registry.ts # PluginRegistry class (~280 lines)
|
||||
plugin-registry.test.ts
|
||||
plugin-registry.md
|
||||
worklet/
|
||||
worklet-loader.ts # loadModule, URL allow-list (~100 lines)
|
||||
worklet-messaging.ts # sendMessage, setMessageListener (~80 lines)
|
||||
worklet-registry.ts # createNode, destroyNode, stats (~120 lines)
|
||||
worklet-manager.ts # facade re-exporting sub-modules (~60 lines)
|
||||
worklet-manager.test.ts
|
||||
worklet-manager.spec-conformance.test.ts
|
||||
worklet-manager.md
|
||||
queue-engine/
|
||||
cue.ts # AudioProcessingTask + task data types (~140 lines)
|
||||
scheduler.ts # serial/parallel chain, retry (~160 lines)
|
||||
queue-engine.ts # AudioQueueEngine class (~180 lines)
|
||||
queue-engine.test.ts
|
||||
queue-engine.md
|
||||
event-bus/
|
||||
event-bus.ts # EventBus class (~120 lines)
|
||||
event-bus.test.ts
|
||||
event-bus.md
|
||||
__benchmarks__/
|
||||
graph.bench.ts
|
||||
graph.bench.md
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
|
||||
### AudioContext injection seam
|
||||
|
||||
`AudioEngine` accepts an optional `audioContext` in `AudioEngineConfig`. If provided, the engine skips `new AudioContext()` and uses the injected instance. This lets tests supply a mock or `standardized-audio-context` shim without patching globals.
|
||||
|
||||
### Error isolation (plugin quarantine)
|
||||
|
||||
When `loadPlugin`, `activatePlugin`, or `unloadPlugin` throws, the engine catches the error, emits an `ERROR` event, and marks the plugin state as `PluginState.ERROR`. The plugin is removed from the active set but not deleted from the map, so callers can inspect `getPlugin(id)?.state === 'error'` and decide whether to retry or remove.
|
||||
|
||||
### OTel instrumentation strategy
|
||||
|
||||
All instruments are created lazily on first access via a private `getMeter()` helper. If no `MeterProvider` is registered, the calls are no-ops (OTel API inertness guarantee). Counters:
|
||||
- `audio_engine.plugin_loaded_total` — incremented on successful `loadPlugin`
|
||||
- `audio_engine.plugin_failed_total` — incremented on failed `loadPlugin`
|
||||
- `audio_engine.worklet_module_loaded_total` — incremented on successful `loadModule`
|
||||
|
||||
Histograms:
|
||||
- `audio_engine.latency_ms` — recorded in `getMetrics()`
|
||||
- `audio_engine.worklet_roundtrip_ms` — recorded around `sendMessage` + reply
|
||||
- `audio_engine.graph_connect_ms` — recorded around `AudioGraph.connect`
|
||||
|
||||
### Worklet URL allow-list
|
||||
|
||||
`AudioWorkletManager` constructor accepts an optional `allowedOrigins: string[]`. `loadModule` rejects URLs whose origin is not in the list. Default allow-list is `['self']` (same-origin only). This prevents dynamic worklet injection attacks.
|
||||
|
||||
### Zod boundaries
|
||||
|
||||
Three schemas are validated at runtime:
|
||||
- `AudioEngineConfigSchema` — parsed in `AudioEngine` constructor
|
||||
- `PluginMetadataSchema` — parsed in `AudioPlugin` constructor
|
||||
- `AudioPluginDescriptorSchema` — parsed in `PluginRegistry.registerDescriptor`
|
||||
|
||||
All other types are TypeScript-only (no runtime validation) because they are internal or consumed only by typed callers.
|
||||
|
||||
### No `standardized-audio-context`
|
||||
|
||||
The upstream imported `AudioContext` from `standardized-audio-context`. In genesis we use native Web Audio API types directly. The API surface is identical for consumers (both expose `AudioContext` and `AudioNode`). This removes a runtime dependency.
|
||||
|
||||
### Typed event map
|
||||
|
||||
`AudioEngineEventMap` replaces the upstream `AudioEngineEvents` enum:
|
||||
|
||||
```ts
|
||||
interface AudioEngineEventMap {
|
||||
stateChanged: { previousState: AudioEngineState; currentState: AudioEngineState };
|
||||
pluginLoaded: { pluginId: string; metadata: PluginMetadata };
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`EventBus` is generic over `EventMap extends Record<string, unknown>` so that `subscribe<K>(type: K, listener: (data: EventMap[K]) => void)` is fully typed.
|
||||
|
||||
### Test strategy
|
||||
|
||||
- **Engine tests**: inject a mock `AudioContext` (minimal object with `createGain`, `destination`, `state`, `resume`, `suspend`, `close`, `sampleRate`, `currentTime`). No real browser required.
|
||||
- **Graph tests**: pure logic, no audio nodes needed. Use a mock `AudioPlugin` with fake `inputNode`/`outputNode` objects.
|
||||
- **Worklet tests**: mock `audioWorklet.addModule` and `AudioWorkletNode`.
|
||||
- **Queue tests**: mock `@suaveplan/queue` types or use the real in-memory queue (it's lightweight).
|
||||
- **Registry tests**: pure logic, no audio nodes.
|
||||
- **EventBus tests**: use `@suaveplan/events` `EventEmitter` directly.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@suaveplan/events` ✅
|
||||
- `@suaveplan/logger` ✅
|
||||
- `@suaveplan/queue` ✅
|
||||
- `@suaveplan/error` ✅
|
||||
- `@suaveplan/types` ✅
|
||||
- `zod` (catalog:forms)
|
||||
- `@opentelemetry/api` (catalog:observability)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Enterprise Migration: @suaveplan/audio-engine
|
||||
|
||||
**Package**: `@suaveplan/audio-engine`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (migration from suaveplan)
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/audio-engine` is the core browser-side Web Audio engine used by every audio surface in the monorepo. It owns the `AudioContext` lifecycle, typed plugin system (`AudioPlugin` + `PluginRegistry`), `AudioGraph` node-routing, `AudioWorkletManager`, `AudioQueueEngine`, and `EventBus`. Migrating it to genesis is a prerequisite for `audio-plugins-fx`, `audio-controllers`, and all `react-audio*` packages. The upstream version lacks tests for graph / queue / worklet / registry, exceeds file-size caps, uses plain `Error` throws, and has no OTel or Zod boundaries.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `AudioEngine` class with `AudioContext` injection seam — initialize, resume, suspend, destroy, plugin lifecycle, metrics.
|
||||
- ADDED: `AudioGraph` class — node add/remove, connect/disconnect with cycle detection, topological sort, BFS path finding, serialization.
|
||||
- ADDED: `AudioQueueEngine` class — extends `AudioEngine` with `@suaveplan/queue`-based serial/parallel plugin chains, retry, rate limiting.
|
||||
- ADDED: `AudioWorkletManager` class — module loading, node creation, messaging, statistics.
|
||||
- ADDED: `PluginRegistry` class — constructor + descriptor registration, dependency validation, search, instantiation.
|
||||
- ADDED: `EventBus` class — typed wrapper around `@suaveplan/events` with per-listener error isolation.
|
||||
- ADDED: `AudioPlugin` abstract base class — state machine, metadata, parameters, node accessors.
|
||||
- ADDED: Error codes via `@suaveplan/error` (`AUDIO_ENGINE_STATE_INVALID`, `PLUGIN_NOT_FOUND`, `GRAPH_CYCLE_DETECTED`, `WORKLET_LOAD_FAILED`, etc.).
|
||||
- ADDED: Zod boundary schemas for `AudioEngineConfig`, `PluginMetadata`, `AudioPluginDescriptor`.
|
||||
- ADDED: OTel counters/histograms for plugin loads, engine latency, worklet round-trip, graph connect duration.
|
||||
- ADDED: Plugin quarantine — a crashing plugin is marked `ERROR` without tearing down the engine or other plugins.
|
||||
- ADDED: Worklet module URL allow-list gate.
|
||||
- ADDED: Co-located `.md` docs for every exported symbol.
|
||||
- ADDED: Benchmarks for graph traversal and worklet messaging.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Plugin hot-swap with crossfade ramp — new feature, not in upstream; deferred to follow-up change.
|
||||
- Latency budget enforcement / watchdog — new feature; deferred.
|
||||
- MIDI clock source transport — new feature; deferred.
|
||||
- Sample-accurate parameter automation curves — new feature; deferred.
|
||||
- `SharedArrayBuffer`-backed meter/analyser — new feature; deferred.
|
||||
- Worklet hot-reload for dev-time — new feature; deferred.
|
||||
- `OfflineAudioContext` render-on-demand mode — new feature; deferred.
|
||||
- `ScriptProcessorNode` fallback for non-worklet browsers — upstream uses `standardized-audio-context` which handles this; we retain the upstream abstraction level without adding a new fallback layer.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/audio-engine.md`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| Split files over 300-line cap | L46 | engine → engine + state + metrics; graph → graph + connect + traverse; queue → queue + cue + scheduler; worklet → loader + messaging + registry |
|
||||
| Unit tests for every module | L47 | graph, queue, worklet, registry, plugin base, event-bus |
|
||||
| AudioContext injection seam | L48 | constructor accepts pre-built context |
|
||||
| Typed event map | L49 | replaces `AudioEngineEvents` enum |
|
||||
| OTel metrics | L50 | counters + histograms |
|
||||
| Worklet URL allow-list | L52 | security gate |
|
||||
| Plugin lifecycle docs | L53 | state diagram in README + per-symbol `.md` |
|
||||
| Coverage ≥ 95 % | L57 | genesis policy is 100 % |
|
||||
| File budget 250/300/500 | L59 | enforced |
|
||||
| No `any` / `ts-ignore` | L64 | enforced |
|
||||
| Zod schemas at boundaries | L65 | `PluginMetadata`, `AudioPluginDescriptor` |
|
||||
| Error hierarchy | L66 | via `@suaveplan/error` |
|
||||
| Plugin quarantine | L111 | error isolation per plugin |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| 100 % Istanbul coverage threshold | Genesis-wide policy (AGENTS.md §5, §13) |
|
||||
| No direct `vitest` imports | Genesis-wide policy (AGENTS.md §7) |
|
||||
| `export *` banned in feature barrels | Genesis-wide policy (AGENTS.md §6a) |
|
||||
| `sideEffects: false` retained | Genesis-wide policy |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/engine/engine.test.ts` — AudioEngine lifecycle, state transitions, plugin load/unload, metrics, destruction.
|
||||
- `src/engine/engine.spec-conformance.test.ts` — OTel instrument assertions, Zod boundary rejection, typed event map.
|
||||
- `src/graph/graph.test.ts` — node management, connect/disconnect, cycle detection, topological sort, path finding, serialization.
|
||||
- `src/graph/graph.spec-conformance.test.ts` — strict-mode error paths, graph stats, clone round-trip.
|
||||
- `src/queue-engine/queue-engine.test.ts` — serial/parallel chain processing, retry, rate limit, queue status.
|
||||
- `src/worklet/worklet-manager.test.ts` — module load/unload, node create/destroy, messaging, statistics.
|
||||
- `src/worklet/worklet-manager.spec-conformance.test.ts` — URL allow-list rejection, OTel round-trip histogram.
|
||||
- `src/registry/plugin-registry.test.ts` — constructor/descriptor registration, dependency validation, search, instantiation.
|
||||
- `src/event-bus/event-bus.test.ts` — subscribe/unsubscribe, emit, listener error isolation.
|
||||
- `src/plugin/plugin-base.test.ts` — AudioPlugin abstract class, state machine, parameter get/set.
|
||||
- `tests/purity.test.ts` — zero import-time side effects, no global mutation.
|
||||
- `tests/integration.test.ts` — full engine + graph + registry round-trip.
|
||||
- `src/__benchmarks__/graph.bench.ts` — graph traversal performance.
|
||||
- `src/__benchmarks__/worklet.bench.ts` — worklet messaging round-trip.
|
||||
|
||||
## Migration impact
|
||||
|
||||
Packages awaiting this dependency:
|
||||
- `@suaveplan/audio-plugins-fx`
|
||||
- `@suaveplan/audio-controllers`
|
||||
- `@suaveplan/react-audio`
|
||||
- `@suaveplan/react-audio-player`
|
||||
- `@suaveplan/react-audio-stream`
|
||||
- `@suaveplan/r3f-audio`
|
||||
|
||||
None are yet migrated, so impact is sequencing-only.
|
||||
@@ -0,0 +1,262 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-engine (enterprise-audio-engine)
|
||||
|
||||
**Package**: `@suaveplan/audio-engine`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: AudioEngine lifecycle and state machine
|
||||
|
||||
The `AudioEngine` class MUST manage a deterministic lifecycle: `UNINITIALIZED → INITIALIZING → INITIALIZED → ACTIVE ↔ SUSPENDED → DESTROYED`. It MUST support `initialize()`, `resume()`, `suspend()`, and `destroy()`. It MUST reject state-transition calls that are invalid for the current state (e.g. `initialize()` when already initialized). It MUST accept an optional pre-built `AudioContext` via `AudioEngineConfig` so tests and hosts can inject a mock.
|
||||
|
||||
#### Scenario: Initialize transitions state correctly
|
||||
|
||||
- **WHEN** `audioEngine.initialize()` is called on an uninitialized engine
|
||||
- **THEN** the engine emits `stateChanged` with `previousState: UNINITIALIZED, currentState: INITIALIZING`, then again with `currentState: INITIALIZED`, and `audioEngine.state === AudioEngineState.INITIALIZED`
|
||||
|
||||
#### Scenario: Double-initialize is rejected
|
||||
|
||||
- **WHEN** `audioEngine.initialize()` is called on an already-initialized engine
|
||||
- **THEN** it throws `AudioEngineError` with code `AUDIO_ENGINE_STATE_INVALID`
|
||||
|
||||
#### Scenario: AudioContext injection seam works
|
||||
|
||||
- **WHEN** `AudioEngine` is constructed with `{ audioContext: mockContext }`
|
||||
- **THEN** `initialize()` uses the injected context instead of creating a new `AudioContext`, and `audioEngine.audioContext === mockContext`
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"initializes with injected AudioContext"` AND `src/engine/engine.spec-conformance.test.ts::"rejects double initialize with AUDIO_ENGINE_STATE_INVALID"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plugin lifecycle management
|
||||
|
||||
The `AudioEngine` MUST support loading, activating, deactivating, and unloading plugins via `loadPlugin()`, `activatePlugin()`, `deactivatePlugin()`, and `unloadPlugin()`. Each operation MUST emit the corresponding typed event. `unloadPlugin` MUST deactivate the plugin first if it is active. `destroy()` MUST unload all plugins before closing the context.
|
||||
|
||||
#### Scenario: Load and activate a plugin
|
||||
|
||||
- **WHEN** `audioEngine.loadPlugin(plugin)` is called followed by `activatePlugin(plugin.id)`
|
||||
- **THEN** `audioEngine.plugins` contains the plugin, `plugin.state === PluginState.ACTIVE`, and `pluginActivated` event is emitted
|
||||
|
||||
#### Scenario: Unload deactivates then destroys
|
||||
|
||||
- **WHEN** `audioEngine.unloadPlugin(id)` is called on an active plugin
|
||||
- **THEN** the plugin is deactivated, then destroyed, then removed from `audioEngine.plugins`, and `pluginDestroyed` event is emitted
|
||||
|
||||
#### Scenario: Destroy unloads all plugins
|
||||
|
||||
- **WHEN** `audioEngine.destroy()` is called with loaded plugins
|
||||
- **THEN** every plugin is unloaded in order, the audio context is closed, and the engine state becomes `DESTROYED`
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"loads and activates a plugin"` AND `src/engine/engine.test.ts::"destroy unloads all plugins and closes context"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plugin quarantine on failure
|
||||
|
||||
When `loadPlugin`, `activatePlugin`, or `unloadPlugin` throws, the engine MUST catch the error, emit an `ERROR` event, and mark the plugin's state as `PluginState.ERROR`. The engine MUST remain operational for other plugins.
|
||||
|
||||
#### Scenario: Crashing plugin is quarantined
|
||||
|
||||
- **WHEN** a plugin's `initialize()` throws during `loadPlugin()`
|
||||
- **THEN** the error is emitted via `ERROR` event, the plugin state becomes `ERROR`, and subsequent operations on other plugins succeed normally
|
||||
|
||||
**Verified by:** `src/engine/engine.spec-conformance.test.ts::"quarantines a plugin whose initialize throws"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Engine metrics and OTel instrumentation
|
||||
|
||||
The `AudioEngine` MUST expose `getMetrics()` returning `sampleRate`, `bufferSize`, `latency`, `cpuUsage`, `memoryUsage`, and `activePlugins`. It MUST record OTel counters `audio_engine.plugin_loaded_total` and `audio_engine.plugin_failed_total`, and histogram `audio_engine.latency_ms`.
|
||||
|
||||
#### Scenario: Metrics reflect current state
|
||||
|
||||
- **WHEN** the engine is initialized with one active plugin
|
||||
- **THEN** `getMetrics().activePlugins === 1` and `getMetrics().sampleRate` matches the context sample rate
|
||||
|
||||
#### Scenario: OTel counters increment
|
||||
|
||||
- **WHEN** a plugin is loaded successfully and another plugin fails to load
|
||||
- **THEN** `plugin_loaded_total` increments by 1 and `plugin_failed_total` increments by 1
|
||||
|
||||
**Verified by:** `src/engine/engine.spec-conformance.test.ts::"getMetrics reflects active plugin count"` AND `src/engine/engine.spec-conformance.test.ts::"increments OTel counters on load and failure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioGraph node and connection management
|
||||
|
||||
The `AudioGraph` MUST support adding/removing nodes (`addNode`, `removeNode`), connecting/disconnecting nodes (`connect`, `disconnect`), and querying connections (`areConnected`, `getConnections`, `getAllConnections`). `removeNode` MUST disconnect all incoming and outgoing connections first. `connect` MUST reject connections that would create a cycle.
|
||||
|
||||
#### Scenario: Connect two nodes
|
||||
|
||||
- **WHEN** `graph.addNode(pluginA)` and `graph.addNode(pluginB)` are called, then `graph.connect(pluginA.id, pluginB.id)`
|
||||
- **THEN** `graph.areConnected(pluginA.id, pluginB.id)` is true and the plugins' audio nodes are connected
|
||||
|
||||
#### Scenario: Cycle detection rejects cyclic connection
|
||||
|
||||
- **WHEN** `graph.connect(A, B)`, `graph.connect(B, C)`, then `graph.connect(C, A)`
|
||||
- **THEN** the third call throws `AudioEngineError` with code `GRAPH_CYCLE_DETECTED`
|
||||
|
||||
#### Scenario: Remove node cleans up connections
|
||||
|
||||
- **WHEN** a node with outgoing connections is removed
|
||||
- **THEN** all connections involving that node are disconnected and `graph.hasNode(id)` is false
|
||||
|
||||
**Verified by:** `src/graph/graph.test.ts::"connects two nodes"` AND `src/graph/graph.test.ts::"rejects connection that creates a cycle"` AND `src/graph/graph.test.ts::"remove node disconnects all connections"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioGraph traversal and serialization
|
||||
|
||||
The `AudioGraph` MUST support `hasCycle()`, `getTopologicalOrder()`, `findPath()`, `getSourceNodes()`, `getSinkNodes()`, `clone()`, `serialize()`, and `getStats()`. `getTopologicalOrder` MUST throw if the graph contains a cycle. `serialize` MUST produce a JSON-serializable snapshot.
|
||||
|
||||
#### Scenario: Topological sort returns valid order
|
||||
|
||||
- **WHEN** a DAG with nodes A→B→C is built
|
||||
- **THEN** `getTopologicalOrder()` returns `[A, B, C]` or another valid topological ordering
|
||||
|
||||
#### Scenario: Serialize round-trips
|
||||
|
||||
- **WHEN** `graph.serialize()` is called on a graph with nodes and connections
|
||||
- **THEN** the result contains all node IDs and all connections as plain objects
|
||||
|
||||
**Verified by:** `src/graph/graph.spec-conformance.test.ts::"topological sort returns valid DAG order"` AND `src/graph/graph.spec-conformance.test.ts::"serialize captures all nodes and connections"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PluginRegistry registration and instantiation
|
||||
|
||||
The `PluginRegistry` MUST support `register()` for class-based plugins and `registerDescriptor()` for factory-based descriptors. `registerDescriptor` MUST validate that all listed dependencies are already registered. `createInstance()` MUST return a new plugin instance. `search()` MUST filter by category, tag, or name (case-insensitive).
|
||||
|
||||
#### Scenario: Register descriptor with dependencies
|
||||
|
||||
- **WHEN** descriptor A is registered, then descriptor B (which lists A as a dependency) is registered
|
||||
- **THEN** both registrations succeed
|
||||
|
||||
#### Scenario: Missing dependency is rejected
|
||||
|
||||
- **WHEN** descriptor B (depending on unregistered A) is registered
|
||||
- **THEN** it throws `AudioEngineError` with code `PLUGIN_DEPENDENCY_MISSING`
|
||||
|
||||
#### Scenario: Search by name is case-insensitive
|
||||
|
||||
- **WHEN** `registry.search({ name: "gain" })` is called and a plugin named "Gain" exists
|
||||
- **THEN** the result includes the "Gain" plugin
|
||||
|
||||
**Verified by:** `src/registry/plugin-registry.test.ts::"registers descriptor with satisfied dependencies"` AND `src/registry/plugin-registry.test.ts::"rejects descriptor with missing dependency"` AND `src/registry/plugin-registry.test.ts::"search by name is case-insensitive"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioWorkletManager module and node lifecycle
|
||||
|
||||
The `AudioWorkletManager` MUST support `loadModule()`, `unloadModule()`, `createNode()`, `destroyNode()`, `sendMessage()`, `setMessageListener()`, `broadcastMessage()`, and statistics. `loadModule` MUST reject URLs not on the allow-list. `unloadModule` MUST destroy all nodes using that module.
|
||||
|
||||
#### Scenario: Load module with allowed URL
|
||||
|
||||
- **WHEN** `manager.loadModule("proc", "/worklets/proc.js")` is called with `/worklets/proc.js` on the allow-list
|
||||
- **THEN** the module is loaded and `moduleLoaded` event is emitted
|
||||
|
||||
#### Scenario: Disallowed URL is rejected
|
||||
|
||||
- **WHEN** `manager.loadModule("proc", "https://evil.com/proc.js")` is called and `evil.com` is not on the allow-list
|
||||
- **THEN** it throws `AudioEngineError` with code `WORKLET_URL_NOT_ALLOWED`
|
||||
|
||||
#### Scenario: Create and destroy node
|
||||
|
||||
- **WHEN** a node is created for a loaded module, then destroyed
|
||||
- **THEN** `hasNode(nodeId)` is true after creation and false after destruction, and `nodeDestroyed` event is emitted
|
||||
|
||||
**Verified by:** `src/worklet/worklet-manager.spec-conformance.test.ts::"rejects disallowed worklet URL"` AND `src/worklet/worklet-manager.test.ts::"creates and destroys a worklet node"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioQueueEngine serial and parallel chains
|
||||
|
||||
The `AudioQueueEngine` MUST support `processPlugin()` for single-plugin processing and `processPluginChain()` for serial or parallel chains. Serial chains MUST process buffers in order. Parallel chains MUST use `ParallelExecutionStrategy`. Both MUST support retry with exponential backoff when `retryEnabled` is true.
|
||||
|
||||
#### Scenario: Serial chain processes in order
|
||||
|
||||
- **WHEN** `processPluginChain([pluginA, pluginB], buffer, { strategy: "serial" })` is called
|
||||
- **THEN** plugin A processes the buffer first, then plugin B processes the result
|
||||
|
||||
#### Scenario: Retry with exponential backoff
|
||||
|
||||
- **WHEN** a plugin fails on first attempt but succeeds on second, with `retryEnabled: true`
|
||||
- **THEN** the task is retried after a delay and the final result is the successful output
|
||||
|
||||
**Verified by:** `src/queue-engine/queue-engine.test.ts::"serial chain processes plugins in order"` AND `src/queue-engine/queue-engine.test.ts::"retries failed plugin with exponential backoff"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EventBus typed subscription and error isolation
|
||||
|
||||
The `EventBus` MUST support `subscribe()`, `unsubscribe()`, `emit()`, `hasListeners()`, and `getListenerCount()`. `subscribe` MUST return an unsubscribe function. When a listener throws, the error MUST be caught, re-emitted as a `bus:error` event, and other listeners MUST continue receiving the event.
|
||||
|
||||
#### Scenario: Listener error does not kill the bus
|
||||
|
||||
- **WHEN** two listeners are subscribed to "event" and the first throws
|
||||
- **THEN** the second listener still receives the event, and a `bus:error` event is emitted
|
||||
|
||||
#### Scenario: Unsubscribe removes listener
|
||||
|
||||
- **WHEN** `subscribe` returns an unsubscribe function that is called, then `emit` is called
|
||||
- **THEN** the unsubscribed listener is not invoked and `getListenerCount` returns 0
|
||||
|
||||
**Verified by:** `src/event-bus/event-bus.test.ts::"listener error does not prevent other listeners from receiving"` AND `src/event-bus/event-bus.test.ts::"unsubscribe removes the listener"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error hierarchy via @suaveplan/error
|
||||
|
||||
All thrown errors MUST use `AudioEngineError` extending `SuaveplanError<AudioEngineErrorCode>`. Error codes MUST be defined via `defineCodes("AUDIO_ENGINE", [...])`. The package MUST export `AUDIO_ENGINE_CODES` and `AUDIO_ENGINE_ERROR_METADATA`.
|
||||
|
||||
#### Scenario: Every code is reachable
|
||||
|
||||
- **WHEN** each error condition is triggered (invalid state, plugin not found, cycle detected, worklet load failed, etc.)
|
||||
- **THEN** the thrown error is an `AudioEngineError` with the expected code from `AUDIO_ENGINE_CODES`
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every error code is reachable from a production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Purity — zero import-time side effects
|
||||
|
||||
Importing any symbol from `@suaveplan/audio-engine` MUST NOT mutate `globalThis`, register global event listeners, or create browser APIs (e.g. `AudioContext`).
|
||||
|
||||
#### Scenario: Import is side-effect free
|
||||
|
||||
- **WHEN** the package is imported in a clean environment
|
||||
- **THEN** `globalThis` keys before and after import are identical (allowing known test globals)
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
`AudioEngineConfig`, `PluginMetadata`, and `AudioPluginDescriptor` MUST have Zod schemas. Invalid values passed to constructors or registry methods MUST throw `AudioEngineError` with code `AUDIO_ENGINE_INVALID_OPTIONS`.
|
||||
|
||||
#### Scenario: Invalid config is rejected
|
||||
|
||||
- **WHEN** `new AudioEngine(emitter, { sampleRate: -1 })` is called
|
||||
- **THEN** it throws `AudioEngineError` with code `AUDIO_ENGINE_INVALID_OPTIONS`
|
||||
|
||||
**Verified by:** `src/engine/engine.spec-conformance.test.ts::"rejects invalid config with Zod error"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Explicit named exports only
|
||||
|
||||
No feature-module barrel (`src/<feature>/index.ts`) MAY use `export * from`. Every re-export MUST list symbols by name. The root `src/index.ts` MAY re-export from feature modules using named re-exports.
|
||||
|
||||
#### Scenario: No export-star in feature barrels
|
||||
|
||||
- **WHEN** `grep 'export \* from' src/*/index.ts` is run
|
||||
- **THEN** the output is empty
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"no feature barrel uses export-star"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,115 @@
|
||||
# Tasks — Enterprise Migration: @suaveplan/audio-engine
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/audio-engine.md` end-to-end
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.3 Draft `proposal.md`
|
||||
- [x] 1.4 Fill `## Source mapping` table
|
||||
- [x] 1.5 Draft `specs/audio-engine/spec.md`
|
||||
- [x] 1.6 Every requirement carries `**Verified by:**` with concrete `it(...)` titles
|
||||
|
||||
## 2. Scaffold
|
||||
|
||||
- [ ] 2.1 Create `packages/audio/audio-engine/` with `src/`, `tests/`, `src/__benchmarks__/`
|
||||
- [ ] 2.2 Write `package.json` at `0.1.0`, `sideEffects: false`, exports map
|
||||
- [ ] 2.3 Write `tsconfig.json`, `vitest.config.ts`, `vite.config.ts`
|
||||
- [ ] 2.4 Add lint/typecheck/test/test:coverage/build scripts
|
||||
|
||||
## 3. Types + errors
|
||||
|
||||
- [ ] 3.1 Write `src/types/common.ts` — `AudioEngineConfig`, `AudioEngineState`, `PerformanceMetrics`, `AudioEngineStateInfo`, `PluginState`, `PluginConfig`, `PluginMetadata`, `AudioPluginDescriptor`, `PluginParameter`, `PluginMetrics`, `AudioGraphConnection`, `AudioGraphNode`, `AudioGraphSerialized`, `AudioWorkletNodeOptions`, `WorkletNodeInfo`, `WorkletModuleInfo`, `AudioEngineEventMap`
|
||||
- [ ] 3.2 Write Zod schemas for `AudioEngineConfig`, `PluginMetadata`, `AudioPluginDescriptor`
|
||||
- [ ] 3.3 Write `src/error-codes.ts` — `AUDIO_ENGINE_CODES`, `AudioEngineError`, `AUDIO_ENGINE_ERROR_METADATA`
|
||||
- [ ] 3.4 Write `src/types/common.md`
|
||||
- [ ] 3.5 Write `src/error-codes.md`
|
||||
|
||||
## 4. Core engine
|
||||
|
||||
- [ ] 4.1 Write `src/engine/engine-state.ts` — `AudioEngineState` enum, state transition helpers
|
||||
- [ ] 4.2 Write `src/engine/engine-metrics.ts` — `PerformanceMetrics`, OTel instruments
|
||||
- [ ] 4.3 Write `src/engine/engine.ts` — `AudioEngine` class (≤300 lines)
|
||||
- [ ] 4.4 Write `src/engine/engine.test.ts` and `engine.spec-conformance.test.ts`
|
||||
- [ ] 4.5 Write `src/engine/engine.md`
|
||||
|
||||
## 5. Audio graph
|
||||
|
||||
- [ ] 5.1 Write `src/graph/graph.ts` — `AudioGraph` class (≤300 lines)
|
||||
- [ ] 5.2 Write `src/graph/graph-connect.ts` — connection helpers, cycle detection
|
||||
- [ ] 5.3 Write `src/graph/graph-traverse.ts` — topological sort, BFS path, source/sink detection
|
||||
- [ ] 5.4 Write `src/graph/graph.test.ts` and `graph.spec-conformance.test.ts`
|
||||
- [ ] 5.5 Write `src/graph/graph.md`
|
||||
|
||||
## 6. Plugin base + registry
|
||||
|
||||
- [ ] 6.1 Write `src/plugin/plugin-base.ts` — `AudioPlugin` abstract class, `definePlugin`
|
||||
- [ ] 6.2 Write `src/plugin/plugin-base.test.ts`
|
||||
- [ ] 6.3 Write `src/registry/plugin-registry.ts` — `PluginRegistry` class
|
||||
- [ ] 6.4 Write `src/registry/plugin-registry.test.ts`
|
||||
- [ ] 6.5 Write `src/plugin/plugin-base.md` and `src/registry/plugin-registry.md`
|
||||
|
||||
## 7. Worklet manager
|
||||
|
||||
- [ ] 7.1 Write `src/worklet/worklet-loader.ts` — module loading, URL allow-list
|
||||
- [ ] 7.2 Write `src/worklet/worklet-messaging.ts` — send/receive/broadcast
|
||||
- [ ] 7.3 Write `src/worklet/worklet-registry.ts` — node create/destroy, statistics
|
||||
- [ ] 7.4 Write `src/worklet/worklet-manager.ts` — facade re-exporting the three sub-modules
|
||||
- [ ] 7.5 Write `src/worklet/worklet-manager.test.ts` and `worklet-manager.spec-conformance.test.ts`
|
||||
- [ ] 7.6 Write `src/worklet/worklet-manager.md`
|
||||
|
||||
## 8. Queue engine
|
||||
|
||||
- [ ] 8.1 Write `src/queue-engine/cue.ts` — `AudioProcessingTask`, task data types
|
||||
- [ ] 8.2 Write `src/queue-engine/scheduler.ts` — serial/parallel chain execution, retry
|
||||
- [ ] 8.3 Write `src/queue-engine/queue-engine.ts` — `AudioQueueEngine` class (≤300 lines)
|
||||
- [ ] 8.4 Write `src/queue-engine/queue-engine.test.ts`
|
||||
- [ ] 8.5 Write `src/queue-engine/queue-engine.md`
|
||||
|
||||
## 9. Event bus
|
||||
|
||||
- [ ] 9.1 Write `src/event-bus/event-bus.ts` — typed wrapper with error isolation
|
||||
- [ ] 9.2 Write `src/event-bus/event-bus.test.ts`
|
||||
- [ ] 9.3 Write `src/event-bus/event-bus.md`
|
||||
|
||||
## 10. Integration + purity
|
||||
|
||||
- [ ] 10.1 Write `tests/integration.test.ts` — full round-trip
|
||||
- [ ] 10.2 Write `tests/purity.test.ts` — zero side effects on import
|
||||
- [ ] 10.3 Write `tests/exports.test.ts` — every public export is reachable
|
||||
|
||||
## 11. Benchmarks
|
||||
|
||||
- [ ] 11.1 Write `src/__benchmarks__/graph.bench.ts`
|
||||
- [ ] 11.2 Write `src/__benchmarks__/worklet.bench.md`
|
||||
|
||||
## 12. Root docs
|
||||
|
||||
- [ ] 12.1 Write `README.md` with quick-start, plugin lifecycle state diagram, module links
|
||||
- [ ] 12.2 Write `src/index.ts` with explicit named re-exports only
|
||||
|
||||
## 13. Pre-archive 5-gate triple
|
||||
|
||||
- [ ] 13.1 `bun run check` — Biome 0/0/0 across monorepo
|
||||
- [ ] 13.2 `bunx turbo lint typecheck test --filter=@suaveplan/audio-engine` — green
|
||||
- [ ] 13.3 `bunx turbo test:coverage --filter=@suaveplan/audio-engine` — 100/100/100/100
|
||||
- [ ] 13.4 `bun scripts/openspec-conformance.ts @suaveplan/audio-engine` — exit 0
|
||||
- [ ] 13.5 `bunx turbo build --filter=@suaveplan/audio-engine` — green
|
||||
|
||||
## 14. Pre-archive 7-row checklist
|
||||
|
||||
- [ ] 14.1 Generics threaded; `expectTypeOf` assertions on literal-type claims
|
||||
- [ ] 14.2 Zod schemas wired at every declared boundary
|
||||
- [ ] 14.3 Every error code thrown by at least one production path
|
||||
- [ ] 14.4 Strict-mode behaviors tested both on and off
|
||||
- [ ] 14.5 OTel instruments emitted and asserted with real `MeterProvider`
|
||||
- [ ] 14.6 Lifecycle dedup tested (in-flight Promise, idempotent set/load)
|
||||
- [ ] 14.7 No retroactive tick
|
||||
|
||||
## 15. Archive
|
||||
|
||||
- [ ] 15.1 Fold spec delta into `openspec/specs/audio-engine/spec.md`
|
||||
- [ ] 15.2 Move `openspec/changes/enterprise-audio-engine/` → `openspec/archive/YYYY-MM-DD-enterprise-audio-engine/`
|
||||
- [ ] 15.3 Update `STATUS.md` row to `✅`
|
||||
- [ ] 15.4 Regenerate `MANIFEST.md`
|
||||
Reference in New Issue
Block a user