12 KiB
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
Clockand 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:
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:
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), elseAUDIO_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:
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—createSchedulerdrives the lookahead loop;Signalbacks the BPM automation curve;FakeClockis the injectedClockin tests. The transport adds no timer of its own.@suaveplan/fsm— the three-state playback machine, including the timestamped transition log the scheduler replays viaforEachBetween.@suaveplan/events— backs the typedTransportEventMap(start,stop,pause,loop,bpm). Listener registration is idempotent.@suaveplan/error—TransportError extends SuaveplanError; the fiveAUDIO_TRANSPORT_*codes are registered throughdefineCodes.@suaveplan/types—ClockandLoggerLikeare consumed type-only; the transport never imports a concrete clock.
7. File layout
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
forEachTickBetweenticks, 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
timeslightly in the future; the contract makes that explicit. - Injected
Clock, no internal timer. Every time reading goes through@suaveplan/timing'sClock. This is what makes the transport deterministic underFakeClockand keeps it universal-tier — noperformance.now(), nosetTimeoutof 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 scalarbpmwould force every consumer to re-implement automation.getValueAtTime/ curve integration is the price. - Illegal FSM transitions throw, not no-op.
pause()whilestoppedis a caller bug. Silent no-ops hide bugs until a downstream symptom appears far from the cause. ppqdefault 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 meansswing === 0has zero cost.