Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
# @suaveplan/audio-controllers - Universal MIDI hardware controller plugins (Kontrol S88, Maschine, Traktor S4): MidiControllerPlugin base with injectable MidiAccessAdapter, hot-plug FSM via @suaveplan/fsm, transport-synced Sequencer + Arpeggiator, CC-mapping DSL, learn mode, LED feedback, bindParameter integration with @suaveplan/audio-fx-plugin-base, AUDIO_CONTROLLERS.* error registry, full OTel surface.
Universal MIDI hardware controller plugins. Built on
`@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/events`,
`@suaveplan/fsm`, and `@suaveplan/audio-fx-plugin-base`. Ships three
concrete controllers (Kontrol S88, Maschine, Traktor S4) plus a shared
`MidiControllerPlugin` base, `Sequencer`, `Arpeggiator`, learn-mode helper,
and `bindParameter` integration with the FX plugin family.
<!-- begin:auto-docs -->
## Documentation tree
- [arpeggiator](./src/arpeggiator/arpeggiator.md) — `Arpeggiator` is the transport-synced note-walker shared between the Kontrol S88's hardware arpeggio mode and any consu…
- [arpeggiator](./src/arpeggiator/arpeggiator.md) — `Arpeggiator` is the transport-synced note-walker shared between the Kontrol S88's hardware arpeggio mode and any consu…
- [connection-fsm](./src/base/connection-fsm.md) — Builds the controller connection-state machine consumed by every `MidiControllerPlugin`. The FSM models the lifecycle o…
- [midi-controller-plugin](./src/base/midi-controller-plugin.md) — The abstract `MidiControllerPlugin` is the universal base class shared by every concrete MIDI controller integrati…
- [otel](./src/base/otel.md) — Centralises the OpenTelemetry surface consumed by every module in the package. The `getControllerOtel()` accessor lazil…
- [binding](./src/binding/bind-parameter.md) — `bindParameter` wires a typed `MidiSource` (a controller CC, note-velocity, or future hardware source) to a parameter o…
- [bind-parameter](./src/binding/bind-parameter.md) — `bindParameter` wires a typed `MidiSource` (a controller CC, note-velocity, or future hardware source) to a parameter o…
- [error-codes](./src/error-codes/error-codes.md) — Defines the canonical `AUDIO_CONTROLLERS.*` error-code registry for the `@suaveplan/audio-controllers` package. Every e…
- [error-codes](./src/error-codes/error-codes.md) — Defines the canonical `AUDIO_CONTROLLERS.*` error-code registry for the `@suaveplan/audio-controllers` package. Every e…
- [learn](./src/learn/learn-mode.md) — Functional helper around the `MidiControllerPlugin.enterLearnMode` / `exitLearnMode` toggle. The base class natively sw…
- [learn-mode](./src/learn/learn-mode.md) — Functional helper around the `MidiControllerPlugin.enterLearnMode` / `exitLearnMode` toggle. The base class natively sw…
- [kontrol-s88](./src/plugins/kontrol-s88/kontrol-s88.md) — `KontrolS88Plugin` is the concrete `MidiControllerPlugin` subclass for Native Instruments Kontrol S88 MK2/MK3 — an 88-k…
- [kontrol-s88](./src/plugins/kontrol-s88/kontrol-s88.md) — `KontrolS88Plugin` is the concrete `MidiControllerPlugin` subclass for Native Instruments Kontrol S88 MK2/MK3 — an 88-k…
- [maschine](./src/plugins/maschine/maschine.md) — `MaschinePlugin` is the concrete `MidiControllerPlugin` subclass for Native Instruments Maschine: a 16-pad grid with pe…
- [maschine](./src/plugins/maschine/maschine.md) — `MaschinePlugin` is the concrete `MidiControllerPlugin` subclass for Native Instruments Maschine: a 16-pad grid with pe…
- [traktor-s4](./src/plugins/traktor-s4/traktor-s4.md) — `TraktorS4Plugin` is the concrete `MidiControllerPlugin` subclass for the Native Instruments Traktor Kontrol S4 MK3 — a…
- [traktor-s4](./src/plugins/traktor-s4/traktor-s4.md) — `TraktorS4Plugin` is the concrete `MidiControllerPlugin` subclass for the Native Instruments Traktor Kontrol S4 MK3 — a…
- [sequencer](./src/sequencer/sequencer.md) — `Sequencer` is the transport-synced step engine consumed by every `MidiControllerPlugin`. It schedules every active ste…
- [sequencer](./src/sequencer/sequencer.md) — `Sequencer` is the transport-synced step engine consumed by every `MidiControllerPlugin`. It schedules every active ste…
<!-- end:auto-docs -->
## Shipped controllers
- `KontrolS88Plugin` — 88-key weighted keyboard with scale modes, arp,
pitch-bend, mod-wheel, and per-key RGB LED feedback.
- `MaschinePlugin` — 16-pad grid with per-pad velocity, step sequencer
rows, and slot-based sample loading.
- `TraktorS4Plugin` — 4-channel DJ controller with mixer, crossfader, and
four decks.
## Features
- Injectable `MidiAccessAdapter` seam — the package never reaches for
`navigator.requestMIDIAccess` directly, so tests run under happy-dom
with a fake-device fixture and zero mocks.
- Connection-state FSM via `@suaveplan/fsm` with hot-plug recovery.
- Declarative CC mapping DSL with Zod-validated bytes.
- Learn mode for runtime CC capture.
- Transport-synced sequencer and arpeggiator — sample-accurate, offline
renderable, no `setInterval`.
- OpenTelemetry counters, histograms, and spans (no-op without an exporter).
- Zod schemas at every public boundary.
- Six `AUDIO_CONTROLLERS.*` error codes with severity metadata.
## Subpath exports
| Path | Module |
|---|---|
| `.` | barrel |
| `./base/midi-controller-plugin` | `MidiControllerPlugin` + types |
| `./sequencer/sequencer` | `Sequencer` |
| `./arpeggiator/arpeggiator` | `Arpeggiator` |
| `./learn/learn-mode` | `captureNextLearn` |
| `./binding` | `bindParameter`, `bindPluginPreset` |
| `./plugins/kontrol-s88` | `KontrolS88Plugin` |
| `./plugins/maschine` | `MaschinePlugin` |
| `./plugins/traktor-s4` | `TraktorS4Plugin` |
| `./error-codes` | `AUDIO_CONTROLLERS_*` registry |
## Status
`0.1.0` — Wave 1 migration. 100/100/100/100 coverage.

View File

@@ -0,0 +1,106 @@
{
"name": "@suaveplan/audio-controllers",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal MIDI hardware controller plugins (Kontrol S88, Maschine, Traktor S4): MidiControllerPlugin base with injectable MidiAccessAdapter, hot-plug FSM via @suaveplan/fsm, transport-synced Sequencer + Arpeggiator, CC-mapping DSL, learn mode, LED feedback, bindParameter integration with @suaveplan/audio-fx-plugin-base, AUDIO_CONTROLLERS.* error registry, full OTel surface.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./base/midi-controller-plugin": {
"import": {
"types": "./dist/base/index.d.ts",
"default": "./dist/base/index.js"
}
},
"./sequencer/sequencer": {
"import": {
"types": "./dist/sequencer/index.d.ts",
"default": "./dist/sequencer/index.js"
}
},
"./arpeggiator/arpeggiator": {
"import": {
"types": "./dist/arpeggiator/index.d.ts",
"default": "./dist/arpeggiator/index.js"
}
},
"./learn/learn-mode": {
"import": {
"types": "./dist/learn/index.d.ts",
"default": "./dist/learn/index.js"
}
},
"./binding": {
"import": {
"types": "./dist/binding/index.d.ts",
"default": "./dist/binding/index.js"
}
},
"./plugins/kontrol-s88": {
"import": {
"types": "./dist/plugins/kontrol-s88/index.d.ts",
"default": "./dist/plugins/kontrol-s88/index.js"
}
},
"./plugins/maschine": {
"import": {
"types": "./dist/plugins/maschine/index.d.ts",
"default": "./dist/plugins/maschine/index.js"
}
},
"./plugins/traktor-s4": {
"import": {
"types": "./dist/plugins/traktor-s4/index.d.ts",
"default": "./dist/plugins/traktor-s4/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
}
},
"scripts": {
"build": "vite build",
"lint": "biome check --max-diagnostics 500 src/ tests/",
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/audio-fx-plugin-base": "workspace:^",
"@suaveplan/audio-transport": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/fsm": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,60 @@
# arpeggiator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Arpeggiator` is the transport-synced note-walker shared between the
Kontrol S88's hardware arpeggio mode and any consumer that wants
pattern-driven note generation tied to the project transport. It
subscribes to a `Transport` via `scheduleRepeat`, so the same pattern
emits identical sequences in real time and under offline transport
advancement — the spec's "renderable offline" guarantee. Supported modes
are `up`, `down`, `updown`, and `random`; the rate is any
`TransportTime` token (`16n`, `8n`, beats, ticks).
## Features
- Four ordering modes: `up`, `down`, `updown`, `random`.
- Injectable RNG for deterministic tests of `random` mode.
- `setRate` accepts any `TransportTime` token.
- `onNote` listener registry with cleanup handles.
## Usage: Basic
```ts
const arp = new Arpeggiator({ transport });
arp.setPattern([60, 64, 67], "up");
arp.onNote(({ note, time }) => synth.trigger(note, time));
arp.start();
```
## Usage: Advanced
```ts
const arp = new Arpeggiator({ transport, rate: "32n", rng: () => 0.5 });
arp.setPattern([48, 55, 60, 64], "random");
arp.start();
transport.start();
// Offline render
transport.scheduleOnce(() => arp.stop(), "4m");
```
## API Reference
- `new Arpeggiator({ transport, rate?, rng? })`.
- `setPattern(notes, mode)` — replaces the pattern and resets the cursor.
- `setRate(interval)`.
- `start()` / `stop()`.
- `onNote(listener)` → unsubscribe function.
## Implementation Notes
The `updown` mode keeps its own direction flag and reverses at the
endpoints so the sequence is `0 1 2 1 0 1 2 1 …` for a three-note
pattern. `random` mode draws the index from the injected RNG; tests
pass a deterministic generator so the property "every output index is
inside `[0, pattern.length)`" is asserted without flakiness. `setPattern`
resets the cursor so a mode change always begins at the first element.

View File

@@ -0,0 +1,11 @@
import z from "zod";
export const ArpModeSchema = z.enum(["up", "down", "updown", "random"]);
export const ArpeggiatorOptionsSchema = z
.object({
transport: z.unknown(),
rate: z.unknown().optional(),
rng: z.function().optional(),
})
.passthrough();

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeTransport } from "../../tests/fake-transport.js";
import { Arpeggiator } from "./arpeggiator.js";
function setup(opts: { rng?: () => number } = {}) {
const transport = new FakeTransport();
const arp = new Arpeggiator({
transport,
...(opts.rng ? { rng: opts.rng } : {}),
});
return { transport, arp };
}
function collect(arp: Arpeggiator): number[] {
const out: number[] = [];
arp.onNote(({ note }) => out.push(note));
return out;
}
describe("Arpeggiator", () => {
it("arpeggio walks the pattern in up mode on transport ticks", {
timeout: 5000,
}, () => {
const { transport, arp } = setup();
const out = collect(arp);
arp.setPattern([60, 64, 67], "up");
arp.start();
transport.tickAll();
transport.tickAll();
transport.tickAll();
expect(out).toEqual([60, 64, 67]);
});
it("arpeggio produces an identical sequence under offline transport advance", {
timeout: 5000,
}, () => {
const { transport: t1, arp: a1 } = setup();
const { transport: t2, arp: a2 } = setup();
const o1 = collect(a1);
const o2 = collect(a2);
a1.setPattern([60, 64, 67], "up");
a2.setPattern([60, 64, 67], "up");
a1.start();
a2.start();
for (let i = 0; i < 6; i++) {
t1.tickAll();
t2.tickAll();
}
expect(o1).toEqual(o2);
});
it("down mode walks pattern in reverse", { timeout: 5000 }, () => {
const { transport, arp } = setup();
const out = collect(arp);
arp.setPattern([60, 64, 67], "down");
arp.start();
for (let i = 0; i < 3; i++) transport.tickAll();
expect(out).toEqual([67, 64, 60]);
});
it("updown mode bounces at endpoints", { timeout: 5000 }, () => {
const { transport, arp } = setup();
const out = collect(arp);
arp.setPattern([60, 64, 67], "updown");
arp.start();
for (let i = 0; i < 6; i++) transport.tickAll();
expect(out.length).toBe(6);
expect(out.every((n) => [60, 64, 67].includes(n))).toBe(true);
});
it("random mode emits notes from the pattern", { timeout: 5000 }, () => {
const { transport, arp } = setup({ rng: () => 0.5 });
const out = collect(arp);
arp.setPattern([60, 64, 67], "random");
arp.start();
for (let i = 0; i < 4; i++) transport.tickAll();
expect(out.every((n) => [60, 64, 67].includes(n))).toBe(true);
});
it("setRate changes the scheduled interval", { timeout: 5000 }, () => {
const { transport, arp } = setup();
arp.setRate("8n");
arp.setPattern([60], "up");
arp.start();
expect(transport.repeats.size).toBe(1);
});
it("start is idempotent; stop cancels", { timeout: 5000 }, () => {
const { transport, arp } = setup();
arp.setPattern([60], "up");
arp.start();
arp.start();
expect(transport.repeats.size).toBe(1);
arp.stop();
expect(transport.repeats.size).toBe(0);
arp.stop();
});
it("empty pattern produces no notes", { timeout: 5000 }, () => {
const { transport, arp } = setup();
const out = collect(arp);
arp.start();
transport.tickAll();
expect(out).toEqual([]);
});
it("onNote listener can be removed", { timeout: 5000 }, () => {
const { transport, arp } = setup();
let count = 0;
const off = arp.onNote(() => count++);
arp.setPattern([60], "up");
arp.start();
transport.tickAll();
off();
transport.tickAll();
expect(count).toBe(1);
});
});

View File

