Files
suaveplan/openspec/specs/audio-timeline/spec.md
2026-05-27 23:29:53 +01:00

367 lines
26 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: audio-timeline
**Package**: `@suaveplan/audio-timeline`
**Status**: STABLE
> 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 -->