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

7.6 KiB

OpenSpec Change: enterprise-audio-engine-browser

  • Change ID: enterprise-audio-engine-browser
  • Package: @suaveplan/audio-engine-browser
  • Category: browser
  • Target path: packages/audio/audio-engine-browser
  • Status: PROPOSED
  • Date: 2026-05-14
  • Design: ./design.md
  • Spec delta: ./specs/audio-engine-browser/spec.md

Summary

Deliver @suaveplan/audio-engine-browser on the genesis branch from scratch at enterprise quality. The package is a Web Audio API rendering backend that implements TimelineAdapter to connect the universal TimelineEngine to the browser's audio output. It manages AudioContext lifecycle (via standardized-audio-context), AudioWorklet loading, and dual-mode FX plugin hosting. An Html5AudioFallback is provided for environments without Web Audio API. This is the only browser-only package in the audio core; it bridges universal data to browser-specific rendering and replaces old browser-only openspecs with a new adapter-based architecture.


Current-state audit (genesis)

This is a new architecture replacing old browser-only openspecs. No prior source on genesis for this specific package; fresh build with an adapter-based design that delegates timeline logic to @suaveplan/audio-timeline.


Available workspace deps on genesis

Package Role
@suaveplan/audio-core (workspace:^) Shared audio primitives and FX plugin interfaces
@suaveplan/audio-timeline (workspace:^) TimelineEngine, TimelineAdapter, Track, Clip
@suaveplan/audio-transport (workspace:^) Transport for musical clock integration
@suaveplan/audio-fx-plugin-base (workspace:^) AudioFxPlugin interface for FX hosting
@suaveplan/error (workspace:^) Base class for BrowserAudioError; AUDIO_ENGINE_BROWSER.* codes
@suaveplan/events (workspace:^) Typed event emitter for adapter events
standardized-audio-context Cross-browser AudioContext / OfflineAudioContext shim

Dev deps: vitest, @vitest/coverage-istanbul, happy-dom (all catalog:testing).


Public API

Core types

interface BrowserAdapterOptions {
  context?: BaseAudioContext
  latencyHint?: AudioContextLatencyCategory
  sampleRate?: number
  lookAhead?: number
  ppqn?: number
}

interface WorkletManagerOptions {
  allowedUrls?: string[]
}

interface WorkletModuleOptions {
  credentials?: RequestCredentials
}

interface AudioGraphSnapshot {
  nodes: Record<string, AudioNodeType>
  connections: Array<{ source: string; destination: string; outputIndex?: number; inputIndex?: number }>
}

class BrowserTimelineAdapter implements TimelineAdapter {
  constructor(options?: BrowserAdapterOptions)
  init(options?: { signal?: AbortSignal }): Promise<void>
  setTracks(tracks: Track[]): void
  addTrack(track: Track): void
  removeTrack(trackId: string): void
  updateTrack(trackId: string, track: Track): void
  play(time?: number): void
  pause(time?: number): void
  stop(time?: number): void
  seek(time: number): void
  getCurrentTime(): number
  setLoop(start: number, end: number, enabled: boolean): void
  setMasterVolume(volume: number): void
  setTrackVolume(trackId: string, volume: number): void
  setTrackMute(trackId: string, muted: boolean): void
  setTrackSolo(trackId: string, soloed: boolean): void
  setTrackPan(trackId: string, pan: number): void
  setTempo(bpm: number, atTick?: number): void
  ticksToSeconds(ticks: number): number
  secondsToTicks(seconds: number): number
  readonly lookAhead: number
  readonly ppqn: number
  dispose(): void
}

class WebAudioGraph {
  constructor(context: BaseAudioContext)
  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
  snapshot(): AudioGraphSnapshot
  restore(snapshot: AudioGraphSnapshot): void
}

class AudioWorkletManager {
  constructor(context: BaseAudioContext, options?: WorkletManagerOptions)
  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
}

class Html5AudioFallback implements TimelineAdapter {
  constructor(options?: { volume?: number; loop?: boolean })
  init(options?: { signal?: AbortSignal }): Promise<void>
  setTracks(tracks: Track[]): void
  addTrack(track: Track): void
  removeTrack(trackId: string): void
  updateTrack(trackId: string, track: Track): void
  play(time?: number): void
  pause(time?: number): void
  stop(time?: number): void
  seek(time: number): void
  getCurrentTime(): number
  setLoop(start: number, end: number, enabled: boolean): void
  setMasterVolume(volume: number): void
  setTrackVolume(trackId: string, volume: number): void
  setTrackMute(trackId: string, muted: boolean): void
  setTrackSolo(trackId: string, soloed: boolean): void
  setTrackPan(trackId: string, pan: number): void
  setTempo(bpm: number, atTick?: number): void
  readonly lookAhead: number
  readonly ppqn: number
  dispose(): void
}

async function renderOffline(
  timeline: TimelineEngine,
  duration: number,
  sampleRate: number
): Promise<AudioBuffer>

Error codes

type BrowserAudioErrorCode =
  | 'AUDIO_ENGINE_BROWSER.CONTEXT_FAILED'
  | 'AUDIO_ENGINE_BROWSER.WORKLET_LOAD_FAILED'
  | 'AUDIO_ENGINE_BROWSER.TRACK_NOT_FOUND'
  | 'AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND'
  | 'AUDIO_ENGINE_BROWSER.OFFLINE_RENDER_FAILED'
  | 'AUDIO_ENGINE_BROWSER.ALREADY_DISPOSED'

const AUDIO_ENGINE_BROWSER_ERROR_METADATA: Readonly<Record<BrowserAudioErrorCode, CodeMetadata>>
function registerBrowserAudioErrorCodes(): void

Zod schemas

BrowserAdapterOptionsSchema
WorkletManagerOptionsSchema
WorkletModuleOptionsSchema
AudioGraphSnapshotSchema

File layout

packages/audio/audio-engine-browser/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts
    error-codes.ts
    adapter/
      browser-adapter.ts
      browser-adapter.md
      browser-adapter.types.ts
      browser-adapter.test.ts
      index.ts
    graph/
      web-audio-graph.ts
      web-audio-graph.md
      web-audio-graph.types.ts
      web-audio-graph.test.ts
      index.ts
    worklet/
      worklet-manager.ts
      worklet-manager.md
      worklet-manager.types.ts
      worklet-manager.test.ts
      index.ts
    fallback/
      html5-fallback.ts
      html5-fallback.md
      html5-fallback.test.ts
      index.ts
    offline/
      offline-render.ts
      offline-render.md
      offline-render.test.ts
      index.ts
    __benchmarks__/
      graph.bench.ts
      worklet.bench.ts
  tests/
    integration.test.ts

Impact

Breaking changes: none — fresh build replacing legacy browser-only openspecs; the package does not yet exist on genesis.

Prerequisites: @suaveplan/audio-core, @suaveplan/audio-timeline, @suaveplan/audio-transport, @suaveplan/audio-fx-plugin-base, @suaveplan/error, @suaveplan/events published to Verdaccio.

Affected dependents

No dependents in this batch. Consumed by @suaveplan/react-audio* and @suaveplan/r3f-audio, which land in a later wave (verified by bunx turbo ls --filter=...@suaveplan/audio-engine-browser).

Once landed, any subsequent change to this package's public surface MUST run bunx turbo test --filter=...@suaveplan/audio-engine-browser per openspec/AGENTS.md §13 step 10.