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

9.7 KiB
Raw Permalink Blame History

Design: enterprise-audio-timeline

Change ID: enterprise-audio-timeline Package: @suaveplan/audio-timeline

@suaveplan/audio-timeline is the Timeline / Transport layer of the @suaveplan/audio-* family — a headless DAW timeline engine that manages multi-track arrangements with clips, playback, loop regions, selection, zoom, and per-track mixer state. It delegates all audio rendering to a pluggable TimelineAdapter (plugin slot 5) so the same engine runs in browsers, servers, and offline renders. It is inspired by waveform-playlist's PlaylistEngine, but universal and integrated with genesis primitives.

The cross-cutting architecture for the whole audio ecosystem — the three-layer separation, the six plugin slots, the zero-duplication policy for genesis primitives, and the universal-tier purity rule — lives in enterprise-audio-core/design.md §1–§4 and is referenced, not duplicated here. This document carries only the package-specific design for the timeline engine.


1. Position in the ecosystem

audio-timeline sits one layer above audio-core (see enterprise-audio-core/design.md §1.2, "three-layer separation"). It consumes audio-core's AudioDocument model by reference only: a Clip carries a documentId string rather than an AudioDocument instance, so the timeline stays a pure arrangement model and never owns PCM. Actual sample rendering is the adapter's job.

The TimelineAdapter interface is plugin slot 5 of the six audio plugin slots (enterprise-audio-core/design.md §1.3). audio-engine-browser and audio-engine-server implement it; this package ships only the interface plus a NullTimelineAdapter for tests.


2. Track / Clip data model

The arrangement is a flat list of tracks, each owning an ordered list of clips:

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      // reference to an audio-core AudioDocument
  startSample: number     // position on the timeline
  offsetSamples: number   // start offset within the source document
  durationSamples: number
  startTick?: number      // optional musical position
  fadeIn?: FadeCurve
  fadeOut?: FadeCurve
}

Design rationale:

  • Sample-domain geometry, optional tick domain. Clip geometry is expressed in samples (startSample, offsetSamples, durationSamples) because that is the unit the rendering engines consume. startTick is optional musical metadata used only when the adapter exposes ticksToSeconds / secondsToTicks; otherwise the engine derives time from bpm/ppqn/ sampleRate.
  • documentId, not AudioDocument. Keeping a string reference instead of the object means a timeline session serializes cleanly through @suaveplan/serialize without dragging PCM pages into the session blob, and it keeps audio-timeline a pure universal-tier package.
  • Ordered clip list per track. Clips are kept sorted by startSample so overlap checks in constrainClipDrag and constrainBoundaryTrim are linear scans of neighbours rather than full passes.

TimelineState is the immutable snapshot returned by getState(). tracksVersion increments on every track-array mutation so React/r3f consumers can ===-diff instead of deep-comparing.


3. Engine internals

3.1. Clip operations

function moveClip(track: Track, clipId: string, delta: number): number {
  const clip = findClip(track, clipId)
  const applied = constrainClipDrag(track, clip, delta)  // clamps vs siblings + zero floor
  if (applied === 0) return 0                            // no-op: no undo, no event
  clip.startSample += applied
  return applied
}
  • constrainClipDrag clamps the requested delta so startSample >= 0 and (unless overlap is permitted by config) the clip does not overlap a sibling. A constrained delta of 0 is a true no-op — no state change, no undo command, no clipChange event.
  • constrainBoundaryTrim enforces the minDuration floor and keeps offsetSamples inside the source document. A left trim adjusts startSample + offsetSamples; a right trim adjusts only durationSamples.
  • canSplitAt gates splitClip: the split point must be strictly interior and both halves must satisfy minDuration. Fade curves are partitioned — fadeIn to the left clip, fadeOut to the right.

3.2. Undo/redo via @suaveplan/undo

import { CommandStack } from '@suaveplan/undo'

const undoStack = new CommandStack<Track[]>({ mergeStrategy: 'replace', maxHistory: 100 })

undoStack.execute({
  do:   () => { this._tracks = newTracks },
  undo: () => { this._tracks = oldTracks },
})

Every mutating operation pushes a paired do/undo command. Transactions (beginTransaction → commitTransaction / abortTransaction) coalesce multiple operations into a single composite command so a multi-step edit undoes in one step; abortTransaction rolls back and pushes nothing. No-op drags (constrained delta 0) never reach the stack. Undo history is not serialized — a reconstructed engine starts with an empty stack.