@@ -0,0 +1,93 @@
import type { TransportLike, TransportTime } from "@suaveplan/audio-transport";
import type {
ArpeggiatorOptions,
ArpListener,
ArpMode,
} from "./arpeggiator.types.js";
/**
* Transport-synced arpeggiator. Behaviour is purely a function of transport
* ticks, so identical sequences render in real time and under offline
* `scheduleRepeat` advancement — the spec's offline-render scenario.
*/
export class Arpeggiator {
private readonly transport: TransportLike;
private readonly rng: () => number;
private rate: TransportTime;
private pattern: ReadonlyArray<number> = [];
private mode: ArpMode = "up";
private cursor = 0;
private direction = 1;
private repeatId: number | undefined;
private readonly listeners = new Set<ArpListener>();
constructor(options: ArpeggiatorOptions) {
this.transport = options.transport;
this.rate = options.rate ?? "16n";
this.rng = options.rng ?? Math.random;
}
public setPattern(notes: ReadonlyArray<number>, mode: ArpMode): void {
this.pattern = notes.slice();
this.mode = mode;
this.cursor = 0;
this.direction = 1;
}
public setRate(interval: TransportTime): void {
this.rate = interval;
}
public start(): void {
if (this.repeatId !== undefined) return;
this.repeatId = this.transport.scheduleRepeat(
(time) => this.tick(time),
this.rate
);
}
public stop(): void {
if (this.repeatId === undefined) return;
this.transport.cancel(this.repeatId);
this.repeatId = undefined;
}
public onNote(listener: ArpListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private tick(time: number): void {
if (this.pattern.length === 0) return;
const note = this.next();
for (const fn of this.listeners) fn({ note, time });
}
private next(): number {
const len = this.pattern.length;
let idx = this.cursor;
switch (this.mode) {
case "up":
idx = this.cursor;
this.cursor = (this.cursor + 1) % len;
break;
case "down":
idx = (len - 1 - this.cursor + len) % len;
this.cursor = (this.cursor + 1) % len;
break;
case "updown":
idx = this.cursor;
if (this.cursor === len - 1) this.direction = -1;
else if (this.cursor === 0) this.direction = 1;
this.cursor = Math.max(
0,
Math.min(len - 1, this.cursor + this.direction)
);
break;
case "random":
idx = Math.floor(this.rng() * len);
break;
}
return this.pattern[idx] as number;
}
}

View File

@@ -0,0 +1,14 @@
import type { TransportLike, TransportTime } from "@suaveplan/audio-transport";
export type ArpMode = "up" | "down" | "updown" | "random";
export interface ArpeggiatorOptions {
readonly transport: TransportLike;
readonly rate?: TransportTime;
readonly rng?: () => number;
}
export type ArpListener = (event: {
readonly note: number;
readonly time: number;
}) => void;

View File

@@ -0,0 +1,10 @@
export { Arpeggiator } from "./arpeggiator.js";
export {
ArpeggiatorOptionsSchema,
ArpModeSchema,
} from "./arpeggiator.schemas.js";
export type {
ArpeggiatorOptions,
ArpListener,
ArpMode,
} from "./arpeggiator.types.js";

View File

@@ -0,0 +1,63 @@
# connection-fsm
## Purpose
Builds the controller connection-state machine consumed by every
`MidiControllerPlugin`. The FSM models the lifecycle of a hardware
controller's MIDI access: a fresh controller starts `disconnected`, moves
through `connecting`, settles in `connected`, and falls into `error` on
any access failure or rejected transition. Because the FSM is driven from
both the adapter (real connect/disconnect) and from `handleStateChange`
(hot-plug events from `navigator.requestMIDIAccess().onstatechange`-shaped
sources), centralising the rules here keeps every device subclass
ignorant of state-machine details.
## Features
- `createConnectionMachine()` returns an `@suaveplan/fsm` interpreter with
the four canonical states pre-wired.
- `sendOrThrow(machine, event)` converts the FSM's underlying
`INVALID_TRANSITION` into a domain-stable
`AudioControllersError("AUDIO_CONTROLLERS.INVALID_STATE")` with the
offending `from` and `event` carried in `context`.
- Transition table mirrors the spec exactly: `DISCONNECT`, `CONNECT`,
`CONNECTED`, `ERROR`, `RESET`.
## Usage: Basic
```ts
import { createConnectionMachine, sendOrThrow } from "@suaveplan/audio-controllers";
const m = createConnectionMachine();
sendOrThrow(m, "CONNECT");
sendOrThrow(m, "CONNECTED");
```
## Usage: Advanced
```ts
const m = createConnectionMachine();
m.onTransition((state) => log.debug({ value: state.value }, "controller-fsm"));
try {
sendOrThrow(m, "CONNECTED"); // illegal from `disconnected`
} catch (err) {
if (err instanceof AudioControllersError) {
expect(err.code).toBe(AUDIO_CONTROLLERS_CODES.INVALID_STATE);
}
}
```
## API Reference
- `createConnectionMachine()` → `FSMInterpreter<{ lastError?: string }>`.
- `sendOrThrow(machine, event)` → `void`.
- `ConnectionMachine` — re-exported interpreter type alias.
## Implementation Notes
A local `TRANSITIONS` table is consulted before delegating to the FSM's
own dispatcher so the package surface throws an `AUDIO_CONTROLLERS.*`
error rather than the raw `FSM.INVALID_TRANSITION` code. The state
graph deliberately omits transitions out of `error` other than `RESET`
to force callers to explicitly acknowledge recovery — accidental
re-`CONNECT` from `error` would mask the original failure.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../error-codes/index.js";
import { createConnectionMachine, sendOrThrow } from "./connection-fsm.js";
describe("connection-fsm", () => {
it("transitions disconnected → connecting → connected on CONNECT then CONNECTED", {
timeout: 5000,
}, () => {
const m = createConnectionMachine();
expect(m.current).toBe("disconnected");
sendOrThrow(m, "CONNECT");
expect(m.current).toBe("connecting");
sendOrThrow(m, "CONNECTED");
expect(m.current).toBe("connected");
});
it("connected → disconnected on DISCONNECT", { timeout: 5000 }, () => {
const m = createConnectionMachine();
sendOrThrow(m, "CONNECT");
sendOrThrow(m, "CONNECTED");
sendOrThrow(m, "DISCONNECT");
expect(m.current).toBe("disconnected");
});
it("any state → error on ERROR", { timeout: 5000 }, () => {
const m = createConnectionMachine();
sendOrThrow(m, "CONNECT");
sendOrThrow(m, "ERROR");
expect(m.current).toBe("error");
});
it("illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE", {
timeout: 5000,
}, () => {
const m = createConnectionMachine();
try {
sendOrThrow(m, "CONNECTED");
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(AudioControllersError);
expect((err as AudioControllersError).code).toBe(
AUDIO_CONTROLLERS_CODES.INVALID_STATE
);
}
});
it("error → disconnected on RESET", { timeout: 5000 }, () => {
const m = createConnectionMachine();
sendOrThrow(m, "ERROR");
sendOrThrow(m, "RESET");
expect(m.current).toBe("disconnected");
});
});

View File

@@ -0,0 +1,74 @@
import { createMachine, type FSMInterpreter } from "@suaveplan/fsm";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../error-codes/index.js";
import type { ConnectionState } from "./midi-controller-plugin.types.js";
/**
* Build the controller connection-state machine.
*
* States: `disconnected` → `connecting` → `connected` ; any state → `error`.
* Reconnect from `disconnected` → `connecting` → `connected` re-applies the
* last known mappings and LED state via the caller's listener.
*/
export type ConnectionMachine = FSMInterpreter<{ lastError?: string }>;
const TRANSITIONS = new Map<ConnectionState, ReadonlySet<string>>([
["disconnected", new Set(["CONNECT", "ERROR"])],
["connecting", new Set(["CONNECTED", "DISCONNECT", "ERROR"])],
["connected", new Set(["DISCONNECT", "ERROR"])],
["error", new Set(["RESET"])],
]);
export function createConnectionMachine(): ConnectionMachine {
return createMachine({
id: "audio-controllers.connection",
initial: "disconnected",
context: {} as { lastError?: string },
states: {
disconnected: {
on: {
CONNECT: { target: "connecting" },
ERROR: { target: "error" },
},
},
connecting: {
on: {
CONNECTED: { target: "connected" },
DISCONNECT: { target: "disconnected" },
ERROR: { target: "error" },
},
},
connected: {
on: {
DISCONNECT: { target: "disconnected" },
ERROR: { target: "error" },
},
},
error: {
on: {
RESET: { target: "disconnected" },
},
},
},
});
}
/**
* Drive an FSM event safely. Wraps `INVALID_TRANSITION` thrown by the FSM
* interpreter into `AudioControllersError("AUDIO_CONTROLLERS.INVALID_STATE")`
* with the offending `from` and `to` (best-effort) in `context`.
*/
export function sendOrThrow(machine: ConnectionMachine, event: string): void {
const from = machine.current as ConnectionState;
const allowed = TRANSITIONS.get(from);
if (!allowed?.has(event)) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.INVALID_STATE,
"audio-controllers.invalid-state",
{ context: { from, event } }
);
}
machine.send(event);
}

View File

@@ -0,0 +1,29 @@
export {
type ConnectionMachine,
createConnectionMachine,
sendOrThrow,
} from "./connection-fsm.js";
export { MidiControllerPlugin } from "./midi-controller-plugin.js";
export {
CCMappingSchema,
ControllerMetadataSchema,
LEDUpdateSchema,
} from "./midi-controller-plugin.schemas.js";
export type {
CCMapping,
ConnectionState,
ControllerEventMap,
ControllerMetadata,
InitializeOptions,
LEDUpdate,
MidiAccessAdapter,
MidiAccessLike,
MidiControllerPluginOptions,
MidiInputLike,
MidiOutputLike,
MidiPortLike,
} from "./midi-controller-plugin.types.js";
export {
type ControllerOtel,
getControllerOtel,
} from "./otel.js";

View File

@@ -0,0 +1,79 @@
# midi-controller-plugin
## Purpose
The abstract `MidiControllerPlugin` is the universal base class shared
by every concrete MIDI controller integration in `@suaveplan/audio-controllers`.
It owns the lifecycle that every device needs — `requestAccess` through an
injected `MidiAccessAdapter`, port binding, `MIDIMessageEvent` parsing into
typed `control`, `raw`, `note`, `learn`, and `state` events, hot-plug
recovery via the `@suaveplan/fsm` connection machine, declarative CC
mapping, LED batching, and learn-mode capture. Concrete subclasses only
need to declare their `ControllerMetadata` and any device-specific output
helpers.
## Features
- Injectable `MidiAccessAdapter` seam — zero `navigator` references.
- Connection-state FSM with explicit `disconnected/connecting/connected/error`.
- Hot-plug recovery: re-applies cached LED state on reconnect.
- CC mapping DSL with Zod-validated bytes and optional `transform`.
- Learn mode that auto-exits on first capture.
- Incoming notes are timestamped against the transport tick timeline.
- Real OpenTelemetry counters, histograms and spans (no-op without exporter).
- Idempotent `destroy()` that releases ports and clears listeners.
## Usage: Basic
```ts
class MyController extends MidiControllerPlugin {
constructor(opts: MidiControllerPluginOptions) {
super(
{
id: "demo",
name: "Demo",
manufacturer: "Acme",
portNamePatterns: ["Demo"],
},
opts
);
}
}
const c = new MyController({ adapter, transport });
await c.initialize();
```
## Usage: Advanced
```ts
const off = c.on("control", ({ target, value }) => mixer.set(target, value));
c.defineMappings([
{ channel: 0, controller: 7, target: "volume", min: 0, max: 1 },
]);
c.flushLEDs([{ status: 0x90, data1: 60, data2: 100 }]);
await c.destroy();
off();
```
## API Reference
- `requestAccess({ sysex?, signal? })` → `Promise<MidiAccessLike>`.
- `initialize({ signal?, sysex? })` → `Promise<void>`.
- `destroy()` → `Promise<void>` (idempotent).
- `defineMappings(mappings)` / `getMappings()`.
- `flushLEDs(updates)` — batches sends through the bound output.
- `enterLearnMode()` / `exitLearnMode()`.
- `on(event, listener)` / `off(event, listener)`.
- `handleStateChange(event)` — invoked by the adapter on hot-plug events.
- `sequencer` — embedded `Sequencer` bound to the same transport.
## Implementation Notes
The base never imports `navigator` or any Node API; the package is
universal and only ever speaks to the injected adapter and transport.
Notes are scheduled with `transport.scheduleOnce` at a transport-relative
time derived from `transport.toSeconds(transport.ticks)` — the spec's
"timestamped against the tick timeline" guarantee. The FSM is wrapped in a
`sendOrThrow` helper that converts the underlying `INVALID_TRANSITION`
error from `@suaveplan/fsm` into `AUDIO_CONTROLLERS.INVALID_STATE` so
callers see a stable error code surface.

View File

@@ -0,0 +1,32 @@
import z from "zod";
const Byte7 = z.number().int().min(0).max(127);
const Byte8 = z.number().int().min(0).max(255);
export const ControllerMetadataSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
manufacturer: z.string().min(1),
portNamePatterns: z.array(z.string()).readonly(),
})
.strict();
export const CCMappingSchema = z
.object({
channel: z.number().int().min(0).max(15),
controller: Byte7,
target: z.string().min(1),
min: z.number().optional(),
max: z.number().optional(),
transform: z.function().optional(),
})
.strict();
export const LEDUpdateSchema = z
.object({
status: Byte8,
data1: Byte7,
data2: Byte7,
})
.strict();

View File

