first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
# @suaveplan/audio-timeline - Headless DAW timeline engine: multi-track arrangements with clips, playback, loop regions, selection, zoom, and per-track mixer state. Delegates rendering to a pluggable TimelineAdapter.
Headless DAW timeline engine with multi-track arrangements, clips, playback, loop regions, selection, zoom, per-track mixer state, undo/redo, and a pluggable `TimelineAdapter`.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter.md) — `NullTimelineAdapter` is a reference no-op implementation of the `TimelineAdapter` interface. It is useful for unit tes…
- [adapter](./src/adapter/adapter.md) — `NullTimelineAdapter` is a reference no-op implementation of the `TimelineAdapter` interface. It is useful for unit tes…
- [clip](./src/clip/clip.md) — The `clip.ts` module contains pure geometry helpers for audio clips. A `Clip` is a slice of an `AudioDocument` placed o…
- [clip](./src/clip/clip.md) — The `clip.ts` module contains pure geometry helpers for audio clips. A `Clip` is a slice of an `AudioDocument` placed o…
- [engine](./src/engine/engine.md) — `TimelineEngine` is the central state container for the `@suaveplan/audio-timeline` package. It owns the multi-track ar…
- [clip](./src/engine/clip/clip.md) — The `clip.ts` module contains the internal bridge between `TimelineEngine` public methods and the pure `clip-operations…
- [clip](./src/engine/clip/clip.md) — The `clip.ts` module contains the internal bridge between `TimelineEngine` public methods and the pure `clip-operations…
- [mixer](./src/engine/mixer/mixer.md) — The `mixer.ts` module bridges `TimelineEngine` mixer public methods to the pure `mixer.ts` helpers. It manages undo pus…
- [mixer](./src/engine/mixer/mixer.md) — The `mixer.ts` module bridges `TimelineEngine` mixer public methods to the pure `mixer.ts` helpers. It manages undo pus…
- [state](./src/engine/state/state.md) — The `state.ts` module builds the immutable `TimelineState` snapshot returned by `TimelineEngine.getState()`. It derives…
- [state](./src/engine/state/state.md) — The `state.ts` module builds the immutable `TimelineState` snapshot returned by `TimelineEngine.getState()`. It derives…
- [transport](./src/engine/transport/transport.md) — The `transport.ts` module bridges `TimelineEngine` transport public methods (`play`, `pause`, `stop`, `seek`) to the `T…
- [transport](./src/engine/transport/transport.md) — The `transport.ts` module bridges `TimelineEngine` transport public methods (`play`, `pause`, `stop`, `seek`) to the `T…
- [transport-fsm](./src/engine/transport-fsm/transport-fsm.md) — The `transport-fsm.ts` module creates the finite state machine that guards timeline transport transitions. It prevents…
- [transport-fsm](./src/engine/transport-fsm/transport-fsm.md) — The `transport-fsm.ts` module creates the finite state machine that guards timeline transport transitions. It prevents…
- [undo](./src/engine/undo/undo.md) — The `undo.ts` module provides the command builder and incremental adapter diff logic used by `TimelineEngine` for undo/…
- [undo](./src/engine/undo/undo.md) — The `undo.ts` module provides the command builder and incremental adapter diff logic used by `TimelineEngine` for undo/…
- [zoom](./src/engine/zoom/zoom.md) — The `zoom.ts` module bridges `TimelineEngine` zoom, selection, and loop public methods to the pure `timeline-operations…
- [zoom](./src/engine/zoom/zoom.md) — The `zoom.ts` module bridges `TimelineEngine` zoom, selection, and loop public methods to the pure `timeline-operations…
- [engine](./src/engine/engine.md) — `TimelineEngine` is the central state container for the `@suaveplan/audio-timeline` package. It owns the multi-track ar…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes.ts` module defines the `AUDIO_TIMELINE` error namespace and the `TimelineError` class used throughout…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes.ts` module defines the `AUDIO_TIMELINE` error namespace and the `TimelineError` class used throughout…
- [mixer](./src/mixer/mixer.md) — The `mixer.ts` module provides pure state updates for per-track and master mixer settings. Every function returns a sha…
- [mixer](./src/mixer/mixer.md) — The `mixer.ts` module provides pure state updates for per-track and master mixer settings. Every function returns a sha…
- [clip-operations](./src/operations/clip-operations/clip-operations.md) — The `clip-operations.ts` module implements the state-changing actions that the timeline engine performs on clips: drag,…
- [clip-operations](./src/operations/clip-operations/clip-operations.md) — The `clip-operations.ts` module implements the state-changing actions that the timeline engine performs on clips: drag,…
- [timeline-operations](./src/operations/timeline-operations/timeline-operations.md) — The `timeline-operations.ts` module handles view-state mutations that do **not** affect the audio arrangement and there…
- [timeline-operations](./src/operations/timeline-operations/timeline-operations.md) — The `timeline-operations.ts` module handles view-state mutations that do **not** affect the audio arrangement and there…
- [track](./src/track/track.md) — The `track.ts` module provides pure, immutable CRUD operations for the `Track` model used by the `@suaveplan/audio-time…
- [track](./src/track/track.md) — The `track.ts` module provides pure, immutable CRUD operations for the `Track` model used by the `@suaveplan/audio-time…
- [zoom](./src/zoom/zoom.md) — The `zoom.ts` module provides a discrete zoom table and navigation helpers for the timeline view. Zoom levels are expre…
- [zoom](./src/zoom/zoom.md) — The `zoom.ts` module provides a discrete zoom table and navigation helpers for the timeline view. Zoom levels are expre…
<!-- end:auto-docs -->

