270 lines
12 KiB
Markdown
270 lines
12 KiB
Markdown
# 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 `MIDIConnectionEvent`s.
|
||
- **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.
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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`:
|
||
|
||
```ts
|
||
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.
|
||
|
||
```ts
|
||
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.
|
||
|
||
```ts
|
||
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
|
||
|
||
```text
|
||
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 `MIDIMessageEvent`s 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.
|