# Design: enterprise-audio-transport **Change ID**: enterprise-audio-transport **Package**: `@suaveplan/audio-transport` `@suaveplan/audio-transport` is the musical-clock layer of the `@suaveplan/audio-*` family: a universal, injectable transport providing sample-accurate scheduling with PPQ, BPM automation, swing, time signatures, and loop regions. It sits in the **Timeline / Transport** layer above `@suaveplan/audio-core` and is consumed by the timeline, both rendering engines, and the controller packages. The design is inspired by Tone.js `Transport` but carries zero browser assumptions — every wall-clock reading flows through an injected `Clock`. ## Cross-cutting architecture The shared `@suaveplan/audio-*` ecosystem model — the three-layer separation, the six plugin slots, the "zero duplication of genesis primitives" rule, and the universal-tier purity constraint — lives in `enterprise-audio-core/design.md` §1–§4. This document does not duplicate it; it references §1.2 (three-layer separation, where transport is named explicitly) and §1.4 (genesis primitive reuse, where `@suaveplan/timing` is mapped to "Transport lookahead scheduler, tempo clock" and `@suaveplan/fsm` to "Transport playback state"). The package-specific design below assumes that shared model. --- ## 1. The transport model ### 1.1. Musical time The transport's native unit is the **tick**. With `ppq` pulses per quarter note, a quarter note is `ppq` ticks, a bar at time signature `n/4` is `n * ppq` ticks, and a sixteenth is `ppq / 4` ticks. `ppq` defaults to `192` (a common DAW value divisible by 2, 3, 4, 6, 8, and 12, so triplets and straight subdivisions are both integral). Position is reported as `bars:beats:sixteenths` — `"2:1:0"` is the start of beat 2 of bar 3 (zero-indexed bars and beats). ### 1.2. Audio time vs. musical time There are two clocks: - **Audio time** — seconds, the domain of the injected `Clock` and the lookahead scheduler. - **Musical time** — ticks, the domain of the transport's playhead and all scheduled events. `TickSource` is the bridge. The conversion is not a simple multiply because tempo varies: the BPM `Signal` defines a piecewise curve, so the tick-to-time map is the integral of the (instantaneous tempo / 60 * ppq) function. At constant tempo the map is linear and `getTicksAtTime` / `getTimeOfTick` are exact inverses; across a ramp they are computed by integrating the curve segment. ### 1.3. Playback FSM Playback state is a three-state machine from `@suaveplan/fsm`: ```text start pause stopped ─────► playing ◄──────────── paused ▲ │ │ ▲ │ stop │ │ start │ └─────────────┘ └────────────────────┘ │ │ stop └──────────────► stopped ``` Legal transitions only. `pause()` on a `stopped` transport is rejected with `AUDIO_TRANSPORT_INVALID_TIME` rather than silently no-op'ing — a caller that pauses something not playing has a logic bug worth surfacing. `dispose()` adds a terminal disposed condition; every mutating method checks it first and throws `AUDIO_TRANSPORT_ALREADY_DISPOSED`. The FSM also records a timestamped transition log so the scheduler can replay state changes that fell between two polls: `fsm.forEachBetween(start, end, …)` emits the `start` / `stop` / `pause` events at their true audio times rather than at poll time. --- ## 2. The lookahead scheduler ### 2.1. Why lookahead A naive transport that fires callbacks "now" is at the mercy of the host's timer jitter — tens of milliseconds, audibly wrong. The fix, from the Web Audio scheduling literature and Tone.js, is a **lookahead loop**: poll on a coarse timer (~100 Hz), and on each poll schedule everything due in the *next* window using sample-accurate times. The callback itself is not sample-accurate, but the `time` value passed to it is — downstream audio code must use that `time`, never `Date.now()`. ### 2.2. The loop `TransportScheduler` is built on `@suaveplan/timing`'s `createScheduler`: ```ts import { createScheduler } from '@suaveplan/timing' const scheduler = createScheduler({ clock: injectableClock, // FakeClock in tests intervalMs: 10, // ~100 Hz poll }) scheduler.onTick((now) => { const start = transport._lastUpdate const end = now transport._lastUpdate = end // 1. Emit FSM state changes that fell in [start, end) fsm.forEachBetween(start, end, (e) => emit(e.state, e.time)) // 2. Fire scheduled callbacks with sample-accurate times tickSource.forEachTickBetween(start, end, (time, ticks) => { transport._processTick(time, ticks) }) }) ``` `_processTick(time, ticks)` checks the event priority queue for callbacks due at `ticks`, applies swing to off-grid ticks, applies the loop wrap, and invokes the matching callbacks with the sample-accurate `time`. ### 2.3. Determinism Because the only time source is the injected `Clock`, a `FakeClock` advanced through a fixed step sequence produces a fully deterministic run. Two runs with the same `TransportOptions`, the same scheduled callbacks, and the same `FakeClock` advancement yield byte-identical `(callbackId, time, ticks)` sequences. This is the property the integration tests assert, and it is why the package is testable without a real audio context or real timers. ### 2.4. Abort `TransportScheduler.start({ signal })` accepts an `AbortSignal`. A pre-aborted signal throws `AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED` before the loop is created; a mid-run abort halts the loop at the next poll boundary (equivalent to `stop()`), so callers can wire transport lifetime to a request scope or a React effect cleanup. --- ## 3. TickSource and tempo curves ### 3.1. The Signal `Signal` wraps `@suaveplan/timing`'s `Signal`. It stores a sorted list of automation points, each tagged `set` | `linear` | `exponential`, and exposes: - `setValueAtTime(value, time)` — a step. - `linearRampToValueAtTime(value, time)` — linear interpolation from the previous point. - `exponentialRampToValueAtTime(value, time)` — exponential interpolation; start and target must be `> 0` (an exponential through zero is undefined), else `AUDIO_TRANSPORT_INVALID_BPM`. - `getValueAtTime(time)` — the interpolated value, holding the first point's value before it and the last point's value after it. ### 3.2. Integrating the curve `TickSource.getTicksAtTime(time)` integrates the tempo curve from the source's start time to `time`. For a `set` segment the integral is `(bpm / 60) * ppq * dt`. For a `linear` segment between `(t0, b0)` and `(t1, b1)` the instantaneous tempo at `t` is `b0 + (b1 - b0) * (t - t0) / (t1 - t0)`, and the tick contribution is the closed-form integral of that linear function over the overlap of `[t0, t1)` with `[start, time)`. `getTimeOfTick` inverts the same per-segment math by solving for `t`. Each segment's math is a pure function with a unit test; the curve evaluator stitches them. ### 3.3. forEachTickBetween Given `[start, end)` in audio time, `forEachTickBetween` computes `getTicksAtTime(start)` and `getTicksAtTime(end)`, then for each integer tick in that range computes `getTimeOfTick(tick)` and invokes the callback. Half-open intervals guarantee a tick at the boundary `t1` belongs to exactly one of two consecutive windows — no skips, no duplicates. --- ## 4. Swing Swing delays off-beat ticks relative to the `swingSubdivision` grid. With `swingSubdivision = "8n"` the grid spacing is `swingTicks = ppq / 2`; ticks landing exactly on the grid are unmoved, ticks in between are pushed later by a sine-shaped fraction of the subdivision duration: ```ts if (swing > 0 && ticks % ppq !== 0 && ticks % (swingTicks * 2) !== 0) { const progress = (ticks % (swingTicks * 2)) / (swingTicks * 2) const amount = Math.sin(progress * Math.PI) * swing tickTime += swingTickDuration * amount } ``` `swing` is clamped to `[0, 1]` at the setter; out of range throws `AUDIO_TRANSPORT_INVALID_TIME`. With `swing === 0` the expression is skipped entirely, so un-swung playback has zero overhead and bit-exact grid times. --- ## 5. Musical time strings The time parser recognises five grammars: | Form | Example | Meaning | |---|---|---| | Subdivision | `"4n"`, `"8n"`, `"16n"`, `"8t"`, `"16t"` | A note duration; `t` suffix = triplet | | Measure | `"1m"` | One bar at the current time signature | | Bars:beats:sixteenths | `"2:1:0"` | An absolute position | | Relative | `"+1"` | One second from the current transport time | | Absolute | `"@4m"` | The absolute position at 4 measures | `toTicks` / `toSeconds` / `toBarsBeatsSixteenths` use the current `ppq`, `timeSignature`, and tempo curve. An unrecognised token throws `AUDIO_TRANSPORT_INVALID_TIME` with the token in `context` — the parser never silently coerces garbage to `0`. --- ## 6. Integration with genesis packages - **`@suaveplan/timing`** — `createScheduler` drives the lookahead loop; `Signal` backs the BPM automation curve; `FakeClock` is the injected `Clock` in tests. The transport adds no timer of its own. - **`@suaveplan/fsm`** — the three-state playback machine, including the timestamped transition log the scheduler replays via `forEachBetween`. - **`@suaveplan/events`** — backs the typed `TransportEventMap` (`start`, `stop`, `pause`, `loop`, `bpm`). Listener registration is idempotent. - **`@suaveplan/error`** — `TransportError extends SuaveplanError`; the five `AUDIO_TRANSPORT_*` codes are registered through `defineCodes`. - **`@suaveplan/types`** — `Clock` and `LoggerLike` are consumed type-only; the transport never imports a concrete clock. --- ## 7. File layout ```text packages/audio/audio-transport/ package.json, tsconfig.json, vitest.config.ts src/ index.ts error-codes.ts transport/ transport.ts .md .types.ts .test.ts index.ts scheduler/ scheduler.ts .md .types.ts .test.ts index.ts tick-source/ tick-source.ts .md .types.ts .test.ts index.ts signal/ signal.ts .md .types.ts .test.ts index.ts time/ time.ts .md .types.ts .test.ts index.ts __benchmarks__/ transport.bench.ts tests/ integration.test.ts purity.test.ts exports.test.ts ``` Each feature module gets a subpath export (`./transport/transport`, `./scheduler/scheduler`, `./tick-source/tick-source`, `./signal/signal`, `./time/time`); `sideEffects` is `false`. --- ## 8. Benchmarks `transport.bench.ts` covers three hot paths: - **Scheduling** — register 1 000 callbacks and measure queue insertion + lookup. - **Tempo automation** — apply 100 BPM ramps and measure curve re-evaluation. - **Tick iteration** — 10 000 `forEachTickBetween` ticks, the per-poll cost that bounds how many tracks the engine can drive. Baseline numbers are published in the package README. --- ## 9. Decisions and trade-offs - **Lookahead, not "fire now".** The coarse-poll-plus-sample-accurate-time pattern is the only way to get musically tight scheduling without a real-time thread. The cost is that callbacks see a `time` slightly in the future; the contract makes that explicit. - **Injected `Clock`, no internal timer.** Every time reading goes through `@suaveplan/timing`'s `Clock`. This is what makes the transport deterministic under `FakeClock` and keeps it universal-tier — no `performance.now()`, no `setTimeout` of its own. - **Tempo as a `Signal`, not a scalar.** Modeling BPM as an automation curve from day one means ramps, holds, and tempo maps are first-class; a scalar `bpm` would force every consumer to re-implement automation. `getValueAtTime` / curve integration is the price. - **Illegal FSM transitions throw, not no-op.** `pause()` while `stopped` is a caller bug. Silent no-ops hide bugs until a downstream symptom appears far from the cause. - **`ppq` default 192.** Divisible by 2/3/4/6/8/12 so straight and triplet subdivisions are both integer tick counts — no rounding drift on `"8t"`. - **Swing is per-tick at `_processTick`, not a global offset.** Applying swing where ticks are emitted keeps on-grid ticks bit-exact and means `swing === 0` has zero cost.