Initial Commit
This commit is contained in:
218
openspec/archive/2026-05-15-enterprise-audio-timeline/design.md
Normal file
218
openspec/archive/2026-05-15-enterprise-audio-timeline/design.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user