Files
2026-05-27 23:29:53 +01:00

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