Initial Commit
This commit is contained in:
218
openspec/archive/2026-05-15-enterprise-audio-timeline/design.md
Normal file
218
openspec/archive/2026-05-15-enterprise-audio-timeline/design.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# 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:
|
||||
|
||||
```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 // 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. `startTick` is optional musical metadata used only when the adapter exposes
|
||||
`ticksToSeconds` / `secondsToTicks`; otherwise the engine derives time from `bpm`/`ppqn`/
|
||||
`sampleRate`.
|
||||
- **`documentId`, not `AudioDocument`.** Keeping a string reference instead of the object means a
|
||||
timeline session serializes cleanly through `@suaveplan/serialize` without dragging PCM pages
|
||||
into the session blob, and it keeps `audio-timeline` a pure universal-tier package.
|
||||
- **Ordered clip list per track.** Clips are kept sorted by `startSample` so overlap checks in
|
||||
`constrainClipDrag` and `constrainBoundaryTrim` are 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
|
||||
|
||||
```ts
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
- `constrainClipDrag` clamps the requested delta so `startSample >= 0` and (unless overlap is
|
||||
permitted by config) the clip does not overlap a sibling. A constrained delta of `0` is a true
|
||||
no-op — no state change, no undo command, no `clipChange` event.
|
||||
- `constrainBoundaryTrim` enforces the `minDuration` floor and keeps `offsetSamples` inside the
|
||||
source document. A left trim adjusts `startSample` + `offsetSamples`; a right trim adjusts only
|
||||
`durationSamples`.
|
||||
- `canSplitAt` gates `splitClip`: the split point must be strictly interior and both halves must
|
||||
satisfy `minDuration`. Fade curves are partitioned — `fadeIn` to the left clip, `fadeOut` to the
|
||||
right.
|
||||
|
||||
### 3.2. Undo/redo via `@suaveplan/undo`
|
||||
|
||||
```ts
|
||||
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`
|
||||
|
||||
```ts
|
||||
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; `Clip`
|
||||
references an `AudioDocument` by `documentId` only.
|
||||
- **`@suaveplan/undo`** — `CommandStack` backs the entire history model, including transaction
|
||||
batching and command merging.
|
||||
- **`@suaveplan/fsm`** — the playback machine guards transport transitions.
|
||||
- **`@suaveplan/events`** — backs the typed `TimelineEventMap` (`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 a `TimelineSessionJSON` (tracks,
|
||||
clips, transport state, selection, loop region, zoom level, tempo) through `serialize` /
|
||||
`deserialize`; `fromJSON` reconstructs an engine with a fresh, empty `CommandStack`.
|
||||
- **`@suaveplan/error`** — `TimelineError` base class and the `AUDIO_TIMELINE_*` code namespace.
|
||||
- **`@suaveplan/types`** — `LoggerLike`, `Clock` for the injectable logger and time source.
|
||||
|
||||
---
|
||||
|
||||
## 5. File layout
|
||||
|
||||
```text
|
||||
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 keeps `audio-timeline` universal
|
||||
and lets browser / server / offline backends share one engine without `#if`-style branching.
|
||||
- **`documentId` reference, not `AudioDocument` instance.** A clip referencing an id keeps the
|
||||
timeline a pure metadata model, makes sessions serialize without PCM, and avoids a hard runtime
|
||||
coupling to `audio-core`'s heavy paged container.
|
||||
- **No-op drags never touch the undo stack.** A constrained delta of `0` produces 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`/`commitTransaction` produce one
|
||||
composite command; nested transactions are out of scope. `commit`/`abort` with no active
|
||||
transaction throws `AUDIO_TIMELINE_TRANSACTION_NOT_ACTIVE` rather than silently no-op'ing, so
|
||||
mis-paired calls surface immediately.
|
||||
- **Undo history is session-local.** `toJSON()` deliberately omits the `CommandStack`; 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 `updateTrack` per changed track
|
||||
instead of `setTracks` avoids tearing down and rebuilding the adapter's node graph mid-playback
|
||||
— the difference between a glitch-free undo and an audible dropout.
|
||||
@@ -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`)
|
||||
@@ -0,0 +1,366 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-timeline (audio-timeline)
|
||||
|
||||
**Package**: `@suaveplan/audio-timeline`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The `@suaveplan/audio-timeline` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All audio rendering, scheduling, and platform-specific behaviour SHALL be reached only through the injected `TimelineAdapter`. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/undo`, `@suaveplan/fsm`, `@suaveplan/events`, `@suaveplan/serialize`, `@suaveplan/error`, `@suaveplan/types`, `tslib`, and `zod`; `@opentelemetry/api` is the only permitted non-workspace runtime dependency.
|
||||
|
||||
#### Scenario: Built dist references no platform globals
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd
|
||||
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `node:fs`, `node:path`, `process`, or `Buffer`
|
||||
|
||||
#### Scenario: Tests pass under both runtimes
|
||||
|
||||
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
|
||||
- **THEN** both runs pass with identical results
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: TimelineEngine construction and state snapshot
|
||||
|
||||
The package SHALL export a `TimelineEngine` class constructed from optional `TimelineEngineOptions` (`sampleRate?`, `ppqn?`, `logger?`). The engine SHALL hold the authoritative timeline state internally and expose it only through `getState()`, which SHALL return an immutable `TimelineState` snapshot — mutating a returned snapshot SHALL NOT affect engine state. `TimelineState` SHALL carry `tracks`, `tracksVersion`, `duration`, `currentTime`, `isPlaying`, `samplesPerPixel`, `sampleRate`, `selectedTrackId`, `zoomIndex`, `canZoomIn`, `canZoomOut`, `selectionStart`, `selectionEnd`, `masterVolume`, `loopStart`, `loopEnd`, `isLoopEnabled`, `bpm`, `ppqn`, `canUndo`, and `canRedo`. `tracksVersion` SHALL increment on every mutation that changes the track array so consumers can diff cheaply.
|
||||
|
||||
#### Scenario: getState returns an immutable snapshot
|
||||
|
||||
- **WHEN** a caller calls `getState()`, mutates the returned `tracks` array, then calls `getState()` again
|
||||
- **THEN** the second snapshot is unchanged by the mutation
|
||||
|
||||
#### Scenario: tracksVersion increments on track mutation
|
||||
|
||||
- **WHEN** a caller calls `addTrack(...)` then reads `getState().tracksVersion`
|
||||
- **THEN** the value is strictly greater than it was before the call
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"getState returns an immutable TimelineState snapshot"` AND `src/engine/engine.test.ts::"tracksVersion increments on every track mutation"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Track model and CRUD operations
|
||||
|
||||
The package SHALL export a `Track` type (`id`, `name`, `clips`, `volume` in `0..1`, `muted`, `soloed`, `pan` in `-1..1`) and the engine SHALL expose `setTracks`, `addTrack`, `removeTrack`, `updateTrack`, and `selectTrack`. `setTracks` SHALL replace the entire track array; `addTrack` SHALL append; `removeTrack` SHALL delete by `id`; `updateTrack` SHALL replace a track in place by `id`. Each mutation SHALL push an undo command onto the `@suaveplan/undo` `CommandStack` and emit a `trackChange` event. Referencing a track id that does not exist SHALL throw `TimelineError` with code `AUDIO_TIMELINE_TRACK_NOT_FOUND`.
|
||||
|
||||
#### Scenario: addTrack then removeTrack round-trips the track array
|
||||
|
||||
- **WHEN** a caller calls `addTrack(track)` then `removeTrack(track.id)`
|
||||
- **THEN** `getState().tracks` no longer contains the track and a `trackChange` event fired for each mutation
|
||||
|
||||
#### Scenario: Unknown track id throws AUDIO_TIMELINE_TRACK_NOT_FOUND
|
||||
|
||||
- **WHEN** a caller calls `removeTrack` with an id not present in the timeline
|
||||
- **THEN** it throws `TimelineError` whose `code === "AUDIO_TIMELINE_TRACK_NOT_FOUND"` with the offending id in `context`
|
||||
|
||||
**Verified by:** `src/track/track.test.ts::"create/update/remove manage tracks"` AND `src/engine/engine.test.ts::"track methods throw AUDIO_TIMELINE_TRACK_NOT_FOUND for unknown id"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Clip model and clip lifecycle
|
||||
|
||||
The package SHALL export a `Clip` type (`id`, `documentId` referencing an `AudioDocument`, `startSample` on the timeline, `offsetSamples` within the source document, `durationSamples`, optional `startTick`, optional `fadeIn`/`fadeOut` `FadeCurve`). The engine SHALL expose `addClip(trackId, clip)` and `removeClip(trackId, clipId)`. `addClip` SHALL insert the clip into the named track; `removeClip` SHALL delete it by `id`. Both SHALL push an undo command and emit a `clipChange` event. Referencing a clip id not present on the track SHALL throw `TimelineError` with code `AUDIO_TIMELINE_CLIP_NOT_FOUND`.
|
||||
|
||||
#### Scenario: addClip places a clip on the named track
|
||||
|
||||
- **WHEN** a caller calls `addClip(trackId, clip)`
|
||||
- **THEN** `getState()` shows the clip on that track and a `clipChange` event fired
|
||||
|
||||
#### Scenario: Unknown clip id throws AUDIO_TIMELINE_CLIP_NOT_FOUND
|
||||
|
||||
- **WHEN** a caller calls `removeClip(trackId, clipId)` with a `clipId` not on the track
|
||||
- **THEN** it throws `TimelineError` whose `code === "AUDIO_TIMELINE_CLIP_NOT_FOUND"`
|
||||
|
||||
**Verified by:** `src/clip/clip.test.ts::"createClip and validateClip build valid clips"` AND `src/engine/engine.test.ts::"clip methods throw AUDIO_TIMELINE_CLIP_NOT_FOUND for unknown id"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Clip drag with overlap constraint
|
||||
|
||||
`moveClip(trackId, clipId, deltaSamples)` SHALL shift a clip along the timeline by `deltaSamples` after passing the request through `constrainClipDrag`, which clamps the delta so the clip's `startSample` never goes negative and — unless overlap is explicitly permitted — so the clip does not overlap a sibling clip on the same track. `moveClip` SHALL return the **applied** delta after constraint. When the constrained delta is `0` the move SHALL be a no-op: no state change, no undo command pushed, no `clipChange` event. A non-zero applied delta SHALL push one undo command and emit `clipChange`.
|
||||
|
||||
#### Scenario: Drag is clamped to avoid sibling overlap
|
||||
|
||||
- **WHEN** a caller calls `moveClip` with a `deltaSamples` large enough to overlap the next clip
|
||||
- **THEN** the returned applied delta is clamped so the clip abuts but does not overlap the sibling
|
||||
|
||||
#### Scenario: Zero constrained delta is a no-op
|
||||
|
||||
- **WHEN** `constrainClipDrag` clamps the requested delta to `0`
|
||||
- **THEN** `moveClip` returns `0`, `getState()` is unchanged, `canUndo` is unchanged, and no `clipChange` event fired
|
||||
|
||||
**Verified by:** `src/operations/clip-operations.test.ts::"moveClip clamps drag to avoid sibling overlap"` AND `src/operations/clip-operations.test.ts::"moveClip with zero constrained delta pushes no undo command"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Clip trim with minimum-duration guard
|
||||
|
||||
`trimClip(trackId, clipId, boundary, deltaSamples)` SHALL move the `'left'` or `'right'` edge of a clip by `deltaSamples` after passing the request through `constrainBoundaryTrim`, which enforces a `minDuration` floor and keeps `offsetSamples` within the bounds of the source document. A left trim SHALL adjust both `startSample` and `offsetSamples`; a right trim SHALL adjust only `durationSamples`. A trim that would push `durationSamples` below `minDuration` SHALL throw `TimelineError` with code `AUDIO_TIMELINE_INVALID_TRIM`. A successful trim SHALL push one undo command and emit `clipChange`.
|
||||
|
||||
#### Scenario: Right trim shortens duration within the floor
|
||||
|
||||
- **WHEN** a caller right-trims a clip by a delta that keeps `durationSamples` above `minDuration`
|
||||
- **THEN** `durationSamples` decreases by the constrained delta and `startSample` is unchanged
|
||||
|
||||
#### Scenario: Over-trim throws AUDIO_TIMELINE_INVALID_TRIM
|
||||
|
||||
- **WHEN** a caller trims a clip such that `durationSamples` would fall below `minDuration`
|
||||
- **THEN** it throws `TimelineError` whose `code === "AUDIO_TIMELINE_INVALID_TRIM"`
|
||||
|
||||
**Verified by:** `src/operations/clip-operations.test.ts::"trimClip shortens duration within the minDuration floor"` AND `src/operations/clip-operations.test.ts::"trimClip throws AUDIO_TIMELINE_INVALID_TRIM below minDuration"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Clip split at a sample boundary
|
||||
|
||||
`splitClip(trackId, clipId, atSample)` SHALL replace one clip with two adjacent clips that together cover the original range, splitting at the timeline position `atSample`. `canSplitAt` SHALL gate the operation: `atSample` MUST lie strictly inside the clip and both resulting clips MUST satisfy the `minDuration` floor. The left clip SHALL retain the original `offsetSamples`; the right clip's `offsetSamples` SHALL advance by the left clip's `durationSamples`; fade curves SHALL be partitioned so `fadeIn` stays on the left clip and `fadeOut` on the right. An invalid split point SHALL throw `TimelineError` with code `AUDIO_TIMELINE_INVALID_SPLIT`. A successful split SHALL push one undo command and emit `clipChange`.
|
||||
|
||||
#### Scenario: Split produces two contiguous clips
|
||||
|
||||
- **WHEN** a caller splits a clip at a valid interior `atSample`
|
||||
- **THEN** the track holds two clips whose ranges are contiguous and together equal the original clip's range
|
||||
|
||||
#### Scenario: Split outside the clip throws AUDIO_TIMELINE_INVALID_SPLIT
|
||||
|
||||
- **WHEN** a caller calls `splitClip` with an `atSample` at or beyond a clip boundary
|
||||
- **THEN** it throws `TimelineError` whose `code === "AUDIO_TIMELINE_INVALID_SPLIT"`
|
||||
|
||||
**Verified by:** `src/operations/clip-operations.test.ts::"splitClip produces two contiguous clips"` AND `src/operations/clip-operations.test.ts::"splitClip throws AUDIO_TIMELINE_INVALID_SPLIT for an out-of-range point"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Per-track mixer state
|
||||
|
||||
The engine SHALL expose `setTrackVolume(trackId, volume)`, `setTrackMute(trackId, muted)`, `setTrackSolo(trackId, soloed)`, `setTrackPan(trackId, pan)`, and `setMasterVolume(volume)`. `volume` and `masterVolume` SHALL be clamped to `0..1` and `pan` to `-1..1`. Each call SHALL update the corresponding `Track` (or master) field, forward the change to the adapter via the matching `TimelineAdapter` method, push an undo command, and emit a `trackChange` event. When at least one track is soloed, the effective gain of every non-soloed track SHALL be `0` while the soloed tracks keep their own `volume`; when no track is soloed every track uses its own `volume`.
|
||||
|
||||
#### Scenario: Volume is clamped and forwarded to the adapter
|
||||
|
||||
- **WHEN** a caller calls `setTrackVolume(trackId, 1.5)`
|
||||
- **THEN** the stored track `volume` is `1` and `adapter.setTrackVolume` was called with the clamped value
|
||||
|
||||
#### Scenario: Solo mutes non-soloed tracks
|
||||
|
||||
- **WHEN** one track is soloed and another is not
|
||||
- **THEN** the soloed track's effective gain equals its `volume` and the non-soloed track's effective gain is `0`
|
||||
|
||||
**Verified by:** `src/mixer/mixer.test.ts::"clamps volume and pan to their valid ranges"` AND `src/mixer/mixer.test.ts::"solo group zeroes the gain of non-soloed tracks"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zoom levels and selection
|
||||
|
||||
The package SHALL export a discrete zoom-level table of `samplesPerPixel` values. `zoomIn` SHALL step to the next finer level and `zoomOut` to the next coarser level; `setZoomLevel(samplesPerPixel)` SHALL snap to the nearest table entry. `getState()` SHALL expose `zoomIndex`, `samplesPerPixel`, `canZoomIn`, and `canZoomOut`, with `canZoomIn`/`canZoomOut` `false` at the table's ends. `setSelection(start, end)` SHALL store a normalised selection range (`start <= end`, both clamped to `0..duration`). Zoom and selection changes SHALL emit `zoomChange` and `selectionChange` events respectively. Zoom and selection are view state — they SHALL NOT push undo commands.
|
||||
|
||||
#### Scenario: zoomOut at the coarsest level is a no-op
|
||||
|
||||
- **WHEN** the timeline is at the coarsest zoom level and a caller calls `zoomOut()`
|
||||
- **THEN** `samplesPerPixel` is unchanged and `canZoomOut` is `false`
|
||||
|
||||
#### Scenario: setSelection normalises a reversed range
|
||||
|
||||
- **WHEN** a caller calls `setSelection(end, start)` with `end > start`
|
||||
- **THEN** `getState().selectionStart <= getState().selectionEnd` and a `selectionChange` event fired
|
||||
|
||||
**Verified by:** `src/zoom/zoom.test.ts::"zoomIn/zoomOut respect the table bounds"` AND `src/operations/timeline-operations.test.ts::"setSelection normalises and clamps the range"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Loop region and transport playback
|
||||
|
||||
The engine SHALL expose `setLoopRegion(start, end)`, `setLoopEnabled(enabled)`, and the transport methods `init`, `play`, `pause`, `stop`, `seek`, and `getCurrentTime`. Transport methods SHALL delegate to the `TimelineAdapter` and SHALL be guarded by a `@suaveplan/fsm` playback machine with states `stopped`, `playing`, and `paused`: `play` is valid from `stopped` or `paused`, `pause` only from `playing`, `stop` from `playing` or `paused`, and `seek` from `playing` or `paused`. An out-of-order transport call (e.g. `pause` while `stopped`) SHALL be rejected by the FSM guard rather than forwarded to the adapter. Successful transitions SHALL emit the matching `play`/`pause`/`stop`/`seek` event and a `loopChange` event SHALL fire on loop-region or loop-enabled changes.
|
||||
|
||||
#### Scenario: Playback follows the FSM transitions
|
||||
|
||||
- **WHEN** a caller calls `play()` from `stopped` then `pause()`
|
||||
- **THEN** `getState().isPlaying` is `false` after `pause`, the adapter received `play` then `pause`, and the matching events fired
|
||||
|
||||
#### Scenario: Invalid transport transition is rejected by the FSM
|
||||
|
||||
- **WHEN** a caller calls `pause()` while the playback machine is in `stopped`
|
||||
- **THEN** the FSM guard rejects the transition and `adapter.pause` is not called
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"transport methods follow the fsm playback transitions"` AND `src/engine/engine.test.ts::"invalid transport transition is rejected before reaching the adapter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Tempo and musical time
|
||||
|
||||
The engine SHALL expose `setTempo(bpm, atTick?)` and `getTempo()`. `setTempo` SHALL update the timeline `bpm`, forward the change to `adapter.setTempo(bpm, atTick?)`, push an undo command, and emit a `tempoChange` event. `ppqn` SHALL be fixed at construction (default supplied by `TimelineEngineOptions`) and exposed on `TimelineState`. When the adapter implements the optional `ticksToSeconds` / `secondsToTicks` members, the engine SHALL use them to convert a clip's `startTick` to a sample position; absent those members the engine SHALL fall back to a `bpm`/`ppqn`/`sampleRate` computation.
|
||||
|
||||
#### Scenario: setTempo updates state and forwards to the adapter
|
||||
|
||||
- **WHEN** a caller calls `setTempo(140)`
|
||||
- **THEN** `getState().bpm === 140`, `adapter.setTempo` was called with `140`, and a `tempoChange` event fired
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"setTempo updates bpm, forwards to the adapter, and emits tempoChange"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: TimelineAdapter interface — plugin slot 5
|
||||
|
||||
The package SHALL export the `TimelineAdapter` interface — plugin slot 5 of the audio family's six plugin slots (see `enterprise-audio-core/design.md` §1.3). It SHALL declare `init`, `setTracks`, `addTrack`, `removeTrack`, `updateTrack`, `play`, `pause`, `stop`, `seek`, `getCurrentTime`, `setLoop`, `setMasterVolume`, `setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`, `setTempo`, `dispose`, the readonly `lookAhead` and `ppqn` properties, and the optional `ticksToSeconds` / `secondsToTicks` converters. The package SHALL ship a `NullTimelineAdapter` reference implementation whose methods are no-ops (with `getCurrentTime` returning a monotonically advanceable test value) so the engine and downstream tests can run without a rendering backend. Calling an engine method that requires the adapter while no adapter is set SHALL throw `TimelineError` with code `AUDIO_TIMELINE_ADAPTER_NOT_SET`.
|
||||
|
||||
#### Scenario: NullTimelineAdapter satisfies the full interface
|
||||
|
||||
- **WHEN** a caller constructs `new NullTimelineAdapter()`
|
||||
- **THEN** every `TimelineAdapter` member is present and callable without throwing
|
||||
|
||||
#### Scenario: Transport without an adapter throws AUDIO_TIMELINE_ADAPTER_NOT_SET
|
||||
|
||||
- **WHEN** a caller calls `play()` on an engine that has no adapter set
|
||||
- **THEN** it throws `TimelineError` whose `code === "AUDIO_TIMELINE_ADAPTER_NOT_SET"`
|
||||
|
||||
**Verified by:** `src/adapter/adapter.test.ts::"NullTimelineAdapter implements the full TimelineAdapter interface"` AND `src/engine/engine.test.ts::"transport without an adapter throws AUDIO_TIMELINE_ADAPTER_NOT_SET"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Undo/redo via @suaveplan/undo
|
||||
|
||||
The engine SHALL delegate history to a `@suaveplan/undo` `CommandStack`. Every mutating operation — track CRUD, clip add/remove/move/trim/split, mixer changes, and `setTempo` — SHALL push a command with paired `do`/`undo` closures. `undo()` SHALL revert the last command and `redo()` SHALL reapply it; `canUndo` / `canRedo` getters SHALL reflect stack state and SHALL also surface on `TimelineState`. `clearHistory()` SHALL empty the stack. `undo` / `redo` SHALL emit the same change events as the forward operation so consumers re-render consistently.
|
||||
|
||||
#### Scenario: Mutation then undo restores prior state
|
||||
|
||||
- **WHEN** a caller calls `addTrack(track)` then `undo()`
|
||||
- **THEN** `getState().tracks` no longer contains the track and `canRedo` is `true`
|
||||
|
||||
#### Scenario: redo reapplies the undone command
|
||||
|
||||
- **WHEN** a caller calls `undo()` then `redo()` after a clip move
|
||||
- **THEN** the clip is back at its post-move position and `canUndo` is `true`
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"undo reverts the last mutation and enables redo"` AND `src/engine/engine.test.ts::"redo reapplies an undone command"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Transaction batching of undo commands
|
||||
|
||||
The engine SHALL expose `beginTransaction()`, `commitTransaction()`, and `abortTransaction()`. Between `beginTransaction` and `commitTransaction`, mutating operations SHALL be coalesced into a single composite command pushed on commit, so a multi-step edit undoes in one step. `abortTransaction()` SHALL roll back every operation performed since `beginTransaction` and push nothing. Calling `commitTransaction` or `abortTransaction` with no active transaction SHALL throw `TimelineError` with code `AUDIO_TIMELINE_TRANSACTION_NOT_ACTIVE`.
|
||||
|
||||
#### Scenario: Committed transaction undoes as one step
|
||||
|
||||
- **WHEN** a caller wraps three clip moves in `beginTransaction` / `commitTransaction` then calls `undo()` once
|
||||
- **THEN** all three moves are reverted and a single `undo` step was consumed
|
||||
|
||||
#### Scenario: commitTransaction without an active transaction throws
|
||||
|
||||
- **WHEN** a caller calls `commitTransaction()` with no transaction open
|
||||
- **THEN** it throws `TimelineError` whose `code === "AUDIO_TIMELINE_TRANSACTION_NOT_ACTIVE"`
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"committed transaction collapses into one undo step"` AND `src/engine/engine.test.ts::"commitTransaction without an active transaction throws AUDIO_TIMELINE_TRANSACTION_NOT_ACTIVE"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Incremental adapter updates on undo/redo
|
||||
|
||||
On `undo()` and `redo()`, when the track **count** is unchanged between the pre- and post-state, the engine SHALL call `adapter.updateTrack(trackId, track)` once per changed track instead of `adapter.setTracks(tracks)`, so playback is not interrupted by a full re-sync. When the track count changes, the engine SHALL fall back to `adapter.setTracks`.
|
||||
|
||||
#### Scenario: Same-count undo issues per-track updates
|
||||
|
||||
- **WHEN** a caller undoes a clip move on a timeline whose track count is unchanged
|
||||
- **THEN** `adapter.updateTrack` is called for the affected track and `adapter.setTracks` is not called
|
||||
|
||||
#### Scenario: Count-changing undo issues a full setTracks
|
||||
|
||||
- **WHEN** a caller undoes an `addTrack` (track count changes)
|
||||
- **THEN** `adapter.setTracks` is called with the restored track array
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"undo with unchanged track count issues per-track updateTrack calls"` AND `src/engine/engine.test.ts::"undo that changes track count falls back to setTracks"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Typed timeline event surface
|
||||
|
||||
`TimelineEngine` SHALL expose `on` / `off` methods typed against `TimelineEventMap` with the topics `play`, `pause`, `stop`, `seek`, `trackChange`, `clipChange`, `tempoChange`, `loopChange`, `selectionChange`, and `zoomChange`, backed by `@suaveplan/events`. Listener registration and removal SHALL be idempotent — registering the same listener twice SHALL fire it once, and `off` for an unregistered listener SHALL be a no-op.
|
||||
|
||||
#### Scenario: clipChange fires once per clip mutation
|
||||
|
||||
- **WHEN** a listener is registered for `clipChange` and a caller calls `addClip(...)`
|
||||
- **THEN** the listener is invoked exactly once
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"on/off manage typed timeline listeners idempotently"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Session save/load via @suaveplan/serialize
|
||||
|
||||
The engine SHALL expose `toJSON()` returning a `TimelineSessionJSON` value — tracks, clips, transport state, selection, loop region, zoom level, tempo — that round-trips exactly through `@suaveplan/serialize`, and a static `fromJSON(json)` (or constructor option) that reconstructs an engine whose `getState()` is deep-equal to the saved snapshot. Undo history SHALL NOT be serialized; a reconstructed engine SHALL start with an empty `CommandStack`.
|
||||
|
||||
#### Scenario: Session round-trips through serialize
|
||||
|
||||
- **WHEN** a caller serializes `engine.toJSON()`, deserializes it, and reconstructs an engine
|
||||
- **THEN** the reconstructed engine's `getState()` is deep-equal to the original's (excluding `canUndo`/`canRedo`, which reset to `false`)
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"toJSON round-trips a session through @suaveplan/serialize"` AND `tests/property.test.ts::"reconstructed session state equals the original"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
Every public options object — `TimelineEngineOptions`, `Track`, `Clip`, `TimelineState` — SHALL have a Zod schema (`TimelineEngineOptionsSchema`, `TrackSchema`, `ClipSchema`, `TimelineStateSchema`) exported from a sibling `*.schemas.ts`, and every public entry point that accepts one SHALL validate its argument against the schema before use. A validation failure SHALL throw `TimelineError` with code `AUDIO_TIMELINE_INVALID_CLIP_MOVE` for clip-geometry violations and a dedicated invalid-options code otherwise, carrying the Zod issue list in `context`.
|
||||
|
||||
#### Scenario: Invalid clip is rejected at the boundary
|
||||
|
||||
- **WHEN** a caller calls `addClip` with a clip whose `durationSamples` is negative
|
||||
- **THEN** it throws `TimelineError` carrying the Zod issues in `context`
|
||||
|
||||
**Verified by:** `src/clip/clip.test.ts::"validateClip rejects invalid clip geometry with the Zod issues attached"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes
|
||||
|
||||
The package SHALL export `TimelineErrorCode`, `AUDIO_TIMELINE_ERROR_METADATA`, and `registerTimelineErrorCodes()`, registering the codes `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`, and `AUDIO_TIMELINE_TRANSACTION_NOT_ACTIVE` with `@suaveplan/error`. Every error thrown by the package SHALL be a `TimelineError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
|
||||
|
||||
#### Scenario: Every registered code is thrown by a call site
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** each code in `AUDIO_TIMELINE_ERROR_METADATA` is asserted thrown by at least one test
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every AUDIO_TIMELINE code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `timeline.clip_operation` (covering move/trim/split) and `timeline.transport` (covering play/pause/stop/seek); counters `audio_timeline_mutations_total` and `audio_timeline_undo_total`; a histogram `audio_timeline_clip_operation_duration_ms`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
|
||||
|
||||
#### Scenario: Instrumentation is inert without an exporter
|
||||
|
||||
- **WHEN** the package is imported and used with no OTel exporter registered
|
||||
- **THEN** no error is thrown and no global state is mutated at import time
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"clip operations record a span and histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation
|
||||
|
||||
`TimelineEngine.init()` and any engine method that awaits adapter work SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to reject before any adapter call is issued; a signal aborted mid-`init` SHALL terminate initialisation and leave the engine without an active adapter session. The rejection SHALL be a `TimelineError` carrying the abort cause.
|
||||
|
||||
#### Scenario: Pre-aborted signal short-circuits init
|
||||
|
||||
- **WHEN** `init()` is called with an already-aborted signal
|
||||
- **THEN** it rejects with a `TimelineError` before `adapter.init` is called
|
||||
|
||||
**Verified by:** `src/engine/engine.test.ts::"init rejects for a pre-aborted signal before calling the adapter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per feature module
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per feature module (`./engine/engine`, `./track/track`, `./clip/clip`, `./operations/clip-operations`, `./operations/timeline-operations`, `./adapter/adapter`, `./mixer/mixer`, `./zoom/zoom`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
|
||||
|
||||
#### Scenario: Each feature module is independently importable
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/audio-timeline/adapter/adapter`
|
||||
- **THEN** the import resolves to the adapter module without pulling the whole package barrel
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
112
openspec/archive/2026-05-15-enterprise-audio-timeline/tasks.md
Normal file
112
openspec/archive/2026-05-15-enterprise-audio-timeline/tasks.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Tasks: enterprise-audio-timeline
|
||||
|
||||
**Change ID**: enterprise-audio-timeline
|
||||
**Package**: `@suaveplan/audio-timeline`
|
||||
**Target path**: `packages/audio/audio-timeline`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/undo`, `@suaveplan/fsm`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] Create directory `packages/audio/audio-timeline/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
|
||||
- [x] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/undo workspace:^`, `@suaveplan/fsm workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `zod catalog:forms`.
|
||||
- [x] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
|
||||
- [x] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types + errors
|
||||
|
||||
- [x] Write `src/engine/engine.types.ts` — `TimelineEngineOptions`, `TimelineState`, `FadeCurve`, `TimelineEventMap`.
|
||||
- [x] Write `src/track/track.types.ts` — `Track`.
|
||||
- [x] Write `src/clip/clip.types.ts` — `Clip`.
|
||||
- [x] Write `src/adapter/adapter.types.ts` — `TimelineAdapter`.
|
||||
- [x] Write co-located `.md` files for each types module (≥200 words).
|
||||
- [x] Write `src/error-codes.ts` — `TimelineErrorCode`, `AUDIO_TIMELINE_ERROR_METADATA`, `registerTimelineErrorCodes()`.
|
||||
- [x] Write Zod schemas: `TrackSchema`, `ClipSchema`, `TimelineStateSchema`, `TimelineEngineOptionsSchema`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Track module
|
||||
|
||||
- [x] Implement `src/track/track.ts` — track CRUD helpers: `createTrack`, `updateTrack`, `removeTrack`, `validateTrack`.
|
||||
- [x] Write `track.test.ts` and `track.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Clip module
|
||||
|
||||
- [x] Implement `src/clip/clip.ts` — clip helpers: `createClip`, `validateClip`, `canSplitAt`, `constrainBoundaryTrim`.
|
||||
- [x] Write `clip.test.ts` and `clip.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Operations
|
||||
|
||||
- [x] Implement `src/operations/clip-operations.ts` — `moveClip`, `splitClip`, `trimClip`, `constrainClipDrag`, `constrainBoundaryTrim` with overlap and `minDuration` guards.
|
||||
- [x] Implement `src/operations/timeline-operations.ts` — `setSelection`, `setLoopRegion`, `setZoomLevel`, `zoomIn`, `zoomOut`.
|
||||
- [x] Write `clip-operations.test.ts`, `timeline-operations.test.ts`, and both `.md` files.
|
||||
|
||||
---
|
||||
|
||||
## 6. Adapter interface
|
||||
|
||||
- [x] Implement `src/adapter/adapter.ts` — `NullTimelineAdapter` for tests (no-op implementation of `TimelineAdapter`).
|
||||
- [x] Write `adapter.test.ts` and `adapter.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mixer
|
||||
|
||||
- [x] Implement `src/mixer/mixer.ts` — `MixerState`, `setMasterVolume`, `setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`, solo-group logic (non-soloed tracks gain = 0 when any track is soloed).
|
||||
- [x] Write `mixer.test.ts` and `mixer.md`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Zoom
|
||||
|
||||
- [x] Implement `src/zoom/zoom.ts` — zoom level table, `zoomIn`, `zoomOut`, `setZoomLevel`, `canZoomIn`, `canZoomOut`.
|
||||
- [x] Write `zoom.test.ts` and `zoom.md`.
|
||||
|
||||
---
|
||||
|
||||
## 9. TimelineEngine
|
||||
|
||||
- [x] Implement `TimelineEngine` in `src/engine/engine.ts`:
|
||||
- Constructor with `TimelineEngineOptions`, optional adapter.
|
||||
- `getState()` returns immutable `TimelineState` snapshot.
|
||||
- Track methods delegate to track module and push undo commands via `@suaveplan/undo` `CommandStack`.
|
||||
- Clip methods delegate to clip-operations; no-op drags do not push undo.
|
||||
- Playback methods delegate to adapter; FSM guards state transitions via `@suaveplan/fsm`.
|
||||
- Undo/redo: `undo()`, `redo()`, `beginTransaction()`, `commitTransaction()`, `abortTransaction()`.
|
||||
- Incremental adapter updates: on undo/redo, if track count is unchanged call `adapter.updateTrack()` per changed track instead of `adapter.setTracks()`.
|
||||
- Events: typed `on`/`off` via `@suaveplan/events` for `play`, `pause`, `stop`, `seek`, `trackChange`, `clipChange`, `tempoChange`, `loopChange`, `selectionChange`, `zoomChange`.
|
||||
- `dispose()` closes adapter and clears history.
|
||||
- [x] Write `engine.test.ts` (≤500 lines): init, play/pause/stop, add/remove track, add/move/split/trim clip, undo/redo round-trip, transaction abort.
|
||||
- [x] Write `engine.md`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Benchmarks
|
||||
|
||||
- [x] Write `src/__benchmarks__/timeline.bench.ts` — create 50 tracks, add 200 clips, perform 100 undo/redo cycles.
|
||||
|
||||
---
|
||||
|
||||
## 11. Integration tests
|
||||
|
||||
- [x] Write `tests/integration.test.ts` — full timeline lifecycle: create tracks, add clips, playback state transitions, undo/redo round-trip, adapter incremental update verification.
|
||||
|
||||
---
|
||||
|
||||
## 12. Docs + verify + publish
|
||||
|
||||
- [x] All co-located `.md` files (≥200 words each).
|
||||
- [x] Write `README.md` at package root.
|
||||
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/audio-timeline` — green.
|
||||
- [x] `bunx turbo test:coverage --filter=@suaveplan/audio-timeline` — 100%.
|
||||
- [x] `bun scripts/publish.ts --package @suaveplan/audio-timeline`.
|
||||
- [x] Fold `specs/audio-timeline/spec.md` into `openspec/specs/audio-timeline/spec.md`.
|
||||
- [x] Archive to `openspec/archive/2026-05-14-enterprise-audio-timeline/`.
|
||||
Reference in New Issue
Block a user