Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View File

@@ -0,0 +1,214 @@
# OpenSpec Change: enterprise-audio-transport
- **Change ID**: enterprise-audio-transport
- **Package**: `@suaveplan/audio-transport`
- **Category**: universal
- **Target path**: `packages/audio/audio-transport`
- **Status**: PROPOSED
- **Date**: 2026-05-14
- **Design**: `./design.md`
- **Spec delta**: `./specs/audio-transport/spec.md`
---
## Summary
Deliver `@suaveplan/audio-transport` on the genesis branch from scratch at enterprise quality. The package is a musical clock and transport providing sample-accurate scheduling with PPQ (pulses per quarter), BPM automation, swing, time signatures, and loop regions. It is built on `@suaveplan/timing` for the lookahead scheduler and `@suaveplan/fsm` for playback state. Inspired by Tone.js `Transport`, but universal and injectable.
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/timing` (`workspace:^`) | `createScheduler`, `Signal`, `FakeClock` for lookahead and tempo curves |
| `@suaveplan/events` (`workspace:^`) | Typed event emitter for `TransportEventMap` |
| `@suaveplan/fsm` (`workspace:^`) | Playback state machine (`stopped` → `playing` → `paused`) |
| `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock` |
| `@suaveplan/error` (`workspace:^`) | Base class for `TransportError`; `AUDIO_TRANSPORT.*` codes |
| `zod` (`catalog:forms`) | Zod schemas for all public option types |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check` (all `catalog:testing`).
---
## Public API
### Core types
```ts
interface TransportOptions {
bpm?: number
ppq?: number
swing?: number
swingSubdivision?: Subdivision
timeSignature?: number
loop?: boolean
loopStart?: TransportTime
loopEnd?: TransportTime
logger?: LoggerLike
}
type TransportTime = string | number | Ticks
type Ticks = number
type BarsBeatsSixteenths = string
type BPM = number
type Subdivision = '4n' | '8n' | '16n' | '8t' | '16t'
type TransportCallback = (time: number) => void
interface TransportEventMap {
start: (time: number) => void
stop: (time: number) => void
pause: (time: number) => void
loop: (time: number) => void
bpm: (bpm: number, time: number) => void
}
class Transport {
constructor(options?: TransportOptions)
get state(): 'stopped' | 'playing' | 'paused'
get position(): BarsBeatsSixteenths
get seconds(): number
get ticks(): number
start(time?: number, offset?: Ticks): void
stop(time?: number): void
pause(time?: number): void
schedule(callback: TransportCallback, time: TransportTime): number
scheduleRepeat(callback: TransportCallback, interval: TransportTime, start?: TransportTime): number
scheduleOnce(callback: TransportCallback, time: TransportTime): number
cancel(eventId: number): void
cancelAfter(time: number): void
bpm: Signal<BPM>
timeSignature: number
ppq: number
loop: boolean
loopStart: TransportTime
loopEnd: TransportTime
swing: number
swingSubdivision: Subdivision
toSeconds(time: TransportTime): number
toTicks(time: TransportTime): Ticks
toBarsBeatsSixteenths(ticks: Ticks): BarsBeatsSixteenths
on<K extends keyof TransportEventMap>(event: K, listener: TransportEventMap[K]): void
off<K extends keyof TransportEventMap>(event: K, listener: TransportEventMap[K]): void
dispose(): void
}
class TransportScheduler {
constructor(transport: Transport, clock: Clock)
start(): void
stop(): void
}
class TickSource {
frequency: Signal<'bpm'>
getTicksAtTime(time: number): Ticks
getTimeOfTick(tick: Ticks, before?: number): number
setTicksAtTime(ticks: Ticks, time: number): void
start(time: number, offset?: Ticks): void
stop(time: number): void
pause(time: number): void
forEachTickBetween(start: number, end: number, callback: (time: number, ticks: Ticks) => void): void
}
```
### Error codes
```ts
type TransportErrorCode =
| 'AUDIO_TRANSPORT_INVALID_TIME'
| 'AUDIO_TRANSPORT_INVALID_BPM'
| 'AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED'
| 'AUDIO_TRANSPORT_EVENT_NOT_FOUND'
| 'AUDIO_TRANSPORT_ALREADY_DISPOSED'
const AUDIO_TRANSPORT_ERROR_METADATA: Readonly<Record<TransportErrorCode, CodeMetadata>>
function registerTransportErrorCodes(): void
```
### Zod schemas
```ts
TransportOptionsSchema
TransportTimeSchema
TicksSchema
BarsBeatsSixteenthsSchema
```
---
## File layout
```text
packages/audio/audio-transport/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts
transport/
transport.ts
transport.md
transport.types.ts
transport.test.ts
index.ts
scheduler/
scheduler.ts
scheduler.md
scheduler.types.ts
scheduler.test.ts
index.ts
tick-source/
tick-source.ts
tick-source.md
tick-source.types.ts
tick-source.test.ts
index.ts
signal/
signal.ts
signal.md
signal.types.ts
signal.test.ts
index.ts
time/
time.ts
time.md
time.types.ts
time.test.ts
index.ts
__benchmarks__/
transport.bench.ts
tests/
integration.test.ts
```
---
## Impact
**Breaking changes**: none — fresh build, package does not yet exist on genesis.
**Prerequisites**: `@suaveplan/timing`, `@suaveplan/events`, `@suaveplan/fsm`, `@suaveplan/types`, `@suaveplan/error` published to Verdaccio.
### Affected dependents
`@suaveplan/audio-transport` is consumed by the timeline, both rendering engines, and the
controller packages. At the time of this change none of the dependents exist on genesis yet — they
land in later changes in this same batch — so there is no existing dependent surface to test or
migrate. Each dependent is created against this package's spec delta, not retrofitted.
- `@suaveplan/audio-timeline` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-timeline`)
- `@suaveplan/audio-engine-browser` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-engine-browser`)
- `@suaveplan/audio-engine-server` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-engine-server`)
- `@suaveplan/audio-controllers` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-controllers`)
Once landed, any subsequent change to this package's public surface MUST run
`bunx turbo test --filter=...@suaveplan/audio-transport` per `openspec/AGENTS.md` §13 step 10.

