Files
2026-05-27 23:29:53 +01:00

22 KiB

Capability Spec Delta: audio-controllers (audio-controllers)

Package: @suaveplan/audio-controllers Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

ADDED Requirements

Requirement: Browser-tier purity with injectable MIDI access

The @suaveplan/audio-controllers package MAY use the Web MIDI API (navigator.requestMIDIAccess, MIDIAccess, MIDIInput, MIDIOutput, MIDIMessageEvent, MIDIConnectionEvent) because it is a browser-tier package, but it SHALL import zero Node-only APIs (node:fs, node:path, node:crypto, process, Buffer). All MIDI access SHALL be reached only through an injected MidiAccessAdapter so the package never calls navigator.requestMIDIAccess directly — tests inject a real fake-device fixture rather than mocking the platform. The package's runtime dependencies SHALL be limited to @suaveplan/audio-core, @suaveplan/audio-transport, @suaveplan/events, @suaveplan/error, @suaveplan/types, @suaveplan/opentelemetry, tslib, and zod.

Scenario: Built dist references no Node globals

  • WHEN the built dist/ is grep'd
  • THEN it MUST NOT reference node:fs, node:path, node:crypto, process, or Buffer

Scenario: MIDI access flows through the injected adapter

  • WHEN a MidiControllerPlugin subclass is constructed with a MidiAccessAdapter fixture and requestAccess() is called
  • THEN the package obtains its MIDIAccess from the adapter and never references navigator directly, so the fake-device fixture drives the test with no mocks

Verified by: tests/purity.test.ts::"dist references no Node globals" AND src/base/midi-controller-plugin.test.ts::"requestAccess resolves MIDIAccess through the injected adapter"


Requirement: MidiControllerPlugin base class

The package SHALL export an abstract MidiControllerPlugin class exposing readonly metadata: ControllerMetadata, a readonly sequencer: Sequencer, the MIDI lifecycle methods requestAccess(sysex?) and onStateChange(event), the feedback method flushLEDs(updates), the mapping method defineMappings(mappings), the learn-mode methods enterLearnMode() / exitLearnMode(), the typed event methods on / off, and the lifecycle methods initialize() / destroy(). requestAccess SHALL obtain a MIDIAccess via the injected MidiAccessAdapter, optionally requesting SysEx. destroy() SHALL release every held MIDI port, unsubscribe every listener, and stop the sequencer, and SHALL be idempotent.

Scenario: initialize wires inputs and exposes a sequencer

  • WHEN a caller constructs a concrete MidiControllerPlugin subclass and awaits initialize()
  • THEN requestAccess has resolved through the adapter, plugin.sequencer is a usable Sequencer, and the plugin is subscribed to its MIDI inputs

Scenario: destroy releases ports and is idempotent

  • WHEN a caller awaits destroy() twice
  • THEN the first call releases every MIDI port, unsubscribes listeners, and stops the sequencer; the second call is a no-op and does not throw

Verified by: src/base/midi-controller-plugin.test.ts::"initialize wires MIDI inputs and exposes a sequencer" AND src/base/midi-controller-plugin.test.ts::"destroy releases ports and is idempotent"


Requirement: MIDI input and output mapping

The package SHALL map raw MIDI input through a declarative control-change DSL: defineMappings(mappings: CCMapping[]) registers CCMapping records that bind a (channel, controller) pair to a named target parameter with an optional value transform and min/max range. Incoming MIDI messages whose status and data bytes match a registered mapping SHALL emit a typed control event carrying the mapped target name and the transformed value. Output mapping SHALL be the inverse: flushLEDs(updates: LEDUpdate[]) batches LEDUpdate records and writes the corresponding MIDI output bytes to the device's MIDIOutput port in a single flush. Unmapped MIDI input SHALL emit a raw event and SHALL NOT throw.

Scenario: a mapped CC message emits a transformed control event

  • WHEN a CCMapping binds (channel 0, controller 7) to "volume" with range 0..1 and a matching MIDI message arrives via the fake-device fixture
  • THEN a control event fires once with { target: "volume", value } where value is the controller byte scaled into 0..1