View File

@@ -0,0 +1,62 @@
# audio-timeline Spec Checklist
## Requirements
1. [ ] **Universal purity**
- [ ] Scenario: Built dist references no platform globals
- [ ] Scenario: Tests pass under both runtimes
2. [ ] **TimelineEngine construction and state snapshot**
- [ ] Scenario: getState returns an immutable snapshot
- [ ] Scenario: tracksVersion increments on track mutation
3. [ ] **Track model and CRUD operations**
- [ ] Scenario: addTrack then removeTrack round-trips the track array
- [ ] Scenario: Unknown track id throws AUDIO_TIMELINE_TRACK_NOT_FOUND
4. [ ] **Clip model and clip lifecycle**
- [ ] Scenario: addClip places a clip on the named track
- [ ] Scenario: Unknown clip id throws AUDIO_TIMELINE_CLIP_NOT_FOUND
5. [ ] **Clip drag with overlap constraint**
- [ ] Scenario: Drag is clamped to avoid sibling overlap
- [ ] Scenario: Zero constrained delta is a no-op
6. [ ] **Clip trim with minimum-duration guard**
- [ ] Scenario: Right trim shortens duration within the floor
- [ ] Scenario: Over-trim throws AUDIO_TIMELINE_INVALID_TRIM
7. [ ] **Clip split at a sample boundary**
- [ ] Scenario: Split produces two contiguous clips
- [ ] Scenario: Split outside the clip throws AUDIO_TIMELINE_INVALID_SPLIT
8. [ ] **Per-track mixer state**
- [ ] Scenario: Volume is clamped and forwarded to the adapter
- [ ] Scenario: Solo mutes non-soloed tracks
9. [ ] **Zoom levels and selection**
- [ ] Scenario: zoomOut at the coarsest level is a no-op
- [ ] Scenario: setSelection normalises a reversed range
10. [ ] **Loop region and transport playback**
- [ ] Scenario: Playback follows the FSM transitions
- [ ] Scenario: Invalid transport transition is rejected by the FSM
11. [ ] **Tempo and musical time**
- [ ] Scenario: setTempo updates state and forwards to the adapter
12. [ ] **TimelineAdapter interface — plugin slot 5**
- [ ] Scenario: NullTimelineAdapter satisfies the full interface
- [ ] Scenario: Transport without an adapter throws AUDIO_TIMELINE_ADAPTER_NOT_SET
13. [ ] **Undo/redo via @suaveplan/undo**
- [ ] Scenario: Mutation then undo restores prior state
- [ ] Scenario: redo reapplies the undone command
14. [ ] **Transaction batching of undo commands**
- [ ] Scenario: Committed transaction undoes as one step
- [ ] Scenario: commitTransaction without an active transaction throws
15. [ ] **Incremental adapter updates on undo/redo**
- [ ] Scenario: Same-count undo issues per-track updates
- [ ] Scenario: Count-changing undo issues a full setTracks
16. [ ] **Typed timeline event surface**
- [ ] Scenario: clipChange fires once per clip mutation
17. [ ] **Session save/load via @suaveplan/serialize**
- [ ] Scenario: Session round-trips through serialize
18. [ ] **Zod boundary validation**
- [ ] Scenario: Invalid clip is rejected at the boundary
19. [ ] **Structured error codes**
- [ ] Scenario: Every registered code is thrown by a call site
20. [ ] **OpenTelemetry instrumentation**
- [ ] Scenario: Instrumentation is inert without an exporter
21. [ ] **AbortSignal propagation**
- [ ] Scenario: Pre-aborted signal short-circuits init
22. [ ] **Subpath exports per feature module**
- [ ] Scenario: Each feature module is independently importable

View File

