# 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"`