273 lines
7.9 KiB
Markdown
273 lines
7.9 KiB
Markdown
# 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<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
|
|
|
|
```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<Record<TimelineErrorCode, CodeMetadata>>
|
|
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`)
|