Scenario: flushLEDs batches output bytes in one write

  • WHEN a caller passes three LEDUpdate records to flushLEDs
  • THEN the injected MIDIOutput fixture receives the three corresponding byte sequences in a single flush, in order

Verified by: src/base/midi-controller-plugin.test.ts::"a mapped CC message emits a transformed control event" AND src/base/midi-controller-plugin.test.ts::"flushLEDs writes batched output bytes through the MIDIOutput fixture"


Requirement: Controller connection-state FSM

The package SHALL model each controller's connection lifecycle as a @suaveplan/fsm state machine with the states disconnected, connecting, connected, and error. onStateChange(event: MIDIConnectionEvent) SHALL drive transitions: a connected port event moves connecting/disconnected → connected; a disconnected port event moves connected → disconnected; an access or port failure moves any state → error. On reconnect the machine SHALL re-apply the last known mappings and LED state (graceful state recovery). Every transition SHALL emit a typed state event carrying { from, to }. An illegal transition request SHALL throw AudioControllersError with code AUDIO_CONTROLLERS.INVALID_STATE.

Scenario: hot-unplug then re-plug recovers mappings

  • WHEN the fake-device fixture emits a disconnected then a connected MIDIConnectionEvent for the same port
  • THEN the FSM transitions connected → disconnected → connecting → connected, two state events fire, and the previously registered CCMapping set and LED state are re-applied

Scenario: illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE

  • WHEN a caller forces a transition the FSM does not permit from the current state
  • THEN it throws AudioControllersError whose code === "AUDIO_CONTROLLERS.INVALID_STATE" with from and to in context

Verified by: src/base/connection-fsm.test.ts::"hot-unplug then re-plug recovers mappings and LED state" AND src/base/connection-fsm.test.ts::"illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE"


Requirement: Transport-synced Sequencer

The package SHALL export a Sequencer class constructed from a @suaveplan/audio-transport Transport. It SHALL expose setTempo(bpm), setSteps(steps: boolean[]), setStep(index, active), start(), stop(), reset(), and onStep(index, callback). The sequencer SHALL schedule every step on the Transport using sample-accurate transport times and SHALL NOT use setInterval or setTimeout for step timing. start() SHALL register a repeating transport callback; on each scheduled tick the active step's onStep callbacks SHALL fire with the transport-provided time. reset() SHALL return the step cursor to index 0 without stopping the transport.

Scenario: active steps fire callbacks at transport ticks

  • WHEN a caller sets a 4-step pattern with steps 0 and 2 active, registers onStep callbacks, and advances the Transport
  • THEN only the callbacks for steps 0 and 2 fire, each with the transport-supplied sample-accurate time, and no setInterval is used

Scenario: sequencer schedules through the transport only

  • WHEN the sequencer is started and the test inspects timing sources
  • THEN every step is scheduled via Transport callbacks and the package issues no setInterval/setTimeout/Date.now calls for step timing

Verified by: src/sequencer/sequencer.test.ts::"active steps fire onStep callbacks at transport ticks" AND src/sequencer/sequencer.test.ts::"sequencer schedules steps through the transport without setInterval"


Requirement: Transport-synced Arpeggiator

The package SHALL export an Arpeggiator class constructed from a Transport. It SHALL expose setPattern(notes: number[], mode: ArpMode), setRate(interval), start(), and stop(). The arpeggiator SHALL be a Transport event subscriber: start() SHALL register a repeating transport callback at the configured rate, and each invocation SHALL emit the next note in the pattern (ordered per ArpMode — up, down, updown, random) with the transport-supplied time. Because scheduling is transport-driven, an arpeggio SHALL be renderable offline by advancing the transport without a real-time clock.

Scenario: arpeggio walks the pattern on transport ticks

  • WHEN a caller sets pattern [60, 64, 67] in up mode at a 16n rate, starts the arpeggiator, and advances the Transport three steps
  • THEN notes 60, 64, 67 are emitted in order, each with the transport-supplied time

Scenario: arpeggio renders offline

  • WHEN the transport is advanced offline (no wall-clock) over a fixed range
  • THEN the arpeggiator emits the same note sequence it would emit in real time, proving it depends only on transport ticks

