Files
suaveplan/openspec/specs/audio-transport/spec.md
2026-05-27 23:29:53 +01:00

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, 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 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 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"