@@ -0,0 +1,47 @@
# NullTimelineAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NullTimelineAdapter` is a reference no-op implementation of the `TimelineAdapter` interface. It is useful for unit tests that need to verify `TimelineEngine` behaviour without setting up a real rendering backend. Because it implements every method, tests can exercise the full engine surface including transport, mixer, and loop APIs.
## Features
- Implements every method of `TimelineAdapter` as a no-op
- `getCurrentTime()` always returns `0`
- `lookAhead = 0` and `ppqn = 96`
- Safe to call `init`, `dispose`, `play`, `pause`, `stop`, `seek`, and all mixer methods
- Accepts all declared parameters so TypeScript strict checks pass
## Usage
### Basic
```ts
import { NullTimelineAdapter } from "@suaveplan/audio-timeline/adapter";
const adapter = new NullTimelineAdapter();
engine.setAdapter(adapter);
engine.play();
```
### Advanced
```ts
const adapter = new NullTimelineAdapter();
adapter.setTracks([]);
adapter.updateTrack("t1", track);
adapter.setLoop(0, 1000, true);
adapter.setTempo(130);
```
## API Reference
`NullTimelineAdapter` implements `TimelineAdapter`. All methods accept their declared arguments and return `void` (or `0` for `getCurrentTime`).
## Implementation Notes
The adapter deliberately does nothing. It exists so that engine tests can focus on arrangement logic, undo/redo, and event emission without needing a real audio context or Web Audio graph. In production, consumers provide their own adapter backed by a renderer such as a Web Audio scheduler or a waveform canvas. The interface is intentionally minimal so that adapters can be thin wrappers around existing audio engines or complex renderers with their own internal state. Because every method is a no-op, the NullTimelineAdapter is also safe to use as a default adapter before the real one is injected.

View File

@@ -0,0 +1,49 @@
# Clip Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `clip.ts` module contains pure geometry helpers for audio clips. A `Clip` is a slice of an `AudioDocument` placed on a timeline; it is defined by `startSample` (timeline position), `offsetSamples` (position inside the source document), and `durationSamples`. These helpers are used by both the timeline engine and by consumers who need to construct or validate clips outside the engine.
## Features
- `createClip` constructs a `Clip` with defaults and validates via `ClipSchema`
- `validateClip` guards clip objects at runtime with Zod
- `canSplitAt` predicates whether a split point is valid and produces two viable halves
- `constrainBoundaryTrim` computes new geometry after dragging a clip edge, enforcing a minimum duration floor
## Usage
### Basic
```ts
import { createClip, canSplitAt } from "@suaveplan/audio-timeline/clip";
const clip = createClip({ documentId: "doc-1", durationSamples: 1000 });
const ok = canSplitAt(clip, 500, 1); // true
```
### Advanced
```ts
import { constrainBoundaryTrim } from "@suaveplan/audio-timeline/clip";
const trimmed = constrainBoundaryTrim(clip, "left", 50, 10, 10000);
// { startSample: 50, offsetSamples: 50, durationSamples: 950 }
```
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `createClip` | `(options?) => Clip` | Build and validate a clip |
| `validateClip` | `(clip) => asserts clip is Clip` | Zod guard |
| `canSplitAt` | `(clip, atSample, minDuration) => boolean` | Split feasibility |
| `constrainBoundaryTrim` | `(clip, boundary, delta, minDuration, docLength) => object` | Trim geometry |
## Implementation Notes
Like the track module, every helper is immutable and side-effect free. Clip geometry is expressed in samples because that is the unit the rendering adapters consume. Musical-time variants (`startTick`) are optional metadata and do not affect the geometric helpers. The `minDuration` parameter prevents clips from shrinking below a usable size, which is important when the user is dragging edges with a mouse.

View File

@@ -0,0 +1,35 @@
# Engine Clip Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `clip.ts` module contains the internal bridge between `TimelineEngine` public methods and the pure `clip-operations.ts` helpers. It wraps each operation with OTel span recording, histogram timing, and event emission so the engine class stays focused on orchestration.
## Features
- `engineAddClip` appends a clip and emits `clipChange`
- `engineRemoveClip` filters a clip and emits `clipChange`
- `engineMoveClip` applies constrained drags, skips no-ops, and records duration
- `engineTrimClip` trims a boundary and records a `timeline.clip_operation` span
- `engineSplitClip` splits a clip and records a `timeline.clip_operation` span
## Usage
These helpers are used internally by `TimelineEngine`. Consumers interact with the engine's public methods instead.
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `engineAddClip` | `(deps, trackId, clip) => void` | Add clip |
| `engineRemoveClip` | `(deps, trackId, clipId) => void` | Remove clip |
| `engineMoveClip` | `(deps, trackId, clipId, delta) => number` | Move clip |
| `engineTrimClip` | `(deps, trackId, clipId, boundary, delta) => void` | Trim clip |
| `engineSplitClip` | `(deps, trackId, clipId, atSample) => void` | Split clip |
## Implementation Notes
The `ClipDeps` interface decouples the helpers from the engine class, making them testable in isolation. Histograms record `audio_timeline_clip_operation_duration_ms` for every trim and split. No-op moves (where the constrained delta is zero) skip both the undo push and the event emission, keeping the history clean. OTel spans include the operation name, track id, and clip id so that performance traces can pinpoint slow interactions.

