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.
|
||||
@@ -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.
|
||||
@@ -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 -->
|
||||
@@ -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/`.
|
||||
Reference in New Issue
Block a user