Initial Commit
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user