Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.

View File

@@ -0,0 +1,208 @@
# OpenSpec Change: enterprise-audio-controllers
- **Change ID**: enterprise-audio-controllers
- **Package**: `@suaveplan/audio-controllers`
- **Category**: browser
- **Target path**: `packages/audio/audio-controllers`
- **Status**: PROPOSED
- **Date**: 2026-05-14
- **Design**: `./design.md`
- **Spec delta**: `./specs/audio-controllers/spec.md`
---
## Summary
Deliver `@suaveplan/audio-controllers` on the genesis branch from scratch at enterprise quality. MIDI hardware controller integrations for the genesis monorepo. Built on `@suaveplan/audio-core` and `@suaveplan/audio-transport` for audio-clock-synced sequencing. Provides a universal `MidiControllerPlugin` base class with hot-plug support, LED feedback, CC-mapping DSL, and learn mode.
Controllers run in the browser (Web MIDI API) but their sequencer/arpeggio output feeds into the universal `Transport` and `TimelineEngine`, so patterns can be rendered offline or on the server.
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data. The old `enterprise-browser-audio-controllers` was browser-only and not tied to the universal transport.
---
## Parity Checklist (vs upstream `../suaveplan/packages/browser/audio-controllers/`)
| Capability | Upstream | Migrated | Notes |
|---|:---:|:---:|---|
| `KontrolS88Plugin` (88-key weighted keyboard) | ✅ | ✅ | Same surface; now consumes `audio-transport` for scale-aware arpeggios instead of `setInterval`. |
| Scale modes (`major`, `minor`, `chromatic`, …) | ✅ | ✅ | Unchanged enum. |
| Arpeggiator | ✅ | ⬆️ | Now transport-synced via `audio-transport` `Transport.schedule`; upstream used `setInterval`. |
| `KontrolS88Preset` save / load | ✅ | ✅ | Now Zod-validated through the package's own preset schemas. |
| Pitch-bend / mod-wheel CC mapping | ✅ | ✅ | Same mapping; routed through the new `bindParameter` helper for FX surfaces. |
| LED feedback / `flushLEDs` | ✅ | ✅ | Same surface; batched per requirement "MIDI input and output mapping". |
| `MaschinePlugin` (16-pad + step sequencer + sample slots) | ✅ | ✅ | Step sequencer now drives off `Sequencer`/`Transport`; sample loading accepts `AbortSignal`. |
| `TraktorS4Plugin` (4-channel DJ controller) | ✅ | ✅ | Same surface; deck-sync now reads transport tempo from `ChainContext.ambient.transport` when hosted in a chain. |
| Web MIDI access via `navigator.requestMIDIAccess` | ✅ | ⬆️ | Now reached only through the injected `MidiAccessAdapter` seam; tests inject fake-device fixtures rather than mocking the platform. |
| Connection-state FSM | ⚠ ad-hoc | ⬆️ | Now `@suaveplan/fsm`-driven with explicit `disconnected/connecting/connected/error` and graceful state recovery on reconnect. |
| Learn mode for CC mapping | – | ⬆️ | Genesis addition. |
| Plugin base class (`AudioPlugin`) | ✅ via `audio-engine` | ⬆️ | Replaced by `MidiControllerPlugin` base whose plugins integrate with `@suaveplan/audio-plugins-fx`'s `AudioFxPlugin` for parameter binding via `bindParameter`. |
| `*.types.ts` files duplicating implementation declarations | ⚠ smell | ⬆️ | Consolidated; one types file per feature module. |
| Unit tests / coverage | ⚠ one `.spec.ts` only | ⬆️ | 100/100/100/100 across every module with real fake-device fixtures (no mocks). |
| Storybook coverage | – | – | n/a — controllers are non-visual. |
No 🚫 rows.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/audio-core` (`workspace:^`) | `AudioDocument`, `BlockProcessor` |
| `@suaveplan/audio-transport` (`workspace:^`) | `Transport`, `TransportTime` |
| `@suaveplan/events` (`workspace:^`) | Typed event bus for controller events |
| `@suaveplan/error` (`workspace:^`) | `AudioControllersError` base class; `AUDIO_CONTROLLER.*` codes |
| `@suaveplan/types` (`workspace:^`) | `LoggerLike` |
| `@suaveplan/opentelemetry` (`workspace:^`) | OTel spans, counters, histograms |
| `zod` (`catalog:forms`) | Zod schemas for option types |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
---
## Public API
### Core types
```ts
abstract class MidiControllerPlugin {
readonly metadata: ControllerMetadata
async requestAccess(sysex?: boolean): Promise<MIDIAccess>
onStateChange(event: MIDIConnectionEvent): void
flushLEDs(updates: LEDUpdate[]): void
defineMappings(mappings: CCMapping[]): void
enterLearnMode(): void
exitLearnMode(): void
on<K extends keyof ControllerEventMap>(event: K, listener: ControllerEventMap[K]): void
off<K extends keyof ControllerEventMap>(event: K, listener: ControllerEventMap[K]): void
readonly sequencer: Sequencer
initialize(): Promise<void>
destroy(): Promise<void>
}
class Sequencer {
constructor(transport: Transport)
setTempo(bpm: number): void
setSteps(steps: boolean[]): void
setStep(index: number, active: boolean): void
start(): void
stop(): void
reset(): void
onStep(index: number, callback: () => void): void
}
class KontrolS88Plugin extends MidiControllerPlugin {
setScale(mode: ScaleMode): void
setArpMode(mode: ArpMode): void
setPitchBend(value: number): void
setModWheel(value: number): void
sendLED(keyIndex: number, color: LEDColor): void
}
class MaschinePlugin extends MidiControllerPlugin {
setPadVelocity(padIndex: number, velocity: number): void
setStep(padIndex: number, stepIndex: number, active: boolean): void
loadSample(slotIndex: number, sampleId: string): Promise<void>
}
class TraktorS4Plugin extends MidiControllerPlugin {
loadDeck(deckIndex: number, track: DeckTrack): void
setCrossfader(value: number): void
setChannelVolume(channel: number, value: number): void
}
```
---
## New additions for genesis
1. **Universal sequencer base** — `Sequencer` uses `@suaveplan/audio-transport` for sample-accurate scheduling, not `setInterval`.
2. **Transport-synced arpeggiator** — arpeggio patterns are `Transport` event subscribers, renderable offline.
3. **Hot-plug support** — `onStateChange` handles connect/disconnect with graceful state recovery.
4. **CC mapping DSL** — declarative control-change to parameter mapping with learn mode.
5. **LED feedback** — bidirectional MIDI for controller LED/state updates.
6. **OTel instrumentation** — spans for MIDI message handling, sequencer step timing.
7. **Property-based tests** — random step patterns → correct sequence, random CC values → mapped parameters.
8. **Benchmark suite** — MIDI message throughput, sequencer scheduling latency.
---
## 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
midi-controller-plugin.md
midi-controller-plugin.types.ts
midi-controller-plugin.test.ts
index.ts
sequencer/
sequencer.ts
sequencer.md
sequencer.types.ts
sequencer.test.ts
index.ts
arpeggiator/
arpeggiator.ts
arpeggiator.md
arpeggiator.test.ts
index.ts
learn/
learn-mode.ts
learn-mode.md
learn-mode.test.ts
index.ts
plugins/
kontrol-s88/
kontrol-s88.ts, kontrol-s88.md, kontrol-s88.test.ts
mapping.ts, state.ts, scale.ts, led.ts, preset.ts
index.ts
maschine/
maschine.ts, maschine.md, maschine.test.ts
mapping.ts, state.ts, sample-loader.ts, preset.ts
index.ts
traktor-s4/
traktor-s4.ts, traktor-s4.md, traktor-s4.test.ts
mapping.ts, deck.ts, mixer.ts, led.ts, preset.ts
index.ts
__benchmarks__/
midi.bench.ts
tests/
integration.test.ts
e2e/
kontrol-s88.e2e.test.ts
maschine.e2e.test.ts
traktor-s4.e2e.test.ts
```
---
## Impact
**Breaking changes**: none — fresh build, package does not yet exist on genesis.
**Prerequisites**: `@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `@suaveplan/fsm` published to Verdaccio.
### Affected dependents
No dependents in this batch (verified by `bunx turbo ls --filter=...@suaveplan/audio-controllers`). `@suaveplan/audio-controllers` is a leaf integration package — it is consumed directly by application code (`@suaveplan/react-audio*`, game audio surfaces), none of which exist on genesis in this 13-change audio batch. Each future consumer is created against this package's spec delta, not retrofitted.
Once landed, any subsequent change to this package's public surface MUST run `bunx turbo test --filter=...@suaveplan/audio-controllers` per `openspec/AGENTS.md` §13 step 10.

View File

@@ -0,0 +1,284 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-controllers (audio-controllers)
**Package**: `@suaveplan/audio-controllers`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Browser-tier purity with injectable MIDI access
The `@suaveplan/audio-controllers` package MAY use the Web MIDI API (`navigator.requestMIDIAccess`, `MIDIAccess`, `MIDIInput`, `MIDIOutput`, `MIDIMessageEvent`, `MIDIConnectionEvent`) because it is a browser-tier package, but it SHALL import zero Node-only APIs (`node:fs`, `node:path`, `node:crypto`, `process`, `Buffer`). All MIDI access SHALL be reached only through an injected `MidiAccessAdapter` so the package never calls `navigator.requestMIDIAccess` directly — tests inject a real fake-device fixture rather than mocking the platform. The package's runtime `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no Node globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:crypto`, `process`, or `Buffer`
#### Scenario: MIDI access flows through the injected adapter
- **WHEN** a `MidiControllerPlugin` subclass is constructed with a `MidiAccessAdapter` fixture and `requestAccess()` is called
- **THEN** the package obtains its `MIDIAccess` from the adapter and never references `navigator` directly, so the fake-device fixture drives the test with no mocks
**Verified by:** `tests/purity.test.ts::"dist references no Node globals"` AND `src/base/midi-controller-plugin.test.ts::"requestAccess resolves MIDIAccess through the injected adapter"`
---
### Requirement: MidiControllerPlugin base class
The package SHALL export an abstract `MidiControllerPlugin` class exposing readonly `metadata: ControllerMetadata`, a readonly `sequencer: Sequencer`, the MIDI lifecycle methods `requestAccess(sysex?)` and `onStateChange(event)`, the feedback method `flushLEDs(updates)`, the mapping method `defineMappings(mappings)`, the learn-mode methods `enterLearnMode()` / `exitLearnMode()`, the typed event methods `on` / `off`, and the lifecycle methods `initialize()` / `destroy()`. `requestAccess` SHALL obtain a `MIDIAccess` via the injected `MidiAccessAdapter`, optionally requesting SysEx. `destroy()` SHALL release every held MIDI port, unsubscribe every listener, and stop the sequencer, and SHALL be idempotent.
#### Scenario: initialize wires inputs and exposes a sequencer
- **WHEN** a caller constructs a concrete `MidiControllerPlugin` subclass and awaits `initialize()`
- **THEN** `requestAccess` has resolved through the adapter, `plugin.sequencer` is a usable `Sequencer`, and the plugin is subscribed to its MIDI inputs
#### Scenario: destroy releases ports and is idempotent
- **WHEN** a caller awaits `destroy()` twice
- **THEN** the first call releases every MIDI port, unsubscribes listeners, and stops the sequencer; the second call is a no-op and does not throw
**Verified by:** `src/base/midi-controller-plugin.test.ts::"initialize wires MIDI inputs and exposes a sequencer"` AND `src/base/midi-controller-plugin.test.ts::"destroy releases ports and is idempotent"`
---
### Requirement: MIDI input and output mapping
The package SHALL map raw MIDI input through a declarative control-change DSL: `defineMappings(mappings: CCMapping[])` registers `CCMapping` records that bind a `(channel, controller)` pair to a named target parameter with an optional value transform and `min`/`max` range. Incoming MIDI messages whose status and data bytes match a registered mapping SHALL emit a typed `control` event carrying the mapped target name and the transformed value. Output mapping SHALL be the inverse: `flushLEDs(updates: LEDUpdate[])` batches `LEDUpdate` records and writes the corresponding MIDI output bytes to the device's `MIDIOutput` port in a single flush. Unmapped MIDI input SHALL emit a `raw` event and SHALL NOT throw.
#### Scenario: a mapped CC message emits a transformed control event
- **WHEN** a `CCMapping` binds `(channel 0, controller 7)` to `"volume"` with range `0..1` and a matching MIDI message arrives via the fake-device fixture
- **THEN** a `control` event fires once with `{ target: "volume", value }` where `value` is the controller byte scaled into `0..1`
#### Scenario: flushLEDs batches output bytes in one write
- **WHEN** a caller passes three `LEDUpdate` records to `flushLEDs`
- **THEN** the injected `MIDIOutput` fixture receives the three corresponding byte sequences in a single flush, in order
**Verified by:** `src/base/midi-controller-plugin.test.ts::"a mapped CC message emits a transformed control event"` AND `src/base/midi-controller-plugin.test.ts::"flushLEDs writes batched output bytes through the MIDIOutput fixture"`
---
### Requirement: Controller connection-state FSM
The package SHALL model each controller's connection lifecycle as a `@suaveplan/fsm` state machine with the states `disconnected`, `connecting`, `connected`, and `error`. `onStateChange(event: MIDIConnectionEvent)` SHALL drive transitions: a `connected` port event moves `connecting`/`disconnected` → `connected`; a `disconnected` port event moves `connected` → `disconnected`; an access or port failure moves any state → `error`. On reconnect the machine SHALL re-apply the last known mappings and LED state (graceful state recovery). Every transition SHALL emit a typed `state` event carrying `{ from, to }`. An illegal transition request SHALL throw `AudioControllersError` with code `AUDIO_CONTROLLERS.INVALID_STATE`.
#### Scenario: hot-unplug then re-plug recovers mappings
- **WHEN** the fake-device fixture emits a `disconnected` then a `connected` `MIDIConnectionEvent` for the same port
- **THEN** the FSM transitions `connected → disconnected → connecting → connected`, two `state` events fire, and the previously registered `CCMapping` set and LED state are re-applied
#### Scenario: illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE
- **WHEN** a caller forces a transition the FSM does not permit from the current state
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.INVALID_STATE"` with `from` and `to` in `context`
**Verified by:** `src/base/connection-fsm.test.ts::"hot-unplug then re-plug recovers mappings and LED state"` AND `src/base/connection-fsm.test.ts::"illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE"`
---
### Requirement: Transport-synced Sequencer
The package SHALL export a `Sequencer` class constructed from a `@suaveplan/audio-transport` `Transport`. It SHALL expose `setTempo(bpm)`, `setSteps(steps: boolean[])`, `setStep(index, active)`, `start()`, `stop()`, `reset()`, and `onStep(index, callback)`. The sequencer SHALL schedule every step on the `Transport` using sample-accurate transport times and SHALL NOT use `setInterval` or `setTimeout` for step timing. `start()` SHALL register a repeating transport callback; on each scheduled tick the active step's `onStep` callbacks SHALL fire with the transport-provided time. `reset()` SHALL return the step cursor to index 0 without stopping the transport.
#### Scenario: active steps fire callbacks at transport ticks
- **WHEN** a caller sets a 4-step pattern with steps 0 and 2 active, registers `onStep` callbacks, and advances the `Transport`
- **THEN** only the callbacks for steps 0 and 2 fire, each with the transport-supplied sample-accurate time, and no `setInterval` is used
#### Scenario: sequencer schedules through the transport only
- **WHEN** the sequencer is started and the test inspects timing sources
- **THEN** every step is scheduled via `Transport` callbacks and the package issues no `setInterval`/`setTimeout`/`Date.now` calls for step timing
**Verified by:** `src/sequencer/sequencer.test.ts::"active steps fire onStep callbacks at transport ticks"` AND `src/sequencer/sequencer.test.ts::"sequencer schedules steps through the transport without setInterval"`
---
### Requirement: Transport-synced Arpeggiator
The package SHALL export an `Arpeggiator` class constructed from a `Transport`. It SHALL expose `setPattern(notes: number[], mode: ArpMode)`, `setRate(interval)`, `start()`, and `stop()`. The arpeggiator SHALL be a `Transport` event subscriber: `start()` SHALL register a repeating transport callback at the configured rate, and each invocation SHALL emit the next note in the pattern (ordered per `ArpMode` — `up`, `down`, `updown`, `random`) with the transport-supplied time. Because scheduling is transport-driven, an arpeggio SHALL be renderable offline by advancing the transport without a real-time clock.
#### Scenario: arpeggio walks the pattern on transport ticks
- **WHEN** a caller sets pattern `[60, 64, 67]` in `up` mode at a `16n` rate, starts the arpeggiator, and advances the `Transport` three steps
- **THEN** notes `60`, `64`, `67` are emitted in order, each with the transport-supplied time
#### Scenario: arpeggio renders offline
- **WHEN** the transport is advanced offline (no wall-clock) over a fixed range
- **THEN** the arpeggiator emits the same note sequence it would emit in real time, proving it depends only on transport ticks
**Verified by:** `src/arpeggiator/arpeggiator.test.ts::"arpeggio walks the pattern in up mode on transport ticks"` AND `src/arpeggiator/arpeggiator.test.ts::"arpeggio produces an identical sequence under offline transport advance"`
---
### Requirement: MIDI clock timestamped against the transport tick timeline
Incoming MIDI messages SHALL be timestamped against the `Transport` tick timeline, not `Date.now()` or `setInterval`. For each `MIDIMessageEvent` the package SHALL compute the message's position relative to `transport.toSeconds(transport.ticks)`, derive the input latency, and schedule the resulting note or control action with `transport.scheduleOnce` at the transport-relative time. This ensures MIDI events are sample-accurate relative to the transport and not subject to JavaScript event-loop jitter.
#### Scenario: an incoming note is scheduled on the transport timeline
- **WHEN** the fake-device fixture emits a note-on `MIDIMessageEvent` with a known `timeStamp`
- **THEN** the package schedules the note via `transport.scheduleOnce` at a transport-relative time derived from `transport.ticks`, and no `Date.now()` or `setInterval` call is involved in the timestamp computation
**Verified by:** `src/base/midi-clock.test.ts::"incoming MIDI message is scheduled against the transport tick timeline"`
---
### Requirement: Per-device controller plugins
The package SHALL ship three concrete `MidiControllerPlugin` subclasses, each with a device-specific `ControllerMetadata` (name, manufacturer, port-name match patterns) and preset `CCMapping` set:
- `KontrolS88Plugin` — exposes `setScale(mode)`, `setArpMode(mode)`, `setPitchBend(value)`, `setModWheel(value)`, and `sendLED(keyIndex, color)`.
- `MaschinePlugin` — exposes `setPadVelocity(padIndex, velocity)`, `setStep(padIndex, stepIndex, active)`, and `loadSample(slotIndex, sampleId)`.
- `TraktorS4Plugin` — exposes `loadDeck(deckIndex, track)`, `setCrossfader(value)`, and `setChannelVolume(channel, value)`.
Each subclass SHALL drive its device through the inherited `flushLEDs` / output path and SHALL validate device-specific arguments (key index, pad index, deck index, channel) against the device's known range, throwing `AudioControllersError` with code `AUDIO_CONTROLLERS.DEVICE_ERROR` on an out-of-range argument.
#### Scenario: KontrolS88Plugin sends a key LED through the output path
- **WHEN** a caller calls `sendLED(keyIndex, color)` on a connected `KontrolS88Plugin`
- **THEN** the injected `MIDIOutput` fixture receives the device-specific LED byte sequence for that key and colour
#### Scenario: out-of-range device argument throws AUDIO_CONTROLLERS.DEVICE_ERROR
- **WHEN** a caller calls `MaschinePlugin.setPadVelocity` with a pad index beyond the device's 16-pad range
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.DEVICE_ERROR"` with the offending index in `context`
**Verified by:** `src/plugins/kontrol-s88/kontrol-s88.test.ts::"sendLED writes the device LED byte sequence through the output fixture"` AND `src/plugins/maschine/maschine.test.ts::"setPadVelocity rejects an out-of-range pad index with AUDIO_CONTROLLERS.DEVICE_ERROR"`
---
### Requirement: Learn mode for CC mapping capture
The package SHALL support a learn mode on `MidiControllerPlugin`: `enterLearnMode()` SHALL put the plugin into a state where the next unmapped MIDI control message is captured as a candidate `CCMapping` and emitted via a typed `learn` event carrying the captured `(channel, controller)` pair; `exitLearnMode()` SHALL leave learn mode. While in learn mode the plugin SHALL NOT emit `control` events for captured messages. Capturing a control message SHALL automatically exit learn mode unless the plugin is configured for continuous capture.
#### Scenario: learn mode captures the next control message
- **WHEN** a caller calls `enterLearnMode()` and the fake-device fixture sends one CC message
- **THEN** a `learn` event fires with the captured `(channel, controller)` pair, no `control` event fires for that message, and the plugin has exited learn mode
**Verified by:** `src/learn/learn-mode.test.ts::"learn mode captures the next control message and exits"`
---
### Requirement: Zod boundary validation
Every public options object — `ControllerMetadata`, `CCMapping`, `LEDUpdate`, `SequencerOptions`, `ArpeggiatorOptions`, and each device plugin's constructor options — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioControllersError` with code `AUDIO_CONTROLLERS.INVALID_OPTIONS` carrying the Zod issue list in `context`.
#### Scenario: invalid CC mapping is rejected at the boundary
- **WHEN** `defineMappings` is called with a `CCMapping` whose `controller` byte is `200` (outside `0..127`)
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.INVALID_OPTIONS"` with the Zod issues in `context`
**Verified by:** `src/base/midi-controller-plugin.test.ts::"defineMappings rejects an invalid CCMapping with AUDIO_CONTROLLERS.INVALID_OPTIONS"`
---
### Requirement: Structured error codes
The package SHALL declare its codes via `defineCodes("AUDIO_CONTROLLERS", [...])` producing the dotted codes `AUDIO_CONTROLLERS.ACCESS_DENIED`, `AUDIO_CONTROLLERS.INVALID_OPTIONS`, `AUDIO_CONTROLLERS.INVALID_STATE`, `AUDIO_CONTROLLERS.DEVICE_ERROR`, `AUDIO_CONTROLLERS.NOT_CONNECTED`, and `AUDIO_CONTROLLERS.UNKNOWN_PARAMETER`. The package SHALL export `AudioControllersErrorCode`, `AUDIO_CONTROLLERS_ERROR_METADATA`, `registerAudioControllersErrorCodes()`, and an `AudioControllersError` class extending `AudioControllersError` from `@suaveplan/audio-core`. Every error thrown by the package SHALL be an `AudioControllersError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: denied MIDI access throws AUDIO_CONTROLLERS.ACCESS_DENIED
- **WHEN** the injected `MidiAccessAdapter` fixture rejects the access request
- **THEN** `requestAccess()` throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.ACCESS_DENIED"` with the underlying rejection as `cause`
#### Scenario: every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_CONTROLLERS_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/base/midi-controller-plugin.test.ts::"requestAccess throws AUDIO_CONTROLLERS.ACCESS_DENIED when the adapter rejects"` AND `src/error-codes.test.ts::"every AUDIO_CONTROLLER code is reachable from a call site"`
---
### Requirement: AbortSignal propagation
`MidiControllerPlugin.requestAccess()` and `MidiControllerPlugin.initialize()`, plus `MaschinePlugin.loadSample()`, SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to reject with `AudioControllersError` code `AUDIO_CONTROLLERS.ACCESS_DENIED` (for access/init) or `AUDIO_CONTROLLERS.DEVICE_ERROR` (for `loadSample`) before any MIDI or device work; a signal aborted mid-operation SHALL terminate the operation at the next await boundary with the same error.
#### Scenario: pre-aborted signal short-circuits initialize
- **WHEN** `initialize()` is called with an already-aborted signal
- **THEN** it rejects with `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.ACCESS_DENIED"` before any MIDI access is requested
#### Scenario: mid-operation abort terminates loadSample
- **WHEN** a signal is aborted while `MaschinePlugin.loadSample()` is awaiting
- **THEN** the operation rejects with `AUDIO_CONTROLLERS.DEVICE_ERROR` at the next await boundary and loads nothing further
**Verified by:** `src/base/midi-controller-plugin.test.ts::"initialize rejects AUDIO_CONTROLLERS.ACCESS_DENIED for a pre-aborted signal"` AND `src/plugins/maschine/maschine.test.ts::"loadSample honours mid-operation abort"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: spans `controller.midi_message`, `controller.sequencer_step`, and `controller.connect`; counters `audio_controller_midi_messages_total` and `audio_controller_sequencer_steps_total`; histograms `audio_controller_midi_latency_ms` and `audio_controller_sequencer_jitter_ms`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: a handled MIDI message records a span and counter
- **WHEN** a MIDI message is handled with an exporter registered
- **THEN** a `controller.midi_message` span is recorded and `audio_controller_midi_messages_total` increments by 1
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"` AND `src/base/midi-clock.test.ts::"handling a MIDI message records a span and counter when an exporter is registered"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./base/midi-controller-plugin`, `./sequencer/sequencer`, `./arpeggiator/arpeggiator`, `./learn/learn-mode`, `./plugins/kontrol-s88`, `./plugins/maschine`, `./plugins/traktor-s4`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-controllers/plugins/kontrol-s88`
- **THEN** the import resolves to the Kontrol S88 plugin module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
---
### Requirement: Bindable parameter control over AudioFxPlugin
The package SHALL expose a `bindParameter(plugin: AudioFxPlugin, paramId: string, source: MidiSource, opts?: { smoothingMs?: number }): UnsubscribeFn` helper from `@suaveplan/audio-controllers/binding` that wires a `MidiSource` (a typed value emitted by a controller — CC, note velocity, pitch-bend, aftertouch, fader, encoder) to a plugin parameter on any `AudioFxPlugin` from `@suaveplan/audio-plugins-fx`. The helper SHALL:
- look up the parameter via `plugin.parameterIndex.get(paramId)` (O(1)) and throw `AudioControllersError` with code `AUDIO_CONTROLLERS.UNKNOWN_PARAMETER` if absent,
- read the parameter's declared `min`, `max`, and optional `scale` and map the source's normalised value into that range using the declared scale (linear / log / exponential),
- call `plugin.setParameter(paramId, mappedValue)` for immediate parameters and `plugin.setParameterCurve(paramId, { type: 'linear', value: mappedValue, duration: opts.smoothingMs / 1000 })` when an optional `smoothingMs` is provided (so a jittery hardware knob produces a smooth audio-rate ramp),
- return an unsubscribe function that removes the binding without disturbing the controller or other bindings,
- bind against `plugin.parameterIndex` references so subsequent parameter metadata changes (where supported by the plugin) are honoured without rebinding.
The package SHALL also expose a `bindPluginPreset(plugin: AudioFxPlugin, source: MidiSource, presets: ReadonlyArray<PresetSnapshot>): UnsubscribeFn` helper that, when triggered, applies the preset whose index corresponds to the source value (typical use: MIDI program-change events). Preset validation failures SHALL propagate the underlying `AudioControllersError` from `plugin.applyPreset` unchanged.
#### Scenario: bindParameter routes a CC into the plugin's parameter
- **WHEN** a caller calls `bindParameter(eq, 'lowShelfGain', { type: 'cc', channel: 1, number: 7 })` and an inbound MIDI CC `1/7` value `64/127` arrives
- **THEN** `eq.setParameter('lowShelfGain', mid)` is called where `mid` equals the linear mapping of `64/127` into the parameter's declared `[min, max]` range within float tolerance
#### Scenario: bindParameter rejects an unknown parameter id
- **WHEN** `bindParameter(eq, 'nonexistent', source)` is called
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.UNKNOWN_PARAMETER"` with the offending id in `context`
#### Scenario: smoothing option produces a parameter curve
- **WHEN** `bindParameter(eq, 'lowShelfGain', source, { smoothingMs: 20 })` is called and a CC value arrives
- **THEN** the binding calls `plugin.setParameterCurve('lowShelfGain', { type: 'linear', value: mappedValue, duration: 0.02 })` instead of `setParameter`
#### Scenario: unbind removes the binding cleanly
- **WHEN** a caller calls the unsubscribe function returned by `bindParameter`
- **THEN** subsequent inbound events for that source no longer call `setParameter` on the plugin, AND other bindings on the same source continue to fire
**Verified by:** `src/binding/bind-parameter.test.ts::"routes a CC into the plugin parameter with linear scale mapping"` AND `src/binding/bind-parameter.test.ts::"rejects unknown parameter id with AUDIO_CONTROLLERS.UNKNOWN_PARAMETER"` AND `src/binding/bind-parameter.test.ts::"smoothing option produces a parameter curve"` AND `src/binding/bind-parameter.test.ts::"unbind stops routing without disturbing other bindings"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,80 @@
# Tasks: enterprise-audio-controllers
**Change ID**: enterprise-audio-controllers
**Package**: `@suaveplan/audio-controllers`
**Target path**: `packages/audio/audio-controllers`
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`
---
## 1. Scaffold
- [ ] Create directory `packages/audio/audio-controllers/` with `src/`, `tests/`, `src/__benchmarks__/`, `src/plugins/`, `tests/e2e/`, and all feature subdirectories per the layout in `proposal.md`.
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/audio-transport workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/opentelemetry workspace:^`, `zod catalog:forms`.
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` with `happy-dom` environment for Web MIDI mocking.
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
---
## 2. Types + errors
- [ ] Write `src/error-codes.ts` — `AudioControllerErrorCode`, `AUDIO_CONTROLLER.*` codes, `registerAudioControllerErrorCodes()`.
- [ ] Write Zod schemas: `ControllerMetadataSchema`, `CCMappingSchema`, `LEDUpdateSchema`.
---
## 3. Base classes
- [ ] Implement `MidiControllerPlugin` abstract base class with:
- MIDI lifecycle (`requestAccess`, `onStateChange`)
- LED feedback (`flushLEDs`)
- CC mapping DSL (`defineMappings`)
- Learn mode (`enterLearnMode`, `exitLearnMode`)
- Event bus (`on`, `off`)
- Sequencer access
- [ ] Write `midi-controller-plugin.test.ts` and `midi-controller-plugin.md` (≥200 words).
- [ ] Implement `Sequencer` class using `@suaveplan/audio-transport` for scheduling.
- [ ] Write `sequencer.test.ts` and `sequencer.md` (≥200 words).
- [ ] Implement `Arpeggiator` class as `Transport` event subscriber.
- [ ] Write `arpeggiator.test.ts` and `arpeggiator.md` (≥200 words).
- [ ] Implement `LearnMode` for CC mapping capture.
- [ ] Write `learn-mode.test.ts` and `learn-mode.md` (≥200 words).
---
## 4. Device plugins
- [ ] Implement `KontrolS88Plugin` with scale modes, arp modes, pitch/mod wheel, LED control.
- [ ] Write `kontrol-s88.test.ts` and `kontrol-s88.md` (≥200 words).
- [ ] Implement `MaschinePlugin` with pad velocity, step sequencing, sample loading.
- [ ] Write `maschine.test.ts` and `maschine.md` (≥200 words).
- [ ] Implement `TraktorS4Plugin` with deck loading, crossfader, channel volume.
- [ ] Write `traktor-s4.test.ts` and `traktor-s4.md` (≥200 words).
---
## 5. Benchmarks
- [ ] Write `src/__benchmarks__/midi.bench.ts` — MIDI message throughput, sequencer step scheduling.
---
## 6. Tests
- [ ] Write `tests/integration.test.ts` — controller → sequencer → transport → synth trigger.
- [ ] Write `tests/e2e/kontrol-s88.e2e.test.ts` with mocked Web MIDI.
- [ ] Write `tests/e2e/maschine.e2e.test.ts` with mocked Web MIDI.
- [ ] Write `tests/e2e/traktor-s4.e2e.test.ts` with mocked Web MIDI.
---
## 7. Docs + verify + publish
- [ ] All co-located `.md` files (≥200 words each).
- [ ] Write `README.md` at package root.
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-controllers` — green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-controllers` — 100%.
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-controllers`.
- [ ] Fold `specs/audio-controllers/spec.md` into `openspec/specs/audio-controllers/spec.md`.
- [ ] Archive to `openspec/archive/2026-05-14-enterprise-audio-controllers/`.