Initial Commit
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
# 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`)
|
||||
Reference in New Issue
Block a user