Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.