View File

@@ -0,0 +1,376 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-transport (audio-transport)
**Package**: `@suaveplan/audio-transport`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-transport` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `performance`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All wall-clock and scheduling behaviour SHALL be reached only through an injected `Clock` and the `@suaveplan/timing` scheduler. The package's `dependencies` SHALL be limited to `@suaveplan/timing`, `@suaveplan/events`, `@suaveplan/fsm`, `@suaveplan/types`, `@suaveplan/error`, `tslib`, and `zod`.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `performance`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"`
---
### Requirement: Transport construction and playback state
The package SHALL export a `Transport` class constructed from optional `TransportOptions` (`bpm`, `ppq`, `swing`, `swingSubdivision`, `timeSignature`, `loop`, `loopStart`, `loopEnd`, `logger`). The constructor SHALL apply documented defaults (`bpm: 120`, `ppq: 192`, `swing: 0`, `swingSubdivision: "8n"`, `timeSignature: 4`, `loop: false`) and initialise an internal playback FSM whose states are exactly `stopped`, `playing`, and `paused`. The `state` getter SHALL reflect the current FSM state. A freshly constructed `Transport` SHALL be in `stopped`.
#### Scenario: Defaults are applied when options are omitted
- **WHEN** a caller constructs `new Transport()` with no options
- **THEN** `transport.bpm.value === 120`, `transport.ppq === 192`, `transport.timeSignature === 4`, `transport.swing === 0`, and `transport.state === "stopped"`
#### Scenario: Construction with options overrides defaults
- **WHEN** a caller constructs `new Transport({ bpm: 90, ppq: 96, timeSignature: 3 })`
- **THEN** `transport.bpm.value === 90`, `transport.ppq === 96`, and `transport.timeSignature === 3`
**Verified by:** `src/transport/transport.test.ts::"applies documented defaults when options are omitted"` AND `src/transport/transport.test.ts::"constructor options override defaults"`
---
### Requirement: Playback control via the FSM
`start(time?, offset?)`, `stop(time?)`, and `pause(time?)` SHALL drive the playback FSM through only its legal transitions: `stopped → playing` and `paused → playing` on `start`, `playing → paused` on `pause`, and `playing → stopped` / `paused → stopped` on `stop`. Each method SHALL forward `time` (defaulting to the injected `Clock`'s current time) and `offset` to the `TickSource`. An illegal transition request — e.g. `pause()` while `stopped` — SHALL throw `TransportError` with code `AUDIO_TRANSPORT_INVALID_TIME` carrying the attempted transition in `context`, leaving the FSM unchanged.
#### Scenario: start then pause then start resumes from the paused tick
- **WHEN** a caller calls `start()`, advances the clock, calls `pause()`, then calls `start()` again
- **THEN** the FSM is `playing`, and `transport.ticks` resumes from the tick value held at `pause` rather than resetting to zero
#### Scenario: Illegal transition throws AUDIO_TRANSPORT_INVALID_TIME
- **WHEN** a caller calls `pause()` on a `stopped` transport
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_INVALID_TIME"` and `transport.state` remains `"stopped"`
**Verified by:** `src/transport/transport.test.ts::"start/pause/start resumes from the paused tick"` AND `src/transport/transport.test.ts::"illegal FSM transition throws AUDIO_TRANSPORT_INVALID_TIME"`
---
### Requirement: TickSource — audio time to musical ticks
The package SHALL export a `TickSource` class converting between audio time (seconds) and musical ticks. `getTicksAtTime(time)` SHALL return the `Ticks` elapsed at an audio time accounting for the tempo curve; `getTimeOfTick(tick, before?)` SHALL return the audio time at which a given tick occurs; `setTicksAtTime(ticks, time)` SHALL anchor the tick counter so that subsequent conversions are offset accordingly. `start(time, offset?)`, `stop(time)`, and `pause(time)` SHALL manage the source's running state with `offset` seeding the initial tick value. Conversions SHALL be exact inverses under a constant tempo: `getTicksAtTime(getTimeOfTick(t)) === t`.
#### Scenario: Tick and time conversions are mutual inverses at constant tempo
- **WHEN** a `TickSource` at a constant 120 BPM is queried with `getTimeOfTick(t)` then `getTicksAtTime` on the result
- **THEN** the returned tick equals `t` within floating-point tolerance
#### Scenario: setTicksAtTime anchors the counter
- **WHEN** a caller calls `setTicksAtTime(960, t)` then `getTicksAtTime(t)`
- **THEN** the result is `960`
**Verified by:** `src/tick-source/tick-source.test.ts::"getTicksAtTime and getTimeOfTick are mutual inverses at constant tempo"` AND `src/tick-source/tick-source.test.ts::"setTicksAtTime anchors the tick counter"`
---
### Requirement: TickSource block iteration is sample-accurate
`TickSource.forEachTickBetween(start, end, callback)` SHALL invoke `callback(time, ticks)` once for every musical tick whose audio time falls in the half-open interval `[start, end)`, in ascending tick order, with `time` the sample-accurate audio time of that tick (not the polling time of the enclosing scheduler tick). No tick SHALL be visited twice across consecutive non-overlapping intervals, and no tick in range SHALL be skipped.
#### Scenario: Every tick in the window is visited exactly once
- **WHEN** a caller calls `forEachTickBetween` over an interval spanning ten ticks at a known tempo
- **THEN** the callback fires exactly ten times with strictly ascending `ticks` and each `time` inside `[start, end)`
#### Scenario: Consecutive windows neither skip nor duplicate ticks
- **WHEN** a caller iterates `[t0, t1)` then `[t1, t2)`
- **THEN** the union of visited ticks is contiguous with no gap at the `t1` boundary and no tick visited in both windows
**Verified by:** `src/tick-source/tick-source.test.ts::"forEachTickBetween visits every tick in range exactly once with sample-accurate times"` AND `src/tick-source/tick-source.test.ts::"consecutive windows neither skip nor duplicate ticks at the boundary"`
---
### Requirement: BPM automation via Signal
`Transport.bpm` SHALL be a `Signal<BPM>` exported by the package. The `Signal` SHALL support `setValueAtTime(value, time)`, `linearRampToValueAtTime(value, time)`, and `exponentialRampToValueAtTime(value, time)`, and SHALL expose `getValueAtTime(time)` returning the interpolated tempo at any audio time. `TickSource` SHALL read the BPM curve through this `Signal` so a tempo ramp scheduled between two times produces a continuously varying tick rate. A non-finite or non-positive BPM passed to any setter SHALL throw `TransportError` with code `AUDIO_TRANSPORT_INVALID_BPM`.
#### Scenario: Linear tempo ramp varies the tick rate continuously
- **WHEN** a caller schedules `bpm.linearRampToValueAtTime(240, t1)` from `120` at `t0` and iterates ticks across `[t0, t1)`
- **THEN** the inter-tick audio spacing decreases monotonically across the window
#### Scenario: Non-positive BPM is rejected
- **WHEN** a caller calls `bpm.setValueAtTime(0, t)` or `bpm.setValueAtTime(-1, t)`
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_INVALID_BPM"`
**Verified by:** `src/signal/signal.test.ts::"linear ramp varies the tick rate continuously across the window"` AND `src/signal/signal.test.ts::"rejects non-positive BPM with AUDIO_TRANSPORT_INVALID_BPM"`
---
### Requirement: Signal curve math
The `Signal` SHALL implement curve evaluation such that `getValueAtTime` returns the held value before the first scheduled point, the exact scheduled value at each point's time, linear interpolation between a point and a `linearRampToValueAtTime` target, and exponential interpolation between a point and an `exponentialRampToValueAtTime` target. Exponential ramps SHALL reject a non-positive start or target value (an exponential curve through zero is undefined) by throwing `TransportError` with code `AUDIO_TRANSPORT_INVALID_BPM`.
#### Scenario: getValueAtTime interpolates linearly between scheduled points
- **WHEN** a `Signal` holds `100` at `t0` and a linear ramp to `200` at `t1` is scheduled
- **THEN** `getValueAtTime((t0 + t1) / 2)` returns approximately `150`
#### Scenario: Exponential ramp through zero is rejected
- **WHEN** a caller schedules `exponentialRampToValueAtTime(0, t)`
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_INVALID_BPM"`
**Verified by:** `src/signal/signal.test.ts::"getValueAtTime interpolates linearly between scheduled points"` AND `src/signal/signal.test.ts::"exponential ramp through zero is rejected"`
---
### Requirement: Musical time string parsing
The package SHALL parse musical time tokens into ticks and seconds: subdivision notation (`"4n"`, `"8n"`, `"16n"`, `"8t"`, `"16t"`), measure notation (`"1m"`), bars:beats:sixteenths notation (`"2:1:0"`), relative-from-now notation (`"+1"`), and absolute notation (`"@4m"`). `toSeconds(time)`, `toTicks(time)`, and `toBarsBeatsSixteenths(ticks)` SHALL perform the conversions using the current `ppq`, `timeSignature`, and tempo curve. A token that does not match any recognised grammar SHALL throw `TransportError` with code `AUDIO_TRANSPORT_INVALID_TIME` carrying the offending token in `context`.
#### Scenario: Subdivision and bars:beats:sixteenths round-trip through ticks
- **WHEN** a caller calls `toTicks("4n")` on a transport with `ppq: 192`
- **THEN** the result is `192`, and `toBarsBeatsSixteenths(192)` yields `"0:1:0"`
#### Scenario: Unrecognised time token throws AUDIO_TRANSPORT_INVALID_TIME
- **WHEN** a caller calls `toTicks("not-a-time")`
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_INVALID_TIME"` with the token in `context`
**Verified by:** `src/time/time.test.ts::"subdivision and bars:beats:sixteenths round-trip through ticks"` AND `src/time/time.test.ts::"unrecognised time token throws AUDIO_TRANSPORT_INVALID_TIME"`
---
### Requirement: Position and time readouts
`Transport.position` SHALL return the current playhead as a `BarsBeatsSixteenths` string derived from the live tick count, `Transport.ticks` SHALL return the current tick count as a number, and `Transport.seconds` SHALL return the current audio time in seconds. The three readouts SHALL be mutually consistent at every instant: `toTicks(position) === ticks` and `toSeconds(ticks-as-time) === seconds` within floating-point tolerance.
#### Scenario: Position, ticks, and seconds agree while playing
- **WHEN** a transport is started and the injected clock is advanced
- **THEN** `toTicks(transport.position)` equals `transport.ticks` and the readouts advance monotonically
**Verified by:** `src/transport/transport.test.ts::"position, ticks, and seconds remain mutually consistent while playing"`
---
### Requirement: Event scheduling and cancellation
`schedule(callback, time)` SHALL register a one-shot callback at an absolute transport time and return a unique numeric event id. `scheduleOnce(callback, time)` SHALL behave identically but additionally remove the event after it fires. `scheduleRepeat(callback, interval, start?)` SHALL register a callback that fires every `interval` starting at `start` (default `0`) and return one id covering all repetitions. `cancel(eventId)` SHALL remove the event with that id; `cancelAfter(time)` SHALL remove every scheduled event whose next fire time is at or after `time`. Calling `cancel` with an id that was never issued or already removed SHALL throw `TransportError` with code `AUDIO_TRANSPORT_EVENT_NOT_FOUND`.
#### Scenario: scheduleOnce fires exactly once then is removed
- **WHEN** a caller schedules a once-callback and runs the transport past its time twice (e.g. via a loop)
- **THEN** the callback is invoked exactly once and a subsequent `cancel` on its id throws `AUDIO_TRANSPORT_EVENT_NOT_FOUND`
#### Scenario: cancelAfter removes only events at or after the cutoff
- **WHEN** a caller schedules callbacks at ticks `0`, `192`, and `384` then calls `cancelAfter` at the time of tick `192`
- **THEN** only the tick-`0` callback survives and fires when the transport runs
#### Scenario: Cancelling an unknown id throws AUDIO_TRANSPORT_EVENT_NOT_FOUND
- **WHEN** a caller calls `cancel(99999)` with an id that was never issued
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_EVENT_NOT_FOUND"`
**Verified by:** `src/transport/transport.test.ts::"scheduleOnce fires exactly once then removes the event"` AND `src/transport/transport.test.ts::"cancelAfter removes only events at or after the cutoff"` AND `src/transport/transport.test.ts::"cancel with an unknown id throws AUDIO_TRANSPORT_EVENT_NOT_FOUND"`
---
### Requirement: scheduleRepeat fires at the configured interval
A callback registered through `scheduleRepeat(callback, interval, start?)` SHALL fire once per `interval` of musical time, with each invocation receiving the sample-accurate audio `time` of that repetition. Repetitions SHALL be evenly spaced in ticks regardless of scheduler poll cadence, and the first repetition SHALL occur at `start`.
#### Scenario: Repeated callback fires evenly across a run
- **WHEN** a caller calls `scheduleRepeat(cb, "4n")` and runs the transport for four quarter notes of musical time
- **THEN** `cb` is invoked four times with `time` values whose tick equivalents are `0`, one quarter, two quarters, and three quarters apart
**Verified by:** `src/transport/transport.test.ts::"scheduleRepeat fires evenly at the configured interval"` AND `tests/integration.test.ts::"repeated callback times are sample-accurate under FakeClock"`
---
### Requirement: Lookahead scheduler loop
The package SHALL export a `TransportScheduler` class constructed from a `Transport` and a `Clock`. `start()` SHALL begin a lookahead loop driven by `@suaveplan/timing`'s `createScheduler` (poll cadence ~100 Hz). On each poll the scheduler SHALL: (1) emit FSM state-change events that fall between the previous poll time and now, then (2) call `TickSource.forEachTickBetween` so every scheduled callback in the window fires with its sample-accurate `time`. `stop()` SHALL halt the loop. Calling any scheduler-dependent operation before `start()` SHALL throw `TransportError` with code `AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED`.
#### Scenario: Callbacks receive sample-accurate times, not poll times
- **WHEN** the scheduler runs under a `FakeClock` advanced in coarse steps and a callback is scheduled mid-step
- **THEN** the callback's `time` argument is the exact tick time, not the coarse poll time at which the loop observed it
#### Scenario: Operating before start throws AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED
- **WHEN** a scheduler-dependent operation runs on a `TransportScheduler` whose `start()` has not been called
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED"`
**Verified by:** `src/scheduler/scheduler.test.ts::"callbacks receive sample-accurate times rather than coarse poll times"` AND `src/scheduler/scheduler.test.ts::"operating before start throws AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED"`
---
### Requirement: Deterministic scheduling under an injected Clock
`TransportScheduler` and `TickSource` SHALL take all wall-clock readings through the injected `Clock` (`@suaveplan/timing`'s `FakeClock` in tests). Given the same `FakeClock` advancement sequence, the same `TransportOptions`, and the same scheduled callbacks, two runs SHALL produce byte-identical sequences of `(callbackId, time, ticks)` tuples — the transport SHALL contain no hidden dependence on `Date.now()` or real timers.
#### Scenario: Identical FakeClock runs produce identical callback sequences
- **WHEN** the same transport program is run twice against fresh `FakeClock`s advanced through the identical step sequence
- **THEN** the recorded `(callbackId, time, ticks)` sequences are deep-equal
**Verified by:** `tests/integration.test.ts::"identical FakeClock runs produce identical callback sequences"`
---
### Requirement: Loop region playback
When `Transport.loop` is `true`, the transport SHALL, on crossing `loopEnd`, jump the playhead back to `loopStart` and continue, emitting a `loop` event with the audio time of the jump. `loopStart` and `loopEnd` SHALL accept any `TransportTime` and SHALL be resolved through the time parser. Scheduled callbacks whose times fall inside the loop region SHALL fire on every pass through the region. Setting `loopEnd` to a time at or before `loopStart` SHALL throw `TransportError` with code `AUDIO_TRANSPORT_INVALID_TIME`.
#### Scenario: Playhead wraps from loopEnd to loopStart
- **WHEN** a transport with `loop: true`, `loopStart: "0:0:0"`, `loopEnd: "1:0:0"` runs past `loopEnd`
- **THEN** `transport.position` wraps back into the region, a `loop` event fired with the jump time, and a callback inside the region fired on each pass
#### Scenario: loopEnd at or before loopStart is rejected
- **WHEN** a caller sets `loopEnd` to a time earlier than `loopStart`
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_INVALID_TIME"`
**Verified by:** `src/transport/transport.test.ts::"playhead wraps from loopEnd back to loopStart and emits loop"` AND `src/transport/transport.test.ts::"loopEnd at or before loopStart is rejected"`
---
### Requirement: Swing application
When `Transport.swing` is greater than `0`, the transport SHALL delay off-beat ticks relative to the `swingSubdivision` grid by a sine-shaped fraction of the subdivision duration, leaving on-grid ticks unmoved. Swing SHALL be a value in `[0, 1]`; a value outside that range SHALL throw `TransportError` with code `AUDIO_TRANSPORT_INVALID_TIME`. With `swing === 0` the tick times SHALL be identical to the un-swung grid.
#### Scenario: Swing delays off-beat ticks and leaves on-grid ticks unmoved
- **WHEN** a transport with `swing: 0.5`, `swingSubdivision: "8n"` iterates ticks across a bar
- **THEN** ticks on the eighth-note grid keep their un-swung times while the intervening off-beat ticks are delayed by a positive sine-shaped offset
#### Scenario: Out-of-range swing is rejected
- **WHEN** a caller sets `transport.swing = 1.5`
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_INVALID_TIME"`
**Verified by:** `src/transport/transport.test.ts::"swing delays off-beat ticks and leaves on-grid ticks unmoved"` AND `src/transport/transport.test.ts::"out-of-range swing value is rejected"`
---
### Requirement: Typed transport event surface
`Transport` SHALL expose `on` / `off` methods typed against `TransportEventMap` with the topics `start`, `stop`, `pause`, `loop`, and `bpm`, backed by `@suaveplan/events`. `start` / `stop` / `pause` / `loop` listeners SHALL receive the audio `time` of the event; `bpm` listeners SHALL receive `(bpm, time)`. Listener registration and removal SHALL be idempotent — registering the same listener twice SHALL fire it once, and `off` for an unregistered listener SHALL be a no-op.
#### Scenario: start event carries the audio time
- **WHEN** a listener is registered for `start` and a caller calls `transport.start(t)`
- **THEN** the listener is invoked once with `t`
#### Scenario: on/off are idempotent
- **WHEN** the same listener is registered twice for `stop` then `off` once
- **THEN** a subsequent `stop` does not invoke the listener
**Verified by:** `src/transport/transport.test.ts::"start event carries the audio time"` AND `src/transport/transport.test.ts::"on/off manage typed listeners idempotently"`
---
### Requirement: Disposal lifecycle
`Transport.dispose()` SHALL cancel every scheduled event, stop the associated `TransportScheduler`, remove every registered event listener, and transition the FSM to a terminal disposed condition. After `dispose()` every public method that mutates transport state — `start`, `stop`, `pause`, `schedule`, `scheduleRepeat`, `scheduleOnce`, `cancel`, `cancelAfter` — SHALL throw `TransportError` with code `AUDIO_TRANSPORT_ALREADY_DISPOSED`. `dispose()` itself SHALL be idempotent.
#### Scenario: Operating on a disposed transport throws AUDIO_TRANSPORT_ALREADY_DISPOSED
- **WHEN** a caller calls `transport.dispose()` then `transport.start()`
- **THEN** `start` throws `TransportError` whose `code === "AUDIO_TRANSPORT_ALREADY_DISPOSED"`
#### Scenario: dispose is idempotent
- **WHEN** a caller calls `transport.dispose()` twice
- **THEN** the second call neither throws nor double-cancels events
**Verified by:** `src/transport/transport.test.ts::"operating on a disposed transport throws AUDIO_TRANSPORT_ALREADY_DISPOSED"` AND `src/transport/transport.test.ts::"dispose is idempotent"`
---
### Requirement: AbortSignal propagation
`TransportScheduler.start()` SHALL accept an `AbortSignal` via its options object. An already-aborted signal SHALL cause `start()` to throw `TransportError` with code `AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED` before the lookahead loop is created; a signal aborted while the loop is running SHALL halt the loop at the next poll boundary and emit no further callbacks, equivalent to `stop()`.
#### Scenario: Pre-aborted signal prevents the loop from starting
- **WHEN** `TransportScheduler.start({ signal })` is called with an already-aborted signal
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED"` and no poll ever runs
#### Scenario: Mid-run abort halts the loop at the next poll boundary
- **WHEN** the signal is aborted after the scheduler has polled at least once
- **THEN** the loop stops at the next poll boundary and fires no further scheduled callbacks
**Verified by:** `src/scheduler/scheduler.test.ts::"pre-aborted signal prevents the lookahead loop from starting"` AND `src/scheduler/scheduler.test.ts::"mid-run abort halts the loop at the next poll boundary"`
---
### Requirement: Zod boundary validation
Every public options object — `TransportOptions`, `TransportTime`, `Ticks`, `BarsBeatsSixteenths` — SHALL have a Zod schema exported from a sibling `*.schemas.ts` (`TransportOptionsSchema`, `TransportTimeSchema`, `TicksSchema`, `BarsBeatsSixteenthsSchema`), and every public entry point SHALL validate its options argument against the schema before use. A validation failure SHALL throw `TransportError` with code `AUDIO_TRANSPORT_INVALID_TIME` for time-shaped inputs or `AUDIO_TRANSPORT_INVALID_BPM` for tempo inputs, carrying the Zod issue list in `context`.
#### Scenario: Invalid constructor options are rejected at the boundary
- **WHEN** a caller constructs `new Transport({ ppq: -1 })`
- **THEN** it throws `TransportError` whose `code === "AUDIO_TRANSPORT_INVALID_TIME"` with the Zod issues in `context`
**Verified by:** `src/transport/transport.test.ts::"constructor rejects invalid options against TransportOptionsSchema"`
---
### Requirement: Structured error codes
The package SHALL export `TransportErrorCode`, `AUDIO_TRANSPORT_ERROR_METADATA`, and `registerTransportErrorCodes()`, registering the codes `AUDIO_TRANSPORT_INVALID_TIME`, `AUDIO_TRANSPORT_INVALID_BPM`, `AUDIO_TRANSPORT_SCHEDULER_NOT_STARTED`, `AUDIO_TRANSPORT_EVENT_NOT_FOUND`, and `AUDIO_TRANSPORT_ALREADY_DISPOSED` with `@suaveplan/error`. Every error thrown by the package SHALL be a `TransportError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_TRANSPORT_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/error-codes.test.ts::"every AUDIO_TRANSPORT code is reachable from a call site"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `transport.tick` (one per scheduler poll) and `transport.schedule`; a counter `audio_transport_callbacks_fired_total`; a histogram `audio_transport_tick_jitter_ms` recording the difference between a callback's scheduled time and its poll-observed time. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
**Verified by:** `src/scheduler/scheduler.test.ts::"records a span and jitter histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./transport/transport`, `./scheduler/scheduler`, `./tick-source/tick-source`, `./signal/signal`, `./time/time`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: Each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-transport/tick-source/tick-source`
- **THEN** the import resolves to the tick-source module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,106 @@
# Tasks: enterprise-audio-transport
**Change ID**: enterprise-audio-transport
**Package**: `@suaveplan/audio-transport`
**Target path**: `packages/audio/audio-transport`
**Prerequisite packages on Verdaccio**: `@suaveplan/timing`, `@suaveplan/events`, `@suaveplan/fsm`, `@suaveplan/types`, `@suaveplan/error`
---
## 1. Scaffold
- [ ] Create directory `packages/audio/audio-transport/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/timing workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/fsm workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/error workspace:^`, `zod catalog:forms`.
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
---
## 2. Types + errors
- [ ] Write `src/transport/transport.types.ts` — `TransportOptions`, `TransportTime`, `Ticks`, `BarsBeatsSixteenths`, `BPM`, `Subdivision`, `TransportCallback`, `TransportEventMap`.
- [ ] Write `src/scheduler/scheduler.types.ts` — `SchedulerState`.
- [ ] Write `src/tick-source/tick-source.types.ts` — `TickSourceState`.
- [ ] Write `src/signal/signal.types.ts` — `SignalConfig`.
- [ ] Write `src/time/time.types.ts` — `TimeToken`, `TimeParseResult`.
- [ ] Write co-located `.md` files for each types module (≥200 words).
- [ ] Write `src/error-codes.ts` — `TransportErrorCode`, `AUDIO_TRANSPORT_ERROR_METADATA`, `registerTransportErrorCodes()`.
- [ ] Write Zod schemas: `TransportOptionsSchema`, `TransportTimeSchema`, `TicksSchema`, `BarsBeatsSixteenthsSchema`.
---
## 3. Signal module
- [ ] Implement `src/signal/signal.ts` — `Signal<BPM>` wrapper around `@suaveplan/timing` `Signal` with ramp/hold curves for tempo automation.
- [ ] Write `signal.test.ts` and `signal.md`.
---
## 4. Time parsing
- [ ] Implement `src/time/time.ts` — parse musical time strings (`"4n"`, `"8t"`, `"1m"`, `"2:1:0"`, `"+1"`, `"@4m"`), convert to seconds and ticks, `toSeconds`, `toTicks`, `toBarsBeatsSixteenths`.
- [ ] Write `time.test.ts` and `time.md`.
---
## 5. TickSource
- [ ] Implement `TickSource` in `src/tick-source/tick-source.ts`:
- `getTicksAtTime`, `getTimeOfTick`, `setTicksAtTime` with tempo curve support.
- `start`, `stop`, `pause` with offset handling.
- `forEachTickBetween` iterating ticks between two audio times, invoking callback with sample-accurate `time` and `ticks`.
- [ ] Write `tick-source.test.ts` and `tick-source.md`.
---
## 6. TransportScheduler
- [ ] Implement `TransportScheduler` in `src/scheduler/scheduler.ts`:
- Constructor takes `Transport` and `Clock`.
- `start()` begins the ~100Hz lookahead loop via `@suaveplan/timing` `createScheduler`.
- `stop()` halts the scheduler.
- On each tick: emit FSM state changes between last update and now, then invoke `tickSource.forEachTickBetween` to fire scheduled callbacks with sample-accurate times.
- [ ] Write `scheduler.test.ts` and `scheduler.md`.
---
## 7. Transport
- [ ] Implement `Transport` in `src/transport/transport.ts`:
- Constructor accepts `TransportOptions`; initializes `TickSource`, FSM state machine (`stopped` → `playing` → `paused`), and internal event list.
- `start`, `stop`, `pause` drive FSM transitions and tick source.
- `schedule`, `scheduleRepeat`, `scheduleOnce` return numeric event IDs; store callbacks in a priority queue.
- `cancel` removes event by ID; `cancelAfter` removes all events after a given time.
- `bpm` as `Signal<BPM>` with ramp/hold support.
- `swing` (0..1) and `swingSubdivision` applied per-tick during `_processTick()` using sine-based offset.
- `toSeconds`, `toTicks`, `toBarsBeatsSixteenths` delegate to time module.
- `loop`, `loopStart`, `loopEnd` — when enabled, transport jumps to `loopStart` after crossing `loopEnd`.
- Events: `start`, `stop`, `pause`, `loop`, `bpm` via `@suaveplan/events`.
- `dispose()` cancels all events, stops scheduler, and cleans up.
- [ ] Write `transport.test.ts` (≤500 lines): start/stop/pause, schedule/cancel, loop region, tempo ramp, swing application, time conversion round-trip.
- [ ] Write `transport.md`.
---
## 8. Benchmarks
- [ ] Write `src/__benchmarks__/transport.bench.ts` — schedule 1 000 callbacks, 100 BPM ramps, 10 000 tick iterations.
---
## 9. Integration tests
- [ ] Write `tests/integration.test.ts` — full transport lifecycle with `FakeClock`: start, schedule repeated callback, verify callback times are sample-accurate, loop jump, cancel after time, tempo automation curve.
---
## 10. Docs + verify + publish
- [ ] All co-located `.md` files (≥200 words each).
- [ ] Write `README.md` at package root.
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-transport` — green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-transport` — 100%.
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-transport`.
- [ ] Fold `specs/audio-transport/spec.md` into `openspec/specs/audio-transport/spec.md`.
- [ ] Archive to `openspec/archive/2026-05-14-enterprise-audio-transport/`.