3.3. Transport state via @suaveplan/fsm

import { createMachine } from '@suaveplan/fsm'

const playbackFsm = createMachine({
  initial: 'stopped',
  states: {
    stopped: { on: { play: 'playing' } },
    playing: { on: { pause: 'paused', stop: 'stopped', seek: 'playing' } },
    paused:  { on: { play: 'playing', stop: 'stopped' } },
  },
})

The FSM guards every transport call: an out-of-order call (e.g. pause while stopped) is rejected by the guard and never forwarded to the adapter. Valid transitions delegate to the adapter and emit the matching play/pause/stop/seek event.

3.4. Incremental adapter updates

On undo() / redo(), when the track count is unchanged, the engine calls adapter.updateTrack(trackId, track) once per changed track instead of adapter.setTracks(tracks) — a full re-sync would interrupt playback. When the count changes (e.g. undoing an addTrack) it falls back to adapter.setTracks.


4. Integration with genesis packages

  • @suaveplan/audio-core — supplies the shared audio primitives and base types; Clip references an AudioDocument by documentId only.
  • @suaveplan/undo — CommandStack backs the entire history model, including transaction batching and command merging.
  • @suaveplan/fsm — the playback machine guards transport transitions.
  • @suaveplan/events — backs the typed TimelineEventMap (play, pause, stop, seek, trackChange, clipChange, tempoChange, loopChange, selectionChange, zoomChange). The interceptor API is the attachment point for OTel metrics and cross-context bridges in the engine packages.
  • @suaveplan/serialize — engine.toJSON() round-trips a TimelineSessionJSON (tracks, clips, transport state, selection, loop region, zoom level, tempo) through serialize / deserialize; fromJSON reconstructs an engine with a fresh, empty CommandStack.
  • @suaveplan/error — TimelineError base class and the AUDIO_TIMELINE_* code namespace.
  • @suaveplan/types — LoggerLike, Clock for the injectable logger and time source.

5. File layout

packages/audio/audio-timeline/
  package.json, tsconfig.json, vitest.config.ts
  src/
    index.ts
    error-codes.ts
    engine/      engine.ts .md .types.ts .test.ts index.ts
    track/       track.ts .md .types.ts .test.ts index.ts
    clip/        clip.ts .md .types.ts .test.ts index.ts
    operations/  clip-operations.ts (+ .md .test.ts) · timeline-operations.ts (+ .md .test.ts) · index.ts
    adapter/     adapter.ts .md .types.ts .test.ts index.ts
    mixer/       mixer.ts .md .test.ts index.ts
    zoom/        zoom.ts .md .test.ts index.ts
    __benchmarks__/  timeline.bench.ts
  tests/
    integration.test.ts  property.test.ts  purity.test.ts  exports.test.ts

6. Benchmarks

  • timeline.bench.ts — create 50 tracks, add 200 clips, then perform 100 undo/redo cycles; measures the cost of command-stack push/pop plus incremental adapter diffing.

7. Decisions and trade-offs

  • Adapter delegation over a built-in renderer. The engine owns arrangement state only; all rendering goes through TimelineAdapter (plugin slot 5). This keeps audio-timeline universal and lets browser / server / offline backends share one engine without #if-style branching.
  • documentId reference, not AudioDocument instance. A clip referencing an id keeps the timeline a pure metadata model, makes sessions serialize without PCM, and avoids a hard runtime coupling to audio-core's heavy paged container.
  • No-op drags never touch the undo stack. A constrained delta of 0 produces no command and no event, so dragging a clip against a wall and releasing does not pollute undo history — a behaviour DAW users expect.
  • Transactions coalesce, they do not nest. beginTransaction/commitTransaction produce one composite command; nested transactions are out of scope. commit/abort with no active transaction throws AUDIO_TIMELINE_TRANSACTION_NOT_ACTIVE rather than silently no-op'ing, so mis-paired calls surface immediately.
  • Undo history is session-local. toJSON() deliberately omits the CommandStack; history is ephemeral UI state, not document state, and serializing it would bloat sessions and leak stale closures.
  • Incremental adapter updates on same-count undo. Calling updateTrack per changed track instead of setTracks avoids tearing down and rebuilding the adapter's node graph mid-playback — the difference between a glitch-free undo and an audible dropout.