7.9 KiB
OpenSpec Change: enterprise-audio-timeline
- Change ID: enterprise-audio-timeline
- Package:
@suaveplan/audio-timeline - Category: universal
- Target path:
packages/audio/audio-timeline - Status: PROPOSED
- Date: 2026-05-14
- Design:
./design.md - Spec delta:
./specs/audio-timeline/spec.md
Summary
Deliver @suaveplan/audio-timeline on the genesis branch from scratch at enterprise quality. The package is a headless DAW timeline engine that manages multi-track arrangements with clips, playback, loop regions, selection, zoom, and per-track mixer state (volume, mute, solo, pan). It delegates all audio rendering to a pluggable TimelineAdapter so the same engine runs in browsers, servers, and offline renders. Undo/redo is delegated to @suaveplan/undo. Transport state uses @suaveplan/fsm. Inspired by waveform-playlist's PlaylistEngine, but universal and integrated with genesis primitives.
Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.
Available workspace deps on genesis
| Package | Role |
|---|---|
@suaveplan/audio-core (workspace:^) |
Shared audio primitives and base types |
@suaveplan/undo (workspace:^) |
CommandStack for undo/redo |
@suaveplan/fsm (workspace:^) |
Playback state machine |
@suaveplan/events (workspace:^) |
Typed event emitter for TimelineEventMap |
@suaveplan/error (workspace:^) |
Base class for TimelineError; AUDIO_TIMELINE.* codes |
@suaveplan/types (workspace:^) |
LoggerLike, Clock |
zod (catalog:forms) |
Zod schemas for all public option types |
Dev deps: vitest, @vitest/coverage-istanbul, fast-check (all catalog:testing).
Public API
Core types
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
startSample: number
offsetSamples: number
durationSamples: number
startTick?: number
fadeIn?: FadeCurve
fadeOut?: FadeCurve
}
interface TimelineState {
tracks: Track[]
tracksVersion: number
duration: number
currentTime: number
isPlaying: boolean
samplesPerPixel: number
sampleRate: number
selectedTrackId: string | null
zoomIndex: number
canZoomIn: boolean
canZoomOut: boolean
selectionStart: number
selectionEnd: number
masterVolume: number
loopStart: number
loopEnd: number
isLoopEnabled: boolean
bpm: number
ppqn: number
canUndo: boolean
canRedo: boolean
}
interface TimelineEngineOptions {
sampleRate?: number
ppqn?: number
logger?: LoggerLike
}
interface TimelineAdapter {
init(): Promise<void>
setTracks(tracks: Track[]): void
addTrack(track: Track): void
removeTrack(trackId: string): void
updateTrack(trackId: string, track: Track): void
play(startTime: number, endTime?: number): void
pause(): void
stop(): void
seek(time: number): void
getCurrentTime(): number
setLoop(enabled: boolean, start: number, end: number): void
setMasterVolume(volume: number): void
setTrackVolume(trackId: string, volume: number): void
setTrackMute(trackId: string, muted: boolean): void
setTrackSolo(trackId: string, soloed: boolean): void
setTrackPan(trackId: string, pan: number): void
setTempo(bpm: number, atTick?: number): void
readonly lookAhead: number
readonly ppqn: number
ticksToSeconds?(tick: number): number
secondsToTicks?(seconds: number): number
dispose(): void
}
class TimelineEngine {
constructor(options?: TimelineEngineOptions)
getState(): TimelineState
setTracks(tracks: Track[]): void
addTrack(track: Track): void
removeTrack(trackId: string): void
updateTrack(trackId: string, track?: Track): void
selectTrack(trackId: string | null): void
addClip(trackId: string, clip: Clip): void
removeClip(trackId: string, clipId: string): void
moveClip(trackId: string, clipId: string, deltaSamples: number): number
splitClip(trackId: string, clipId: string, atSample: number): void
trimClip(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number): void
init(): Promise<void>
play(startTime?: number, endTime?: number): void
pause(): void
stop(): void
seek(time: number): void
getCurrentTime(): number
setTempo(bpm: number, atTick?: number): void
getTempo(): number
setSelection(start: number, end: number): void
setLoopRegion(start: number, end: number): void
setLoopEnabled(enabled: boolean): void
setMasterVolume(volume: number): void
setTrackVolume(trackId: string, volume: number): void
setTrackMute(trackId: string, muted: boolean): void
setTrackSolo(trackId: string, soloed: boolean): void
setTrackPan(trackId: string, pan: number): void
zoomIn(): void
zoomOut(): void
setZoomLevel(samplesPerPixel: number): void
get canUndo(): boolean
get canRedo(): boolean
undo(): void
redo(): void
clearHistory(): void
beginTransaction(): void
commitTransaction(): void
abortTransaction(): void
on<K extends keyof TimelineEventMap>(event: K, listener: TimelineEventMap[K]): void
off<K extends keyof TimelineEventMap>(event: K, listener: TimelineEventMap[K]): void
setAdapter(adapter: TimelineAdapter): void
dispose(): void
}
Error codes
type TimelineErrorCode =
| 'AUDIO_TIMELINE_TRACK_NOT_FOUND'
| 'AUDIO_TIMELINE_CLIP_NOT_FOUND'
| 'AUDIO_TIMELINE_INVALID_CLIP_MOVE'
| 'AUDIO_TIMELINE_INVALID_SPLIT'
| 'AUDIO_TIMELINE_INVALID_TRIM'
| 'AUDIO_TIMELINE_ADAPTER_NOT_SET'
| 'AUDIO_TIMELINE_TRANSACTION_NOT_ACTIVE'
const AUDIO_TIMELINE_ERROR_METADATA: Readonly<Record<TimelineErrorCode, CodeMetadata>>
function registerTimelineErrorCodes(): void
Zod schemas
TrackSchema
ClipSchema
TimelineStateSchema
TimelineEngineOptionsSchema
File layout
packages/audio/audio-timeline/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts
engine/
engine.ts
engine.md
engine.types.ts
engine.test.ts
index.ts
track/
track.ts
track.md
track.types.ts
track.test.ts
index.ts
clip/
clip.ts
clip.md
clip.types.ts
clip.test.ts
index.ts
operations/
clip-operations.ts
clip-operations.md
clip-operations.test.ts
timeline-operations.ts
timeline-operations.md
timeline-operations.test.ts
index.ts
adapter/
adapter.ts
adapter.md
adapter.types.ts
adapter.test.ts
index.ts
mixer/
mixer.ts
mixer.md
mixer.test.ts
index.ts
zoom/
zoom.ts
zoom.md
zoom.test.ts
index.ts
__benchmarks__/
timeline.bench.ts
tests/
integration.test.ts
Impact
Breaking changes: none — fresh build, package does not yet exist on genesis.
Prerequisites: @suaveplan/audio-core, @suaveplan/undo, @suaveplan/fsm, @suaveplan/events, @suaveplan/serialize, @suaveplan/error, @suaveplan/types published to Verdaccio.
Affected dependents
@suaveplan/audio-timeline is consumed by the rendering-engine tier — audio-engine-browser and
audio-engine-server each implement its TimelineAdapter interface. At the time of this change
none of the dependents exist on genesis yet (they land in later changes in this same batch), so
there is no existing dependent surface to test or migrate. Each dependent is created against this
package's spec delta, not retrofitted.
@suaveplan/audio-engine-browser— [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in enterprise-audio-engine-browser, where it implementsTimelineAdapter)@suaveplan/audio-engine-server— [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in enterprise-audio-engine-server, where it implementsTimelineAdapter)