Files
2026-05-27 23:29:53 +01:00

26 KiB

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"