Files
suaveplan/openspec/archive/2026-05-19-enterprise-audio-controllers/design.md
2026-05-27 23:29:53 +01:00

12 KiB
Raw Blame History

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 injected MidiAccessAdapter.
  • Connection FSM — a @suaveplan/fsm machine (disconnected → connecting → connected → error) driven by MIDIConnectionEvents.
  • CC-mapping DSL — defineMappings registers CCMapping records; the base matches incoming messages and emits typed control events.
  • LED feedback — flushLEDs batches LEDUpdate records into a single MIDI output write.
  • Learn mode — enterLearnMode / exitLearnMode capture the next control message as a candidate mapping.
  • Sequencer — a transport-synced Sequencer instance, 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 the Transport that the Sequencer, Arpeggiator, and MIDI-clock path schedule against. The package holds no clock of its own.
  • @suaveplan/audio-core — supplies AudioDocument (used by MaschinePlugin.loadSample to hold loaded pad samples) and the BlockProcessor contract for any DSP a device exposes.
  • @suaveplan/events — backs the typed ControllerEventMap (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 — AudioControllersError base and the AUDIO_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: 100k MIDIMessageEvents 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 MidiControllerPlugin and 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. A MidiAccessAdapter makes the platform call injectable; the test fixture is a real fake-device implementation that emits genuine MIDIMessageEvent / MIDIConnectionEvent values. 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.now are 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 scattered if (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.