@@ -0,0 +1,485 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createFakeAccess,
createFakeAdapter,
getFirstInput,
getFirstOutput,
} from "../../tests/fake-midi.js";
import { FakeTransport } from "../../tests/fake-transport.js";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../error-codes/index.js";
import { MidiControllerPlugin } from "./midi-controller-plugin.js";
import type {
ControllerMetadata,
MidiControllerPluginOptions,
} from "./midi-controller-plugin.types.js";
const META: ControllerMetadata = {
id: "demo",
name: "Demo",
manufacturer: "Acme",
portNamePatterns: ["Demo"],
};
class DemoController extends MidiControllerPlugin {
constructor(opts: MidiControllerPluginOptions) {
super(META, opts);
}
public expose(data: ReadonlyArray<number>): void {
this.sendOutput(data);
}
}
function setup(opts: { reject?: Error } = {}) {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access, opts);
const plugin = new DemoController({ adapter, transport });
return { transport, access, adapter, plugin };
}
describe("MidiControllerPlugin", () => {
it("initialize wires MIDI inputs and exposes a sequencer", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
expect(plugin.sequencer).toBeDefined();
expect(plugin.state).toBe("connected");
expect(getFirstInput(access).onmidimessage).not.toBeNull();
});
it("requestAccess resolves MIDIAccess through the injected adapter", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
const got = await plugin.requestAccess();
expect(got).toBe(access);
});
it("requestAccess wraps a non-Error rejection from the adapter", {
timeout: 5000,
}, async () => {
const transport = new FakeTransport();
const adapter = {
async requestAccess() {
throw "string-reason";
},
};
const plugin = new DemoController({ adapter, transport });
await expect(plugin.requestAccess()).rejects.toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.ACCESS_DENIED,
})
);
});
it("requestAccess throws AUDIO_CONTROLLERS.ACCESS_DENIED when the adapter rejects", {
timeout: 5000,
}, async () => {
const { plugin } = setup({ reject: new Error("denied") });
try {
await plugin.requestAccess();
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(AudioControllersError);
expect((err as AudioControllersError).code).toBe(
AUDIO_CONTROLLERS_CODES.ACCESS_DENIED
);
expect(plugin.state).toBe("error");
}
});
it("initialize rejects AUDIO_CONTROLLERS.ACCESS_DENIED for a pre-aborted signal", {
timeout: 5000,
}, async () => {
const { plugin } = setup();
const ctl = new AbortController();
ctl.abort();
await expect(plugin.initialize({ signal: ctl.signal })).rejects.toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.ACCESS_DENIED,
})
);
});
it("requestAccess rejects a pre-aborted signal", {
timeout: 5000,
}, async () => {
const { plugin } = setup();
const ctl = new AbortController();
ctl.abort();
await expect(
plugin.requestAccess({ signal: ctl.signal })
).rejects.toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.ACCESS_DENIED,
})
);
});
it("requestAccess rejects an abort after the adapter resolves", {
timeout: 5000,
}, async () => {
const { plugin } = setup();
const ctl = new AbortController();
const promise = plugin.requestAccess({ signal: ctl.signal });
ctl.abort();
await expect(promise).rejects.toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.ACCESS_DENIED,
})
);
});
it("destroy is safe before initialize", { timeout: 5000 }, async () => {
const { plugin } = setup();
await plugin.destroy();
expect(plugin.state).toBe("disconnected");
});
it("destroy from error state does not throw", {
timeout: 5000,
}, async () => {
const { plugin } = setup({ reject: new Error("nope") });
await plugin.requestAccess().catch(() => {});
await plugin.destroy();
expect(plugin.state).toBe("error");
});
it("destroy releases ports and is idempotent", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
await plugin.destroy();
expect(getFirstInput(access).onmidimessage).toBeNull();
expect(plugin.state).toBe("disconnected");
await plugin.destroy();
});
it("a mapped CC message emits a transformed control event", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
plugin.defineMappings([
{ channel: 0, controller: 7, target: "volume", min: 0, max: 1 },
]);
const got: { target: string; value: number }[] = [];
plugin.on("control", (e) => got.push(e));
getFirstInput(access).emit([0xb0, 7, 64]);
expect(got).toHaveLength(1);
expect(got[0]?.target).toBe("volume");
expect(got[0]?.value).toBeCloseTo(64 / 127, 5);
});
it("applies transform when present", { timeout: 5000 }, async () => {
const { plugin, access } = setup();
await plugin.initialize();
plugin.defineMappings([
{
channel: 0,
controller: 8,
target: "x",
min: 0,
max: 1,
transform: (v) => v * 2,
},
]);
const got: number[] = [];
plugin.on("control", (e) => got.push(e.value));
getFirstInput(access).emit([0xb0, 8, 64]);
expect(got[0]).toBeCloseTo((64 / 127) * 2, 5);
});
it("mapping without explicit min/max defaults to [0, 1]", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
plugin.defineMappings([{ channel: 0, controller: 7, target: "v" }]);
const got: number[] = [];
plugin.on("control", (e) => got.push(e.value));
getFirstInput(access).emit([0xb0, 7, 127]);
expect(got[0]).toBeCloseTo(1, 5);
});
it("unmapped CC fires a raw event", { timeout: 5000 }, async () => {
const { plugin, access } = setup();
await plugin.initialize();
let raw = false;
plugin.on("raw", () => {
raw = true;
});
getFirstInput(access).emit([0xb0, 99, 1]);
expect(raw).toBe(true);
});
it("defineMappings rejects an invalid CCMapping with AUDIO_CONTROLLERS.INVALID_OPTIONS", {
timeout: 5000,
}, async () => {
const { plugin } = setup();
await plugin.initialize();
expect(() =>
plugin.defineMappings([
{ channel: 0, controller: 200, target: "x" },
])
).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.INVALID_OPTIONS,
})
);
});
it("flushLEDs writes batched output bytes through the MIDIOutput fixture", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
plugin.flushLEDs([
{ status: 0x90, data1: 60, data2: 100 },
{ status: 0x90, data1: 64, data2: 100 },
{ status: 0x90, data1: 67, data2: 100 },
]);
expect(getFirstOutput(access).sent).toEqual([
[0x90, 60, 100],
[0x90, 64, 100],
[0x90, 67, 100],
]);
});
it("flushLEDs throws NOT_CONNECTED before initialize", {
timeout: 5000,
}, () => {
const { plugin } = setup();
expect(() =>
plugin.flushLEDs([{ status: 0x90, data1: 1, data2: 1 }])
).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.NOT_CONNECTED,
})
);
});
it("flushLEDs rejects out-of-range bytes via Zod", {
timeout: 5000,
}, async () => {
const { plugin } = setup();
await plugin.initialize();
expect(() =>
plugin.flushLEDs([{ status: 999, data1: 0, data2: 0 }])
).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.INVALID_OPTIONS,
})
);
});
it("hot-unplug then re-plug recovers mappings and LED state", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
plugin.defineMappings([{ channel: 0, controller: 1, target: "x" }]);
plugin.flushLEDs([{ status: 0x90, data1: 60, data2: 100 }]);
const output = getFirstOutput(access);
const before = output.sent.length;
plugin.handleStateChange({ port: { state: "disconnected" } });
expect(plugin.state).toBe("disconnected");
plugin.handleStateChange({ port: { state: "connected" } });
expect(plugin.state).toBe("connected");
expect(output.sent.length).toBe(before + 1); // LED replayed
expect(plugin.getMappings().length).toBe(1);
});
it("illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE", {
timeout: 5000,
}, async () => {
const { plugin } = setup();
await plugin.initialize();
// Already connected — pushing another CONNECTED is illegal
expect(() =>
plugin.handleStateChange({ port: { state: "connected" } })
).not.toThrow();
// But forcing a CONNECTED via re-init... we instead test via FSM directly
const { sendOrThrow, createConnectionMachine } = await import(
"./connection-fsm.js"
);
const m = createConnectionMachine();
expect(() => sendOrThrow(m, "CONNECTED")).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.INVALID_STATE,
})
);
});
it("learn mode captures the next control message and exits", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
plugin.enterLearnMode();
let learnPayload: { channel: number; controller: number } | undefined;
plugin.on("learn", (e) => {
learnPayload = e;
});
let controlFired = false;
plugin.on("control", () => {
controlFired = true;
});
getFirstInput(access).emit([0xb1, 33, 64]);
expect(learnPayload).toEqual({ channel: 1, controller: 33 });
expect(controlFired).toBe(false);
// Subsequent CC should NOT fire learn again (we auto-exited)
learnPayload = undefined;
plugin.defineMappings([{ channel: 1, controller: 33, target: "x" }]);
getFirstInput(access).emit([0xb1, 33, 1]);
expect(learnPayload).toBeUndefined();
expect(controlFired).toBe(true);
});
it("exitLearnMode cancels learn capture", { timeout: 5000 }, async () => {
const { plugin, access } = setup();
await plugin.initialize();
plugin.enterLearnMode();
plugin.exitLearnMode();
let learnFired = false;
plugin.on("learn", () => {
learnFired = true;
});
plugin.defineMappings([{ channel: 0, controller: 1, target: "x" }]);
getFirstInput(access).emit([0xb0, 1, 1]);
expect(learnFired).toBe(false);
});
it("on/off subscription lifecycle", { timeout: 5000 }, async () => {
const { plugin, access } = setup();
await plugin.initialize();
plugin.defineMappings([{ channel: 0, controller: 1, target: "x" }]);
let count = 0;
const listener = () => {
count++;
};
plugin.on("control", listener);
getFirstInput(access).emit([0xb0, 1, 64]);
plugin.off("control", listener);
getFirstInput(access).emit([0xb0, 1, 64]);
expect(count).toBe(1);
});
it("incoming MIDI message is scheduled against the transport tick timeline", {
timeout: 5000,
}, async () => {
const { plugin, access, transport } = setup();
await plugin.initialize();
let scheduled = false;
plugin.on("note", () => {
scheduled = true;
});
getFirstInput(access).emit([0x90, 60, 100], 500);
transport.flushOnces();
expect(scheduled).toBe(true);
});
it("rejects invalid controller metadata", { timeout: 5000 }, () => {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
class Bad extends MidiControllerPlugin {
constructor() {
super(
{
id: "",
name: "x",
manufacturer: "x",
portNamePatterns: [],
},
{ adapter, transport }
);
}
}
expect(() => new Bad()).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.INVALID_OPTIONS,
})
);
});
it("falls back to first port when patterns do not match", {
timeout: 5000,
}, async () => {
const transport = new FakeTransport();
const access = createFakeAccess({
inputName: "MysteryDevice",
outputName: "MysteryDevice",
});
const adapter = createFakeAdapter(access);
const plugin = new DemoController({ adapter, transport });
await plugin.initialize();
expect(plugin.state).toBe("connected");
});
it("sendOutput from a subclass funnels through the bound port", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
plugin.expose([0xb0, 1, 64]);
expect(getFirstOutput(access).sent.at(-1)).toEqual([0xb0, 1, 64]);
});
it("sendOutput throws NOT_CONNECTED when no output bound", {
timeout: 5000,
}, () => {
const { plugin } = setup();
expect(() => plugin.expose([0x90, 60, 100])).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.NOT_CONNECTED,
})
);
});
it("handles access with no inputs/outputs gracefully", {
timeout: 5000,
}, async () => {
const transport = new FakeTransport();
const access = createFakeAccess();
const inputId = Array.from(access.mutableInputs.keys())[0];
const outputId = Array.from(access.mutableOutputs.keys())[0];
if (inputId) access.mutableInputs.delete(inputId);
if (outputId) access.mutableOutputs.delete(outputId);
const adapter = createFakeAdapter(access);
const plugin = new DemoController({ adapter, transport });
await plugin.initialize();
expect(plugin.state).toBe("connected");
// hot-plug reconnect with no output exercises replayCachedState !output branch
plugin.handleStateChange({ port: { state: "disconnected" } });
plugin.handleStateChange({ port: { state: "connected" } });
expect(plugin.state).toBe("connected");
});
it("access.onstatechange handler is installed and reacts to events", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
// Direct invocation through access fires the installed listener.
access.onstatechange?.({ port: { state: "disconnected" } as never });
expect(plugin.state).toBe("disconnected");
});
it("note-off and zero-velocity note-on emit raw events", {
timeout: 5000,
}, async () => {
const { plugin, access } = setup();
await plugin.initialize();
let raws = 0;
plugin.on("raw", () => raws++);
getFirstInput(access).emit([0x80, 60, 0]); // note off
getFirstInput(access).emit([0x90, 60, 0]); // zero-velocity note-on
expect(raws).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,374 @@
import type z from "zod";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../error-codes/index.js";
import { Sequencer } from "../sequencer/sequencer.js";
import {
type ConnectionMachine,
createConnectionMachine,
sendOrThrow,
} from "./connection-fsm.js";
import {
CCMappingSchema,
ControllerMetadataSchema,
LEDUpdateSchema,
} from "./midi-controller-plugin.schemas.js";
import type {
CCMapping,
ConnectionState,
ControllerEventMap,
ControllerMetadata,
InitializeOptions,
LEDUpdate,
MidiAccessLike,
MidiControllerPluginOptions,
MidiInputLike,
MidiOutputLike,
} from "./midi-controller-plugin.types.js";
import { getControllerOtel } from "./otel.js";
type Listener<K extends keyof ControllerEventMap> = (
payload: ControllerEventMap[K]
) => void;
function rejectAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.ACCESS_DENIED,
"audio-controllers.access-aborted",
{ context: { reason: "aborted" } }
);
}
}
function validateOptions<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (!result.success) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.INVALID_OPTIONS,
"audio-controllers.invalid-options",
{ context: { issues: result.error.issues } }
);
}
return result.data;
}
/**
* Abstract `MidiControllerPlugin` base — universal MIDI controller host.
*
* Concrete subclasses declare `metadata` and reach hardware through the
* injected `MidiAccessAdapter`. All MIDI byte traffic, hot-plug FSM driving,
* LED batching, and CC-mapping DSL is shared in this base.
*/
export abstract class MidiControllerPlugin {
public readonly sequencer: Sequencer;
public readonly metadata: ControllerMetadata;
protected readonly options: MidiControllerPluginOptions;
protected access: MidiAccessLike | undefined;
protected input: MidiInputLike | undefined;
protected output: MidiOutputLike | undefined;
private readonly machine: ConnectionMachine;
private readonly mappings: CCMapping[] = [];
private readonly listeners = new Map<string, Set<Listener<never>>>();
private readonly ledHistory: LEDUpdate[] = [];
private learnMode = false;
private destroyed = false;
private onStateChange:
| ((event: { readonly port: { readonly state: string } }) => void)
| undefined;
protected constructor(
metadata: ControllerMetadata,
options: MidiControllerPluginOptions
) {
this.metadata = validateOptions(ControllerMetadataSchema, metadata);
this.options = options;
this.sequencer = new Sequencer({ transport: options.transport });
this.machine = createConnectionMachine();
this.machine.onTransition((state) => {
const last = state.history.at(-1) as { from: string };
this.emit("state", {
from: last.from,
to: state.value as ConnectionState,
});
});
}
public get state(): ConnectionState {
return this.machine.current as ConnectionState;
}
/**
* Acquire `MIDIAccess` via the injected adapter. Honours an optional
* `AbortSignal` and SysEx flag.
*/
public async requestAccess(
opts: { readonly sysex?: boolean; readonly signal?: AbortSignal } = {}
): Promise<MidiAccessLike> {
rejectAborted(opts.signal);
try {
sendOrThrow(this.machine, "CONNECT");
const access = await this.options.adapter.requestAccess({
sysex: opts.sysex ?? false,
});
rejectAborted(opts.signal);
this.access = access;
return access;
} catch (err) {
if (err instanceof AudioControllersError) throw err;
this.transitionToError();
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.ACCESS_DENIED,
"audio-controllers.access-denied",
{
...(err instanceof Error ? { cause: err } : {}),
context: { reason: "adapter-rejected" },
}
);
}
}
/**
* Initialize: request access, bind to the device's first matching input
* and output port, subscribe to MIDI messages, and move the FSM to
* `connected`. Idempotent only inasmuch as it tracks port references —
* re-invocation rebinds.
*/
public async initialize(opts: InitializeOptions = {}): Promise<void> {
rejectAborted(opts.signal);
const access =
this.access ??
(await this.requestAccess({ ...opts, sysex: opts.sysex ?? false }));
this.bindPorts(access);
this.installStateChangeHook(access);
sendOrThrow(this.machine, "CONNECTED");
}
/** Idempotent teardown: releases ports, stops sequencer, clears listeners. */
public async destroy(): Promise<void> {
if (this.destroyed) return;
this.destroyed = true;
this.sequencer.stop();
const input = this.input;
if (input) input.onmidimessage = null;
const access = this.access;
if (access) access.onstatechange = null;
this.input = undefined;
this.output = undefined;
this.listeners.clear();
if (this.state === "connected") {
sendOrThrow(this.machine, "DISCONNECT");
}
}
/**
* Receive a `MIDIConnectionEvent`-shaped object. `state === "connected"`
* moves the FSM forward; `disconnected` clears port refs.
*/
public handleStateChange(event: {
readonly port: { readonly state: string };
}): void {
const portState = event.port.state;
if (portState === "connected" && this.state === "disconnected") {
sendOrThrow(this.machine, "CONNECT");
sendOrThrow(this.machine, "CONNECTED");
this.replayCachedState();
} else if (portState === "disconnected" && this.state === "connected") {
sendOrThrow(this.machine, "DISCONNECT");
}
}
/** Register declarative CC mappings. Validates each entry against Zod. */
public defineMappings(mappings: ReadonlyArray<CCMapping>): void {
for (const m of mappings) {
validateOptions(CCMappingSchema, m);
}
this.mappings.push(...mappings);
}
/** Returns the currently registered mappings (read-only snapshot). */
public getMappings(): ReadonlyArray<CCMapping> {
return this.mappings.slice();
}
/** Flush LED updates to the device's `MIDIOutput` in a single write batch. */
public flushLEDs(updates: ReadonlyArray<LEDUpdate>): void {
if (!this.output) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.NOT_CONNECTED,
"audio-controllers.no-output"
);
}
for (const u of updates) {
validateOptions(LEDUpdateSchema, u);
this.output.send([u.status, u.data1, u.data2]);
this.ledHistory.push(u);
}
}
public enterLearnMode(): void {
this.learnMode = true;
}
public exitLearnMode(): void {
this.learnMode = false;
}
public on<K extends keyof ControllerEventMap & string>(
event: K,
listener: Listener<K>
): () => void {
const bucket = this.listeners.get(event) ?? new Set<Listener<never>>();
bucket.add(listener as Listener<never>);
this.listeners.set(event, bucket);
return () => this.off(event, listener);
}
public off<K extends keyof ControllerEventMap & string>(
event: K,
listener: Listener<K>
): void {
this.listeners.get(event)?.delete(listener as Listener<never>);
}
protected emit<K extends keyof ControllerEventMap & string>(
event: K,
payload: ControllerEventMap[K]
): void {
const bucket = this.listeners.get(event);
if (!bucket) return;
for (const fn of bucket) (fn as Listener<K>)(payload);
}
/**
* Send raw MIDI bytes through the bound `MIDIOutput`. Throws
* `AUDIO_CONTROLLERS.NOT_CONNECTED` if no port is bound.
*/
protected sendOutput(data: ReadonlyArray<number>): void {
if (!this.output) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.NOT_CONNECTED,
"audio-controllers.no-output"
);
}
this.output.send(data);
}
private bindPorts(access: MidiAccessLike): void {
const patterns = this.metadata.portNamePatterns;
const input = pickPort(access.inputs.values(), patterns);
const output = pickPort(access.outputs.values(), patterns);
this.input = input;
this.output = output;
if (!input) return;
input.onmidimessage = (event) =>
this.handleMessage(event.data, event.timeStamp);
}
private installStateChangeHook(access: MidiAccessLike): void {
this.onStateChange = (event) => this.handleStateChange(event);
access.onstatechange = this.onStateChange;
}
private handleMessage(data: Uint8Array, timeStamp: number): void {
const otel = getControllerOtel();
const span = otel.tracer.startSpan("controller.midi_message");
otel.midiMessages.add(1);
try {
const status = data[0] as number;
const data1 = data[1] as number;
const data2 = data[2] as number;
const command = status & 0xf0;
const channel = status & 0x0f;
if (command === 0xb0) {
this.handleControlChange(channel, data1, data2);
} else if (command === 0x90 && data2 > 0) {
this.scheduleNote(data1, data2, timeStamp);
} else {
this.emit("raw", { data: Array.from(data), time: timeStamp });
}
const transport = this.options.transport;
const latencyMs = Math.max(0, transport.seconds * 1000 - timeStamp);
otel.midiLatencyMs.record(latencyMs);
} finally {
span.end();
}
}
private handleControlChange(
channel: number,
controller: number,
raw: number
): void {
if (this.learnMode) {
this.emit("learn", { channel, controller });
this.learnMode = false;
return;
}
const mapping = this.mappings.find(
(m) => m.channel === channel && m.controller === controller
);
if (!mapping) {
this.emit("raw", {
data: [0xb0 | channel, controller, raw],
time: 0,
});
return;
}
const min = mapping.min ?? 0;
const max = mapping.max ?? 1;
const normalised = raw / 127;
const scaled = min + (max - min) * normalised;
const value = mapping.transform ? mapping.transform(scaled) : scaled;
this.emit("control", { target: mapping.target, value });
}
private scheduleNote(
note: number,
velocity: number,
timeStamp: number
): void {
const transport = this.options.transport;
const tickSeconds = transport.toSeconds(transport.ticks);
const messageSec = timeStamp / 1000;
const delta = Math.max(0, messageSec - tickSeconds);
const transportTimeSec = tickSeconds + delta;
transport.scheduleOnce(() => {
this.emit("note", { note, velocity, transportTimeSec });
}, transportTimeSec);
}
private replayCachedState(): void {
const output = this.output;
if (!output) return;
for (const u of this.ledHistory) {
output.send([u.status, u.data1, u.data2]);
}
}
private transitionToError(): void {
try {
sendOrThrow(this.machine, "ERROR");
} catch {
// Already in error
}
}
}
function pickPort<P extends { readonly name: string | null }>(
ports: IterableIterator<P>,
patterns: ReadonlyArray<string>
): P | undefined {
const list = Array.from(ports);
if (patterns.length === 0) return list[0];
const lowerPatterns = patterns.map((q) => q.toLowerCase());
for (const p of list) {
const name = (p.name as string).toLowerCase();
if (lowerPatterns.some((q) => name.includes(q))) return p;
}
return list[0];
}

