26 KiB
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, orBuffer
Scenario: Tests pass under both runtimes
- WHEN the suite runs under
bun test(server) and under vitest with ahappy-domshim (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, andtransport.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, andtransport.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, callspause(), then callsstart()again - THEN the FSM is
playing, andtransport.ticksresumes from the tick value held atpauserather than resetting to zero
Scenario: Illegal transition throws AUDIO_TRANSPORT_INVALID_TIME
- WHEN a caller calls
pause()on astoppedtransport - THEN it throws
TransportErrorwhosecode === "AUDIO_TRANSPORT_INVALID_TIME"andtransport.stateremains"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
TickSourceat a constant 120 BPM is queried withgetTimeOfTick(t)thengetTicksAtTimeon the result - THEN the returned tick equals
twithin floating-point tolerance
Scenario: setTicksAtTime anchors the counter
- WHEN a caller calls
setTicksAtTime(960, t)thengetTicksAtTime(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
forEachTickBetweenover an interval spanning ten ticks at a known tempo - THEN the callback fires exactly ten times with strictly ascending
ticksand eachtimeinside[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
t1boundary 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)from120att0and 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)orbpm.setValueAtTime(-1, t) - THEN it throws
TransportErrorwhosecode === "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
Signalholds100att0and a linear ramp to200att1is scheduled - THEN
getValueAtTime((t0 + t1) / 2)returns approximately150
Scenario: Exponential ramp through zero is rejected
- WHEN a caller schedules
exponentialRampToValueAtTime(0, t) - THEN it throws
TransportErrorwhosecode === "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 withppq: 192 - THEN the result is
192, andtoBarsBeatsSixteenths(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
TransportErrorwhosecode === "AUDIO_TRANSPORT_INVALID_TIME"with the token incontext
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)equalstransport.ticksand 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
cancelon its id throwsAUDIO_TRANSPORT_EVENT_NOT_FOUND
Scenario: cancelAfter removes only events at or after the cutoff
- WHEN a caller schedules callbacks at ticks
0,192, and384then callscancelAfterat the time of tick192 - THEN only the tick-
0callback 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
TransportErrorwhosecode === "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
cbis invoked four times withtimevalues whose tick equivalents are0, 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
FakeClockadvanced in coarse steps and a callback is scheduled mid-step - THEN the callback's
timeargument 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
TransportSchedulerwhosestart()has not been called - THEN it throws
TransportErrorwhosecode === "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
FakeClocks 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 pastloopEnd - THEN
transport.positionwraps back into the region, aloopevent 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
loopEndto a time earlier thanloopStart - THEN it throws
TransportErrorwhosecode === "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
TransportErrorwhosecode === "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
startand a caller callstransport.start(t) - THEN the listener is invoked once with
t
Scenario: on/off are idempotent
- WHEN the same listener is registered twice for
stopthenoffonce - THEN a subsequent
stopdoes 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()thentransport.start() - THEN
startthrowsTransportErrorwhosecode === "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
TransportErrorwhosecode === "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
TransportErrorwhosecode === "AUDIO_TRANSPORT_INVALID_TIME"with the Zod issues incontext
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_METADATAis 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"