Verified by: src/arpeggiator/arpeggiator.test.ts::"arpeggio walks the pattern in up mode on transport ticks" AND src/arpeggiator/arpeggiator.test.ts::"arpeggio produces an identical sequence under offline transport advance"


Requirement: MIDI clock timestamped against the transport tick timeline

Incoming MIDI messages SHALL be timestamped against the Transport tick timeline, not Date.now() or setInterval. For each MIDIMessageEvent the package SHALL compute the message's position relative to transport.toSeconds(transport.ticks), derive the input latency, and schedule the resulting note or control action with transport.scheduleOnce at the transport-relative time. This ensures MIDI events are sample-accurate relative to the transport and not subject to JavaScript event-loop jitter.

Scenario: an incoming note is scheduled on the transport timeline

  • WHEN the fake-device fixture emits a note-on MIDIMessageEvent with a known timeStamp
  • THEN the package schedules the note via transport.scheduleOnce at a transport-relative time derived from transport.ticks, and no Date.now() or setInterval call is involved in the timestamp computation

Verified by: src/base/midi-clock.test.ts::"incoming MIDI message is scheduled against the transport tick timeline"


Requirement: Per-device controller plugins

The package SHALL ship three concrete MidiControllerPlugin subclasses, each with a device-specific ControllerMetadata (name, manufacturer, port-name match patterns) and preset CCMapping set:

  • KontrolS88Plugin — exposes setScale(mode), setArpMode(mode), setPitchBend(value), setModWheel(value), and sendLED(keyIndex, color).
  • MaschinePlugin — exposes setPadVelocity(padIndex, velocity), setStep(padIndex, stepIndex, active), and loadSample(slotIndex, sampleId).
  • TraktorS4Plugin — exposes loadDeck(deckIndex, track), setCrossfader(value), and setChannelVolume(channel, value).

Each subclass SHALL drive its device through the inherited flushLEDs / output path and SHALL validate device-specific arguments (key index, pad index, deck index, channel) against the device's known range, throwing AudioControllersError with code AUDIO_CONTROLLERS.DEVICE_ERROR on an out-of-range argument.

Scenario: KontrolS88Plugin sends a key LED through the output path

  • WHEN a caller calls sendLED(keyIndex, color) on a connected KontrolS88Plugin
  • THEN the injected MIDIOutput fixture receives the device-specific LED byte sequence for that key and colour

Scenario: out-of-range device argument throws AUDIO_CONTROLLERS.DEVICE_ERROR

  • WHEN a caller calls MaschinePlugin.setPadVelocity with a pad index beyond the device's 16-pad range
  • THEN it throws AudioControllersError whose code === "AUDIO_CONTROLLERS.DEVICE_ERROR" with the offending index in context

Verified by: src/plugins/kontrol-s88/kontrol-s88.test.ts::"sendLED writes the device LED byte sequence through the output fixture" AND src/plugins/maschine/maschine.test.ts::"setPadVelocity rejects an out-of-range pad index with AUDIO_CONTROLLERS.DEVICE_ERROR"


Requirement: Learn mode for CC mapping capture

The package SHALL support a learn mode on MidiControllerPlugin: enterLearnMode() SHALL put the plugin into a state where the next unmapped MIDI control message is captured as a candidate CCMapping and emitted via a typed learn event carrying the captured (channel, controller) pair; exitLearnMode() SHALL leave learn mode. While in learn mode the plugin SHALL NOT emit control events for captured messages. Capturing a control message SHALL automatically exit learn mode unless the plugin is configured for continuous capture.

Scenario: learn mode captures the next control message

  • WHEN a caller calls enterLearnMode() and the fake-device fixture sends one CC message
  • THEN a learn event fires with the captured (channel, controller) pair, no control event fires for that message, and the plugin has exited learn mode

Verified by: src/learn/learn-mode.test.ts::"learn mode captures the next control message and exits"


Requirement: Zod boundary validation

Every public options object — ControllerMetadata, CCMapping, LEDUpdate, SequencerOptions, ArpeggiatorOptions, and each device plugin's constructor options — SHALL have a Zod schema exported from a sibling *.schemas.ts, and every public entry point SHALL validate its options argument against the schema before use. A validation failure SHALL throw AudioControllersError with code AUDIO_CONTROLLERS.INVALID_OPTIONS carrying the Zod issue list in context.

