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, orBuffer
Scenario: MIDI access flows through the injected adapter
- WHEN a
MidiControllerPluginsubclass is constructed with aMidiAccessAdapterfixture andrequestAccess()is called - THEN the package obtains its
MIDIAccessfrom the adapter and never referencesnavigatordirectly, 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
MidiControllerPluginsubclass and awaitsinitialize() - THEN
requestAccesshas resolved through the adapter,plugin.sequenceris a usableSequencer, 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
CCMappingbinds(channel 0, controller 7)to"volume"with range0..1and a matching MIDI message arrives via the fake-device fixture - THEN a
controlevent fires once with{ target: "volume", value }wherevalueis the controller byte scaled into0..1
Scenario: flushLEDs batches output bytes in one write
- WHEN a caller passes three
LEDUpdaterecords toflushLEDs - THEN the injected
MIDIOutputfixture 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
disconnectedthen aconnectedMIDIConnectionEventfor the same port - THEN the FSM transitions
connected → disconnected → connecting → connected, twostateevents fire, and the previously registeredCCMappingset 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
AudioControllersErrorwhosecode === "AUDIO_CONTROLLERS.INVALID_STATE"withfromandtoincontext
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
onStepcallbacks, and advances theTransport - THEN only the callbacks for steps 0 and 2 fire, each with the transport-supplied sample-accurate time, and no
setIntervalis 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
Transportcallbacks and the package issues nosetInterval/setTimeout/Date.nowcalls 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]inupmode at a16nrate, starts the arpeggiator, and advances theTransportthree steps - THEN notes
60,64,67are 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
MIDIMessageEventwith a knowntimeStamp - THEN the package schedules the note via
transport.scheduleOnceat a transport-relative time derived fromtransport.ticks, and noDate.now()orsetIntervalcall 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— exposessetScale(mode),setArpMode(mode),setPitchBend(value),setModWheel(value), andsendLED(keyIndex, color).MaschinePlugin— exposessetPadVelocity(padIndex, velocity),setStep(padIndex, stepIndex, active), andloadSample(slotIndex, sampleId).TraktorS4Plugin— exposesloadDeck(deckIndex, track),setCrossfader(value), andsetChannelVolume(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 connectedKontrolS88Plugin - THEN the injected
MIDIOutputfixture 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.setPadVelocitywith a pad index beyond the device's 16-pad range - THEN it throws
AudioControllersErrorwhosecode === "AUDIO_CONTROLLERS.DEVICE_ERROR"with the offending index incontext
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
learnevent fires with the captured(channel, controller)pair, nocontrolevent 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
defineMappingsis called with aCCMappingwhosecontrollerbyte is200(outside0..127) - THEN it throws
AudioControllersErrorwhosecode === "AUDIO_CONTROLLERS.INVALID_OPTIONS"with the Zod issues incontext
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
MidiAccessAdapterfixture rejects the access request - THEN
requestAccess()throwsAudioControllersErrorwhosecode === "AUDIO_CONTROLLERS.ACCESS_DENIED"with the underlying rejection ascause
Scenario: every registered code is thrown by a call site
- WHEN the test suite runs
- THEN each code in
AUDIO_CONTROLLERS_ERROR_METADATAis 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
AudioControllersErrorwhosecode === "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_ERRORat 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_messagespan is recorded andaudio_controller_midi_messages_totalincrements 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 throwAudioControllersErrorwith codeAUDIO_CONTROLLERS.UNKNOWN_PARAMETERif absent, - read the parameter's declared
min,max, and optionalscaleand map the source's normalised value into that range using the declared scale (linear / log / exponential), - call
plugin.setParameter(paramId, mappedValue)for immediate parameters andplugin.setParameterCurve(paramId, { type: 'linear', value: mappedValue, duration: opts.smoothingMs / 1000 })when an optionalsmoothingMsis 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.parameterIndexreferences 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 CC1/7value64/127arrives - THEN
eq.setParameter('lowShelfGain', mid)is called wheremidequals the linear mapping of64/127into 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
AudioControllersErrorwhosecode === "AUDIO_CONTROLLERS.UNKNOWN_PARAMETER"with the offending id incontext
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 ofsetParameter
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
setParameteron 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"