View File

@@ -0,0 +1,52 @@
# TimelineEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TimelineEngine` is the central state container for the `@suaveplan/audio-timeline` package. It owns the multi-track arrangement, playback transport, mixer state, zoom/selection view state, and a full undo/redo history. All rendering is delegated to a pluggable `TimelineAdapter`, keeping the engine headless and suitable for both web and native targets.
## Features
- Track CRUD: `setTracks`, `addTrack`, `removeTrack`, `updateTrack`, `selectTrack`
- Clip CRUD: `addClip`, `removeClip`, `moveClip`, `trimClip`, `splitClip`
- Transport: `play`, `pause`, `stop`, `seek` guarded by an internal FSM
- Mixer: `setMasterVolume`, `setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`
- View: `zoomIn`, `zoomOut`, `setZoomLevel`, `setSelection`, `setLoopRegion`, `setLoopEnabled`
- Undo/redo with transaction support (`beginTransaction`, `commitTransaction`, `abortTransaction`)
- Serialization via `toJSON` and static `fromJSON`
- Typed events via `on` / `off`
- OTel spans and counters for clip operations and transport
## Usage
### Basic
```ts
import { TimelineEngine, NullTimelineAdapter } from "@suaveplan/audio-timeline";
const engine = new TimelineEngine({ sampleRate: 44100 });
engine.setAdapter(new NullTimelineAdapter());
engine.addTrack({ name: "Drums" });
engine.play();
```
### Advanced
```ts
engine.beginTransaction();
engine.addClip(trackId, clip);
engine.moveClip(trackId, clip.id, 500);
engine.commitTransaction();
engine.undo();
```
## API Reference
`TimelineEngine` exposes the full public surface documented in the spec. Key getters: `getState()` returns an immutable snapshot; `getCurrentTime()` reads from the adapter; `getTempo()` returns current BPM.
## Implementation Notes
The engine is deliberately headless. It does not touch the DOM or Web Audio directly; every output goes through the adapter. State mutations push `Command<Track[]>` instances onto an `UndoStack`. On undo/redo, if the track count is unchanged the engine calls `adapter.updateTrack` per changed track instead of `adapter.setTracks` to avoid playback interruption. Events are emitted through an `EventBus` so UI layers can subscribe to granular changes without polling.

View File

@@ -0,0 +1,35 @@
# Engine Mixer Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `mixer.ts` module bridges `TimelineEngine` mixer public methods to the pure `mixer.ts` helpers. It manages undo pushes and adapter forwarding for every mixer mutation, keeping the engine class from growing too large.
## Features
- `engineSetMasterVolume` updates master volume and pushes undo
- `engineSetTrackVolume` delegates to pure helper and forwards to adapter
- `engineSetTrackMute` toggles mute and emits via adapter
- `engineSetTrackSolo` toggles solo and emits via adapter
- `engineSetTrackPan` clamps pan and emits via adapter
## Usage
These helpers are used internally by `TimelineEngine`. Consumers call `engine.setTrackVolume(id, 0.8)` etc.
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `engineSetMasterVolume` | `(deps, volume) => void` | Master volume |
| `engineSetTrackVolume` | `(deps, trackId, volume) => void` | Track volume |
| `engineSetTrackMute` | `(deps, trackId, muted) => void` | Track mute |
| `engineSetTrackSolo` | `(deps, trackId, soloed) => void` | Track solo |
| `engineSetTrackPan` | `(deps, trackId, pan) => void` | Track pan |
## Implementation Notes
The `MixerDeps` interface isolates the helpers from the engine class. Every track mutation goes through `updateTrackInPlace`, which pushes an undo command automatically. The master volume operation is special-cased because it does not live on a track; it pushes its own undo command directly. All other mixer state lives on individual tracks and is therefore covered by the standard track-update undo path. This symmetry makes it easy to reason about what changes are reversible.

View File

@@ -0,0 +1,28 @@
# Engine State Builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `state.ts` module builds the immutable `TimelineState` snapshot returned by `TimelineEngine.getState()`. It derives computed fields such as `duration`, `samplesPerPixel`, `canZoomIn`, and `canZoomOut` from the engine's internal fields so consumers always see a consistent, derived view.
## Features
- `buildTimelineState` assembles a complete `TimelineState` object from internal deps
- `computeDuration` scans all clips to find the timeline end
## Usage
Used internally by `TimelineEngine.getState()`.
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `buildTimelineState` | `(deps: StateDeps) => TimelineState` | Build snapshot |
## Implementation Notes
Duration is calculated by iterating every clip in every track and taking the maximum `startSample + durationSamples`. This is `O(clips)` and is only run when `getState()` is called, not on every mutation. The `StateDeps` interface decouples the builder from the engine class, allowing tests to construct snapshots without instantiating a full engine. Because duration is derived rather than stored, it always reflects the current arrangement even after complex undo/redo sequences. Consumers can rely on `duration` being accurate without subscribing to additional events. The snapshot also includes `canZoomIn` and `canZoomOut` booleans so UI buttons can disable themselves at table bounds. All derived fields are recalculated on every `getState()` call, which keeps the internal engine state minimal and avoids stale derived data.