View File

@@ -0,0 +1,122 @@
import type { TransportLike } from "@suaveplan/audio-transport";
import type { LoggerLike } from "@suaveplan/types";
/**
* Metadata describing a concrete `MidiControllerPlugin` subclass.
*
* `portNamePatterns` is consulted at `initialize()` time to select the first
* `MIDIInput` / `MIDIOutput` whose `name` matches any of the supplied
* substring patterns (case-insensitive). Empty list → first available port.
*/
export interface ControllerMetadata {
readonly id: string;
readonly name: string;
readonly manufacturer: string;
readonly portNamePatterns: ReadonlyArray<string>;
}
/**
* Declarative control-change to parameter mapping. `transform` is applied
* after scaling the controller byte (0..127) into `[min, max]`.
*/
export interface CCMapping {
readonly channel: number;
readonly controller: number;
readonly target: string;
readonly min?: number;
readonly max?: number;
readonly transform?: (value: number) => number;
}
/**
* One LED update destined for the device's `MIDIOutput`. Status byte is
* supplied directly so device subclasses can pick CC, Note-on, or SysEx.
*/
export interface LEDUpdate {
readonly status: number;
readonly data1: number;
readonly data2: number;
}
/** Typed event map emitted by `MidiControllerPlugin`. */
export interface ControllerEventMap {
readonly control: { readonly target: string; readonly value: number };
readonly raw: {
readonly data: ReadonlyArray<number>;
readonly time: number;
};
readonly state: { readonly from: string; readonly to: string };
readonly learn: { readonly channel: number; readonly controller: number };
readonly note: {
readonly note: number;
readonly velocity: number;
readonly transportTimeSec: number;
};
[key: string]: unknown;
}
/**
* Minimal `MIDIAccess`-shaped contract surfaced through the injectable
* adapter. Mirrors the Web MIDI surface needed by the package — every other
* field is intentionally omitted to keep the package universal.
*/
export interface MidiAccessLike {
readonly inputs: ReadonlyMap<string, MidiInputLike>;
readonly outputs: ReadonlyMap<string, MidiOutputLike>;
onstatechange: ((event: { readonly port: MidiPortLike }) => void) | null;
}
export interface MidiPortLike {
readonly id: string;
readonly name: string | null;
readonly state: "connected" | "disconnected";
readonly type: "input" | "output";
}
export interface MidiInputLike extends MidiPortLike {
readonly type: "input";
onmidimessage:
| ((event: {
readonly data: Uint8Array;
readonly timeStamp: number;
}) => void)
| null;
}
export interface MidiOutputLike extends MidiPortLike {
readonly type: "output";
send(data: ReadonlyArray<number> | Uint8Array, timestamp?: number): void;
}
/**
* Adapter resolving access to a `MIDIAccess`-shaped object. Tests provide a
* fake-device fixture; production callers wrap `navigator.requestMIDIAccess`.
*/
export interface MidiAccessAdapter {
requestAccess(options?: {
readonly sysex?: boolean;
}): Promise<MidiAccessLike>;
}
/**
* Construction-time options shared by every `MidiControllerPlugin` subclass.
*/
export interface MidiControllerPluginOptions {
readonly adapter: MidiAccessAdapter;
readonly transport: TransportLike;
readonly logger?: LoggerLike;
readonly signal?: AbortSignal;
}
/** Options passed to the abstract base's `initialize()`. */
export interface InitializeOptions {
readonly signal?: AbortSignal;
readonly sysex?: boolean;
}
/** Connection-state FSM values. */
export type ConnectionState =
| "disconnected"
| "connecting"
| "connected"
| "error";

View File

@@ -0,0 +1,65 @@
# otel
## Purpose
Centralises the OpenTelemetry surface consumed by every module in the
package. The `getControllerOtel()` accessor lazily resolves the tracer,
counters, and histograms required by the spec — `controller.midi_message`,
`controller.sequencer_step`, and `controller.connect` spans plus the
`audio_controller_midi_messages_total`,
`audio_controller_sequencer_steps_total`,
`audio_controller_midi_latency_ms`, and
`audio_controller_sequencer_jitter_ms` metrics — without any import-time
side effects. Resolution is deferred until first call so importing the
package never mutates any global state.
## Features
- Lazy, cached resolution of `Tracer`, `Counter` and `Histogram`.
- No-op when no exporter is registered (per the OpenTelemetry contract).
- Single instrument names across the package — keeps dashboards stable.
- Cached across calls to amortise the `getMeter`/`getTracer` lookups.
## Usage: Basic
```ts
import { getControllerOtel } from "@suaveplan/audio-controllers/base/midi-controller-plugin";
const otel = getControllerOtel();
otel.midiMessages.add(1, { controller: "kontrol-s88" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
// Subsequent first call from inside the package now records to `reader`.
const otel = getControllerOtel();
const span = otel.tracer.startSpan("controller.connect");
try {
// ...
} finally {
span.end();
}
```
## API Reference
- `getControllerOtel(): ControllerOtel` — lazy singleton accessor.
- `ControllerOtel` — `{ tracer, midiMessages, sequencerSteps,
midiLatencyMs, sequencerJitterMs }`.
## Implementation Notes
The cached singleton is intentionally module-scoped — once an exporter is
registered subsequent calls keep using the same instrument handles, so
dashboards see one continuous time series. Because OTel's `getMeter` and
`getTracer` are themselves idempotent when called with the same name we
could omit the cache, but caching also makes the resolved instruments
observable in test fixtures. Instrument names mirror the broader
`audio_controller_*` naming convention used by every audio package in the
project so cross-cutting dashboards align across packages and
adopters of multiple controllers see consistent metric semantics.

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { getControllerOtel } from "./otel.js";
describe("getControllerOtel", () => {
it("returns a cached singleton with the expected instruments", {
timeout: 5000,
}, () => {
const a = getControllerOtel();
const b = getControllerOtel();
expect(a).toBe(b);
expect(a.tracer).toBeDefined();
expect(a.midiMessages).toBeDefined();
expect(a.sequencerSteps).toBeDefined();
expect(a.midiLatencyMs).toBeDefined();
expect(a.sequencerJitterMs).toBeDefined();
// Counter API is no-op without a registered exporter.
expect(() => a.midiMessages.add(1)).not.toThrow();
});
});

View File

@@ -0,0 +1,46 @@
import {
type Counter,
type Histogram,
metrics,
type Tracer,
trace,
} from "@opentelemetry/api";
const NAME = "@suaveplan/audio-controllers";
export interface ControllerOtel {
readonly tracer: Tracer;
readonly midiMessages: Counter;
readonly sequencerSteps: Counter;
readonly midiLatencyMs: Histogram;
readonly sequencerJitterMs: Histogram;
}
let cached: ControllerOtel | undefined;
/**
* Lazily resolve the package's OTel surface. Resolution is deferred until
* first call so the package never mutates any global state at import time;
* instruments are no-ops when no exporter is registered.
*/
export function getControllerOtel(): ControllerOtel {
if (cached) return cached;
const tracer = trace.getTracer(NAME);
const meter = metrics.getMeter(NAME);
cached = {
tracer,
midiMessages: meter.createCounter(
"audio_controller_midi_messages_total"
),
sequencerSteps: meter.createCounter(
"audio_controller_sequencer_steps_total"
),
midiLatencyMs: meter.createHistogram(
"audio_controller_midi_latency_ms"
),
sequencerJitterMs: meter.createHistogram(
"audio_controller_sequencer_jitter_ms"
),
};
return cached;
}

View File

@@ -0,0 +1,73 @@
# bind-parameter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`bindParameter` wires a typed `MidiSource` (a controller CC, note-velocity,
or future hardware source) to a parameter on any `AudioFxPlugin` from
`@suaveplan/audio-fx-plugin-base`. It looks the parameter up via the
plugin's O(1) `parameterIndex`, maps the inbound 0–127 byte into the
declared `[min, max]` range honouring the parameter's `scale`
(`linear` / `log` / `exponential`), and dispatches either `setParameter`
or `setParameterCurve` depending on whether a `smoothingMs` was supplied.
The complement `bindPluginPreset` listens for program-change-style CCs and
applies `PresetSnapshot`s by index.
## Features
- Source-aware mapping: linear, logarithmic, exponential parameter scales.
- Optional `smoothingMs` engages `setParameterCurve` so jittery knobs ramp.
- Unknown `paramId` raises `AUDIO_CONTROLLERS.UNKNOWN_PARAMETER`.
- Unbind returns a `UnsubscribeFn` that removes only this binding —
other bindings on the same source keep firing.
- Preset binding for MIDI program-change-style flows.
## Usage: Basic
```ts
const off = bindParameter(eq, "lowShelfGain", { type: "cc", channel: 0, number: 7 }, {
controller,
});
// ...
off();
```
## Usage: Advanced
```ts
const off = bindParameter(
eq,
"lowShelfGain",
{ type: "cc", channel: 0, number: 7 },
{ controller, smoothingMs: 20 }
);
const offPreset = bindPluginPreset(
eq,
{ type: "cc", channel: 0, number: 32 },
presetSnapshots,
{ controller }
);
```
## API Reference
- `bindParameter(plugin, paramId, source, { controller, smoothingMs? })`
→ `UnsubscribeFn`.
- `bindPluginPreset(plugin, source, presets, { controller })` →
`UnsubscribeFn`.
- `MidiSource` — union of `cc` and `note-velocity` shapes.
- `UnsubscribeFn` — `() => void`.
## Implementation Notes
The binding subscribes to the controller's `raw` event so it can observe
even messages that have no `defineMappings` entry. A `(channel, controller)`
filter inside the handler keeps the dispatch O(1). When the parameter
declares `scale: "log"` the mapping does `min * (max/min)^t`, falling back
to `1e-6` if `min === 0` to avoid a division by zero. Preset application
delegates to `plugin.applyPreset`, which performs Zod validation itself —
any failure propagates unchanged.

View File

@@ -0,0 +1,408 @@
import type {
AudioFxPlugin,
PluginParameter,
PresetSnapshot,
} from "@suaveplan/audio-fx-plugin-base";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createFakeAccess,
createFakeAdapter,
getFirstInput,
} from "../../tests/fake-midi.js";
import { FakeTransport } from "../../tests/fake-transport.js";
import { MidiControllerPlugin } from "../base/midi-controller-plugin.js";
import type {
ControllerMetadata,
MidiControllerPluginOptions,
} from "../base/midi-controller-plugin.types.js";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../error-codes/index.js";
import { bindParameter, bindPluginPreset } from "./bind-parameter.js";
const META: ControllerMetadata = {
id: "demo",
name: "Demo",
manufacturer: "Acme",
portNamePatterns: [],
};
class DemoController extends MidiControllerPlugin {
constructor(opts: MidiControllerPluginOptions) {
super(META, opts);
}
}
function makeFakePlugin(param: PluginParameter): {
plugin: AudioFxPlugin;
calls: {
id: string;
value: number;
via: "set" | "curve" | "preset" | "at";
}[];
} {
const calls: {
id: string;
value: number;
via: "set" | "curve" | "preset" | "at";
}[] = [];
const parameterIndex = new Map<string, PluginParameter>([
[param.id, param],
]);
const plugin: AudioFxPlugin = {
metadata: {
id: "fake",
name: "Fake",
version: "0.1.0",
schemaVersion: 1,
},
parameters: [param],
parameterIndex,
io: { inputs: [{ count: 0 }], output: { count: 1 } },
capabilities: {},
setParameter(id, value) {
calls.push({ id, value, via: "set" });
},
setParameterCurve(id, curve) {
calls.push({ id, value: curve.value, via: "curve" });
},
setParameterAt(id, value) {
calls.push({ id, value, via: "at" });
},
applyPreset(preset) {
calls.push({
id: "preset",
value: preset.schemaVersion,
via: "preset",
});
},
serializePreset() {
return {
schemaVersion: 1,
pluginId: "fake",
data: {},
} as PresetSnapshot;
},
dispose() {},
} as unknown as AudioFxPlugin;
return { plugin, calls };
}
async function setup() {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
const controller = new DemoController({ adapter, transport });
await controller.initialize();
return { transport, controller, access };
}
describe("bindParameter", () => {
it("routes a CC into the plugin parameter with linear scale mapping", {
timeout: 5000,
}, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "lowShelfGain",
name: "low",
value: 0,
min: -12,
max: 12,
default: 0,
});
const off = bindParameter(
plugin,
"lowShelfGain",
{ type: "cc", channel: 1, number: 7 },
{ controller }
);
// Unmapped raw CC bypasses defineMappings, so make sure mapping doesn't consume it
getFirstInput(access).emit([0xb1, 7, 64]);
expect(calls[0]?.via).toBe("set");
expect(calls[0]?.value).toBeCloseTo(-12 + 24 * (64 / 127), 5);
off();
});
it("rejects unknown parameter id with AUDIO_CONTROLLERS.UNKNOWN_PARAMETER", {
timeout: 5000,
}, async () => {
const { controller } = await setup();
const { plugin } = makeFakePlugin({
id: "real",
name: "r",
value: 0,
min: 0,
max: 1,
default: 0,
});
try {
bindParameter(
plugin,
"nonexistent",
{ type: "cc", channel: 0, number: 1 },
{ controller }
);
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(AudioControllersError);
expect((err as AudioControllersError).code).toBe(
AUDIO_CONTROLLERS_CODES.UNKNOWN_PARAMETER
);
}
});
it("smoothing option produces a parameter curve", {
timeout: 5000,
}, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "g",
name: "g",
value: 0,
min: 0,
max: 1,
default: 0,
});
bindParameter(
plugin,
"g",
{ type: "cc", channel: 0, number: 7 },
{ controller, smoothingMs: 20 }
);
getFirstInput(access).emit([0xb0, 7, 127]);
expect(calls[0]?.via).toBe("curve");
});
it("unbind stops routing without disturbing other bindings", {
timeout: 5000,
}, async () => {
const { controller, access } = await setup();
const a = makeFakePlugin({
id: "g",
name: "g",
value: 0,
min: 0,
max: 1,
default: 0,
});
const b = makeFakePlugin({
id: "g",
name: "g",
value: 0,
min: 0,
max: 1,
default: 0,
});
const offA = bindParameter(
a.plugin,
"g",
{ type: "cc", channel: 0, number: 7 },
{ controller }
);
bindParameter(
b.plugin,
"g",
{ type: "cc", channel: 0, number: 7 },
{ controller }
);
offA();
getFirstInput(access).emit([0xb0, 7, 64]);
expect(a.calls.length).toBe(0);
expect(b.calls.length).toBe(1);
});
it("log scale maps via geometric mean", { timeout: 5000 }, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "freq",
name: "freq",
value: 100,
min: 100,
max: 10000,
default: 1000,
scale: "log",
});
bindParameter(
plugin,
"freq",
{ type: "cc", channel: 0, number: 7 },
{ controller }
);
getFirstInput(access).emit([0xb0, 7, 127]);
expect(calls[0]?.value).toBeCloseTo(10000, 0);
});
it("log scale falls back when min is 0", { timeout: 5000 }, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "x",
name: "x",
value: 0,
min: 0,
max: 100,
default: 1,
scale: "log",
});
bindParameter(
plugin,
"x",
{ type: "cc", channel: 0, number: 7 },
{ controller }
);
getFirstInput(access).emit([0xb0, 7, 127]);
expect(calls[0]?.value).toBeGreaterThan(0);
});
it("exponential scale applies square mapping", {
timeout: 5000,
}, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "y",
name: "y",
value: 0,
min: 0,
max: 1,
default: 0,
scale: "exponential",
});
bindParameter(
plugin,
"y",
{ type: "cc", channel: 0, number: 7 },
{ controller }
);
getFirstInput(access).emit([0xb0, 7, 64]);
const normalised = 64 / 127;
expect(calls[0]?.value).toBeCloseTo(normalised * normalised, 5);
});
it("ignores non-cc sources and mismatched channels", {
timeout: 5000,
}, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "x",
name: "x",
value: 0,
min: 0,
max: 1,
default: 0,
});
bindParameter(
plugin,
"x",
{ type: "cc", channel: 1, number: 7 },
{ controller }
);
getFirstInput(access).emit([0xb0, 7, 64]); // wrong channel
getFirstInput(access).emit([0xb1, 8, 64]); // wrong number
getFirstInput(access).emit([0x80, 7, 64]); // wrong command (note-off)
expect(calls.length).toBe(0);
});
it("note-velocity source is currently a passive type", {
timeout: 5000,
}, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "x",
name: "x",
value: 0,
min: 0,
max: 1,
default: 0,
});
bindParameter(
plugin,
"x",
{ type: "note-velocity", channel: 0, note: 60 },
{ controller }
);
getFirstInput(access).emit([0xb0, 7, 64]);
expect(calls.length).toBe(0);
});
});
describe("bindPluginPreset", () => {
it("applies a preset by index on a matching CC", {
timeout: 5000,
}, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "x",
name: "x",
value: 0,
min: 0,
max: 1,
default: 0,
});
const presets: PresetSnapshot[] = [
{ schemaVersion: 1, pluginId: "fake", data: {} } as PresetSnapshot,
{ schemaVersion: 2, pluginId: "fake", data: {} } as PresetSnapshot,
];
bindPluginPreset(
plugin,
{ type: "cc", channel: 0, number: 32 },
presets,
{ controller }
);
getFirstInput(access).emit([0xb0, 32, 1]);
expect(calls.find((c) => c.via === "preset")?.value).toBe(2);
});
it("ignores non-matching messages", { timeout: 5000 }, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "x",
name: "x",
value: 0,
min: 0,
max: 1,
default: 0,
});
bindPluginPreset(
plugin,
{ type: "cc", channel: 0, number: 32 },
[
{
schemaVersion: 1,
pluginId: "fake",
data: {},
} as PresetSnapshot,
],
{ controller }
);
getFirstInput(access).emit([0x80, 32, 1]); // wrong command (note-off → raw)
getFirstInput(access).emit([0xb1, 32, 1]); // wrong channel
getFirstInput(access).emit([0xb0, 33, 1]); // wrong cc
expect(calls.filter((c) => c.via === "preset").length).toBe(0);
});
it("ignores note-velocity source variant", { timeout: 5000 }, async () => {
const { controller, access } = await setup();
const { plugin, calls } = makeFakePlugin({
id: "x",
name: "x",
value: 0,
min: 0,
max: 1,
default: 0,
});
bindPluginPreset(
plugin,
{ type: "note-velocity", channel: 0, note: 32 },
[
{
schemaVersion: 1,
pluginId: "fake",
data: {},
} as PresetSnapshot,
],
{ controller }
);
getFirstInput(access).emit([0xb0, 32, 1]);
expect(calls.filter((c) => c.via === "preset").length).toBe(0);
});
});

