14 KiB
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
stateChangedwithpreviousState: UNINITIALIZED, currentState: INITIALIZING, then again withcurrentState: INITIALIZED, andaudioEngine.state === AudioEngineState.INITIALIZED
Scenario: Double-initialize is rejected
- WHEN
audioEngine.initialize()is called on an already-initialized engine - THEN it throws
AudioEngineErrorwith codeAUDIO_ENGINE_STATE_INVALID
Scenario: AudioContext injection seam works
- WHEN
AudioEngineis constructed with{ audioContext: mockContext } - THEN
initialize()uses the injected context instead of creating a newAudioContext, andaudioEngine.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 byactivatePlugin(plugin.id) - THEN
audioEngine.pluginscontains the plugin,plugin.state === PluginState.ACTIVE, andpluginActivatedevent 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, andpluginDestroyedevent 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 duringloadPlugin() - THEN the error is emitted via
ERRORevent, the plugin state becomesERROR, 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 === 1andgetMetrics().sampleRatematches the context sample rate
Scenario: OTel counters increment
- WHEN a plugin is loaded successfully and another plugin fails to load
- THEN
plugin_loaded_totalincrements by 1 andplugin_failed_totalincrements 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)andgraph.addNode(pluginB)are called, thengraph.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), thengraph.connect(C, A) - THEN the third call throws
AudioEngineErrorwith codeGRAPH_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
AudioEngineErrorwith codePLUGIN_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.json the allow-list - THEN the module is loaded and
moduleLoadedevent is emitted
Scenario: Disallowed URL is rejected
- WHEN
manager.loadModule("proc", "https://evil.com/proc.js")is called andevil.comis not on the allow-list - THEN it throws
AudioEngineErrorwith codeWORKLET_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, andnodeDestroyedevent 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:errorevent is emitted
Scenario: Unsubscribe removes listener
- WHEN
subscribereturns an unsubscribe function that is called, thenemitis called - THEN the unsubscribed listener is not invoked and
getListenerCountreturns 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
AudioEngineErrorwith the expected code fromAUDIO_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
globalThiskeys 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
AudioEngineErrorwith codeAUDIO_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.tsis run - THEN the output is empty
Verified by: tests/exports.test.ts::"no feature barrel uses export-star"