Initial Commit
This commit is contained in:
376
openspec/specs/audio-transport/spec.md
Normal file
376
openspec/specs/audio-transport/spec.md
Normal 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 -->
|
||||
Reference in New Issue
Block a user