View File

@@ -0,0 +1,34 @@
# Playback FSM
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `transport-fsm.ts` module creates the finite state machine that guards timeline transport transitions. It prevents illegal transitions such as pausing while already stopped, ensuring that the engine and adapter never receive contradictory commands.
## Features
- Three states: `stopped`, `playing`, `paused`
- Valid transitions: `stopped --play--> playing`, `playing --pause--> paused`, `playing --stop--> stopped`, `paused --play--> playing`, `paused --stop--> stopped`, `stopped --seek--> stopped`, `playing --seek--> playing`, `paused --seek--> paused`
## Usage
```ts
import { createPlaybackFsm } from "./transport-fsm.js";
const fsm = createPlaybackFsm();
fsm.send("play");
console.log(fsm.current); // "playing"
```
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `createPlaybackFsm` | `() => FSMInterpreter` | Create playback FSM |
## Implementation Notes
The FSM is created with `@suaveplan/fsm`. It uses an empty context object because the timeline engine stores playback state separately. Invalid transitions throw `FSMError`, which the engine catches and ignores. This design keeps transport state management declarative and easy to extend if new states (such as `recording`) are added later. The FSM interpreter is created fresh for each engine instance, so multiple engines can coexist with independent playback states. This is important for applications that may host multiple timeline views or sessions simultaneously. Each engine instance manages its own FSM lifecycle independently. The FSM definition is hard-coded in this module because the playback state machine is universal and does not need customisation for different adapters or rendering backends.

View File

@@ -0,0 +1,33 @@
# Engine Transport Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `transport.ts` module bridges `TimelineEngine` transport public methods (`play`, `pause`, `stop`, `seek`) to the `TimelineAdapter`. Each method is guarded by the playback FSM and records OTel spans, ensuring that illegal transitions are silently ignored and every transport change is observable.
## Features
- `enginePlay` sends `play` to FSM, calls `adapter.play`, and emits event
- `enginePause` sends `pause` to FSM, calls `adapter.pause`, and emits event
- `engineStop` sends `stop` to FSM, calls `adapter.stop`, resets time, and emits event
- `engineSeek` calls `adapter.seek` directly without FSM involvement
## Usage
Used internally by `TimelineEngine`. Consumers call `engine.play()` etc.
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `enginePlay` | `(deps) => void` | Start playback |
| `enginePause` | `(deps) => void` | Pause playback |
| `engineStop` | `(deps) => void` | Stop and reset |
| `engineSeek` | `(deps, time) => void` | Seek to time |
## Implementation Notes
Every transport mutation creates a `timeline.transport` OTel span. The FSM prevents illegal transitions (e.g. `pause` while stopped) by throwing `FSMError`, which the engine silently catches and ignores. Seek does not consult the FSM because it is valid in any state. This allows users to scrub the playhead while playback is running, paused, or stopped without triggering transition errors. The adapter receives the seek command immediately so audio output can stay in sync with the UI.

View File

@@ -0,0 +1,31 @@
# Engine Undo Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `undo.ts` module provides the command builder and incremental adapter diff logic used by `TimelineEngine` for undo/redo. It converts before/after track snapshots into `Command<Track[]>` instances and detects which individual tracks changed so the engine can forward minimal updates to the adapter.
## Features
- `createTracksUndoStack` constructs an `UndoStack<Track[]>` with a 500-entry limit
- `buildTracksCommand` creates a `Command<Track[]>` from before/after snapshots
- `findChangedTrackIds` compares two track arrays to detect which tracks changed
## Usage
Used internally by `TimelineEngine`.
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `createTracksUndoStack` | `(initial) => UndoStack<Track[]>` | Create undo stack |
| `buildTracksCommand` | `(label, prev, next) => TracksCommand` | Build command |
| `findChangedTrackIds` | `(prev, next) => string[]` | Diff tracks |
## Implementation Notes
`findChangedTrackIds` uses `JSON.stringify` for a fast deep-equal check. This is sufficient because tracks are plain JSON-compatible objects with no circular references. The function returns ids present in `prev` but not `next` (removed tracks) as well as ids whose contents differ. When the track count is unchanged, the engine uses these ids to call `adapter.updateTrack` individually, avoiding a full `setTracks` reset that could interrupt playback. The 500-entry limit prevents unbounded memory growth in long editing sessions. When the limit is reached, the oldest entries are discarded automatically so the stack stays bounded.

