Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View File

@@ -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`)

View File

@@ -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 -->

View 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/`.