Files
suaveplan/packages/media/audio/audio-controllers/src/base/midi-controller-plugin.ts
2026-05-27 23:47:53 +01:00

375 lines
12 KiB
TypeScript

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];
}