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