Initial Commit
This commit is contained in:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user