9.7 KiB
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:
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.startTickis optional musical metadata used only when the adapter exposesticksToSeconds/secondsToTicks; otherwise the engine derives time frombpm/ppqn/sampleRate. documentId, notAudioDocument. Keeping a string reference instead of the object means a timeline session serializes cleanly through@suaveplan/serializewithout dragging PCM pages into the session blob, and it keepsaudio-timelinea pure universal-tier package.- Ordered clip list per track. Clips are kept sorted by
startSampleso overlap checks inconstrainClipDragandconstrainBoundaryTrimare 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
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
}
constrainClipDragclamps the requested delta sostartSample >= 0and (unless overlap is permitted by config) the clip does not overlap a sibling. A constrained delta of0is a true no-op — no state change, no undo command, noclipChangeevent.constrainBoundaryTrimenforces theminDurationfloor and keepsoffsetSamplesinside the source document. A left trim adjustsstartSample+offsetSamples; a right trim adjusts onlydurationSamples.canSplitAtgatessplitClip: the split point must be strictly interior and both halves must satisfyminDuration. Fade curves are partitioned —fadeInto the left clip,fadeOutto the right.
3.2. Undo/redo via @suaveplan/undo
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
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;Clipreferences anAudioDocumentbydocumentIdonly.@suaveplan/undo—CommandStackbacks the entire history model, including transaction batching and command merging.@suaveplan/fsm— the playback machine guards transport transitions.@suaveplan/events— backs the typedTimelineEventMap(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 aTimelineSessionJSON(tracks, clips, transport state, selection, loop region, zoom level, tempo) throughserialize/deserialize;fromJSONreconstructs an engine with a fresh, emptyCommandStack.@suaveplan/error—TimelineErrorbase class and theAUDIO_TIMELINE_*code namespace.@suaveplan/types—LoggerLike,Clockfor the injectable logger and time source.
5. File layout
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 keepsaudio-timelineuniversal and lets browser / server / offline backends share one engine without#if-style branching. documentIdreference, notAudioDocumentinstance. A clip referencing an id keeps the timeline a pure metadata model, makes sessions serialize without PCM, and avoids a hard runtime coupling toaudio-core's heavy paged container.- No-op drags never touch the undo stack. A constrained delta of
0produces 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/commitTransactionproduce one composite command; nested transactions are out of scope.commit/abortwith no active transaction throwsAUDIO_TIMELINE_TRANSACTION_NOT_ACTIVErather than silently no-op'ing, so mis-paired calls surface immediately. - Undo history is session-local.
toJSON()deliberately omits theCommandStack; 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
updateTrackper changed track instead ofsetTracksavoids tearing down and rebuilding the adapter's node graph mid-playback — the difference between a glitch-free undo and an audible dropout.