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

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

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