View File

@@ -0,0 +1,127 @@
import type {
AudioFxPlugin,
PresetSnapshot,
} from "@suaveplan/audio-fx-plugin-base";
import type { MidiControllerPlugin } from "../base/midi-controller-plugin.js";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../error-codes/index.js";
/**
* Normalised inbound MIDI source — what `bindParameter` listens for. The
* `channel`/`number` pair identifies a CC; `type` is open for future source
* kinds without rewriting the binding helper.
*/
export type MidiSource =
| {
readonly type: "cc";
readonly channel: number;
readonly number: number;
}
| {
readonly type: "note-velocity";
readonly channel: number;
readonly note: number;
};
export type UnsubscribeFn = () => void;
interface BindParameterOptions {
readonly smoothingMs?: number;
readonly controller: MidiControllerPlugin;
}
function applyScale(
param: {
readonly min: number;
readonly max: number;
readonly scale?: string;
},
normalised: number
): number {
const { min, max, scale } = param;
if (scale === "log") {
const lo = Math.max(min, 1e-6);
const hi = Math.max(max, lo + 1e-6);
return lo * (hi / lo) ** normalised;
}
if (scale === "exponential") {
return min + (max - min) * normalised * normalised;
}
return min + (max - min) * normalised;
}
/**
* Wire a `MidiSource` to a parameter on any `AudioFxPlugin`. Returns an
* unsubscribe function. Smoothing engages `setParameterCurve` instead of
* the bare `setParameter` so jittery hardware knobs do not click.
*/
export function bindParameter(
plugin: AudioFxPlugin,
paramId: string,
source: MidiSource,
opts: BindParameterOptions
): UnsubscribeFn {
const param = plugin.parameterIndex.get(paramId);
if (!param) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.UNKNOWN_PARAMETER,
"audio-controllers.unknown-parameter",
{ context: { paramId, pluginId: plugin.metadata.id } }
);
}
const off = opts.controller.on("raw", (payload) => {
if (source.type !== "cc") return;
const status = payload.data[0] as number;
const data1 = payload.data[1] as number;
const data2 = payload.data[2] as number;
if ((status & 0xf0) !== 0xb0) return;
if ((status & 0x0f) !== source.channel) return;
if (data1 !== source.number) return;
const mapped = applyScale(
param as {
readonly min: number;
readonly max: number;
readonly scale?: string;
},
data2 / 127
);
if (opts.smoothingMs !== undefined && opts.smoothingMs > 0) {
plugin.setParameterCurve(paramId, {
type: "linear",
value: mapped,
duration: opts.smoothingMs / 1000,
});
} else {
plugin.setParameter(paramId, mapped);
}
});
return off;
}
/**
* Bind a controller source to a preset slot — typical MIDI program-change
* use-case. The `presets[index]` snapshot whose index matches the integer
* source value is applied via `plugin.applyPreset`.
*/
export function bindPluginPreset(
plugin: AudioFxPlugin,
source: MidiSource,
presets: ReadonlyArray<PresetSnapshot>,
opts: { readonly controller: MidiControllerPlugin }
): UnsubscribeFn {
return opts.controller.on("raw", (payload) => {
if (source.type !== "cc") return;
const status = payload.data[0] as number;
const data1 = payload.data[1] as number;
const data2 = payload.data[2] as number;
if ((status & 0xf0) !== 0xb0) return;
if ((status & 0x0f) !== source.channel) return;
if (data1 !== source.number) return;
const preset = presets[data2 % presets.length] as PresetSnapshot;
plugin.applyPreset(preset);
});
}

View File

@@ -0,0 +1,6 @@
export {
bindParameter,
bindPluginPreset,
type MidiSource,
type UnsubscribeFn,
} from "./bind-parameter.js";

View File

@@ -0,0 +1,72 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical `AUDIO_CONTROLLERS.*` error-code registry for the
`@suaveplan/audio-controllers` package. Every error thrown anywhere inside
the package — from the abstract `MidiControllerPlugin`, the connection-state
FSM, the sequencer, the arpeggiator, the learn-mode helper, the binding
helpers, or any concrete device subclass — funnels through the
`AudioControllersError` class declared here with one of the registered
codes. Centralising the registry guarantees a single, audit-able vocabulary
and a stable wire shape for downstream telemetry.
## Features
- `defineCodes("AUDIO_CONTROLLERS", […])` produces dotted codes such as
`AUDIO_CONTROLLERS.ACCESS_DENIED` and `AUDIO_CONTROLLERS.DEVICE_ERROR`.
- `AUDIO_CONTROLLERS_ERROR_METADATA` is a frozen description + severity
table consulted by both error formatting and the conformance probe.
- `AudioControllersError` extends `@suaveplan/error`'s `SuaveplanError` and
carries a `category = "audio-controllers"` discriminator.
- Idempotent `registerAudioControllersErrorCodes()` for explicit bootstrap.
## Usage: Basic
```ts
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "@suaveplan/audio-controllers";
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"kontrol.key-index",
{ context: { keyIndex: 999 } }
);
```
## Usage: Advanced
```ts
import {
AUDIO_CONTROLLERS_ERROR_METADATA,
AudioControllersError,
} from "@suaveplan/audio-controllers";
function severity(err: unknown): "warning" | "error" | undefined {
if (!(err instanceof AudioControllersError)) return undefined;
return AUDIO_CONTROLLERS_ERROR_METADATA[err.code]?.severity;
}
```
## API Reference
- `AUDIO_CONTROLLERS_CODES` — frozen record of code constants.
- `AudioControllersErrorCode` — TypeScript union of every code value.
- `AUDIO_CONTROLLERS_ERROR_METADATA[code]` — `{ description, severity }`.
- `AudioControllersError` — extends `SuaveplanError<AudioControllersErrorCode>`.
- `registerAudioControllersErrorCodes()` — idempotent bootstrap hook.
## Implementation Notes
Codes are defined as a tuple in a single `as const` array so the public
type union stays exhaustive — adding a code requires updating the metadata
table or TypeScript fails. The metadata object is `Object.freeze`d to
prevent mutation in production code. The `name` property of every error
instance includes the metadata severity so log aggregators can fan out
warnings vs. errors without parsing the code string.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_CONTROLLERS_CODES,
AUDIO_CONTROLLERS_ERROR_METADATA,
AudioControllersError,
registerAudioControllersErrorCodes,
} from "./error-codes.js";
describe("AUDIO_CONTROLLERS error registry", () => {
it("defines every required dotted code", { timeout: 5000 }, () => {
const required = [
"AUDIO_CONTROLLERS.ACCESS_DENIED",
"AUDIO_CONTROLLERS.INVALID_OPTIONS",
"AUDIO_CONTROLLERS.INVALID_STATE",
"AUDIO_CONTROLLERS.DEVICE_ERROR",
"AUDIO_CONTROLLERS.NOT_CONNECTED",
"AUDIO_CONTROLLERS.UNKNOWN_PARAMETER",
];
const values = Object.values(AUDIO_CONTROLLERS_CODES);
for (const code of required) {
expect(values).toContain(code);
}
});
it("every code has frozen metadata with description + severity", {
timeout: 5000,
}, () => {
for (const code of Object.values(AUDIO_CONTROLLERS_CODES)) {
const meta = AUDIO_CONTROLLERS_ERROR_METADATA[code];
expect(meta.description.length).toBeGreaterThan(0);
expect(["warning", "error"]).toContain(meta.severity);
}
});
it("AudioControllersError carries the code + category discriminator", {
timeout: 5000,
}, () => {
const err = new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"k",
{ context: { ok: true } }
);
expect(err.code).toBe(AUDIO_CONTROLLERS_CODES.DEVICE_ERROR);
expect(err.category).toBe("audio-controllers");
expect(err.name).toContain("AudioControllersError");
});
it("registerAudioControllersErrorCodes is idempotent", {
timeout: 5000,
}, () => {
expect(() => {
registerAudioControllersErrorCodes();
registerAudioControllersErrorCodes();
}).not.toThrow();
});
});

View File

@@ -0,0 +1,73 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const AUDIO_CONTROLLERS_CODES = defineCodes("AUDIO_CONTROLLERS", [
"ACCESS_DENIED",
"INVALID_OPTIONS",
"INVALID_STATE",
"DEVICE_ERROR",
"NOT_CONNECTED",
"UNKNOWN_PARAMETER",
] as const);
export type AudioControllersErrorCode =
(typeof AUDIO_CONTROLLERS_CODES)[keyof typeof AUDIO_CONTROLLERS_CODES];
export interface AudioControllersErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const AUDIO_CONTROLLERS_ERROR_METADATA: Readonly<
Record<AudioControllersErrorCode, AudioControllersErrorMetadata>
> = Object.freeze({
[AUDIO_CONTROLLERS_CODES.ACCESS_DENIED]: {
description:
"the injected MidiAccessAdapter rejected the access request, or initialize() was called with an aborted signal.",
severity: "error",
},
[AUDIO_CONTROLLERS_CODES.INVALID_OPTIONS]: {
description:
"a public options bag failed Zod boundary validation; issues are carried in context.",
severity: "error",
},
[AUDIO_CONTROLLERS_CODES.INVALID_STATE]: {
description:
"the connection FSM rejected an illegal state transition; from/to in context.",
severity: "error",
},
[AUDIO_CONTROLLERS_CODES.DEVICE_ERROR]: {
description:
"a device-specific argument was out of range, or a device operation was aborted.",
severity: "error",
},
[AUDIO_CONTROLLERS_CODES.NOT_CONNECTED]: {
description:
"an output method was called before the device's MIDIOutput was connected.",
severity: "warning",
},
[AUDIO_CONTROLLERS_CODES.UNKNOWN_PARAMETER]: {
description:
"bindParameter received a paramId not present on the target plugin's parameterIndex.",
severity: "error",
},
});
export class AudioControllersError extends SuaveplanError<AudioControllersErrorCode> {
public readonly category = "audio-controllers" as const;
public constructor(
code: AudioControllersErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = `AudioControllersError(${AUDIO_CONTROLLERS_ERROR_METADATA[code].severity})`;
}
}
export function registerAudioControllersErrorCodes(): void {
/* Idempotent — metadata is frozen and exported for direct use. */
}
registerAudioControllersErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
AudioControllersErrorCode,
AudioControllersErrorMetadata,
} from "./error-codes.js";
export {
AUDIO_CONTROLLERS_CODES,
AUDIO_CONTROLLERS_ERROR_METADATA,
AudioControllersError,
registerAudioControllersErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,61 @@
export {
Arpeggiator,
type ArpeggiatorOptions,
type ArpListener,
type ArpMode,
} from "./arpeggiator/index.js";
export {
type CCMapping,
type ConnectionMachine,
type ConnectionState,
type ControllerEventMap,
type ControllerMetadata,
createConnectionMachine,
type InitializeOptions,
type LEDUpdate,
type MidiAccessAdapter,
type MidiAccessLike,
MidiControllerPlugin,
type MidiControllerPluginOptions,
type MidiInputLike,
type MidiOutputLike,
type MidiPortLike,
sendOrThrow,
} from "./base/index.js";
export {
bindParameter,
bindPluginPreset,
type MidiSource,
type UnsubscribeFn,
} from "./binding/index.js";
export {
AUDIO_CONTROLLERS_CODES,
AUDIO_CONTROLLERS_ERROR_METADATA,
AudioControllersError,
type AudioControllersErrorCode,
type AudioControllersErrorMetadata,
registerAudioControllersErrorCodes,
} from "./error-codes/index.js";
export {
captureNextLearn,
type LearnResult,
} from "./learn/index.js";
export {
KontrolS88Plugin,
type LEDColor,
type ScaleMode,
} from "./plugins/kontrol-s88/index.js";
export {
type MaschineOptions,
MaschinePlugin,
type SampleLoader,
} from "./plugins/maschine/index.js";
export {
type DeckTrack,
TraktorS4Plugin,
} from "./plugins/traktor-s4/index.js";
export {
Sequencer,
type SequencerOptions,
type StepCallback,
} from "./sequencer/index.js";

View File

@@ -0,0 +1 @@
export { captureNextLearn, type LearnResult } from "./learn-mode.js";

View File

@@ -0,0 +1,61 @@
# learn-mode
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Functional helper around the `MidiControllerPlugin.enterLearnMode` /
`exitLearnMode` toggle. The base class natively swallows the next
unmapped CC message and emits a `learn` event when in learn mode; this
module wraps that flow into a promise so application code can simply
`await captureNextLearn(controller)` to get the captured
`(channel, controller)` pair. It also exposes a cancellation handle for
UI flows that need to abort the capture (user closes the dialog,
controller disconnects, etc.).
## Features
- Promise-based capture of the next CC message.
- `cancel()` releases the subscription without exiting learn mode, so
callers can compose multiple captures.
- Zero new state on the plugin — uses the existing `on('learn', ...)`
surface and `enterLearnMode()` toggle.
## Usage: Basic
```ts
const { channel, controller: cc } = await captureNextLearn(controller);
console.log("Mapped to channel", channel, "CC", cc);
```
## Usage: Advanced
```ts
const capture = captureNextLearn(controller);
const timeout = setTimeout(() => capture.cancel(), 5000);
try {
const { channel, controller: cc } = await capture;
controller.defineMappings([
{ channel, controller: cc, target: "volume", min: 0, max: 1 },
]);
} finally {
clearTimeout(timeout);
}
```
## API Reference
- `captureNextLearn(plugin)` → `Promise<LearnResult> & { cancel(): void }`.
- `LearnResult` — `{ channel: number, controller: number }`.
## Implementation Notes
The promise resolves at most once. After the first `learn` emission the
internal subscription is released so the resolver cannot fire twice; the
base class also auto-exits learn mode on first capture, layering defence
in depth. `cancel()` simply unsubscribes the listener, but intentionally
does not call `exitLearnMode()` — that's the caller's policy choice if
the UI hand-off requires keeping learn-mode armed across multiple
controls in the same session for batch learn-mode capture workflows.

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createFakeAccess,
createFakeAdapter,
getFirstInput,
} from "../../tests/fake-midi.js";
import { FakeTransport } from "../../tests/fake-transport.js";
import { MidiControllerPlugin } from "../base/midi-controller-plugin.js";
import type {
ControllerMetadata,
MidiControllerPluginOptions,
} from "../base/midi-controller-plugin.types.js";
import { captureNextLearn } from "./learn-mode.js";
const META: ControllerMetadata = {
id: "demo",
name: "Demo",
manufacturer: "Acme",
portNamePatterns: [],
};
class DemoController extends MidiControllerPlugin {
constructor(opts: MidiControllerPluginOptions) {
super(META, opts);
}
}
async function setup() {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
const plugin = new DemoController({ adapter, transport });
await plugin.initialize();
return { plugin, access };
}
describe("captureNextLearn", () => {
it("learn mode captures the next control message and exits", {
timeout: 5000,
}, async () => {
const { plugin, access } = await setup();
const promise = captureNextLearn(plugin);
getFirstInput(access).emit([0xb2, 42, 64]);
const result = await promise;
expect(result).toEqual({ channel: 2, controller: 42 });
});
it("cancel releases the subscription", { timeout: 5000 }, async () => {
const { plugin, access } = await setup();
const promise = captureNextLearn(plugin);
promise.cancel();
let resolved = false;
void promise.then(() => {
resolved = true;
});
getFirstInput(access).emit([0xb0, 1, 1]);
await new Promise((r) => setTimeout(r, 5));
expect(resolved).toBe(false);
});
});