View File

@@ -0,0 +1,36 @@
# Engine Zoom Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `zoom.ts` module bridges `TimelineEngine` zoom, selection, and loop public methods to the pure `timeline-operations.ts` and `zoom.ts` helpers. It manages internal state updates and event emission so the engine class stays focused on high-level orchestration.
## Features
- `engineZoomIn` / `engineZoomOut` step through the zoom table and emit `zoomChange`
- `engineSetZoomLevel` snaps to the nearest table entry
- `engineSetSelection` normalises ranges and emits `selectionChange`
- `engineSetLoopRegion` updates loop bounds and forwards to adapter
- `engineSetLoopEnabled` toggles loop and forwards to adapter
## Usage
These helpers are used internally by `TimelineEngine`. Consumers call `engine.zoomIn()` etc.
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `engineZoomIn` | `(deps) => void` | Zoom in |
| `engineZoomOut` | `(deps) => void` | Zoom out |
| `engineSetZoomLevel` | `(deps, samplesPerPixel) => void` | Set zoom |
| `engineSetSelection` | `(deps, start, end) => void` | Set selection |
| `engineSetLoopRegion` | `(deps, start, end) => void` | Set loop region |
| `engineSetLoopEnabled` | `(deps, enabled) => void` | Toggle loop |
## Implementation Notes
The `ZoomDeps` interface isolates the helpers from the engine class, making them testable in isolation. Loop changes are forwarded to the adapter immediately so the audio backend can update its loop markers. Selection changes are clamped to the current document duration so invalid ranges cannot be created. Zoom changes only affect the view and do not touch the arrangement, which is why they bypass the undo stack entirely.

View File

@@ -0,0 +1,46 @@
# Error Codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `error-codes.ts` module defines the `AUDIO_TIMELINE` error namespace and the `TimelineError` class used throughout the timeline engine. Every error thrown by the package uses a code from this registry, making it easy for consumers to handle specific failure modes programmatically without parsing human-readable messages.
## Features
- `AUDIO_TIMELINE_CODES` object with typed constants for all timeline errors
- `TimelineError` extends `SuaveplanError` with the `AUDIO_TIMELINE` namespace
- `registerTimelineErrorCodes` for explicit side-effect dependency
- Covers track not found, clip not found, invalid move, invalid split, invalid trim, adapter not set, and transaction not active
## Usage
### Basic
```ts
import { AUDIO_TIMELINE_CODES, TimelineError } from "@suaveplan/audio-timeline";
throw new TimelineError(AUDIO_TIMELINE_CODES.TRACK_NOT_FOUND, "Track missing");
```
### Advanced
```ts
import { registerTimelineErrorCodes } from "@suaveplan/audio-timeline";
registerTimelineErrorCodes(); // ensures codes are registered with the error system
```
## API Reference
| Export | Type | Description |
|--------|------|-------------|
| `AUDIO_TIMELINE_CODES` | `Record<string, string>` | Typed code constants |
| `TimelineError` | `class` | Namespaced error class |
| `registerTimelineErrorCodes` | `() => void` | Explicit registration hook |
## Implementation Notes
Codes are created via `defineCodes("AUDIO_TIMELINE", [...])` from `@suaveplan/error`. The resulting string values are the full dotted form (`AUDIO_TIMELINE.TRACK_NOT_FOUND`). Hand-typing code literals is prohibited by project policy; always use the exported constants. This prevents silent drift when the namespace changes. The error metadata object is also exported so that telemetry systems can register the full code list at startup. This makes it easy to generate alerts and dashboards for specific timeline failure modes without grepping logs. The code list is statically typed, so TypeScript will catch any attempt to use an undefined error code at compile time.

View File

