375 lines
12 KiB
TypeScript
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];
|
|
}
|