View File

@@ -0,0 +1,30 @@
import type { MidiControllerPlugin } from "../base/midi-controller-plugin.js";
/**
* Functional helper to drive learn-mode capture. Subscribes to the plugin's
* `learn` event; on the next emission resolves with the captured pair and
* automatically exits learn mode. Returns a cancel function that releases
* the subscription without exiting learn mode (so callers can compose).
*/
export interface LearnResult {
readonly channel: number;
readonly controller: number;
}
export function captureNextLearn(
plugin: MidiControllerPlugin
): Promise<LearnResult> & { cancel: () => void } {
let unsubscribe: (() => void) | undefined;
const promise = new Promise<LearnResult>((resolve) => {
unsubscribe = plugin.on("learn", (payload) => {
unsubscribe?.();
resolve({
channel: payload.channel,
controller: payload.controller,
});
});
plugin.enterLearnMode();
}) as Promise<LearnResult> & { cancel: () => void };
promise.cancel = () => unsubscribe?.();
return promise;
}

View File

@@ -0,0 +1,6 @@
export {
type ArpMode,
KontrolS88Plugin,
type LEDColor,
type ScaleMode,
} from "./kontrol-s88.js";

View File

@@ -0,0 +1,68 @@
# kontrol-s88
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`KontrolS88Plugin` is the concrete `MidiControllerPlugin` subclass for
Native Instruments Kontrol S88 MK2/MK3 — an 88-key weighted MIDI
keyboard with pitch-bend, mod-wheel, RGB key LEDs, scale modes, and
arpeggio routing. It inherits the entire MIDI lifecycle (adapter-driven
`requestAccess`, port binding, CC mapping, learn mode, FSM, OTel) from
the base and only adds device-specific surface: `setScale`, `setArpMode`,
`setPitchBend`, `setModWheel`, and `sendLED`.
## Features
- Scale modes: chromatic, major, minor, dorian, mixolydian, pentatonic, blues.
- Arpeggiator modes: up, down, updown, random.
- Pitch-bend `[-1, 1]` mapped to 14-bit MIDI pitch-bend message.
- Mod-wheel `[0, 1]` mapped to CC 1.
- Per-key RGB LED feedback via the inherited output port.
- Range-checked device arguments → `AUDIO_CONTROLLERS.DEVICE_ERROR`.
## Usage: Basic
```ts
const kontrol = new KontrolS88Plugin({ adapter, transport });
await kontrol.initialize();
kontrol.setScale("major");
kontrol.sendLED(0, { red: 1, green: 0, blue: 0 });
```
## Usage: Advanced
```ts
const kontrol = new KontrolS88Plugin({ adapter, transport, logger });
await kontrol.initialize({ sysex: true });
kontrol.defineMappings([
{ channel: 0, controller: 0x10, target: "filter.cutoff", min: 20, max: 20000 },
]);
kontrol.setArpMode("updown");
kontrol.setPitchBend(0.5);
kontrol.setModWheel(0.25);
for (let key = 0; key < 88; key++) {
kontrol.sendLED(key, { red: key / 88, green: 0, blue: 1 - key / 88 });
}
```
## API Reference
- `new KontrolS88Plugin(options)`.
- `setScale(mode)` / `getScale()`.
- `setArpMode(mode)` / `getArpMode()`.
- `setPitchBend(value)` / `getPitchBend()`.
- `setModWheel(value)` / `getModWheel()`.
- `sendLED(keyIndex, color)` — key index `[0, 87]`.
## Implementation Notes
Key index 0 corresponds to MIDI note 21 (A0); the helper translates the
input into a note-on message addressed to that key. Pitch-bend is encoded
as the standard 14-bit MIDI value (`status 0xE0`, two 7-bit data bytes).
All output paths funnel through the inherited `sendOutput` helper, which
raises `AUDIO_CONTROLLERS.NOT_CONNECTED` if no `MIDIOutput` is bound. The
declared `portNamePatterns` (`KOMPLETE KONTROL`, `S88`) cover both MK2 and
MK3 hardware revisions reported by the OS-level MIDI driver.

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createFakeAccess,
createFakeAdapter,
getFirstOutput,
} from "../../../tests/fake-midi.js";
import { FakeTransport } from "../../../tests/fake-transport.js";
import { AUDIO_CONTROLLERS_CODES } from "../../error-codes/index.js";
import { KontrolS88Plugin } from "./kontrol-s88.js";
async function setup() {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
const plugin = new KontrolS88Plugin({ adapter, transport });
await plugin.initialize();
return { plugin, access };
}
describe("KontrolS88Plugin", () => {
it("setScale / getScale round-trip", { timeout: 5000 }, async () => {
const { plugin } = await setup();
plugin.setScale("major");
expect(plugin.getScale()).toBe("major");
});
it("setArpMode / getArpMode round-trip", { timeout: 5000 }, async () => {
const { plugin } = await setup();
plugin.setArpMode("updown");
expect(plugin.getArpMode()).toBe("updown");
});
it("setPitchBend writes the standard 14-bit pitch-bend message", {
timeout: 5000,
}, async () => {
const { plugin, access } = await setup();
plugin.setPitchBend(0);
const sent = getFirstOutput(access).sent.at(-1);
expect(sent?.[0]).toBe(0xe0);
expect(plugin.getPitchBend()).toBe(0);
});
it("setPitchBend rejects values outside [-1, 1]", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
expect(() => plugin.setPitchBend(2)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.setPitchBend(-2)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("setModWheel writes CC 1", { timeout: 5000 }, async () => {
const { plugin, access } = await setup();
plugin.setModWheel(0.5);
const sent = getFirstOutput(access).sent.at(-1);
expect(sent?.[0]).toBe(0xb0);
expect(sent?.[1]).toBe(0x01);
expect(plugin.getModWheel()).toBe(0.5);
});
it("setModWheel rejects values outside [0, 1]", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
expect(() => plugin.setModWheel(2)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.setModWheel(-0.1)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("sendLED writes the device LED byte sequence through the output fixture", {
timeout: 5000,
}, async () => {
const { plugin, access } = await setup();
plugin.sendLED(0, { red: 1, green: 0, blue: 0 });
const sent = getFirstOutput(access).sent.at(-1);
expect(sent).toEqual([0x90, 21, 127]);
plugin.sendLED(87, { red: 0, green: 1, blue: 1 });
const last = getFirstOutput(access).sent.at(-1);
expect(last).toEqual([0x90, 108, 127]);
});
it("sendLED rejects out-of-range key index", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
expect(() => plugin.sendLED(88, { red: 0, green: 0, blue: 0 })).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.sendLED(-1, { red: 0, green: 0, blue: 0 })).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
});

View File

@@ -0,0 +1,119 @@
import { MidiControllerPlugin } from "../../base/midi-controller-plugin.js";
import type {
ControllerMetadata,
MidiControllerPluginOptions,
} from "../../base/midi-controller-plugin.types.js";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../../error-codes/index.js";
export type ScaleMode =
| "chromatic"
| "major"
| "minor"
| "dorian"
| "mixolydian"
| "pentatonic"
| "blues";
export type ArpMode = "up" | "down" | "updown" | "random";
export interface LEDColor {
readonly red: number;
readonly green: number;
readonly blue: number;
}
const META: ControllerMetadata = {
id: "kontrol-s88",
name: "Kontrol S88",
manufacturer: "Native Instruments",
portNamePatterns: ["KOMPLETE KONTROL", "S88"],
};
const KEY_NOTE_START = 21;
const KEY_NOTE_END = 108;
const KEY_COUNT = KEY_NOTE_END - KEY_NOTE_START + 1;
/**
* Native Instruments Kontrol S88 controller — 88-key weighted keyboard with
* pitch-bend, mod-wheel, scale modes, arpeggiator integration, and per-key
* RGB LED feedback. Inherits MIDI lifecycle and CC-mapping from the base.
*/
export class KontrolS88Plugin extends MidiControllerPlugin {
private scale: ScaleMode = "chromatic";
private arpMode: ArpMode = "up";
private pitchBend = 0;
private modWheel = 0;
constructor(options: MidiControllerPluginOptions) {
super(META, options);
}
public setScale(mode: ScaleMode): void {
this.scale = mode;
}
public getScale(): ScaleMode {
return this.scale;
}
public setArpMode(mode: ArpMode): void {
this.arpMode = mode;
}
public getArpMode(): ArpMode {
return this.arpMode;
}
public setPitchBend(value: number): void {
if (value < -1 || value > 1) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.kontrol-s88.pitch-bend-range",
{ context: { value } }
);
}
this.pitchBend = value;
const v = Math.round(((value + 1) / 2) * 0x3fff);
const lsb = v & 0x7f;
const msb = (v >> 7) & 0x7f;
this.sendOutput([0xe0, lsb, msb]);
}
public getPitchBend(): number {
return this.pitchBend;
}
public setModWheel(value: number): void {
if (value < 0 || value > 1) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.kontrol-s88.mod-wheel-range",
{ context: { value } }
);
}
this.modWheel = value;
this.sendOutput([0xb0, 0x01, Math.round(value * 127)]);
}
public getModWheel(): number {
return this.modWheel;
}
public sendLED(keyIndex: number, color: LEDColor): void {
if (keyIndex < 0 || keyIndex >= KEY_COUNT) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.kontrol-s88.key-index",
{ context: { keyIndex, max: KEY_COUNT - 1 } }
);
}
const note = KEY_NOTE_START + keyIndex;
const intensity = Math.round(
Math.max(color.red, color.green, color.blue) * 127
);
this.sendOutput([0x90, note, intensity]);
}
}

View File

@@ -0,0 +1,5 @@
export {
type MaschineOptions,
MaschinePlugin,
type SampleLoader,
} from "./maschine.js";

View File

