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

7.9 KiB

OpenSpec Change: enterprise-audio-timeline

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

Summary

Deliver @suaveplan/audio-timeline on the genesis branch from scratch at enterprise quality. The package is a headless DAW timeline engine that manages multi-track arrangements with clips, playback, loop regions, selection, zoom, and per-track mixer state (volume, mute, solo, pan). It delegates all audio rendering to a pluggable TimelineAdapter so the same engine runs in browsers, servers, and offline renders. Undo/redo is delegated to @suaveplan/undo. Transport state uses @suaveplan/fsm. Inspired by waveform-playlist's PlaylistEngine, but universal and integrated with genesis primitives.


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/audio-core (workspace:^) Shared audio primitives and base types
@suaveplan/undo (workspace:^) CommandStack for undo/redo
@suaveplan/fsm (workspace:^) Playback state machine
@suaveplan/events (workspace:^) Typed event emitter for TimelineEventMap
@suaveplan/error (workspace:^) Base class for TimelineError; AUDIO_TIMELINE.* codes
@suaveplan/types (workspace:^) LoggerLike, Clock
zod (catalog:forms) Zod schemas for all public option types

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


Public API

Core types

interface Track {
  id: string
  name: string
  clips: Clip[]
  volume: number      // 0..1
  muted: boolean
  soloed: boolean
  pan: number         // -1..1
}

interface Clip {
  id: string
  documentId: string
  startSample: number
  offsetSamples: number
  durationSamples: number
  startTick?: number
  fadeIn?: FadeCurve
  fadeOut?: FadeCurve
}

interface TimelineState {
  tracks: Track[]
  tracksVersion: number
  duration: number
  currentTime: number
  isPlaying: boolean
  samplesPerPixel: number
  sampleRate: number
  selectedTrackId: string | null
  zoomIndex: number
  canZoomIn: boolean
  canZoomOut: boolean
  selectionStart: number
  selectionEnd: number
  masterVolume: number
  loopStart: number
  loopEnd: number
  isLoopEnabled: boolean
  bpm: number
  ppqn: number
  canUndo: boolean
  canRedo: boolean
}

interface TimelineEngineOptions {
  sampleRate?: number
  ppqn?: number
  logger?: LoggerLike
}

interface TimelineAdapter {
  init(): Promise<void>
  setTracks(tracks: Track[]): void
  addTrack(track: Track): void
  removeTrack(trackId: string): void
  updateTrack(trackId: string, track: Track): void
  play(startTime: number, endTime?: number): void
  pause(): void
  stop(): void
  seek(time: number): void
  getCurrentTime(): number
  setLoop(enabled: boolean, start: number, end: number): 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
  ticksToSeconds?(tick: number): number
  secondsToTicks?(seconds: number): number
  dispose(): void
}

class TimelineEngine {
  constructor(options?: TimelineEngineOptions)
  getState(): TimelineState
  setTracks(tracks: Track[]): void
  addTrack(track: Track): void
  removeTrack(trackId: string): void
  updateTrack(trackId: string, track?: Track): void
  selectTrack(trackId: string | null): void
  addClip(trackId: string, clip: Clip): void
  removeClip(trackId: string, clipId: string): void
  moveClip(trackId: string, clipId: string, deltaSamples: number): number
  splitClip(trackId: string, clipId: string, atSample: number): void
  trimClip(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number): void
  init(): Promise<void>
  play(startTime?: number, endTime?: number): void
  pause(): void
  stop(): void
  seek(time: number): void
  getCurrentTime(): number
  setTempo(bpm: number, atTick?: number): void
  getTempo(): number
  setSelection(start: number, end: number): void
  setLoopRegion(start: number, end: number): void
  setLoopEnabled(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
  zoomIn(): void
  zoomOut(): void
  setZoomLevel(samplesPerPixel: number): void
  get canUndo(): boolean
  get canRedo(): boolean
  undo(): void
  redo(): void
  clearHistory(): void
  beginTransaction(): void
  commitTransaction(): void
  abortTransaction(): void
  on<K extends keyof TimelineEventMap>(event: K, listener: TimelineEventMap[K]): void
  off<K extends keyof TimelineEventMap>(event: K, listener: TimelineEventMap[K]): void
  setAdapter(adapter: TimelineAdapter): void
  dispose(): void
}

Error codes

type TimelineErrorCode =
  | 'AUDIO_TIMELINE_TRACK_NOT_FOUND'
  | 'AUDIO_TIMELINE_CLIP_NOT_FOUND'
  | 'AUDIO_TIMELINE_INVALID_CLIP_MOVE'
  | 'AUDIO_TIMELINE_INVALID_SPLIT'
  | 'AUDIO_TIMELINE_INVALID_TRIM'
  | 'AUDIO_TIMELINE_ADAPTER_NOT_SET'
  | 'AUDIO_TIMELINE_TRANSACTION_NOT_ACTIVE'

const AUDIO_TIMELINE_ERROR_METADATA: Readonly<Record<TimelineErrorCode, CodeMetadata>>
function registerTimelineErrorCodes(): void

Zod schemas

TrackSchema
ClipSchema
TimelineStateSchema
TimelineEngineOptionsSchema

File layout

packages/audio/audio-timeline/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts
    error-codes.ts
    engine/
      engine.ts
      engine.md
      engine.types.ts
      engine.test.ts
      index.ts
    track/
      track.ts
      track.md
      track.types.ts
      track.test.ts
      index.ts
    clip/
      clip.ts
      clip.md
      clip.types.ts
      clip.test.ts
      index.ts
    operations/
      clip-operations.ts
      clip-operations.md
      clip-operations.test.ts
      timeline-operations.ts
      timeline-operations.md
      timeline-operations.test.ts
      index.ts
    adapter/
      adapter.ts
      adapter.md
      adapter.types.ts
      adapter.test.ts
      index.ts
    mixer/
      mixer.ts
      mixer.md
      mixer.test.ts
      index.ts
    zoom/
      zoom.ts
      zoom.md
      zoom.test.ts
      index.ts
    __benchmarks__/
      timeline.bench.ts
  tests/
    integration.test.ts

Impact

Breaking changes: none — fresh build, package does not yet exist on genesis.

Prerequisites: @suaveplan/audio-core, @suaveplan/undo, @suaveplan/fsm, @suaveplan/events, @suaveplan/serialize, @suaveplan/error, @suaveplan/types published to Verdaccio.

Affected dependents

@suaveplan/audio-timeline is consumed by the rendering-engine tier — audio-engine-browser and audio-engine-server each implement its TimelineAdapter interface. At the time of this change none of the dependents exist on genesis yet (they land in later changes in this same batch), so there is no existing dependent surface to test or migrate. Each dependent is created against this package's spec delta, not retrofitted.

  • @suaveplan/audio-engine-browser — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in enterprise-audio-engine-browser, where it implements TimelineAdapter)
  • @suaveplan/audio-engine-server — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in enterprise-audio-engine-server, where it implements TimelineAdapter)