7.1 KiB
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 successfulloadPluginaudio_engine.plugin_failed_total— incremented on failedloadPluginaudio_engine.worklet_module_loaded_total— incremented on successfulloadModule
Histograms:
audio_engine.latency_ms— recorded ingetMetrics()audio_engine.worklet_roundtrip_ms— recorded aroundsendMessage+ replyaudio_engine.graph_connect_ms— recorded aroundAudioGraph.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 inAudioEngineconstructorPluginMetadataSchema— parsed inAudioPluginconstructorAudioPluginDescriptorSchema— parsed inPluginRegistry.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:
interface AudioEngineEventMap {
stateChanged: { previousState: AudioEngineState; currentState: AudioEngineState };
pluginLoaded: { pluginId: string; metadata: PluginMetadata };
// ...
}
EventBus is generic over EventMap extends Record<string, unknown> so that subscribe<K>(type: K, listener: (data: EventMap[K]) => void) is fully typed.
Test strategy
- Engine tests: inject a mock
AudioContext(minimal object withcreateGain,destination,state,resume,suspend,close,sampleRate,currentTime). No real browser required. - Graph tests: pure logic, no audio nodes needed. Use a mock
AudioPluginwith fakeinputNode/outputNodeobjects. - Worklet tests: mock
audioWorklet.addModuleandAudioWorkletNode. - Queue tests: mock
@suaveplan/queuetypes or use the real in-memory queue (it's lightweight). - Registry tests: pure logic, no audio nodes.
- EventBus tests: use
@suaveplan/eventsEventEmitterdirectly.
Dependencies
@suaveplan/events✅@suaveplan/logger✅@suaveplan/queue✅@suaveplan/error✅@suaveplan/types✅zod(catalog:forms)@opentelemetry/api(catalog:observability)