Initial Commit
This commit is contained in:
@@ -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 -->
|
||||
Reference in New Issue
Block a user