# 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 ```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 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 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 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(event: K, listener: TimelineEventMap[K]): void off(event: K, listener: TimelineEventMap[K]): void setAdapter(adapter: TimelineAdapter): void dispose(): void } ``` ### Error codes ```ts 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> function registerTimelineErrorCodes(): void ``` ### Zod schemas ```ts TrackSchema ClipSchema TimelineStateSchema TimelineEngineOptionsSchema ``` --- ## File layout ```text 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 implements `TimelineAdapter`) - `@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 implements `TimelineAdapter`)