# Design — @suaveplan/audio-engine ## Architecture overview The package is a layered audio engine: ``` ┌─────────────────────────────────────────────────────────┐ │ AudioQueueEngine (extends AudioEngine + queue logic) │ ├─────────────────────────────────────────────────────────┤ │ AudioEngine (context lifecycle, plugin map, metrics) │ ├─────────────────────────────────────────────────────────┤ │ AudioGraph (plugin node connections, DAG algorithms) │ │ PluginRegistry (constructor + descriptor registration) │ │ AudioWorkletManager (module load, node lifecycle) │ │ EventBus (typed wrapper over @suaveplan/events) │ ├─────────────────────────────────────────────────────────┤ │ AudioPlugin (abstract base: state, metadata, params) │ └─────────────────────────────────────────────────────────┘ ``` All layers depend downward. `AudioQueueEngine` is the only class that extends another; everything else uses composition. ## File layout ``` src/ index.ts # explicit named re-exports only error-codes.ts # defineCodes + AudioEngineError error-codes.md types/ common.ts # all public types + Zod schemas common.md engine/ engine-state.ts # AudioEngineState enum (~20 lines) engine-metrics.ts # OTel meter instruments (~40 lines) engine.ts # AudioEngine class (~280 lines) engine.test.ts engine.spec-conformance.test.ts engine.md graph/ graph-connect.ts # connect/disconnect + cycle detection (~100 lines) graph-traverse.ts # topological sort, BFS, source/sink (~100 lines) graph.ts # AudioGraph class facade (~120 lines) graph.test.ts graph.spec-conformance.test.ts graph.md plugin/ plugin-base.ts # AudioPlugin abstract class + definePlugin (~180 lines) plugin-base.test.ts plugin-base.md registry/ plugin-registry.ts # PluginRegistry class (~280 lines) plugin-registry.test.ts plugin-registry.md worklet/ worklet-loader.ts # loadModule, URL allow-list (~100 lines) worklet-messaging.ts # sendMessage, setMessageListener (~80 lines) worklet-registry.ts # createNode, destroyNode, stats (~120 lines) worklet-manager.ts # facade re-exporting sub-modules (~60 lines) worklet-manager.test.ts worklet-manager.spec-conformance.test.ts worklet-manager.md queue-engine/ cue.ts # AudioProcessingTask + task data types (~140 lines) scheduler.ts # serial/parallel chain, retry (~160 lines) queue-engine.ts # AudioQueueEngine class (~180 lines) queue-engine.test.ts queue-engine.md event-bus/ event-bus.ts # EventBus class (~120 lines) event-bus.test.ts event-bus.md __benchmarks__/ graph.bench.ts graph.bench.md ``` ## Key design decisions ### AudioContext injection seam `AudioEngine` accepts an optional `audioContext` in `AudioEngineConfig`. If provided, the engine skips `new AudioContext()` and uses the injected instance. This lets tests supply a mock or `standardized-audio-context` shim without patching globals. ### Error isolation (plugin quarantine) When `loadPlugin`, `activatePlugin`, or `unloadPlugin` throws, the engine catches the error, emits an `ERROR` event, and marks the plugin state as `PluginState.ERROR`. The plugin is removed from the active set but not deleted from the map, so callers can inspect `getPlugin(id)?.state === 'error'` and decide whether to retry or remove. ### OTel instrumentation strategy All instruments are created lazily on first access via a private `getMeter()` helper. If no `MeterProvider` is registered, the calls are no-ops (OTel API inertness guarantee). Counters: - `audio_engine.plugin_loaded_total` — incremented on successful `loadPlugin` - `audio_engine.plugin_failed_total` — incremented on failed `loadPlugin` - `audio_engine.worklet_module_loaded_total` — incremented on successful `loadModule` Histograms: - `audio_engine.latency_ms` — recorded in `getMetrics()` - `audio_engine.worklet_roundtrip_ms` — recorded around `sendMessage` + reply - `audio_engine.graph_connect_ms` — recorded around `AudioGraph.connect` ### Worklet URL allow-list `AudioWorkletManager` constructor accepts an optional `allowedOrigins: string[]`. `loadModule` rejects URLs whose origin is not in the list. Default allow-list is `['self']` (same-origin only). This prevents dynamic worklet injection attacks. ### Zod boundaries Three schemas are validated at runtime: - `AudioEngineConfigSchema` — parsed in `AudioEngine` constructor - `PluginMetadataSchema` — parsed in `AudioPlugin` constructor - `AudioPluginDescriptorSchema` — parsed in `PluginRegistry.registerDescriptor` All other types are TypeScript-only (no runtime validation) because they are internal or consumed only by typed callers. ### No `standardized-audio-context` The upstream imported `AudioContext` from `standardized-audio-context`. In genesis we use native Web Audio API types directly. The API surface is identical for consumers (both expose `AudioContext` and `AudioNode`). This removes a runtime dependency. ### Typed event map `AudioEngineEventMap` replaces the upstream `AudioEngineEvents` enum: ```ts interface AudioEngineEventMap { stateChanged: { previousState: AudioEngineState; currentState: AudioEngineState }; pluginLoaded: { pluginId: string; metadata: PluginMetadata }; // ... } ``` `EventBus` is generic over `EventMap extends Record` so that `subscribe(type: K, listener: (data: EventMap[K]) => void)` is fully typed. ### Test strategy - **Engine tests**: inject a mock `AudioContext` (minimal object with `createGain`, `destination`, `state`, `resume`, `suspend`, `close`, `sampleRate`, `currentTime`). No real browser required. - **Graph tests**: pure logic, no audio nodes needed. Use a mock `AudioPlugin` with fake `inputNode`/`outputNode` objects. - **Worklet tests**: mock `audioWorklet.addModule` and `AudioWorkletNode`. - **Queue tests**: mock `@suaveplan/queue` types or use the real in-memory queue (it's lightweight). - **Registry tests**: pure logic, no audio nodes. - **EventBus tests**: use `@suaveplan/events` `EventEmitter` directly. ## Dependencies - `@suaveplan/events` ✅ - `@suaveplan/logger` ✅ - `@suaveplan/queue` ✅ - `@suaveplan/error` ✅ - `@suaveplan/types` ✅ - `zod` (catalog:forms) - `@opentelemetry/api` (catalog:observability)