219 lines
9.7 KiB
Markdown
219 lines
9.7 KiB
Markdown
# 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<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`
|
||
|
||
```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.
|