Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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 -->