# 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: ```ts 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 ```ts 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` ```ts import { CommandStack } from '@suaveplan/undo' const undoStack = new CommandStack({ 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` ```ts 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 ```text 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.