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

219 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.