@@ -0,0 +1,70 @@
# maschine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MaschinePlugin` is the concrete `MidiControllerPlugin` subclass for
Native Instruments Maschine: a 16-pad grid with per-pad velocity, a
16-step sequencer row per pad, and slot-based sample loading. It inherits
the universal MIDI lifecycle from the base and adds the device-specific
surface declared by the spec: `setPadVelocity`, `setStep`, and
`loadSample`. Sample loading is delegated to an injectable
`SampleLoader` so the package stays universal and side-effect-free.
## Features
- 16 pads × 16 steps internal grid.
- Per-pad velocity table (0–127) with `MIDIOutput` echo.
- `loadSample(slot, id, { signal })` honours `AbortSignal` both before
and after the loader resolves.
- Range-checked pad index, step index, velocity, and slot index.
- Sample registry exposed via `getSample(slotIndex)`.
## Usage: Basic
```ts
const maschine = new MaschinePlugin({ adapter, transport });
await maschine.initialize();
maschine.setPadVelocity(3, 96);
maschine.setStep(3, 0, true);
await maschine.loadSample(3, "kick-808");
```
## Usage: Advanced
```ts
const ctl = new AbortController();
const maschine = new MaschinePlugin({
adapter,
transport,
sampleLoader: async (id, signal) => loadFromDrive(id, signal),
});
await maschine.initialize({ signal: ctl.signal });
maschine.setPadVelocity(0, 127);
maschine.setStep(0, 0, true);
maschine.setStep(0, 4, true);
maschine.setStep(0, 8, true);
maschine.setStep(0, 12, true);
await maschine.loadSample(0, "kick-808", { signal: ctl.signal });
```
## API Reference
- `new MaschinePlugin({ adapter, transport, sampleLoader? })`.
- `setPadVelocity(padIndex, velocity)` / `getPadVelocity(padIndex)`.
- `setStep(padIndex, stepIndex, active)` / `getStep(padIndex, stepIndex)`.
- `loadSample(slotIndex, sampleId, { signal? })` → `Promise<void>`.
- `getSample(slotIndex)` → `string | undefined`.
## Implementation Notes
`loadSample` performs a pre-await abort check, awaits the loader, then
re-checks the signal at the next await boundary — the spec's
"mid-operation abort" guarantee. Any failure inside the loader is
re-thrown as `AUDIO_CONTROLLERS.DEVICE_ERROR` carrying the original error
as `cause`, so consumers can introspect transport-layer failures without
re-implementing error mapping. The default loader is a no-op suitable for
unit-test scenarios; production callers wire in their own.

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createFakeAccess,
createFakeAdapter,
getFirstOutput,
} from "../../../tests/fake-midi.js";
import { FakeTransport } from "../../../tests/fake-transport.js";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../../error-codes/index.js";
import { MaschinePlugin, type SampleLoader } from "./maschine.js";
async function setup(loader?: SampleLoader) {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
const plugin = new MaschinePlugin({
adapter,
transport,
...(loader ? { sampleLoader: loader } : {}),
});
await plugin.initialize();
return { plugin, access };
}
describe("MaschinePlugin", () => {
it("setPadVelocity writes to the output and stores velocity", {
timeout: 5000,
}, async () => {
const { plugin, access } = await setup();
plugin.setPadVelocity(3, 96);
expect(plugin.getPadVelocity(3)).toBe(96);
expect(getFirstOutput(access).sent.at(-1)).toEqual([0x90, 0x33, 96]);
});
it("setPadVelocity rejects an out-of-range pad index with AUDIO_CONTROLLERS.DEVICE_ERROR", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
expect(() => plugin.setPadVelocity(16, 64)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.setPadVelocity(-1, 64)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.setPadVelocity(1.5, 64)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("setPadVelocity rejects out-of-range velocity", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
expect(() => plugin.setPadVelocity(0, 200)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.setPadVelocity(0, -1)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("setStep / getStep maintain the per-pad grid", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
plugin.setStep(0, 0, true);
plugin.setStep(0, 4, true);
expect(plugin.getStep(0, 0)).toBe(true);
expect(plugin.getStep(0, 1)).toBe(false);
expect(plugin.getStep(0, 4)).toBe(true);
});
it("setStep rejects out-of-range step", { timeout: 5000 }, async () => {
const { plugin } = await setup();
expect(() => plugin.setStep(0, 16, true)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.setStep(0, -1, true)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("loadSample resolves via the injected loader", {
timeout: 5000,
}, async () => {
let called: string | undefined;
const { plugin } = await setup(async (id) => {
called = id;
});
await plugin.loadSample(2, "kick");
expect(called).toBe("kick");
expect(plugin.getSample(2)).toBe("kick");
});
it("loadSample without explicit loader is a no-op", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
await plugin.loadSample(0, "x");
expect(plugin.getSample(0)).toBe("x");
});
it("loadSample wraps loader failure in AUDIO_CONTROLLERS.DEVICE_ERROR", {
timeout: 5000,
}, async () => {
const { plugin } = await setup(async () => {
throw new Error("io");
});
try {
await plugin.loadSample(0, "boom");
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(AudioControllersError);
expect((err as AudioControllersError).code).toBe(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR
);
}
});
it("loadSample rejects pre-aborted signal", { timeout: 5000 }, async () => {
const { plugin } = await setup();
const ctl = new AbortController();
ctl.abort();
await expect(
plugin.loadSample(0, "x", { signal: ctl.signal })
).rejects.toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("loadSample honours mid-operation abort", {
timeout: 5000,
}, async () => {
const ctl = new AbortController();
const { plugin } = await setup(async (_id, signal) => {
ctl.abort();
if (signal?.aborted) {
return;
}
});
await expect(
plugin.loadSample(0, "x", { signal: ctl.signal })
).rejects.toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("loadSample wraps non-Error throw in AUDIO_CONTROLLERS.DEVICE_ERROR", {
timeout: 5000,
}, async () => {
const { plugin } = await setup(async () => {
throw "string";
});
await expect(plugin.loadSample(0, "x")).rejects.toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("getPadVelocity returns 0 for fresh pad", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
expect(plugin.getPadVelocity(0)).toBe(0);
});
});

View File

@@ -0,0 +1,140 @@
import { MidiControllerPlugin } from "../../base/midi-controller-plugin.js";
import type {
ControllerMetadata,
MidiControllerPluginOptions,
} from "../../base/midi-controller-plugin.types.js";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../../error-codes/index.js";
export type SampleLoader = (
sampleId: string,
signal?: AbortSignal
) => Promise<void>;
export interface MaschineOptions extends MidiControllerPluginOptions {
readonly sampleLoader?: SampleLoader;
}
const META: ControllerMetadata = {
id: "maschine",
name: "Maschine",
manufacturer: "Native Instruments",
portNamePatterns: ["Maschine"],
};
const PAD_COUNT = 16;
const STEPS_PER_PAD = 16;
/**
* Native Instruments Maschine controller — 16-pad grid with per-pad
* step-sequencer rows and slot-based sample loading. Step state is held
* locally and surfaced to the inherited `sequencer`'s step bank.
*/
export class MaschinePlugin extends MidiControllerPlugin {
private readonly velocities = new Uint8Array(PAD_COUNT);
private readonly steps: boolean[][] = Array.from(
{ length: PAD_COUNT },
() => new Array<boolean>(STEPS_PER_PAD).fill(false)
);
private readonly samples = new Map<number, string>();
private readonly sampleLoader: SampleLoader;
constructor(options: MaschineOptions) {
super(META, options);
this.sampleLoader = options.sampleLoader ?? defaultSampleLoader;
}
public setPadVelocity(padIndex: number, velocity: number): void {
this.assertPadIndex(padIndex);
if (velocity < 0 || velocity > 127) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.maschine.velocity",
{ context: { velocity } }
);
}
this.velocities[padIndex] = velocity;
this.sendOutput([0x90, 0x30 + padIndex, velocity]);
}
public getPadVelocity(padIndex: number): number {
this.assertPadIndex(padIndex);
return this.velocities[padIndex] as number;
}
public setStep(padIndex: number, stepIndex: number, active: boolean): void {
this.assertPadIndex(padIndex);
if (stepIndex < 0 || stepIndex >= STEPS_PER_PAD) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.maschine.step-index",
{ context: { stepIndex, max: STEPS_PER_PAD - 1 } }
);
}
(this.steps[padIndex] as boolean[])[stepIndex] = active;
}
public getStep(padIndex: number, stepIndex: number): boolean {
this.assertPadIndex(padIndex);
return (this.steps[padIndex] as boolean[])[stepIndex] as boolean;
}
public async loadSample(
slotIndex: number,
sampleId: string,
opts: { readonly signal?: AbortSignal } = {}
): Promise<void> {
this.assertPadIndex(slotIndex);
if (opts.signal?.aborted) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.maschine.aborted",
{ context: { slotIndex, sampleId } }
);
}
try {
await this.sampleLoader(sampleId, opts.signal);
} catch (err) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.maschine.load-failed",
{
...(err instanceof Error ? { cause: err } : {}),
context: { slotIndex, sampleId },
}
);
}
if (opts.signal?.aborted) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.maschine.aborted",
{ context: { slotIndex, sampleId } }
);
}
this.samples.set(slotIndex, sampleId);
}
public getSample(slotIndex: number): string | undefined {
return this.samples.get(slotIndex);
}
private assertPadIndex(padIndex: number): void {
if (
!Number.isInteger(padIndex) ||
padIndex < 0 ||
padIndex >= PAD_COUNT
) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.maschine.pad-index",
{ context: { padIndex, max: PAD_COUNT - 1 } }
);
}
}
}
async function defaultSampleLoader(): Promise<void> {
/* No-op: callers wire their own loader via MaschineOptions.sampleLoader. */
}

View File

@@ -0,0 +1 @@
export { type DeckTrack, TraktorS4Plugin } from "./traktor-s4.js";

View File

@@ -0,0 +1,70 @@
# traktor-s4
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TraktorS4Plugin` is the concrete `MidiControllerPlugin` subclass for the
Native Instruments Traktor Kontrol S4 MK3 — a 4-channel DJ controller with
four decks, four channel volume faders, and a crossfader. It inherits the
universal MIDI lifecycle (adapter-driven `requestAccess`, CC mapping,
hot-plug FSM, learn mode, OTel) from the base and only declares the
device-specific surface required by the spec: `loadDeck`, `setCrossfader`,
and `setChannelVolume`. Deck-sync against transport tempo happens by way
of the inherited `transport.bpm` signal.
## Features
- Four decks with per-deck `DeckTrack` records (id, title, bpm, duration).
- Per-channel volume in `[0, 1]` with output echo to the device fader LED.
- Crossfader in `[0, 1]` mapped to CC `0x08`.
- Range-checked deck index and channel index.
## Usage: Basic
```ts
const traktor = new TraktorS4Plugin({ adapter, transport });
await traktor.initialize();
traktor.loadDeck(0, { id: "track-a" });
traktor.setCrossfader(0.5);
traktor.setChannelVolume(0, 0.8);
```
## Usage: Advanced
```ts
const traktor = new TraktorS4Plugin({ adapter, transport });
await traktor.initialize();
traktor.loadDeck(0, { id: "a", title: "Side A", bpm: 124 });
traktor.loadDeck(1, { id: "b", title: "Side B", bpm: 124 });
traktor.defineMappings([
{ channel: 0, controller: 0x08, target: "crossfader", min: 0, max: 1 },
]);
traktor.on("control", ({ target, value }) =>
log.debug({ target, value }, "traktor.cc")
);
traktor.setChannelVolume(0, 1);
traktor.setChannelVolume(1, 0.5);
traktor.setCrossfader(0.5);
```
## API Reference
- `new TraktorS4Plugin(options)`.
- `loadDeck(deckIndex, track)` / `getDeck(deckIndex)`.
- `setCrossfader(value)` / `getCrossfader()`.
- `setChannelVolume(channel, value)` / `getChannelVolume(channel)`.
## Implementation Notes
`loadDeck` writes a confirmation note-on to the device so the on-deck LED
lights up when a track is staged. Crossfader and per-channel volume are
clamped at the boundary — values outside `[0, 1]` raise
`AUDIO_CONTROLLERS.DEVICE_ERROR` with the offending value in `context`.
The internal `volumes` buffer is a `Float32Array` to keep the read path
allocation-free in hot loops, even though the public API is plain numbers.

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createFakeAccess,
createFakeAdapter,
getFirstOutput,
} from "../../../tests/fake-midi.js";
import { FakeTransport } from "../../../tests/fake-transport.js";
import { AUDIO_CONTROLLERS_CODES } from "../../error-codes/index.js";
import { TraktorS4Plugin } from "./traktor-s4.js";
async function setup() {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
const plugin = new TraktorS4Plugin({ adapter, transport });
await plugin.initialize();
return { plugin, access };
}
describe("TraktorS4Plugin", () => {
it("loadDeck stores the track and writes a confirmation note", {
timeout: 5000,
}, async () => {
const { plugin, access } = await setup();
plugin.loadDeck(0, { id: "a" });
expect(plugin.getDeck(0)).toEqual({ id: "a" });
const sent = getFirstOutput(access).sent.at(-1);
expect(sent?.[0]).toBe(0x90);
expect(sent?.[1]).toBe(0x40);
});
it("loadDeck rejects out-of-range deck index", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
expect(() => plugin.loadDeck(4, { id: "x" })).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.loadDeck(-1, { id: "x" })).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.getDeck(99)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("setCrossfader / getCrossfader round-trip", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
plugin.setCrossfader(0.5);
expect(plugin.getCrossfader()).toBe(0.5);
});
it("setCrossfader rejects out-of-range value", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
expect(() => plugin.setCrossfader(2)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.setCrossfader(-0.1)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
it("setChannelVolume / getChannelVolume per channel", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
plugin.setChannelVolume(0, 0.8);
plugin.setChannelVolume(3, 0.2);
expect(plugin.getChannelVolume(0)).toBeCloseTo(0.8, 5);
expect(plugin.getChannelVolume(3)).toBeCloseTo(0.2, 5);
});
it("setChannelVolume rejects out-of-range channel and value", {
timeout: 5000,
}, async () => {
const { plugin } = await setup();
expect(() => plugin.setChannelVolume(4, 0.5)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.setChannelVolume(-1, 0.5)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.setChannelVolume(0, 2)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
expect(() => plugin.getChannelVolume(99)).toThrow(
expect.objectContaining({
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
})
);
});
});

View File

@@ -0,0 +1,114 @@
import { MidiControllerPlugin } from "../../base/midi-controller-plugin.js";
import type {
ControllerMetadata,
MidiControllerPluginOptions,
} from "../../base/midi-controller-plugin.types.js";
import {
AUDIO_CONTROLLERS_CODES,
AudioControllersError,
} from "../../error-codes/index.js";
export interface DeckTrack {
readonly id: string;
readonly title?: string;
readonly bpm?: number;
readonly durationSec?: number;
}
const META: ControllerMetadata = {
id: "traktor-s4",
name: "Traktor Kontrol S4",
manufacturer: "Native Instruments",
portNamePatterns: ["Traktor", "Kontrol S4"],
};
const DECK_COUNT = 4;
const CHANNEL_COUNT = 4;
/**
* Native Instruments Traktor S4 MK3 — 4-channel DJ controller with mixer,
* crossfader, and four decks. Inherits MIDI lifecycle, mapping, FSM and
* OTel from the base.
*/
export class TraktorS4Plugin extends MidiControllerPlugin {
private readonly decks = new Map<number, DeckTrack>();
private readonly volumes = new Float32Array(CHANNEL_COUNT);
private crossfader = 0.5;
constructor(options: MidiControllerPluginOptions) {
super(META, options);
}
public loadDeck(deckIndex: number, track: DeckTrack): void {
this.assertDeckIndex(deckIndex);
this.decks.set(deckIndex, track);
this.sendOutput([0x90, 0x40 + deckIndex, 0x7f]);
}
public getDeck(deckIndex: number): DeckTrack | undefined {
this.assertDeckIndex(deckIndex);
return this.decks.get(deckIndex);
}
public setCrossfader(value: number): void {
if (value < 0 || value > 1) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.traktor-s4.crossfader",
{ context: { value } }
);
}
this.crossfader = value;
this.sendOutput([0xb0, 0x08, Math.round(value * 127)]);
}
public getCrossfader(): number {
return this.crossfader;
}
public setChannelVolume(channel: number, value: number): void {
this.assertChannel(channel);
if (value < 0 || value > 1) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.traktor-s4.volume",
{ context: { channel, value } }
);
}
this.volumes[channel] = value;
this.sendOutput([0xb0, 0x10 + channel, Math.round(value * 127)]);
}
public getChannelVolume(channel: number): number {
this.assertChannel(channel);
return this.volumes[channel] as number;
}
private assertDeckIndex(deckIndex: number): void {
if (
!Number.isInteger(deckIndex) ||
deckIndex < 0 ||
deckIndex >= DECK_COUNT
) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.traktor-s4.deck-index",
{ context: { deckIndex, max: DECK_COUNT - 1 } }
);
}
}
private assertChannel(channel: number): void {
if (
!Number.isInteger(channel) ||
channel < 0 ||
channel >= CHANNEL_COUNT
) {
throw new AudioControllersError(
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
"audio-controllers.traktor-s4.channel",
{ context: { channel, max: CHANNEL_COUNT - 1 } }
);
}
}
}

View File

@@ -0,0 +1,6 @@
export { Sequencer } from "./sequencer.js";
export { SequencerOptionsSchema } from "./sequencer.schemas.js";
export type {
SequencerOptions,
StepCallback,
} from "./sequencer.types.js";

View File

@@ -0,0 +1,65 @@
# sequencer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Sequencer` is the transport-synced step engine consumed by every
`MidiControllerPlugin`. It schedules every active step through
`Transport.scheduleRepeat`, never `setInterval` or `setTimeout`, so step
playback is sample-accurate relative to the project transport and renders
identically offline. The class exposes the spec-required surface —
`setTempo`, `setSteps`, `setStep`, `start`, `stop`, `reset`, `onStep` —
and serves as the shared sequencing primitive behind Maschine's step
sequencer and Kontrol S88's arpeggiator routing.
## Features
- Sample-accurate scheduling via `@suaveplan/audio-transport`.
- Per-index `onStep` listener registry with unsubscribe handles.
- `setTempo` mirrors the bpm onto the transport's `bpm` signal so other
consumers of the transport see the change.
- Resilient cursor logic: `reset()` returns the cursor to 0 without
stopping the transport.
## Usage: Basic
```ts
const seq = new Sequencer({ transport });
seq.setSteps([true, false, true, false]);
seq.onStep(0, () => synth.trigger("kick"));
seq.start();
```
## Usage: Advanced
```ts
const seq = new Sequencer({ transport, stepDuration: "8n" });
seq.setSteps(Array.from({ length: 16 }, (_, i) => i % 4 === 0));
const off = seq.onStep(4, (time) => log.debug({ time }, "snare"));
seq.setTempo(140);
seq.start();
// ...
seq.stop();
off();
seq.reset();
```
## API Reference
- `new Sequencer({ transport, stepDuration? })`.
- `setTempo(bpm)` / `getTempo()`.
- `setSteps(steps)` / `setStep(index, active)` / `getSteps()`.
- `start()` / `stop()` / `reset()`.
- `onStep(index, callback)` → unsubscribe function.
## Implementation Notes
The repeat callback is registered once and reused — `start()` is a no-op
when called twice. `setTempo` writes directly into the
`transport.bpm` signal from `@suaveplan/audio-transport`; that's
deliberate, so an automation lane sweeping bpm continues to drive the
sequencer without any extra wiring. The cursor wraps modulo the step
count so an empty step list still ticks without firing any callback.