@@ -0,0 +1,54 @@
# Mixer Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `mixer.ts` module provides pure state updates for per-track and master mixer settings. Every function returns a shallow copy so the timeline engine can diff changes cheaply and forward them to the `TimelineAdapter`. Because the helpers are pure, they can be used in reducers, tests, and audio-thread code without side effects.
## Features
- `setMasterVolume` clamps master volume to `0..1`
- `setTrackVolume` clamps per-track volume to `0..1`
- `setTrackMute` toggles the muted flag
- `setTrackSolo` toggles the soloed flag
- `setTrackPan` clamps pan to `-1..1`
- `computeEffectiveGain` resolves final gain considering solo-group logic
## Usage
### Basic
```ts
import { setTrackVolume, setTrackMute, computeEffectiveGain } from "@suaveplan/audio-timeline/mixer";
const t1 = setTrackVolume(track, 0.8);
const t2 = setTrackMute(t1, true);
const gain = computeEffectiveGain(t2, false); // 0 (muted takes precedence)
```
### Advanced
```ts
import { computeEffectiveGain } from "@suaveplan/audio-timeline/mixer";
const anySoloed = tracks.some((t) => t.soloed);
const gain = computeEffectiveGain(track, anySoloed); // 0 if non-soloed and any soloed
```
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `setMasterVolume` | `(state, volume) => { masterVolume }` | Clamp and return master |
| `setTrackVolume` | `(track, volume) => Track` | Clamp and return track |
| `setTrackMute` | `(track, muted) => Track` | Toggle mute |
| `setTrackSolo` | `(track, soloed) => Track` | Toggle solo |
| `setTrackPan` | `(track, pan) => Track` | Clamp pan |
| `computeEffectiveGain` | `(track, anySoloed) => number` | Resolve effective gain |
## Implementation Notes
Clamping happens inside the helpers so the engine never has to repeat the logic. The solo-group calculation is a simple predicate: the engine passes `anySoloed` (a boolean derived from the current track array) and the helper returns the effective gain without needing the full session context. This separation keeps the mixer module tiny and easy to test in isolation.

View File

@@ -0,0 +1,50 @@
# Clip Operations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `clip-operations.ts` module implements the state-changing actions that the timeline engine performs on clips: drag, trim, and split. Each operation is a pure function that takes a `Track` and returns an updated `Track`, making them easy to wrap in undo/redo commands. Because the functions are pure, they can be tested in isolation without instantiating a full `TimelineEngine`.
## Features
- `constrainClipDrag` clamps drag deltas against timeline bounds and sibling overlap
- `moveClip` applies constrained drags and signals no-ops when the delta is zero
- `trimClip` resizes clip boundaries with a configurable minimum duration floor
- `splitClip` replaces one clip with two adjacent clips, partitioning fade curves
- `removeClip` filters a clip from a track by id
## Usage
### Basic
```ts
import { moveClip, trimClip, splitClip } from "@suaveplan/audio-timeline/operations";
const result = moveClip(track, "clip-1", 500);
const trimmed = trimClip(track, "clip-1", "left", 100, 1, 10000);
```
### Advanced
```ts
import { constrainClipDrag } from "@suaveplan/audio-timeline/operations";
const safeDelta = constrainClipDrag(track, "clip-1", 2000, { allowOverlap: true });
```
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `constrainClipDrag` | `(track, clipId, delta, opts?) => number` | Clamp drag delta |
| `moveClip` | `(track, clipId, delta, opts?) => { track, appliedDelta }` | Move clip with constraint |
| `trimClip` | `(track, clipId, boundary, delta, minDuration, docLength) => Track` | Resize boundary |
| `splitClip` | `(track, clipId, atSample, minDuration) => Track` | Split into two clips |
| `removeClip` | `(track, clipId) => Track` | Remove clip by id |
## Implementation Notes
All operations preserve immutability: they shallow-copy the track and the clips array. The overlap check is a linear scan of siblings, which is fast because clips are kept sorted by `startSample`. When `moveClip` returns `appliedDelta: 0`, the engine skips pushing an undo command, keeping the history clean. The `minDuration` parameter enforces a floor on clip length so users cannot accidentally delete a clip by over-trimming. Splitting preserves fade curves by assigning `fadeIn` to the left clip and `fadeOut` to the right clip.

View File

@@ -0,0 +1,50 @@
# Timeline Operations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `timeline-operations.ts` module handles view-state mutations that do **not** affect the audio arrangement and therefore do not push undo commands. These helpers operate on `TimelineState` snapshots and return partial updates that the engine merges into its internal state. Keeping view logic separate from arrangement logic makes the engine easier to reason about and test.
## Features
- `setSelection` normalises reversed ranges and clamps to document duration
- `setLoopRegion` updates loop boundaries without clamping
- `zoomIn` / `zoomOut` step through a discrete zoom table
- `setZoomLevel` snaps arbitrary `samplesPerPixel` values to the table
## Usage
### Basic
```ts
import { setSelection, setLoopRegion } from "@suaveplan/audio-timeline/operations";
const sel = setSelection(state, 100, 50); // normalises to 50..100
const loop = setLoopRegion(state, 0, 5000);
```
### Advanced
```ts
import { zoomIn, zoomOut, setZoomLevel } from "@suaveplan/audio-timeline/operations";
const z1 = zoomIn(zoomTable, 3); // { zoomIndex: 4, samplesPerPixel: 16 }
const z2 = setZoomLevel(zoomTable, 150); // snaps to 128
```
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `setSelection` | `(state, start, end) => { selectionStart, selectionEnd }` | Normalise and clamp |
| `setLoopRegion` | `(state, start, end) => { loopStart, loopEnd }` | Set loop bounds |
| `zoomIn` | `(table, index) => { zoomIndex, samplesPerPixel }` | Step in |
| `zoomOut` | `(table, index) => { zoomIndex, samplesPerPixel }` | Step out |
| `setZoomLevel` | `(table, spp) => { zoomIndex, samplesPerPixel }` | Snap to table |
## Implementation Notes
Zoom and selection are purely view state; they are not part of the undo history because users expect zoom gestures to be ephemeral. Keeping these helpers pure makes them trivial to unit-test without instantiating a full `TimelineEngine`. The clamping logic in `setSelection` guarantees that consumers always read `selectionStart <= selectionEnd`, even when the user drags the selection backwards. Loop boundaries are not clamped because the engine may allow loops that extend beyond the current document length for live expansion workflows.

