12 KiB
Design: enterprise-audio-controllers
Change ID: enterprise-audio-controllers
Package: @suaveplan/audio-controllers
@suaveplan/audio-controllers is the MIDI hardware-controller integration layer of the
@suaveplan/audio-* family. It is a browser-tier Integration package: it sits above the
universal core and transport, may use the Web MIDI API, and is consumed directly by application
code. This document carries the package-specific design only.
Cross-cutting architecture
The shared @suaveplan/audio-* ecosystem architecture — the three-layer separation, the six plugin
slots, the genesis-package reuse table, the universal-tier purity rule, and the error hierarchy —
is defined once in enterprise-audio-core/design.md §1–§4. This package conforms to that model and
does not duplicate it. Where this document references "the transport", "the event bus", or "the
error base", those are the abstractions established there.
Controllers are an Integration-tier package: unlike audio-core, audio-timeline, and the
analysis packages (universal tier), audio-controllers is permitted to touch a browser platform
API (Web MIDI). It earns that permission by routing every platform call through an injected
adapter, so the package itself stays testable without a browser and without mocks.
1. The controller-plugin abstraction
Every supported device is a subclass of one abstract base, MidiControllerPlugin. The base owns
everything that is device-independent:
- MIDI lifecycle —
requestAccess/onStateChange, obtained through the injectedMidiAccessAdapter. - Connection FSM — a
@suaveplan/fsmmachine (disconnected → connecting → connected → error) driven byMIDIConnectionEvents. - CC-mapping DSL —
defineMappingsregistersCCMappingrecords; the base matches incoming messages and emits typedcontrolevents. - LED feedback —
flushLEDsbatchesLEDUpdaterecords into a single MIDI output write. - Learn mode —
enterLearnMode/exitLearnModecapture the next control message as a candidate mapping. - Sequencer — a transport-synced
Sequencerinstance, exposed readonly.
Subclasses (KontrolS88Plugin, MaschinePlugin, TraktorS4Plugin) add only device-specific
surface: a device ControllerMetadata, a preset CCMapping set, device-specific argument
validation, and convenience methods (setScale, setPadVelocity, loadDeck, …). This keeps the
device files thin — most behaviour is inherited and tested once on the base.
1.1. Injectable MIDI access — no mocks
The repo bans mocks. MidiControllerPlugin never calls navigator.requestMIDIAccess directly;
it calls adapter.requestAccess(opts) on an injected MidiAccessAdapter. In production the
adapter is a thin wrapper over navigator.requestMIDIAccess. In tests the adapter is a real
fake-device fixture — a concrete object that implements the MIDIAccess / MIDIInput /
MIDIOutput shape and can be driven to emit MIDIMessageEvent and MIDIConnectionEvent values.
The fixture is a genuine implementation, not a vi.mock stand-in, so tests exercise real code
paths.
interface MidiAccessAdapter {
requestAccess(opts?: { sysex?: boolean; signal?: AbortSignal }): Promise<MIDIAccess>
}
abstract class MidiControllerPlugin {
constructor(protected readonly deps: {
adapter: MidiAccessAdapter
transport: Transport
logger?: LoggerLike
}) {}
async requestAccess(sysex = false): Promise<MIDIAccess> {
try {
return await this.deps.adapter.requestAccess({ sysex })
} catch (cause) {
throw new AudioControllersError('AUDIO_CONTROLLERS.ACCESS_DENIED', { cause })
}
}
}
1.2. Connection-state FSM
The connection lifecycle is a @suaveplan/fsm machine rather than ad-hoc booleans, so illegal
transitions are caught structurally and reconnect recovery is explicit:
const connectionFsm = createFsm({
initial: 'disconnected',
states: {
disconnected: { connect: 'connecting' },
connecting: { ready: 'connected', fail: 'error' },
connected: { unplug: 'disconnected', fail: 'error' },
error: { retry: 'connecting' },
},
})
On connected, the base re-applies the last known CCMapping set and LED state — a controller
that is unplugged and re-plugged comes back fully configured. Every transition emits a typed
state event { from, to }.
2. MIDI mapping strategy
2.1. Input — the CC-mapping DSL
defineMappings takes declarative CCMapping records:
interface CCMapping {
channel: number // 0..15
controller: number // 0..127
target: string // named parameter
min?: number // output range floor (default 0)
max?: number // output range ceiling (default 1)
transform?: (raw: number) => number // optional custom curve
}
Incoming messages are matched on (channel, controller); the raw 0..127 byte is scaled into
min..max (or run through transform) and emitted as a control event. Unmatched input emits a
raw event — never an error — so a device with controls the app has not mapped is harmless.
2.2. Output — batched LED feedback
flushLEDs is the inverse path. LEDUpdate records ({ index, color } or device-specific shape)
are translated to MIDI output bytes and written to the device's MIDIOutput in one flush, so a
full LED refresh is a single port write rather than N writes.
2.3. MIDI clock against the transport timeline
The critical timing decision: MIDI input is timestamped against the transport tick timeline, not
Date.now() and not setInterval. For each MIDIMessageEvent:
onMIDIMessage(message: MIDIMessageEvent) {
const transportTime = this.transport.toSeconds(this.transport.ticks)
const latency = message.timeStamp - transportTime
this.transport.scheduleOnce((time) => {
this.emit('note', { note, velocity: velocity / 127, time })
}, `+${latency}`)
}
The note is scheduled on the transport, so it is sample-accurate relative to the audio clock and immune to JavaScript event-loop jitter. The same principle drives the sequencer and arpeggiator — nothing in this package times anything off the wall clock.
3. Sequencer and arpeggiator design
3.1. Sequencer
Sequencer is constructed from a Transport. start() registers a repeating transport callback;
each tick advances a step cursor and fires the onStep callbacks registered for the active step.
Because the cursor is driven by transport ticks, the same pattern renders identically in real time
(browser) and offline (advance the transport without a clock). reset() returns the cursor to 0
without stopping the transport.
class Sequencer {
constructor(private readonly transport: Transport) {}
start() {
this.handle = this.transport.scheduleRepeat((time) => {
const i = this.cursor
if (this.steps[i]) for (const cb of this.callbacks.get(i) ?? []) cb(time)
this.cursor = (this.cursor + 1) % this.steps.length
}, '16n')
}
}
3.2. Arpeggiator
Arpeggiator is a transport event subscriber. setPattern(notes, mode) stores the pattern and an
ArpMode ordering (up, down, updown, random); start() registers a repeating transport
callback at the configured rate that emits the next note. The ordering is computed lazily so
updown and random do not pre-materialise an expanded list.
class Arpeggiator {
start() {
this.handle = this.transport.scheduleRepeat((time) => {
const note = this.order()[this.step % this.pattern.length]
this.emit('note', { note, time })
this.step++
}, this.rate)
}
}
Both classes deliberately have zero wall-clock dependency — that is what makes controller-driven patterns renderable on the server.
4. Integration notes
@suaveplan/audio-transport— supplies theTransportthat theSequencer,Arpeggiator, and MIDI-clock path schedule against. The package holds no clock of its own.@suaveplan/audio-core— suppliesAudioDocument(used byMaschinePlugin.loadSampleto hold loaded pad samples) and theBlockProcessorcontract for any DSP a device exposes.@suaveplan/events— backs the typedControllerEventMap(control,raw,note,state,learn,error). Listener registration and removal are idempotent per the events package contract.@suaveplan/fsm— backs the connection-state machine in §1.2.@suaveplan/opentelemetry— spans (controller.midi_message,controller.sequencer_step,controller.connect), counters, and the MIDI-latency / sequencer-jitter histograms.@suaveplan/error—AudioControllersErrorbase and theAUDIO_CONTROLLERS.*code namespace.
5. File layout
packages/audio/audio-controllers/
package.json, tsconfig.json, vitest.config.ts
src/
index.ts
error-codes.ts
base/
midi-controller-plugin.ts (+ .md .types.ts .test.ts)
connection-fsm.ts (+ .md .types.ts .test.ts)
midi-clock.ts (+ .md .types.ts .test.ts)
midi-access-adapter.ts (+ .md .types.ts .test.ts)
index.ts
sequencer/ sequencer.ts (+ .md .types.ts .test.ts) · index.ts
arpeggiator/ arpeggiator.ts (+ .md .types.ts .test.ts) · index.ts
learn/ learn-mode.ts (+ .md .types.ts .test.ts) · index.ts
plugins/
kontrol-s88/ kontrol-s88.ts (+ .md .test.ts) · mapping.ts · state.ts · scale.ts · led.ts · preset.ts · index.ts
maschine/ maschine.ts (+ .md .test.ts) · mapping.ts · state.ts · sample-loader.ts · preset.ts · index.ts
traktor-s4/ traktor-s4.ts (+ .md .test.ts) · mapping.ts · deck.ts · mixer.ts · led.ts · preset.ts · index.ts
__benchmarks__/ midi.bench.ts
tests/
integration.test.ts purity.test.ts exports.test.ts
e2e/ kontrol-s88.e2e.test.ts maschine.e2e.test.ts traktor-s4.e2e.test.ts
6. Benchmarks
midi.bench.ts— MIDI message throughput: 100kMIDIMessageEvents through the CC-mapping match path; and sequencer step-scheduling latency: 10k steps scheduled and fired through a fake transport, measuring per-step jitter.
7. Decisions and trade-offs
- One abstract base, thin subclasses. Every device-independent concern (FSM, mapping DSL, LED
flush, learn mode, sequencer wiring) lives on
MidiControllerPluginand is tested once. Device files carry only metadata, presets, range validation, and convenience methods. This keeps three very different controllers (a keyboard, a groovebox, a DJ deck) from diverging in their core behaviour. - MIDI access through an injected adapter — never mocked. The repo bans mocks, and the Web MIDI
API is unavailable under
bun test. AMidiAccessAdaptermakes the platform call injectable; the test fixture is a real fake-device implementation that emits genuineMIDIMessageEvent/MIDIConnectionEventvalues. Tests exercise real code, not stubs. - Everything schedules on the transport, nothing on the wall clock. The sequencer, arpeggiator,
and MIDI-clock path all schedule via the
Transport. This is what lets a controller-driven pattern be rendered offline or on the server — and it removes event-loop jitter from live playback.setInterval/setTimeout/Date.noware not used for any timing. - Connection state is an FSM, not booleans. Hot-plug recovery and illegal-transition detection
are structural, using
@suaveplan/fsm, rather than scatteredif (connected)checks. - Integration tier, not universal. Unlike the core/timeline/analysis packages, this package is allowed a browser platform API. It is not allowed Node-only APIs — the purity requirement in the spec delta enforces the one-sided boundary.