Files
suaveplan/openspec/archive/2026-05-15-enterprise-audio-transport/design.md
2026-05-27 23:29:53 +01:00

12 KiB
Raw Blame History

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:

        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), 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:

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

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.