View File

@@ -0,0 +1,8 @@
import z from "zod";
export const SequencerOptionsSchema = z
.object({
transport: z.unknown(),
stepDuration: z.unknown().optional(),
})
.passthrough();

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeTransport } from "../../tests/fake-transport.js";
import { Sequencer } from "./sequencer.js";
function setup() {
const transport = new FakeTransport();
const seq = new Sequencer({ transport });
return { transport, seq };
}
describe("Sequencer", () => {
it("active steps fire onStep callbacks at transport ticks", {
timeout: 5000,
}, () => {
const { transport, seq } = setup();
seq.setSteps([true, false, true, false]);
const fired: number[] = [];
seq.onStep(0, (t) => fired.push(t * 1000 + 0));
seq.onStep(2, (t) => fired.push(t * 1000 + 2));
seq.start();
transport.tickAll(0); // step 0
transport.tickAll(0); // step 1 (inactive)
transport.tickAll(0); // step 2
transport.tickAll(0); // step 3 (inactive)
expect(fired).toEqual([0, 2]);
});
it("sequencer schedules steps through the transport without setInterval", {
timeout: 5000,
}, () => {
const { transport, seq } = setup();
seq.setSteps([true]);
seq.start();
expect(transport.repeats.size).toBe(1);
});
it("start is a no-op when already running", { timeout: 5000 }, () => {
const { transport, seq } = setup();
seq.setSteps([true]);
seq.start();
seq.start();
expect(transport.repeats.size).toBe(1);
});
it("stop cancels and is a no-op when already stopped", {
timeout: 5000,
}, () => {
const { transport, seq } = setup();
seq.setSteps([true]);
seq.start();
seq.stop();
expect(transport.repeats.size).toBe(0);
seq.stop();
});
it("setStep grows the array as needed", { timeout: 5000 }, () => {
const { seq } = setup();
seq.setStep(5, true);
expect(seq.getSteps()).toEqual([
false,
false,
false,
false,
false,
true,
]);
});
it("setTempo updates bpm signal", { timeout: 5000 }, () => {
const { transport, seq } = setup();
seq.setTempo(140);
expect(seq.getTempo()).toBe(140);
expect(transport.bpm.value).toBe(140);
});
it("reset returns cursor to 0 without stopping", { timeout: 5000 }, () => {
const { transport, seq } = setup();
seq.setSteps([true, true]);
const fired: number[] = [];
seq.onStep(0, () => fired.push(0));
seq.onStep(1, () => fired.push(1));
seq.start();
transport.tickAll(0); // step 0
seq.reset();
transport.tickAll(0); // back to step 0
expect(fired).toEqual([0, 0]);
});
it("onStep listener can be removed", { timeout: 5000 }, () => {
const { transport, seq } = setup();
seq.setSteps([true]);
let count = 0;
const off = seq.onStep(0, () => count++);
seq.start();
transport.tickAll(0);
off();
transport.tickAll(0);
expect(count).toBe(1);
});
it("empty step list still ticks without firing", { timeout: 5000 }, () => {
const { transport, seq } = setup();
seq.start();
transport.tickAll(0);
expect(seq.getSteps()).toEqual([]);
});
it("active step with no listeners is a no-op", { timeout: 5000 }, () => {
const { transport, seq } = setup();
seq.setSteps([true]);
seq.start();
expect(() => transport.tickAll(0)).not.toThrow();
});
});

View File

@@ -0,0 +1,83 @@
import type { TransportLike, TransportTime } from "@suaveplan/audio-transport";
import { getControllerOtel } from "../base/otel.js";
import type { SequencerOptions, StepCallback } from "./sequencer.types.js";
/**
* Transport-synced step sequencer. Every step fires through
* `Transport.scheduleRepeat`; no `setInterval`/`setTimeout`/`Date.now`
* appears anywhere on the timing path.
*/
export class Sequencer {
private readonly transport: TransportLike;
private readonly stepDuration: TransportTime;
private steps: boolean[] = [];
private readonly stepListeners = new Map<number, Set<StepCallback>>();
private repeatId: number | undefined;
private cursor = 0;
private bpm = 120;
constructor(options: SequencerOptions) {
this.transport = options.transport;
this.stepDuration = options.stepDuration ?? "16n";
}
public setTempo(bpm: number): void {
this.bpm = bpm;
this.transport.bpm.setValueAtTime(bpm, 0);
}
public getTempo(): number {
return this.bpm;
}
public setSteps(steps: ReadonlyArray<boolean>): void {
this.steps = steps.slice();
}
public setStep(index: number, active: boolean): void {
while (this.steps.length <= index) this.steps.push(false);
this.steps[index] = active;
}
public getSteps(): ReadonlyArray<boolean> {
return this.steps.slice();
}
public start(): void {
if (this.repeatId !== undefined) return;
this.repeatId = this.transport.scheduleRepeat(
(time) => this.tick(time),
this.stepDuration
);
}
public stop(): void {
if (this.repeatId === undefined) return;
this.transport.cancel(this.repeatId);
this.repeatId = undefined;
}
public reset(): void {
this.cursor = 0;
}
public onStep(index: number, callback: StepCallback): () => void {
const bucket = this.stepListeners.get(index) ?? new Set<StepCallback>();
bucket.add(callback);
this.stepListeners.set(index, bucket);
return () => bucket.delete(callback);
}
private tick(time: number): void {
const otel = getControllerOtel();
otel.sequencerSteps.add(1);
const idx = this.cursor % Math.max(this.steps.length, 1);
if (this.steps[idx]) {
const bucket = this.stepListeners.get(idx);
if (bucket) {
for (const fn of bucket) fn(time);
}
}
this.cursor = (this.cursor + 1) % Math.max(this.steps.length, 1);
}
}

View File

@@ -0,0 +1,8 @@
import type { TransportLike, TransportTime } from "@suaveplan/audio-transport";
export interface SequencerOptions {
readonly transport: TransportLike;
readonly stepDuration?: TransportTime;
}
export type StepCallback = (transportTimeSec: number) => void;

View File

@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_DIR = join(__dirname, "..");
interface PackageJson {
exports: Record<string, unknown>;
sideEffects?: boolean;
}
describe("subpath exports", () => {
const pkg = JSON.parse(
readFileSync(join(PKG_DIR, "package.json"), "utf8")
) as PackageJson;
it("every feature module has a subpath export entry", {
timeout: 5000,
}, () => {
const required = [
".",
"./base/midi-controller-plugin",
"./sequencer/sequencer",
"./arpeggiator/arpeggiator",
"./learn/learn-mode",
"./binding",
"./plugins/kontrol-s88",
"./plugins/maschine",
"./plugins/traktor-s4",
"./error-codes",
];
for (const path of required) {
expect(pkg.exports[path], `missing export: ${path}`).toBeDefined();
}
});
it("sideEffects is false", { timeout: 5000 }, () => {
expect(pkg.sideEffects).toBe(false);
});
});

View File

@@ -0,0 +1,132 @@
/**
* Fake MIDI fixture — supplies the package's spec-mandated injectable seam.
* Lives outside `src/` so it does not appear in coverage reports yet remains
* shared between the unit, integration, and e2e suites.
*/
import type {
MidiAccessAdapter,
MidiAccessLike,
MidiInputLike,
MidiOutputLike,
} from "../src/base/midi-controller-plugin.types.js";
export interface FakeMidiInput extends MidiInputLike {
emit(data: ReadonlyArray<number>, timeStamp?: number): void;
}
export interface FakeMidiOutput extends MidiOutputLike {
readonly sent: number[][];
disconnect(): void;
}
export interface FakeMidiAccess extends MidiAccessLike {
readonly mutableInputs: Map<string, FakeMidiInput>;
readonly mutableOutputs: Map<string, FakeMidiOutput>;
disconnectPort(port: FakeMidiInput | FakeMidiOutput): void;
reconnectPort(port: FakeMidiInput | FakeMidiOutput): void;
}
function createInput(id: string, name: string): FakeMidiInput {
const port: FakeMidiInput = {
id,
name,
state: "connected",
type: "input",
onmidimessage: null,
emit(data: ReadonlyArray<number>, timeStamp = 0) {
port.onmidimessage?.({
data: new Uint8Array(data),
timeStamp,
});
},
} as FakeMidiInput;
return port;
}
function createOutput(id: string, name: string): FakeMidiOutput {
const sent: number[][] = [];
let connectedState: "connected" | "disconnected" = "connected";
const port = {
id,
name,
get state(): "connected" | "disconnected" {
return connectedState;
},
type: "output" as const,
sent,
send(data: ReadonlyArray<number> | Uint8Array) {
sent.push(Array.isArray(data) ? data.slice() : Array.from(data));
},
disconnect() {
connectedState = "disconnected";
},
} satisfies FakeMidiOutput;
return port;
}
export function createFakeAccess(
options: { readonly inputName?: string; readonly outputName?: string } = {}
): FakeMidiAccess {
const inputName = options.inputName ?? "Demo Input";
const outputName = options.outputName ?? "Demo Output";
const input = createInput("in-1", inputName);
const output = createOutput("out-1", outputName);
const mutableInputs = new Map<string, FakeMidiInput>([[input.id, input]]);
const mutableOutputs = new Map<string, FakeMidiOutput>([
[output.id, output],
]);
const access: FakeMidiAccess = {
get inputs(): ReadonlyMap<string, MidiInputLike> {
return mutableInputs;
},
get outputs(): ReadonlyMap<string, MidiOutputLike> {
return mutableOutputs;
},
mutableInputs,
mutableOutputs,
onstatechange: null,
disconnectPort(port) {
if (port.type === "input") mutableInputs.delete(port.id);
else {
port.disconnect();
mutableOutputs.delete(port.id);
}
access.onstatechange?.({
port: { ...port, state: "disconnected" },
});
},
reconnectPort(port) {
if (port.type === "input") mutableInputs.set(port.id, port);
else mutableOutputs.set(port.id, port);
access.onstatechange?.({
port: { ...port, state: "connected" },
});
},
};
return access;
}
export function createFakeAdapter(
access: FakeMidiAccess,
options: { readonly reject?: Error } = {}
): MidiAccessAdapter {
return {
async requestAccess() {
if (options.reject) throw options.reject;
return access;
},
};
}
export function getFirstInput(access: FakeMidiAccess): FakeMidiInput {
const first = access.mutableInputs.values().next().value;
if (!first) throw new Error("no input");
return first;
}
export function getFirstOutput(access: FakeMidiAccess): FakeMidiOutput {
const first = access.mutableOutputs.values().next().value;
if (!first) throw new Error("no output");
return first;
}

View File

@@ -0,0 +1,131 @@
/**
* Real `TransportLike` implementation backed by an in-memory event log.
* Used in tests so step/arpeggio callbacks can be driven deterministically
* without spinning a real wall-clock scheduler. This is a real object that
* satisfies the `TransportLike` contract — not a runner mock.
*/
import type {
BarsBeatsSixteenths,
SignalLike,
TransportCallback,
TransportLike,
TransportTime,
} from "@suaveplan/audio-transport";
interface ScheduledRepeat {
readonly id: number;
readonly callback: TransportCallback;
readonly interval: TransportTime;
}
interface ScheduledOnce {
readonly id: number;
readonly callback: TransportCallback;
readonly time: number;
}
const NOOP_BBS: BarsBeatsSixteenths = "0:0:0";
function makeFakeSignal(): SignalLike {
let v = 120;
const points: { time: number; value: number; type: "set" }[] = [];
const sig: SignalLike = {
get points() {
return points.slice();
},
get initialValue() {
return 120;
},
get value() {
return v;
},
setValueAtTime(value: number, time: number) {
v = value;
points.push({ time, value, type: "set" });
},
linearRampToValueAtTime(value: number) {
v = value;
},
exponentialRampToValueAtTime(value: number) {
v = value;
},
getValueAtTime() {
return v;
},
cancelScheduledValues() {
points.length = 0;
},
};
return sig;
}
export class FakeTransport implements TransportLike {
public bpm: SignalLike = makeFakeSignal();
public readonly ppq = 192;
public readonly timeSignature = 4;
public swing = 0;
public swingSubdivision: TransportTime = "8n";
public loop = false;
public loopStart: TransportTime = 0;
public loopEnd: TransportTime = 0;
public state: "stopped" | "playing" | "paused" = "stopped";
public position: BarsBeatsSixteenths = NOOP_BBS;
public ticks = 0;
public seconds = 0;
public readonly repeats = new Map<number, ScheduledRepeat>();
public readonly onces = new Map<number, ScheduledOnce>();
private nextId = 1;
start(): void {
this.state = "playing";
}
stop(): void {
this.state = "stopped";
}
pause(): void {
this.state = "paused";
}
schedule(callback: TransportCallback, time: TransportTime): number {
return this.scheduleOnce(callback, time);
}
scheduleOnce(callback: TransportCallback, time: TransportTime): number {
const id = this.nextId++;
const num = typeof time === "number" ? time : 0;
this.onces.set(id, { id, callback, time: num });
return id;
}
scheduleRepeat(
callback: TransportCallback,
interval: TransportTime
): number {
const id = this.nextId++;
this.repeats.set(id, { id, callback, interval });
return id;
}
cancel(eventId: number): void {
this.repeats.delete(eventId);
this.onces.delete(eventId);
}
cancelAfter(_time: TransportTime): void {
// Not used by audio-controllers tests.
}
toSeconds(time: TransportTime): number {
return typeof time === "number" ? time / this.ppq : 0;
}
toTicks(time: TransportTime): number {
return typeof time === "number" ? time * this.ppq : 0;
}
/** Test helper: drive every active repeat once. */
tickAll(time = 0): void {
for (const r of this.repeats.values()) r.callback(time);
}
/** Test helper: fire every scheduled `scheduleOnce`. */
flushOnces(): void {
for (const o of this.onces.values()) o.callback(o.time);
this.onces.clear();
}
}

View File

@@ -0,0 +1,56 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_DIR = join(__dirname, "..");
const DIST_DIR = join(PKG_DIR, "dist");
function walk(dir: string, out: string[] = []): string[] {
if (!existsSync(dir)) return out;
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) walk(p, out);
else if (/\.(js|mjs|cjs)$/.test(p)) out.push(p);
}
return out;
}
describe("purity", () => {
it("dist references no Node globals", { timeout: 30_000 }, () => {
if (!existsSync(DIST_DIR)) {
// dist will exist after build; skip purity if not yet built so the
// test suite can still run on a fresh clone.
expect(true).toBe(true);
return;
}
const offenders: string[] = [];
for (const file of walk(DIST_DIR)) {
const src = readFileSync(file, "utf8");
if (/\bnode:[a-z]+\b/.test(src)) offenders.push(`${file}: node:*`);
if (/\bprocess\.(env|cwd|platform)\b/.test(src))
offenders.push(`${file}: process`);
if (/\bBuffer\.from\b/.test(src)) offenders.push(`${file}: Buffer`);
}
expect(offenders).toEqual([]);
});
it("importing the package mutates no global state", {
timeout: 5000,
}, async () => {
const filter = (keys: string[]): string[] =>
keys
.filter(
(k) =>
!k.startsWith("__zod") &&
!k.startsWith("__vitest") &&
!k.startsWith("__VITEST")
)
.sort();
const beforeKeys = filter(Object.keys(globalThis));
const mod = await import("../src/index.js");
expect(mod.MidiControllerPlugin).toBeDefined();
const afterKeys = filter(Object.keys(globalThis));
expect(afterKeys).toEqual(beforeKeys);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"tests/**",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,44 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-controllers",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"base/index": "src/base/index.ts",
"sequencer/index": "src/sequencer/index.ts",
"arpeggiator/index": "src/arpeggiator/index.ts",
"learn/index": "src/learn/index.ts",
"binding/index": "src/binding/index.ts",
"plugins/kontrol-s88/index": "src/plugins/kontrol-s88/index.ts",
"plugins/maschine/index": "src/plugins/maschine/index.ts",
"plugins/traktor-s4/index": "src/plugins/traktor-s4/index.ts",
"error-codes/index": "src/error-codes/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [/^@suaveplan\//, /^@opentelemetry\//, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,6 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 30_000,
environment: "happy-dom",
});