Scenario: invalid CC mapping is rejected at the boundary

  • WHEN defineMappings is called with a CCMapping whose controller byte is 200 (outside 0..127)
  • THEN it throws AudioControllersError whose code === "AUDIO_CONTROLLERS.INVALID_OPTIONS" with the Zod issues in context

Verified by: src/base/midi-controller-plugin.test.ts::"defineMappings rejects an invalid CCMapping with AUDIO_CONTROLLERS.INVALID_OPTIONS"


Requirement: Structured error codes

The package SHALL declare its codes via defineCodes("AUDIO_CONTROLLERS", [...]) producing the dotted codes AUDIO_CONTROLLERS.ACCESS_DENIED, AUDIO_CONTROLLERS.INVALID_OPTIONS, AUDIO_CONTROLLERS.INVALID_STATE, AUDIO_CONTROLLERS.DEVICE_ERROR, AUDIO_CONTROLLERS.NOT_CONNECTED, and AUDIO_CONTROLLERS.UNKNOWN_PARAMETER. The package SHALL export AudioControllersErrorCode, AUDIO_CONTROLLERS_ERROR_METADATA, registerAudioControllersErrorCodes(), and an AudioControllersError class extending AudioControllersError from @suaveplan/audio-core. Every error thrown by the package SHALL be an AudioControllersError and carry one of these codes, cause, and context. Every registered code SHALL be reachable from a real call site exercised by tests.

Scenario: denied MIDI access throws AUDIO_CONTROLLERS.ACCESS_DENIED

  • WHEN the injected MidiAccessAdapter fixture rejects the access request
  • THEN requestAccess() throws AudioControllersError whose code === "AUDIO_CONTROLLERS.ACCESS_DENIED" with the underlying rejection as cause

Scenario: every registered code is thrown by a call site

  • WHEN the test suite runs
  • THEN each code in AUDIO_CONTROLLERS_ERROR_METADATA is asserted thrown by at least one test

Verified by: src/base/midi-controller-plugin.test.ts::"requestAccess throws AUDIO_CONTROLLERS.ACCESS_DENIED when the adapter rejects" AND src/error-codes.test.ts::"every AUDIO_CONTROLLER code is reachable from a call site"


Requirement: AbortSignal propagation

MidiControllerPlugin.requestAccess() and MidiControllerPlugin.initialize(), plus MaschinePlugin.loadSample(), SHALL accept an AbortSignal via their options object. An already-aborted signal SHALL cause the method to reject with AudioControllersError code AUDIO_CONTROLLERS.ACCESS_DENIED (for access/init) or AUDIO_CONTROLLERS.DEVICE_ERROR (for loadSample) before any MIDI or device work; a signal aborted mid-operation SHALL terminate the operation at the next await boundary with the same error.

Scenario: pre-aborted signal short-circuits initialize

  • WHEN initialize() is called with an already-aborted signal
  • THEN it rejects with AudioControllersError whose code === "AUDIO_CONTROLLERS.ACCESS_DENIED" before any MIDI access is requested

Scenario: mid-operation abort terminates loadSample

  • WHEN a signal is aborted while MaschinePlugin.loadSample() is awaiting
  • THEN the operation rejects with AUDIO_CONTROLLERS.DEVICE_ERROR at the next await boundary and loads nothing further

Verified by: src/base/midi-controller-plugin.test.ts::"initialize rejects AUDIO_CONTROLLERS.ACCESS_DENIED for a pre-aborted signal" AND src/plugins/maschine/maschine.test.ts::"loadSample honours mid-operation abort"


Requirement: OpenTelemetry instrumentation

The package SHALL instrument its public entry points via @suaveplan/opentelemetry: spans controller.midi_message, controller.sequencer_step, and controller.connect; counters audio_controller_midi_messages_total and audio_controller_sequencer_steps_total; histograms audio_controller_midi_latency_ms and audio_controller_sequencer_jitter_ms. 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

Scenario: a handled MIDI message records a span and counter

  • WHEN a MIDI message is handled with an exporter registered
  • THEN a controller.midi_message span is recorded and audio_controller_midi_messages_total increments by 1

