Initial Commit
This commit is contained in:
270
openspec/archive/2026-05-15-enterprise-audio-transport/design.md
Normal file
270
openspec/archive/2026-05-15-enterprise-audio-transport/design.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user