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

270 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.