Verified by: tests/purity.test.ts::"importing the package mutates no global state" AND src/base/midi-clock.test.ts::"handling a MIDI message records a span and counter when an exporter is registered"


Requirement: Subpath exports per feature module

package.json SHALL declare one exports entry per feature module (./base/midi-controller-plugin, ./sequencer/sequencer, ./arpeggiator/arpeggiator, ./learn/learn-mode, ./plugins/kontrol-s88, ./plugins/maschine, ./plugins/traktor-s4) 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-controllers/plugins/kontrol-s88
  • THEN the import resolves to the Kontrol S88 plugin module without pulling the whole package barrel

Verified by: tests/exports.test.ts::"every feature module has a subpath export entry"


Requirement: Bindable parameter control over AudioFxPlugin

The package SHALL expose a bindParameter(plugin: AudioFxPlugin, paramId: string, source: MidiSource, opts?: { smoothingMs?: number }): UnsubscribeFn helper from @suaveplan/audio-controllers/binding that wires a MidiSource (a typed value emitted by a controller — CC, note velocity, pitch-bend, aftertouch, fader, encoder) to a plugin parameter on any AudioFxPlugin from @suaveplan/audio-plugins-fx. The helper SHALL:

  • look up the parameter via plugin.parameterIndex.get(paramId) (O(1)) and throw AudioControllersError with code AUDIO_CONTROLLERS.UNKNOWN_PARAMETER if absent,
  • read the parameter's declared min, max, and optional scale and map the source's normalised value into that range using the declared scale (linear / log / exponential),
  • call plugin.setParameter(paramId, mappedValue) for immediate parameters and plugin.setParameterCurve(paramId, { type: 'linear', value: mappedValue, duration: opts.smoothingMs / 1000 }) when an optional smoothingMs is provided (so a jittery hardware knob produces a smooth audio-rate ramp),
  • return an unsubscribe function that removes the binding without disturbing the controller or other bindings,
  • bind against plugin.parameterIndex references so subsequent parameter metadata changes (where supported by the plugin) are honoured without rebinding.

The package SHALL also expose a bindPluginPreset(plugin: AudioFxPlugin, source: MidiSource, presets: ReadonlyArray<PresetSnapshot>): UnsubscribeFn helper that, when triggered, applies the preset whose index corresponds to the source value (typical use: MIDI program-change events). Preset validation failures SHALL propagate the underlying AudioControllersError from plugin.applyPreset unchanged.

Scenario: bindParameter routes a CC into the plugin's parameter

  • WHEN a caller calls bindParameter(eq, 'lowShelfGain', { type: 'cc', channel: 1, number: 7 }) and an inbound MIDI CC 1/7 value 64/127 arrives
  • THEN eq.setParameter('lowShelfGain', mid) is called where mid equals the linear mapping of 64/127 into the parameter's declared [min, max] range within float tolerance

Scenario: bindParameter rejects an unknown parameter id

  • WHEN bindParameter(eq, 'nonexistent', source) is called
  • THEN it throws AudioControllersError whose code === "AUDIO_CONTROLLERS.UNKNOWN_PARAMETER" with the offending id in context

Scenario: smoothing option produces a parameter curve

  • WHEN bindParameter(eq, 'lowShelfGain', source, { smoothingMs: 20 }) is called and a CC value arrives
  • THEN the binding calls plugin.setParameterCurve('lowShelfGain', { type: 'linear', value: mappedValue, duration: 0.02 }) instead of setParameter

Scenario: unbind removes the binding cleanly

  • WHEN a caller calls the unsubscribe function returned by bindParameter
  • THEN subsequent inbound events for that source no longer call setParameter on the plugin, AND other bindings on the same source continue to fire

Verified by: src/binding/bind-parameter.test.ts::"routes a CC into the plugin parameter with linear scale mapping" AND src/binding/bind-parameter.test.ts::"rejects unknown parameter id with AUDIO_CONTROLLERS.UNKNOWN_PARAMETER" AND src/binding/bind-parameter.test.ts::"smoothing option produces a parameter curve" AND src/binding/bind-parameter.test.ts::"unbind stops routing without disturbing other bindings"