Initial Commit
This commit is contained in:
75
packages/media/audio/audio-controllers/README.md
Normal file
75
packages/media/audio/audio-controllers/README.md
Normal 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.
|
||||
|
||||
106
packages/media/audio/audio-controllers/package.json
Normal file
106
packages/media/audio/audio-controllers/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
29
packages/media/audio/audio-controllers/src/base/index.ts
Normal file
29
packages/media/audio/audio-controllers/src/base/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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";
|
||||
65
packages/media/audio/audio-controllers/src/base/otel.md
Normal file
65
packages/media/audio/audio-controllers/src/base/otel.md
Normal 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.
|
||||
19
packages/media/audio/audio-controllers/src/base/otel.test.ts
Normal file
19
packages/media/audio/audio-controllers/src/base/otel.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
46
packages/media/audio/audio-controllers/src/base/otel.ts
Normal file
46
packages/media/audio/audio-controllers/src/base/otel.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
bindParameter,
|
||||
bindPluginPreset,
|
||||
type MidiSource,
|
||||
type UnsubscribeFn,
|
||||
} from "./bind-parameter.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
61
packages/media/audio/audio-controllers/src/index.ts
Normal file
61
packages/media/audio/audio-controllers/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { captureNextLearn, type LearnResult } from "./learn-mode.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type ArpMode,
|
||||
KontrolS88Plugin,
|
||||
type LEDColor,
|
||||
type ScaleMode,
|
||||
} from "./kontrol-s88.js";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type MaschineOptions,
|
||||
MaschinePlugin,
|
||||
type SampleLoader,
|
||||
} from "./maschine.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type DeckTrack, TraktorS4Plugin } from "./traktor-s4.js";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { Sequencer } from "./sequencer.js";
|
||||
export { SequencerOptionsSchema } from "./sequencer.schemas.js";
|
||||
export type {
|
||||
SequencerOptions,
|
||||
StepCallback,
|
||||
} from "./sequencer.types.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
import z from "zod";
|
||||
|
||||
export const SequencerOptionsSchema = z
|
||||
.object({
|
||||
transport: z.unknown(),
|
||||
stepDuration: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
40
packages/media/audio/audio-controllers/tests/exports.test.ts
Normal file
40
packages/media/audio/audio-controllers/tests/exports.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
132
packages/media/audio/audio-controllers/tests/fake-midi.ts
Normal file
132
packages/media/audio/audio-controllers/tests/fake-midi.ts
Normal 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;
|
||||
}
|
||||
131
packages/media/audio/audio-controllers/tests/fake-transport.ts
Normal file
131
packages/media/audio/audio-controllers/tests/fake-transport.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
56
packages/media/audio/audio-controllers/tests/purity.test.ts
Normal file
56
packages/media/audio/audio-controllers/tests/purity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
packages/media/audio/audio-controllers/tsconfig.json
Normal file
12
packages/media/audio/audio-controllers/tsconfig.json
Normal 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"]
|
||||
}
|
||||
18
packages/media/audio/audio-controllers/tsconfig.lib.json
Normal file
18
packages/media/audio/audio-controllers/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
44
packages/media/audio/audio-controllers/vite.config.ts
Normal file
44
packages/media/audio/audio-controllers/vite.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
6
packages/media/audio/audio-controllers/vitest.config.ts
Normal file
6
packages/media/audio/audio-controllers/vitest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
testTimeout: 30_000,
|
||||
environment: "happy-dom",
|
||||
});
|
||||
Reference in New Issue
Block a user