12 KiB
OpenSpec Change: enterprise-browser-audio-engine
- Change ID: enterprise-browser-audio-engine
- Package:
@suaveplan/audio-engine - Category: browser
- Target path:
browser/audio/audio-engine - Status: IN PROGRESS
- Date: 2026-04-24
- Reference spec:
/mnt/s/web/suaveplan/enterprise/audio-engine.md
Summary
Deliver @suaveplan/audio-engine on the genesis branch from scratch at enterprise quality. The package is the core browser-side Web Audio engine consumed by every React and R3F audio surface in the monorepo. It owns the global AudioContext lifecycle, a typed plugin system (AudioPlugin base class, PluginRegistry, AudioEngine), an AudioGraph node-routing layer, an AudioWorkletManager for loading and messaging worklet modules, an AudioQueueEngine for cue/sequence playback, and an EventBus facade. Zero React dependencies — consumed by @suaveplan/audio-controllers, @suaveplan/audio-plugins-fx, and React audio packages.
Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.
Available workspace deps on genesis
| Package | Role |
|---|---|
@suaveplan/error (workspace:^) |
Base class for AudioEngineError; defineCodes for AUDIO.* error codes |
@suaveplan/types (workspace:^) |
LoggerLike, EventBusLike, Clock for injectable clock support |
@suaveplan/events (workspace:^) |
EventEmitter for AudioEngineEventMap; typed event bus |
@suaveplan/schema-registry (workspace:^) |
Register AudioPluginDescriptor Zod schema |
@suaveplan/api-schemas (workspace:^) |
Shared schema registration surface |
zod (catalog:forms) |
Zod schemas for PluginMetadata, AudioPluginDescriptor, configs |
@opentelemetry/api (catalog:observability) |
Counters + histograms for plugin lifecycle and latency |
standardized-audio-context (catalog:browser) |
Cross-browser AudioContext + OfflineAudioContext shim |
tslib (catalog:build) |
Runtime TS helpers |
Dev deps: vitest, @vitest/coverage-istanbul, mitata (all catalog:testing).
Preserved public API surface (from main branch)
Core
class AudioEngine {
constructor(config?: AudioEngineConfig, context?: BaseAudioContext)
start(): Promise<void>
stop(): Promise<void>
getState(): AudioEngineState
getStateInfo(): AudioEngineStateInfo
getMetrics(): AudioEngineMetrics
loadPlugin(id: string, plugin: AudioPlugin): Promise<void>
unloadPlugin(id: string): Promise<void>
getPlugin<T extends AudioPlugin>(id: string): T | undefined
on<K extends keyof AudioEngineEventMap>(event: K, listener: AudioEngineEventMap[K]): void
off<K extends keyof AudioEngineEventMap>(event: K, listener: AudioEngineEventMap[K]): void
}
interface AudioEngineConfig { sampleRate?: number; latencyHint?: AudioContextLatencyCategory; bufferSize?: number; maxPlugins?: number }
type AudioEngineState = 'uninitialized' | 'starting' | 'running' | 'suspended' | 'stopping' | 'stopped' | 'error'
interface AudioEngineStateInfo { state: AudioEngineState; previousState: AudioEngineState; timestamp: number }
interface AudioEngineMetrics { pluginCount: number; activeNodes: number; cpuUsage: number; sampleRate: number; currentTime: number }
type AudioEngineEventMap = { 'state:change': (info: AudioEngineStateInfo) => void; 'plugin:loaded': (id: string) => void; 'plugin:unloaded': (id: string) => void; 'plugin:error': (id: string, error: Error) => void; 'error': (error: Error) => void }
AudioGraph
class AudioGraph {
addNode(id: string, node: AudioNode): void
removeNode(id: string): void
connect(sourceId: string, destinationId: string, outputIndex?: number, inputIndex?: number): void
disconnect(sourceId: string, destinationId?: string): void
getNode(id: string): AudioNode | undefined
traverse(startId: string): string[]
snapshot(): AudioGraphSnapshot
restore(snapshot: AudioGraphSnapshot): void
}
AudioQueueEngine
class AudioQueueEngine {
enqueue(cue: AudioCue): string
dequeue(cueId: string): void
play(cueId: string): Promise<void>
stop(cueId: string): void
getQueue(): AudioCue[]
getSequences(): AudioSequence[]
addSequence(seq: AudioSequence): void
removeSequence(seqId: string): void
}
interface AudioCue { id?: string; buffer: AudioBuffer; startTime?: number; offset?: number; duration?: number; gain?: number; loop?: boolean }
interface AudioSequence { id: string; cues: AudioCue[]; loop?: boolean; tempo?: number }
AudioWorkletManager
class AudioWorkletManager {
addModule(url: string, options?: WorkletModuleOptions): Promise<void>
createNode(processorName: string, options?: AudioWorkletNodeOptions): AudioWorkletNode
postMessage(processorName: string, message: unknown, transfer?: Transferable[]): void
onMessage(processorName: string, handler: (event: MessageEvent) => void): void
isLoaded(url: string): boolean
}
interface WorkletModuleOptions { allowedUrls?: string[]; credentials?: RequestCredentials }
Plugin system
abstract class AudioPlugin {
abstract readonly metadata: PluginMetadata
state: PluginState
abstract initialize(context: BaseAudioContext): Promise<void>
abstract connect(graph: AudioGraph): void
abstract disconnect(): void
abstract destroy(): Promise<void>
setParameter(id: string, value: number): void
getParameter(id: string): number | undefined
getParameters(): PluginParameter[]
}
type PluginState = 'uninitialized' | 'initializing' | 'initialized' | 'active' | 'inactive' | 'error' | 'destroyed'
interface PluginMetadata { id: string; name: string; version: string; category: string; description?: string }
interface PluginParameter { id: string; name: string; value: number; min: number; max: number; default: number; automatable?: boolean }
class PluginRegistry {
register(descriptor: AudioPluginDescriptor): void
unregister(id: string): void
get(id: string): AudioPluginDescriptor | undefined
list(): AudioPluginDescriptor[]
instantiate(id: string): AudioPlugin
}
interface AudioPluginDescriptor { id: string; name: string; version: string; factory: () => AudioPlugin; metadata: PluginMetadata }
EventBus facade
class EventBus {
constructor(bus?: EventBusLike)
emit<K extends keyof AudioEngineEventMap>(event: K, ...args: Parameters<AudioEngineEventMap[K]>): void
on<K extends keyof AudioEngineEventMap>(event: K, listener: AudioEngineEventMap[K]): void
off<K extends keyof AudioEngineEventMap>(event: K, listener: AudioEngineEventMap[K]): void
}
New additions for genesis
1. Structured errors via @suaveplan/error
Error codes with stable AUDIO.* namespace:
| Code | Severity | Retryable |
|---|---|---|
AUDIO_ENGINE_STATE_INVALID |
error |
false |
AUDIO_PLUGIN_NOT_FOUND |
error |
false |
AUDIO_PLUGIN_LOAD_FAILED |
error |
true |
AUDIO_WORKLET_LOAD_FAILED |
error |
true |
AUDIO_WORKLET_URL_REJECTED |
error |
false |
AUDIO_GRAPH_CONNECT_FAILED |
error |
false |
AUDIO_CONTEXT_UNAVAILABLE |
fatal |
false |
2. AudioContext injection seam
AudioEngine constructor accepts an optional pre-built BaseAudioContext so tests and hosts can inject a standardized-audio-context context or a shared singleton without touching globalThis.
3. Plugin quarantine / error isolation
A plugin that throws during process or initialize is quarantined: marked ERROR state, disconnected from the graph, and a plugin:error event is emitted. The engine and other plugins continue operating.
4. Typed AudioEngineEventMap
Replace string-enum AudioEngineEvents with AudioEngineEventMap so EventEmitter<AudioEngineEventMap> provides per-event argument typing.
5. Graph snapshot / restore
AudioGraph.snapshot() serializes the routing topology (node IDs and connections) to a plain JSON object; restore() re-wires from that snapshot.
6. Worklet URL allow-list
AudioWorkletManager rejects addModule calls for URLs not present in the engine's configured allowedWorkletUrls list; throws AUDIO_WORKLET_URL_REJECTED.
7. OTel metrics
- Counter
audio.plugin_loaded_total - Counter
audio.plugin_failed_total - Histogram
audio.engine_latency_ms - Histogram
audio.worklet_roundtrip_ms - Histogram
audio.graph_connect_ms
8. Render-on-demand offline mode
AudioEngine.renderOffline(durationSeconds, sampleRate) drives an OfflineAudioContext and returns an AudioBuffer for export / bounce workflows.
9. Parameter automation
AudioPlugin.setParameterCurve(id, curve: ParameterCurve) schedules sample-accurate linear/exponential/target-at-time automation via the Web Audio API param scheduling methods.
10. Benchmarks
src/__benchmarks__/graph.bench.ts, queue.bench.ts, worklet.bench.ts with mitata.
File layout
browser/audio/audio-engine/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts
constants/
index.ts
types/
common.ts
common.md
engine/
engine/
engine.ts
engine.md
engine.types.ts
engine.test.ts
index.ts
engine-state/
engine-state.ts
engine-state.md
engine-state.test.ts
index.ts
engine-metrics/
engine-metrics.ts
engine-metrics.md
engine-metrics.test.ts
index.ts
graph/
graph/
graph.ts
graph.md
graph.types.ts
graph.test.ts
index.ts
graph-connect/
graph-connect.ts
graph-connect.md
graph-connect.test.ts
index.ts
graph-traverse/
graph-traverse.ts
graph-traverse.md
graph-traverse.test.ts
index.ts
queue-engine/
queue-engine/
queue-engine.ts
queue-engine.md
queue-engine.types.ts
queue-engine.test.ts
index.ts
cue/
cue.ts
cue.md
cue.test.ts
index.ts
scheduler/
scheduler.ts
scheduler.md
scheduler.test.ts
index.ts
worklet/
worklet-loader/
worklet-loader.ts
worklet-loader.md
worklet-loader.test.ts
index.ts
worklet-messaging/
worklet-messaging.ts
worklet-messaging.md
worklet-messaging.test.ts
index.ts
worklet-registry/
worklet-registry.ts
worklet-registry.md
worklet-registry.test.ts
index.ts
plugin/
plugin/
plugin.ts
plugin.md
plugin.types.ts
plugin.test.ts
index.ts
plugin-registry/
plugin-registry.ts
plugin-registry.md
plugin-registry.types.ts
plugin-registry.test.ts
index.ts
event-bus/
event-bus/
event-bus.ts
event-bus.md
event-bus.types.ts
event-bus.test.ts
index.ts
__benchmarks__/
graph.bench.ts
queue.bench.ts
worklet.bench.ts
tests/
integration.test.ts
Impact
Downstream packages that will consume @suaveplan/audio-engine: @suaveplan/audio-plugins-fx, @suaveplan/audio-controllers, @suaveplan/react-audio, @suaveplan/react-audio-player, @suaveplan/r3f-audio, @suaveplan/react-audio-stream.
Breaking changes from main-branch API: none. All main-branch call sites compile unchanged. New optional fields and methods are additive.
Workspace dep prerequisites: @suaveplan/error, @suaveplan/types, @suaveplan/events must be published to Verdaccio before this package can be built or tested.