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 = ( 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(schema: z.ZodType, 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>>(); 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 { 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 { 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 { 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): void { for (const m of mappings) { validateOptions(CCMappingSchema, m); } this.mappings.push(...mappings); } /** Returns the currently registered mappings (read-only snapshot). */ public getMappings(): ReadonlyArray { return this.mappings.slice(); } /** Flush LED updates to the device's `MIDIOutput` in a single write batch. */ public flushLEDs(updates: ReadonlyArray): 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( event: K, listener: Listener ): () => void { const bucket = this.listeners.get(event) ?? new Set>(); bucket.add(listener as Listener); this.listeners.set(event, bucket); return () => this.off(event, listener); } public off( event: K, listener: Listener ): void { this.listeners.get(event)?.delete(listener as Listener); } protected emit( event: K, payload: ControllerEventMap[K] ): void { const bucket = this.listeners.get(event); if (!bucket) return; for (const fn of bucket) (fn as Listener)(payload); } /** * Send raw MIDI bytes through the bound `MIDIOutput`. Throws * `AUDIO_CONTROLLERS.NOT_CONNECTED` if no port is bound. */ protected sendOutput(data: ReadonlyArray): 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

( ports: IterableIterator

, patterns: ReadonlyArray ): 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]; }