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

271 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<BPM>` 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.