View File

@@ -0,0 +1,49 @@
# Track Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `track.ts` module provides pure, immutable CRUD operations for the `Track` model used by the `@suaveplan/audio-timeline` engine. A track represents a horizontal lane in a DAW timeline that owns an ordered list of clips together with per-track mixer state (`volume`, `muted`, `soloed`, `pan`). These helpers are the building blocks for the engine's track operations and can be used independently in reducers or tests.
## Features
- `createTrack` builds a validated track with sensible defaults and a generated id
- `updateTrack` returns a shallow-copied track with validated overrides
- `removeTrack` filters a track array by id without mutating the input
- `validateTrack` runs runtime Zod validation for ad-hoc track objects
## Usage
### Basic
```ts
import { createTrack, updateTrack, removeTrack } from "@suaveplan/audio-timeline/track";
const track = createTrack({ name: "Vocals" });
const renamed = updateTrack(track, { name: "Lead Vocals" });
const fewer = removeTrack([track, renamed], track.id);
```
### Advanced
```ts
import { validateTrack } from "@suaveplan/audio-timeline/track";
validateTrack({ id: "t1", name: "X", clips: [], volume: 1, muted: false, soloed: false, pan: 0 });
```
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `createTrack` | `(options?) => Track` | Build a validated track |
| `updateTrack` | `(track, updates) => Track` | Immutable update with re-validation |
| `removeTrack` | `(tracks, trackId) => Track[]` | Filter out one track |
| `validateTrack` | `(track) => asserts track is Track` | Zod guard |
## Implementation Notes
All helpers are side-effect free. They never mutate input objects, which makes them trivial to use inside undo/redo commands and React render cycles. The validation step guarantees that every track that leaves the module satisfies the invariants required by the timeline engine (e.g. `volume` in `0..1`, `pan` in `-1..1`). Track ids are generated via `crypto.randomUUID` when available, with a deterministic counter fallback for test environments.

View File

@@ -0,0 +1,52 @@
# Zoom Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `zoom.ts` module provides a discrete zoom table and navigation helpers for the timeline view. Zoom levels are expressed as `samplesPerPixel`; larger values show more timeline in fewer pixels. The module is pure and side-effect free, making it safe to use in reducers, audio-thread code, and React memoization.
## Features
- Predefined `DEFAULT_ZOOM_TABLE` with 14 powers-of-two entries (1 to 8192)
- `zoomIn` / `zoomOut` index arithmetic with boundary capping
- `setZoomLevel` to snap arbitrary `samplesPerPixel` values to the nearest table entry
- `canZoomIn` / `canZoomOut` boolean predicates for UI button disabled states
## Usage
### Basic
```ts
import { zoomIn, zoomOut, DEFAULT_ZOOM_TABLE } from "@suaveplan/audio-timeline/zoom";
let index = 0;
index = zoomIn(DEFAULT_ZOOM_TABLE, index); // 1
index = zoomOut(DEFAULT_ZOOM_TABLE, index); // 0
```
### Advanced
```ts
import { setZoomLevel, canZoomIn } from "@suaveplan/audio-timeline/zoom";
const result = setZoomLevel(DEFAULT_ZOOM_TABLE, 150); // snaps to 128
const canGoFiner = canZoomIn(DEFAULT_ZOOM_TABLE, result.zoomIndex);
```
## API Reference
| Export | Signature | Description |
|--------|-----------|-------------|
| `DEFAULT_ZOOM_TABLE` | `readonly number[]` | Default discrete zoom table |
| `zoomIn` | `(table, index) => number` | Increment zoom index, capped at max |
| `zoomOut` | `(table, index) => number` | Decrement zoom index, floored at 0 |
| `setZoomLevel` | `(table, spp) => { zoomIndex, samplesPerPixel }` | Snap to nearest table entry |
| `canZoomIn` | `(table, index) => boolean` | True if index < max |
| `canZoomOut` | `(table, index) => boolean` | True if index > 0 |
## Implementation Notes
Using a discrete table instead of a continuous float keeps zoom states comparable by simple index equality. This is useful for React memoization and for persisting user preferences. All helpers are pure and operate only on the table and index, so they can be used in reducers, tests, and even audio-thread code without side effects.