import { clamp, normalize14BitValue, normalizeValue } from "../utils/midi.js"
import { EventEmitter } from "./EventEmitter.js"
import { MIDIValidationError } from "./errors.js"
import { CONNECTION_EVENTS, MIDIConnection } from "./MIDIConnection.js"
/**
* Complete patch data structure for saving/loading controller state
* @typedef {Object} PatchData
* @memberof MIDIController
* @property {string} name - Patch/preset name
* @property {string|null} device - Output device name (if saved with device)
* @property {string} timestamp - ISO timestamp when patch was created
* @property {string} version - Patch format version (e.g., "1.0")
* @property {Object.<number, ChannelData>} channels - Channel data indexed by channel number (1-16)
* @property {Object.<string, SettingData>} settings - Control settings indexed by control key
*
* @example
* const patch = {
* name: "My Synth Patch",
* device: "My MIDI Keyboard",
* timestamp: "2024-01-15T10:30:00.000Z",
* version: "1.0",
* channels: {
* 1: {
* ccs: { 7: 100, 74: 64 },
* program: 5,
* pitchBend: 8192
* }
* },
* settings: {
* cc7: { min: 0, max: 127, invert: false, is14Bit: false, label: "Volume" }
* }
* };
*/
/**
* Channel-specific MIDI data
* @typedef {Object} ChannelData
* @memberof MIDIController
* @property {Object.<number, number>} ccs - Control Change values indexed by CC number (0-127)
* @property {Object.<number, number>} notes - Note on/off states indexed by note number (0-127)
* @property {number} [program] - Program change value for the channel (0-127)
* @property {number} [pitchBend] - Pitch bend value for the channel (0-16383, center=8192)
* @property {number} [monoPressure] - Channel pressure/aftertouch value (0-127)
* @property {Object.<number, number>} [polyPressure] - Polyphonic pressure values indexed by note number
*/
/**
* Control binding settings
* @typedef {Object} SettingData
* @memberof MIDIController
* @property {number} min - Minimum input value
* @property {number} max - Maximum input value
* @property {boolean} invert - Whether to invert the value mapping
* @property {boolean} is14Bit - Whether this is a 14-bit CC control
* @property {string|null} label - Optional display label
* @property {string|null} elementId - Associated DOM element ID
* @property {Function|null} onInput - Optional callback for value updates
*/
/**
* Controller event constants. Events are organized by category and follow a consistent naming pattern:
* - Core events (READY, ERROR, DESTROYED)
* - Device events (DEV_*)
* - Channel events (CH_*)
* - System events (SYS_*)
* - Patch events (PATCH_*)
*
* @namespace CONTROLLER_EVENTS
* @property {string} READY="ready" - Emitted when controller is initialized and ready
* @property {string} ERROR="error" - Emitted on errors
* @property {string} DESTROYED="destroyed" - Emitted when controller is destroyed
*
* @property {string} DEV_OUT_CONNECTED="dev-out-connected" - Emitted when output device is connected
* @property {string} DEV_OUT_DISCONNECTED="dev-out-disconnected" - Emitted when output device is disconnected
* @property {string} DEV_IN_CONNECTED="dev-in-connected" - Emitted when input device is connected
* @property {string} DEV_IN_DISCONNECTED="dev-in-disconnected" - Emitted when input device is disconnected
*
* @property {string} CH_CC_SEND="ch-cc-send" - Emitted when sending control change
* @property {string} CH_CC_RECV="ch-cc-recv" - Emitted when receiving control change
* @property {string} CH_NOTE_ON_SEND="ch-note-on-send" - Emitted when sending note on
* @property {string} CH_NOTE_ON_RECV="ch-note-on-recv" - Emitted when receiving note on
* @property {string} CH_NOTE_OFF_SEND="ch-note-off-send" - Emitted when sending note off
* @property {string} CH_NOTE_OFF_RECV="ch-note-off-recv" - Emitted when receiving note off
* @property {string} CH_PC_SEND="ch-pc-send" - Emitted when sending program change
* @property {string} CH_PC_RECV="ch-pc-recv" - Emitted when receiving program change
* @property {string} CH_PITCH_BEND_SEND="ch-pitch-bend-send" - Emitted when sending pitch bend
* @property {string} CH_PITCH_BEND_RECV="ch-pitch-bend-recv" - Emitted when receiving pitch bend
* @property {string} CH_MONO_PRESS_SEND="ch-mono-press-send" - Emitted when sending channel pressure
* @property {string} CH_MONO_PRESS_RECV="ch-mono-press-recv" - Emitted when receiving channel pressure
* @property {string} CH_POLY_PRESS_SEND="ch-poly-press-send" - Emitted when sending polyphonic pressure
* @property {string} CH_POLY_PRESS_RECV="ch-poly-press-recv" - Emitted when receiving polyphonic pressure
* @property {string} CH_ALL_SOUNDS_OFF_SEND="ch-all-sounds-off-send" - Emitted when sending all sounds off
* @property {string} CH_RESET_CONTROLLERS_SEND="ch-reset-controllers-send" - Emitted when sending reset all controllers
* @property {string} CH_LOCAL_CONTROL_SEND="ch-local-control-send" - Emitted when sending local control
* @property {string} CH_ALL_NOTES_OFF_SEND="ch-all-notes-off-send" - Emitted when sending all notes off
* @property {string} CH_OMNI_OFF_SEND="ch-omni-off-send" - Emitted when sending omni mode off
* @property {string} CH_OMNI_ON_SEND="ch-omni-on-send" - Emitted when sending omni mode on
* @property {string} CH_MONO_ON_SEND="ch-mono-on-send" - Emitted when sending mono mode on
* @property {string} CH_POLY_ON_SEND="ch-poly-on-send" - Emitted when sending poly mode on
*
* @property {string} SYS_EX_SEND="sys-ex-send" - Emitted when sending SysEx
* @property {string} SYS_EX_RECV="sys-ex-recv" - Emitted when receiving SysEx
* @property {string} SYS_CLOCK_RECV="sys-clock-recv" - Emitted when receiving MIDI clock
* @property {string} SYS_START_RECV="sys-start-recv" - Emitted when receiving start command
* @property {string} SYS_CONTINUE_RECV="sys-continue-recv" - Emitted when receiving continue command
* @property {string} SYS_STOP_RECV="sys-stop-recv" - Emitted when receiving stop command
* @property {string} SYS_MTC_RECV="sys-mtc-recv" - Emitted when receiving MTC
* @property {string} SYS_SONG_POS_RECV="sys-song-pos-recv" - Emitted when receiving song position
* @property {string} SYS_SONG_SEL_RECV="sys-song-sel-recv" - Emitted when receiving song selection
* @property {string} SYS_TUNE_REQ_RECV="sys-tune-req-recv" - Emitted when receiving tune request
* @property {string} SYS_ACT_SENSE_RECV="sys-act-sense-recv" - Emitted when receiving active sensing
* @property {string} SYS_RESET_RECV="sys-reset-recv" - Emitted when receiving system reset
*
* @property {string} MIDI_RAW="midi-raw" - Emitted for unhandled raw MIDI messages
*
* @property {string} PATCH_SAVED="patch-saved" - Emitted when a patch is saved
* @property {string} PATCH_LOADED="patch-loaded" - Emitted when a patch is loaded
* @property {string} PATCH_DELETED="patch-deleted" - Emitted when a patch is deleted
*
* @example
* // Listen for controller ready
* midi.on(CONTROLLER_EVENTS.READY, (controller) => {
* console.log("MIDI ready!");
* });
*
* @example
* // Listen for CC messages (both send and receive)
* midi.on(CONTROLLER_EVENTS.CH_CC_SEND, ({ cc, value, channel }) => {
* console.log(`Sent CC ${cc}: ${value} on channel ${channel}`);
* });
*
* midi.on(CONTROLLER_EVENTS.CH_CC_RECV, ({ cc, value, channel }) => {
* console.log(`Received CC ${cc}: ${value} on channel ${channel}`);
* });
*
* @example
* // Listen for device connections
* midi.on(CONTROLLER_EVENTS.DEV_OUT_CONNECTED, (device) => {
* console.log(`Output connected: ${device.name}`);
* });
*
* @example
* // Using the short alias (CTRL)
* midi.on(CTRL.READY, (controller) => {
* console.log("MIDI ready!");
* });
*/
export const CONTROLLER_EVENTS = {
// Core controller events
READY: "ready",
ERROR: "error",
DESTROYED: "destroyed",
// Device events (midi.device.*)
DEV_OUT_CONNECTED: "dev-out-connected",
DEV_OUT_DISCONNECTED: "dev-out-disconnected",
DEV_IN_CONNECTED: "dev-in-connected",
DEV_IN_DISCONNECTED: "dev-in-disconnected",
// Channel Voice Messages (midi.channel.*)
CH_CC_SEND: "ch-cc-send",
CH_CC_RECV: "ch-cc-recv",
CH_NOTE_ON_SEND: "ch-note-on-send",
CH_NOTE_ON_RECV: "ch-note-on-recv",
CH_NOTE_OFF_SEND: "ch-note-off-send",
CH_NOTE_OFF_RECV: "ch-note-off-recv",
CH_PC_SEND: "ch-pc-send",
CH_PC_RECV: "ch-pc-recv",
CH_PITCH_BEND_SEND: "ch-pitch-bend-send",
CH_PITCH_BEND_RECV: "ch-pitch-bend-recv",
CH_MONO_PRESS_SEND: "ch-mono-press-send",
CH_MONO_PRESS_RECV: "ch-mono-press-recv",
CH_POLY_PRESS_SEND: "ch-poly-press-send",
CH_POLY_PRESS_RECV: "ch-poly-press-recv",
CH_ALL_SOUNDS_OFF_SEND: "ch-all-sounds-off-send",
CH_RESET_CONTROLLERS_SEND: "ch-reset-controllers-send",
CH_LOCAL_CONTROL_SEND: "ch-local-control-send",
CH_ALL_NOTES_OFF_SEND: "ch-all-notes-off-send",
CH_OMNI_OFF_SEND: "ch-omni-off-send",
CH_OMNI_ON_SEND: "ch-omni-on-send",
CH_MONO_ON_SEND: "ch-mono-on-send",
CH_POLY_ON_SEND: "ch-poly-on-send",
// System Messages (midi.system.*)
SYS_EX_SEND: "sys-ex-send",
SYS_EX_RECV: "sys-ex-recv",
SYS_CLOCK_RECV: "sys-clock-recv",
SYS_START_RECV: "sys-start-recv",
SYS_CONTINUE_RECV: "sys-continue-recv",
SYS_STOP_RECV: "sys-stop-recv",
SYS_MTC_RECV: "sys-mtc-recv",
SYS_SONG_POS_RECV: "sys-song-pos-recv",
SYS_SONG_SEL_RECV: "sys-song-sel-recv",
SYS_TUNE_REQ_RECV: "sys-tune-req-recv",
SYS_ACT_SENSE_RECV: "sys-act-sense-recv",
SYS_RESET_RECV: "sys-reset-recv",
// Raw MIDI messages
MIDI_RAW: "midi-raw",
// Patch events (midi.patch.*)
PATCH_SAVED: "patch-saved",
PATCH_LOADED: "patch-loaded",
PATCH_DELETED: "patch-deleted",
}
/**
* Main controller for browser-based MIDI operations. Provides APIs for:
* - Device management (connect/disconnect, enumerate devices)
* - Control binding (DOM elements <-> MIDI CC)
* - MIDI messaging (send/receive CC, Note, SysEx)
* - Patch management (save/load presets)
* - Event handling (MIDI messages, connection changes, errors)
* @extends EventEmitter
*
* @example
* // Basic initialization
* const midi = new MIDIController({ inputChannel: 1, outputChannel: 1 });
* await midi.init();
*
* @example
* // Bind UI controls to MIDI CCs
* const volumeSlider = document.getElementById("volume");
* midi.bind(volumeSlider, { cc: 7 }); // Bind CC 7 (volume)
*
* @example
* // Send MIDI messages
* midi.channel.sendCC(74, 64); // Send CC 74 (filter cutoff) value 64
* midi.channel.sendNoteOn(60, 100); // Play middle C with velocity 100
* midi.channel.sendNoteOff(60); // Stop middle C
*
* @example
* // Listen for MIDI events
* midi.on(CONTROLLER_EVENTS.CH_CC_RECV, ({ cc, value, channel }) => {
* console.log(`CC ${cc} received: ${value}`);
* });
*
* @example
* // Save and load patches
* const patch = midi.patch.get("My Patch");
* midi.patch.save("My Patch");
* midi.patch.load("My Patch");
*/
export class MIDIController extends EventEmitter {
/**
* @param {Object} options
* @param {number} [options.inputChannel=1] - Input MIDI channel (1-16)
* @param {number} [options.outputChannel=1] - Output MIDI channel (1-16)
* @param {string|number} [options.output] - MIDI output device
* @param {string|number} [options.input] - MIDI input device
* @param {boolean} [options.sysex=false] - Request SysEx access
* @param {boolean} [options.autoConnect=true] - Auto-connect to first available output
* @param {Function} [options.onReady] - Callback when MIDI is ready
* @param {Function} [options.onError] - Error handler
*/
constructor(options = {}) {
super()
this.options = {
inputChannel: 1,
outputChannel: 1,
autoConnect: true,
sysex: false,
...options,
}
this.connection = null
this.bindings = new Map()
this.state = {
controlChange: new Map(), // ${channel}:${cc} -> value
programChange: new Map(), // ${channel} -> program
pitchBend: new Map(), // ${channel} -> value (0-16383)
monoPressure: new Map(), // ${channel} -> pressure
polyPressure: new Map(), // ${channel}:${note} -> pressure
}
this.initialized = false
// Initialize namespaces after all methods are defined
this._initNamespaces()
}
/**
* Initialize MIDI access. Requests browser permission and connects to devices.
* Emits "ready" event on success, "error" event on failure.
*
* @returns {Promise<void>}
* @throws {MIDIAccessError} If MIDI access is denied or browser doesn't support Web MIDI
*
* @emits CONTROLLER_EVENTS.READY - When initialization succeeds
* @emits CONTROLLER_EVENTS.ERROR - When initialization fails
*
* @example
* // Basic initialization
* const midi = new MIDIController();
* await midi.init();
*
* @example
* // Initialization with custom options
* const midi = new MIDIController({
* channel: 2,
* sysex: true,
* autoConnect: false
* });
* await midi.init();
*
* @example
* // With event listeners
* const midi = new MIDIController();
* midi.on(CONTROLLER_EVENTS.READY, (controller) => {
* console.log("MIDI ready!");
* });
* midi.on(CONTROLLER_EVENTS.ERROR, (error) => {
* console.error("MIDI error:", error);
* });
* await midi.init();
*/
async init() {
if (this.initialized) {
console.warn("MIDI Controller already initialized")
return
}
try {
this.connection = new MIDIConnection({
sysex: this.options.sysex,
})
await this.connection.requestAccess()
this.connection.on(CONNECTION_EVENTS.DEVICE_CHANGE, async ({ state, type, device }) => {
try {
if (state === "connected") {
if (type === "output") {
this.emit(CONTROLLER_EVENTS.DEV_OUT_CONNECTED, device)
} else if (type === "input") {
this.emit(CONTROLLER_EVENTS.DEV_IN_CONNECTED, device)
}
} else if (state === "disconnected") {
if (type === "output" && device) {
const currentOutput = this.connection.getCurrentOutput()
if (currentOutput && currentOutput.id === device.id) {
await this._disconnectOutput()
} else {
this.emit(CONTROLLER_EVENTS.DEV_OUT_DISCONNECTED, device)
}
}
if (type === "input" && device) {
const currentInput = this.connection.getCurrentInput()
if (currentInput && currentInput.id === device.id) {
await this._disconnectInput()
} else {
this.emit(CONTROLLER_EVENTS.DEV_IN_DISCONNECTED, device)
}
}
}
} catch (error) {
console.error("Error in device change handler:", error)
this.emit(CONTROLLER_EVENTS.ERROR, error)
}
})
if (this.options.autoConnect) {
await this.connection.connect(this.options.output)
}
if (this.options.input !== undefined) {
await this._connectInput(this.options.input)
}
this.initialized = true
this.emit(CONTROLLER_EVENTS.READY, this)
this.options.onReady?.(this)
} catch (err) {
this.emit(CONTROLLER_EVENTS.ERROR, err)
this.options.onError?.(err)
throw err
}
}
/**
* Initialize namespace bindings
* This must be called after all private methods are defined
* @private
*/
_initNamespaces() {
/**
* Device-related MIDI operations
* @namespace
*/
this.device = {
/**
* Connect to a MIDI output device
* @param {string|number} device - Device name, ID, or index
* @returns {Promise<void>}
* @example
* // Connect by device name
* await midi.device.connect("Korg minilogue xd");
*
* @example
* // Connect by device index
* await midi.device.connect(0);
*/
connect: this._connect.bind(this),
/**
* Disconnect from current MIDI device
* @returns {Promise<void>}
*/
disconnect: this._disconnect.bind(this),
/**
* Connect to a MIDI input device
* @param {string|number} device - Device name, ID, or index
* @returns {Promise<void>}
* @example
* // Connect to device by name
* await midi.device.connectInput("Korg microKEY");
*/
connectInput: this._connectInput.bind(this),
/**
* Disconnect from the MIDI input device
* @returns {Promise<void>}
* @example
* await midi.device.disconnectInput();
*/
disconnectInput: this._disconnectInput.bind(this),
/**
* Switch to a different output device
* @param {string|number} output - New device name, ID, or index
* @returns {Promise<void>}
*/
connectOutput: this._connectOutput.bind(this),
/**
* Disconnect from the MIDI output device
* @returns {Promise<void>}
* @example
* await midi.device.disconnectOutput();
*/
disconnectOutput: this._disconnectOutput.bind(this),
/**
* Get current output device info
* @returns {Object|null} Device info with id, name, manufacturer
* @example
* const device = midi.device.getCurrentOutput();
* if (device) {
* console.log(`Connected to ${device.name}`);
* }
*/
getCurrentOutput: this._getCurrentOutput.bind(this),
/**
* Get current input device info
* @returns {Object|null} Device info with id, name, manufacturer
*/
getCurrentInput: this._getCurrentInput.bind(this),
/**
* List all available MIDI output devices
* @returns {Array<{id: string, name: string, manufacturer: string}>}
* @example
* const outputs = midi.device.getOutputs();
* outputs.forEach(device => {
* console.log(`${device.name} by ${device.manufacturer}`);
* });
*/
getOutputs: this._getOutputs.bind(this),
/**
* List all available MIDI input devices
* @returns {Array<{id: string, name: string, manufacturer: string}>}
*/
getInputs: this._getInputs.bind(this),
}
/**
* Channel-specific MIDI operations
* @namespace
*/
this.channel = {
/**
* Send a note on message
* @param {number} note - Note number (0-127)
* @param {number} [velocity=64] - Note velocity (0-127)
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {void}
* @example
* // Play middle C (60) with velocity 100
* midi.channel.sendNoteOn(60, 100);
*
* @example
* // Play note 64 (E4) on channel 5
* midi.channel.sendNoteOn(64, 80, 5);
*/
sendNoteOn: this._sendNoteOn.bind(this),
/**
* Send a note off message
* @param {number} note - Note number (0-127)
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @param {number} [velocity=0] - Release velocity (0-127)
* @returns {void}
* @example
* // Stop middle C
* midi.channel.sendNoteOff(60);
*/
sendNoteOff: this._sendNoteOff.bind(this),
/**
* Send a control change message
* @param {number} cc - CC number (0-127, e.g., 7 for volume, 74 for filter cutoff)
* @param {number} value - CC value (0-127)
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {void}
* @example
* // Set volume (CC 7) to 100 on default channel
* midi.channel.sendCC(7, 100);
*
* @example
* // Set filter cutoff (CC 74) to 64 on channel 2
* midi.channel.sendCC(74, 64, 2);
*/
sendCC: this._sendCC.bind(this),
/**
* Get current value of a CC
* @param {number} cc - CC number (0-127)
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {number|undefined} Current CC value or undefined if not set
* @example
* const volume = midi.channel.getCC(7);
* if (volume !== undefined) {
* console.log(`Current volume: ${volume}`);
* }
*/
getCC: this._getCC.bind(this),
/**
* Send a program change message
* @param {number} program - Program number (0-127)
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {void}
* @example
* // Change to program 5 on default channel
* midi.channel.sendPC(5);
*/
sendPC: this._sendPC.bind(this),
/**
* Get current program for a channel
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {number|undefined} Current program or undefined if not set
*/
getPC: this._getPC.bind(this),
/**
* Send a pitch bend message
* @param {number} value - Pitch bend value (0-16383, center = 8192)
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {void}
* @example
* // Bend pitch up (value > 8192)
* midi.channel.sendPitchBend(10000);
*
* @example
* // Return to center (no bend)
* midi.channel.sendPitchBend(8192);
*/
sendPitchBend: this._sendPitchBend.bind(this),
/**
* Get current pitch bend value for a channel
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {number|undefined} Current pitch bend value or undefined if not set
*/
getPitchBend: this._getPitchBend.bind(this),
/**
* Send a channel pressure (aftertouch) message
* @param {number} pressure - Pressure value (0-127)
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {void}
* @example
* // Send channel pressure at max (127)
* midi.channel.sendMonoPressure(127);
*/
sendMonoPressure: this._sendMonoPressure.bind(this),
/**
* Get current channel pressure for a channel
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {number|undefined} Current pressure value or undefined if not set
*/
getMonoPressure: this._getMonoPressure.bind(this),
/**
* Send a polyphonic key pressure (polyphonic aftertouch) message
* @param {number} note - Note number (0-127)
* @param {number} pressure - Pressure value (0-127)
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {void}
* @example
* // Send poly pressure for middle C
* midi.channel.sendPolyPressure(60, 100);
*/
sendPolyPressure: this._sendPolyPressure.bind(this),
/**
* Get current polyphonic pressure for a note on a channel
* @param {number} note - Note number (0-127)
* @param {number} [channel] - MIDI channel (defaults to controller's default channel)
* @returns {number|undefined} Current pressure value or undefined if not set
*/
getPolyPressure: this._getPolyPressure.bind(this),
/**
* Send All Sounds Off message (CC 120, value 0)
* @param {number} [channel] - MIDI channel (1-16). If not provided, sends to all channels
* @returns {void}
* @example
* // Stop all sounds on channel 1
* midi.channel.allSoundsOff(1);
*
* @example
* // Send to all channels
* midi.channel.allSoundsOff();
*/
allSoundsOff: this._allSoundsOff.bind(this),
/**
* Send Reset All Controllers message (CC 121, value 0)
* @param {number} [channel] - MIDI channel (1-16). If not provided, sends to all channels
* @returns {void}
*/
resetControllers: this._resetControllers.bind(this),
/**
* Send Local Control On/Off message (CC 122)
* @param {boolean} enabled - true for on (value 127), false for off (value 0)
* @param {number} [channel] - MIDI channel (1-16). If not provided, sends to all channels
* @returns {void}
* @example
* // Turn off local control on channel 1
* midi.channel.localControl(false, 1);
*/
localControl: this._localControl.bind(this),
/**
* Send All Notes Off message (CC 123, value 0)
* @param {number} [channel] - MIDI channel (1-16). If not provided, sends to all channels
* @returns {void}
*/
allNotesOff: this._allNotesOff.bind(this),
/**
* Send Omni Mode Off message (CC 124, value 0)
* @param {number} [channel] - MIDI channel (1-16). If not provided, sends to all channels
* @returns {void}
*/
omniOff: this._omniOff.bind(this),
/**
* Send Omni Mode On message (CC 125, value 0)
* @param {number} [channel] - MIDI channel (1-16). If not provided, sends to all channels
* @returns {void}
*/
omniOn: this._omniOn.bind(this),
/**
* Send Mono Mode On message (CC 126)
* @param {number} [channels=1] - Number of channels for mono mode (0-16, 0=omni)
* @param {number} [channel] - MIDI channel (1-16). If not provided, sends to all channels
* @returns {void}
*/
monoOn: this._monoOn.bind(this),
/**
* Send Poly Mode On message (CC 127, value 0)
* @param {number} [channel] - MIDI channel (1-16). If not provided, sends to all channels
* @returns {void}
*/
polyOn: this._polyOn.bind(this),
}
/**
* System-level MIDI operations
* @namespace
*/
this.system = {
/**
* Send a SysEx message
* @param {Array<number>} data - SysEx data bytes (0-127)
* @param {boolean} [includeWrapper=false] - If true, data already includes F0/F7
* @returns {void}
* @throws {Error} If SysEx not enabled in controller options
* @example
* // Send data with wrapper
* midi.system.sendEx([0xF0, 0x42, 0x30, 0x00, 0x01, 0x2F, 0x12, 0xF7], true);
*
* @example
* // Send data without wrapper (wrapper added automatically)
* midi.system.sendEx([0x42, 0x30, 0x00, 0x7F]);
*/
sendEx: function (data, includeWrapper = false) {
return this._sendSysEx(data, includeWrapper)
}.bind(this),
/**
* Send a timing clock message (0xF8)
* Typically used for synchronizing tempo-dependent devices
* @returns {void}
* @example
* // Send clock pulse (usually 24 pulses per quarter note)
* midi.system.sendClock();
*/
sendClock: this._sendClock.bind(this),
/**
* Send a start message (0xFA)
* Indicates start of playback from beginning of song
* @returns {void}
*/
start: this._sendStart.bind(this),
/**
* Send a continue message (0xFB)
* Indicates resume playback from current position
* @returns {void}
*/
continue: this._sendContinue.bind(this),
/**
* Send a stop message (0xFC)
* Indicates stop playback
* @returns {void}
*/
stop: this._sendStop.bind(this),
/**
* Send an MTC quarter frame message
* @param {number} data - MTC quarter frame data (0-127)
* @returns {void}
* @example
* midi.system.sendMTC(0x00); // First quarter frame
*/
sendMTC: this._sendMTC.bind(this),
/**
* Send a song position pointer message
* @param {number} position - Song position in beats (0-16383)
* @returns {void}
* @example
* midi.system.sendSongPosition(0); // Beginning of song
*/
sendSongPosition: this._sendSongPosition.bind(this),
/**
* Send a song select message
* @param {number} song - Song/sequence number (0-127)
* @returns {void}
* @example
* midi.system.sendSongSelect(0); // Select first song
*/
sendSongSelect: this._sendSongSelect.bind(this),
/**
* Send a tune request message (0xF6)
* Requests analog synthesizers to tune themselves
* @returns {void}
*/
sendTuneRequest: this._sendTuneRequest.bind(this),
/**
* Send an active sensing message (0xFE)
* Used to indicate controller is still active
* @returns {void}
*/
sendActiveSensing: this._sendActiveSensing.bind(this),
/**
* Send a system reset message (0xFF)
* Resets all devices to power-up default state
* @returns {void}
*/
sendSystemReset: this._sendSystemReset.bind(this),
}
/**
* Patch management operations
* @namespace
*/
this.patch = {
/**
* Get current state as a patch object
* @param {string} [name="Unnamed Patch"] - Patch name
* @returns {PatchData} Patch data object
* @example
* const patch = midi.patch.get("My Patch");
* console.log(patch.channels);
*/
get: this._getPatch.bind(this),
/**
* Apply a patch to the controller
* @param {PatchData} patch - Patch object to apply
* @returns {Promise<void>}
* @throws {MIDIValidationError} If patch format is invalid
* @example
* const patch = { name: "My Patch", channels: { 1: { ccs: { 7: 100 } } } };
* await midi.patch.set(patch);
*/
set: this._setPatch.bind(this),
/**
* Save a patch to localStorage
* @param {string} name - Patch name
* @param {Object} [patch] - Optional patch object (will use get() if not provided)
* @returns {string} Storage key used
* @example
* midi.patch.save("My Favorite Settings");
*/
save: this._savePatch.bind(this),
/**
* Load a patch from localStorage
* @param {string} name - Patch name
* @returns {PatchData|null} Patch object or null if not found
* @example
* const patch = midi.patch.load("My Patch");
* if (patch) {
* await midi.patch.set(patch);
* }
*/
load: this._loadPatch.bind(this),
/**
* Delete a patch from localStorage
* @param {string} name - Patch name
* @returns {boolean} Success
* @example
* const success = midi.patch.delete("Old Patch");
*/
delete: this._deletePatch.bind(this),
/**
* List all saved patches
* @returns {Array<{name: string, patch: PatchData}>} Array of patch objects with name and data
* @example
* const patches = midi.patch.list();
* patches.forEach(p => {
* console.log(p.name);
* });
*/
list: this._listPatches.bind(this),
}
}
/**
* Bind a DOM element to a MIDI CC for bidirectional control. Automatically handles
* value normalization based on element type (slider, knob, etc.) and MIDI range (0-127).
* Sends initial MIDI value when binding if controller is initialized.
*
* @param {HTMLElement} element - DOM element to bind (e.g., input range, number input)
* @param {Object} config - Binding configuration
* @param {number} config.cc - CC number (0-127, e.g., 7 for volume, 74 for filter cutoff)
* @param {number} [config.min=0] - Minimum input value (auto-detected from element if available)
* @param {number} [config.max=127] - Maximum input value (auto-detected from element if available)
* @param {number} [config.channel] - MIDI channel (defaults to controller's default channel)
* @param {boolean} [config.invert=false] - Invert the value mapping
* @param {boolean} [config.is14Bit=false] - Use 14-bit CC (MSB + LSB, requires msb/lsb config)
* @param {number} [config.msb] - MSB CC number for 14-bit control
* @param {number} [config.lsb] - LSB CC number for 14-bit control
* @param {Function} [config.onInput] - Optional callback for value updates (receives normalized element value)
* @param {Object} [options={}] - Additional options
* @param {number} [options.debounce=0] - Debounce delay in ms for high-frequency updates
* @returns {Function} Unbind function - Call to remove binding and cleanup event listeners
*
* @example
* // Basic slider binding
* const volumeSlider = document.getElementById("volume");
* const unbindVolume = midi.bind(volumeSlider, { cc: 7 }); // CC 7 = Channel Volume
*
* @example
* // Binding with custom range and inversion
* const filterKnob = document.getElementById("filter");
* midi.bind(filterKnob, {
* cc: 74, // CC 74 = Filter Cutoff
* min: 0, // Knob minimum value
* max: 127, // Knob maximum value
* invert: true // Invert value (useful for cutoff - higher CC = brighter)
* });
*
* @example
* // 14-bit high-resolution control
* const fineTune = document.getElementById("fine-tune");
* midi.bind(fineTune, {
* is14Bit: true,
* msb: 0, // CC 0 = MSB
* lsb: 32 // CC 32 = LSB
* });
*
* @example
* // With debouncing for high-frequency updates
* const lfoRate = document.getElementById("lfo-rate");
* midi.bind(lfoRate, { cc: 76 }, { debounce: 50 }); // 50ms debounce
*
* @example
* // Using callback to update display
* const pitchSlider = document.getElementById("pitch");
* const pitchDisplay = document.getElementById("pitch-value");
* midi.bind(pitchSlider, {
* cc: 75,
* onInput: (value) => {
* pitchDisplay.textContent = Math.round(value);
* }
* });
*/
bind(element, config, options = {}) {
if (!element) {
console.warn("Cannot bind: element is null or undefined")
return () => {}
}
const binding = this._createBinding(element, config, options)
this.bindings.set(element, binding)
if (this.initialized && this.connection?.isConnected()) {
binding.handler({ target: element })
}
return () => this.unbind(element)
}
/**
* Create a binding between an element and MIDI CC
* @private
* @param {HTMLElement} element - DOM element to bind
* @param {Object} config - Binding configuration
* @param {Object} options - Additional binding options
* @returns {Object} Binding object with element, config, handler, and destroy function
*/
_createBinding(element, config, options = {}) {
const {
min = parseFloat(element.getAttribute("min")) || 0,
max = parseFloat(element.getAttribute("max")) || 127,
channel,
invert = false,
onInput = undefined,
} = config
const { debounce = 0 } = options
const resolvedConfig = {
...config,
min,
max,
invert,
onInput,
}
if (channel !== undefined) {
resolvedConfig.channel = channel
}
if (config.is14Bit) {
const { msb, lsb } = config
const handler = (event) => {
const value = parseFloat(event.target.value)
if (Number.isNaN(value)) return
// Normalize to 14-bit range (0-16383)
const { msb: msbValue, lsb: lsbValue } = normalize14BitValue(value, min, max, invert)
const channelToUse = channel || this.options.outputChannel
this._sendCC(msb, msbValue, channelToUse)
this._sendCC(lsb, lsbValue, channelToUse)
}
let timeoutId = null
const debouncedHandler =
debounce > 0
? (event) => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
handler(event)
timeoutId = null
}, debounce)
}
: handler
element.addEventListener("input", debouncedHandler)
element.addEventListener("change", debouncedHandler)
return {
element,
config: resolvedConfig,
handler,
destroy: () => {
if (timeoutId) clearTimeout(timeoutId)
element.removeEventListener("input", debouncedHandler)
element.removeEventListener("change", debouncedHandler)
},
}
}
const { cc } = config
const handler = (event) => {
const value = parseFloat(event.target.value)
if (Number.isNaN(value)) return
const midiValue = normalizeValue(value, min, max, invert)
const channelToUse = channel === undefined ? this.options.outputChannel : channel
this._sendCC(cc, midiValue, channelToUse)
}
let timeoutId = null
const debouncedHandler =
debounce > 0
? (event) => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
handler(event)
timeoutId = null
}, debounce)
}
: handler
element.addEventListener("input", debouncedHandler)
element.addEventListener("change", debouncedHandler)
return {
element,
config: resolvedConfig,
handler,
destroy: () => {
if (timeoutId) clearTimeout(timeoutId)
element.removeEventListener("input", debouncedHandler)
element.removeEventListener("change", debouncedHandler)
},
}
}
/**
* Unbind a control
* @param {HTMLElement} element
*/
unbind(element) {
const binding = this.bindings.get(element)
if (binding) {
binding.destroy()
this.bindings.delete(element)
}
}
/**
* Clean up resources
* @returns {Promise<void>}
*/
async destroy() {
for (const binding of this.bindings.values()) {
binding.destroy()
}
this.bindings.clear()
this.state.controlChange.clear()
this.state.programChange.clear()
this.state.pitchBend.clear()
this.state.monoPressure.clear()
this.state.polyPressure.clear()
await this._disconnect()
this.initialized = false
this.emit(CONTROLLER_EVENTS.DESTROYED)
this.removeAllListeners()
}
/**
* Connect to MIDI output device
* @private
* @param {string|number|undefined} device - Device identifier (name, ID, index) or undefined for auto-connect
* @returns {Promise<void>}
*/
async _connect(device) {
if (device) {
await this.connection.connect(device)
} else if (this.options.output !== undefined) {
await this.connection.connect(this.options.output)
} else if (this.options.autoConnect) {
await this.connection.connect()
}
}
/**
* Disconnect from current MIDI output device
* @private
* @returns {Promise<void>}
*/
async _disconnect() {
this.connection.disconnect()
}
/**
* Connect to MIDI input device
* @private
* @param {string|number} device - Input device identifier (name, ID, index)
* @returns {Promise<void>}
*/
async _connectInput(device) {
await this.connection.connectInput(device, (event) => {
this._handleMIDIMessage(event)
})
this.emit(CONTROLLER_EVENTS.DEV_IN_CONNECTED, this.connection.getCurrentInput())
}
/**
* Disconnect from MIDI input device
* @private
* @returns {Promise<void>}
*/
async _disconnectInput() {
const currentInput = this.connection.getCurrentInput()
this.connection.disconnectInput()
this.emit(CONTROLLER_EVENTS.DEV_IN_DISCONNECTED, currentInput)
}
/**
* Connect to MIDI output device
* @private
* @param {string|number} output - Output device identifier (name, ID, index)
* @returns {Promise<void>}
*/
async _connectOutput(output) {
await this.connection.connect(output)
this.emit(CONTROLLER_EVENTS.DEV_OUT_CONNECTED, this.connection.getCurrentOutput())
}
/**
* Disconnect from MIDI output device
* @private
* @returns {Promise<void>}
*/
async _disconnectOutput() {
const currentOutput = this.connection.getCurrentOutput()
this.connection.disconnectOutput()
this.emit(CONTROLLER_EVENTS.DEV_OUT_DISCONNECTED, currentOutput)
}
/**
* Get current output device information
* @private
* @returns {Object|null} Current output device info with id, name, manufacturer or null if not connected
*/
_getCurrentOutput() {
return this.connection?.getCurrentOutput() || null
}
/**
* Get current input device information
* @private
* @returns {Object|null} Current input device info with id, name, manufacturer or null if not connected
*/
_getCurrentInput() {
return this.connection?.getCurrentInput() || null
}
/**
* Get list of available MIDI output devices
* @private
* @returns {Array<Object>} Array of output device objects with id, name, manufacturer
*/
_getOutputs() {
return this.connection?.getOutputs() || []
}
/**
* Get list of available MIDI input devices
* @private
* @returns {Array<Object>} Array of input device objects with id, name, manufacturer
*/
_getInputs() {
return this.connection?.getInputs() || []
}
/**
* Send raw MIDI data
* @param {Array<number>} data - MIDI message bytes
*/
send(data) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
this.connection.send(data)
}
/**
* Send Note On message
* @private
* @param {number} note - Note number (0-127)
* @param {number} velocity - Note velocity (0-127, default 64)
* @param {number} channel - MIDI channel (1-16, default outputChannel)
* @returns {void}
*/
_sendNoteOn(note, velocity = 64, channel = this.options.outputChannel) {
if (!this.initialized) return
note = clamp(Math.round(note), 0, 127)
velocity = clamp(Math.round(velocity), 0, 127)
channel = clamp(Math.round(channel), 1, 16)
const status = 0x90 + (channel - 1)
this.connection.send([status, note, velocity])
this.emit(CONTROLLER_EVENTS.CH_NOTE_ON_SEND, { note, velocity, channel })
}
/**
* Send Note Off message
* @private
* @param {number} note - Note number (0-127)
* @param {number} channel - MIDI channel (1-16, default outputChannel)
* @param {number} velocity - Release velocity (0-127, default 0)
* @returns {void}
*/
_sendNoteOff(note, channel = this.options.outputChannel, velocity = 0) {
if (!this.initialized) return
note = clamp(Math.round(note), 0, 127)
velocity = clamp(Math.round(velocity), 0, 127)
channel = clamp(Math.round(channel), 1, 16)
// Use Note On with velocity 0 for better compatibility with some synths
const status = 0x90 + (channel - 1)
this.connection.send([status, note, velocity])
this.emit(CONTROLLER_EVENTS.CH_NOTE_OFF_SEND, { note, channel, velocity })
}
/**
* Send Control Change message
* @private
* @param {number} cc - CC number (0-127)
* @param {number} value - CC value (0-127)
* @param {number} channel - MIDI channel (1-16, default outputChannel)
* @returns {void}
*/
_sendCC(cc, value, channel = this.options.outputChannel) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
cc = clamp(Math.round(cc), 0, 127)
value = clamp(Math.round(value), 0, 127)
channel = clamp(Math.round(channel), 1, 16)
const status = 0xb0 + (channel - 1)
this.connection.send([status, cc, value])
const key = `${channel}:${cc}`
this.state.controlChange.set(key, value)
this.emit(CONTROLLER_EVENTS.CH_CC_SEND, { cc, value, channel })
}
/**
* Send Program Change message
* @private
* @param {number} program - Program number (0-127)
* @param {number} channel - MIDI channel (1-16, default outputChannel)
* @returns {void}
*/
_sendPC(program, channel = this.options.outputChannel) {
if (!this.initialized) return
program = clamp(Math.round(program), 0, 127)
channel = clamp(Math.round(channel), 1, 16)
const status = 0xc0 + (channel - 1)
this.connection.send([status, program])
this.state.programChange.set(channel.toString(), program)
this.emit(CONTROLLER_EVENTS.CH_PC_SEND, { program, channel })
}
/**
* Get current Program Change value for channel
* @private
* @param {number} channel - MIDI channel (1-16, default inputChannel)
* @returns {number|undefined} Program number or undefined if not set
*/
_getPC(channel = this.options.inputChannel) {
return this.state.programChange.get(channel.toString())
}
/**
* Get current Control Change value
* @private
* @param {number} cc - CC number (0-127)
* @param {number} channel - MIDI channel (1-16, default inputChannel)
* @returns {number|undefined} CC value or undefined if not set
*/
_getCC(cc, channel = this.options.inputChannel) {
const key = `${channel}:${cc}`
return this.state.controlChange.get(key)
}
/**
* Send Pitch Bend message on specified channel
* @private
* @param {number} value - Pitch bend value (0-16383, where 8192 is center)
* @param {number} [channel=this.options.outputChannel] - MIDI channel (1-16)
* @returns {void}
*/
_sendPitchBend(value, channel = this.options.outputChannel) {
if (!this.initialized) return
value = clamp(Math.round(value), 0, 16383)
channel = clamp(Math.round(channel), 1, 16)
const status = 0xe0 + (channel - 1)
const lsb = value & 0x7f // Least significant 7 bits
const msb = (value >> 7) & 0x7f // Most significant 7 bits
this.connection.send([status, lsb, msb])
this.state.pitchBend.set(channel.toString(), value)
this.emit(CONTROLLER_EVENTS.CH_PITCH_BEND_SEND, { value, channel })
}
/**
* Get current Pitch Bend value for a channel
* @private
* @param {number} channel - MIDI channel (1-16, default inputChannel)
* @returns {number|undefined} Pitch bend value (0-16383) or undefined if not set
*/
_getPitchBend(channel = this.options.inputChannel) {
return this.state.pitchBend.get(channel.toString())
}
/**
* Send Channel Pressure (Aftertouch) message
* @private
* @param {number} pressure - Pressure value (0-127)
* @param {number} channel - MIDI channel (1-16, default outputChannel)
* @returns {void}
*/
_sendMonoPressure(pressure, channel = this.options.outputChannel) {
if (!this.initialized) return
pressure = clamp(Math.round(pressure), 0, 127)
channel = clamp(Math.round(channel), 1, 16)
const status = 0xd0 + (channel - 1)
this.connection.send([status, pressure])
this.state.monoPressure.set(channel.toString(), pressure)
this.emit(CONTROLLER_EVENTS.CH_MONO_PRESS_SEND, { pressure, channel })
}
/**
* Get current Channel Pressure (Aftertouch) value for a channel
* @private
* @param {number} channel - MIDI channel (1-16, default inputChannel)
* @returns {number|undefined} Pressure value (0-127) or undefined if not set
*/
_getMonoPressure(channel = this.options.inputChannel) {
return this.state.monoPressure.get(channel.toString())
}
/**
* Send Polyphonic Key Pressure (Polyphonic Aftertouch) message
* @private
* @param {number} note - Note number (0-127)
* @param {number} pressure - Pressure value (0-127)
* @param {number} channel - MIDI channel (1-16, default outputChannel)
* @returns {void}
*/
_sendPolyPressure(note, pressure, channel = this.options.outputChannel) {
if (!this.initialized) return
note = clamp(Math.round(note), 0, 127)
pressure = clamp(Math.round(pressure), 0, 127)
channel = clamp(Math.round(channel), 1, 16)
const status = 0xa0 + (channel - 1)
this.connection.send([status, note, pressure])
const key = `${channel}:${note}`
this.state.polyPressure.set(key, pressure)
this.emit(CONTROLLER_EVENTS.CH_POLY_PRESS_SEND, { note, pressure, channel })
}
/**
* Get current Polyphonic Pressure (Aftertouch) value for a specific note on a channel
* @private
* @param {number} note - Note number (0-127)
* @param {number} [channel=this.options.inputChannel] - MIDI channel (1-16)
* @returns {number|undefined} Pressure value (0-127) or undefined if not set
*/
_getPolyPressure(note, channel = this.options.inputChannel) {
const key = `${channel}:${note}`
return this.state.polyPressure.get(key)
}
/**
* @private
*/
_allSoundsOff(channel) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
if (channel !== undefined) {
channel = clamp(Math.round(channel), 1, 16)
const status = 0xb0 + (channel - 1)
this.connection.send([status, 120, 0])
this.emit(CONTROLLER_EVENTS.CH_ALL_SOUNDS_OFF_SEND, { channel })
} else {
for (let ch = 1; ch <= 16; ch++) {
const status = 0xb0 + (ch - 1)
this.connection.send([status, 120, 0])
}
this.emit(CONTROLLER_EVENTS.CH_ALL_SOUNDS_OFF_SEND, { channel: null })
}
}
/**
* @private
*/
_resetControllers(channel) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
if (channel !== undefined) {
channel = clamp(Math.round(channel), 1, 16)
const status = 0xb0 + (channel - 1)
this.connection.send([status, 121, 0])
this.emit(CONTROLLER_EVENTS.CH_RESET_CONTROLLERS_SEND, { channel })
} else {
for (let ch = 1; ch <= 16; ch++) {
const status = 0xb0 + (ch - 1)
this.connection.send([status, 121, 0])
}
this.emit(CONTROLLER_EVENTS.CH_RESET_CONTROLLERS_SEND, { channel: null })
}
}
/**
* @private
*/
_localControl(enabled, channel) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
const value = enabled ? 127 : 0
if (channel !== undefined) {
channel = clamp(Math.round(channel), 1, 16)
const status = 0xb0 + (channel - 1)
this.connection.send([status, 122, value])
this.emit(CONTROLLER_EVENTS.CH_LOCAL_CONTROL_SEND, { enabled, channel })
} else {
for (let ch = 1; ch <= 16; ch++) {
const status = 0xb0 + (ch - 1)
this.connection.send([status, 122, value])
}
this.emit(CONTROLLER_EVENTS.CH_LOCAL_CONTROL_SEND, { enabled, channel: null })
}
}
/**
* @private
*/
_allNotesOff(channel) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
if (channel !== undefined) {
channel = clamp(Math.round(channel), 1, 16)
const status = 0xb0 + (channel - 1)
this.connection.send([status, 123, 0])
this.emit(CONTROLLER_EVENTS.CH_ALL_NOTES_OFF_SEND, { channel })
} else {
for (let ch = 1; ch <= 16; ch++) {
const status = 0xb0 + (ch - 1)
this.connection.send([status, 123, 0])
}
this.emit(CONTROLLER_EVENTS.CH_ALL_NOTES_OFF_SEND, { channel: null })
}
}
/**
* @private
*/
_omniOff(channel) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
if (channel !== undefined) {
channel = clamp(Math.round(channel), 1, 16)
const status = 0xb0 + (channel - 1)
this.connection.send([status, 124, 0])
this.emit(CONTROLLER_EVENTS.CH_OMNI_OFF_SEND, { channel })
} else {
for (let ch = 1; ch <= 16; ch++) {
const status = 0xb0 + (ch - 1)
this.connection.send([status, 124, 0])
}
this.emit(CONTROLLER_EVENTS.CH_OMNI_OFF_SEND, { channel: null })
}
}
/**
* @private
*/
_omniOn(channel) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
if (channel !== undefined) {
channel = clamp(Math.round(channel), 1, 16)
const status = 0xb0 + (channel - 1)
this.connection.send([status, 125, 0])
this.emit(CONTROLLER_EVENTS.CH_OMNI_ON_SEND, { channel })
} else {
for (let ch = 1; ch <= 16; ch++) {
const status = 0xb0 + (ch - 1)
this.connection.send([status, 125, 0])
}
this.emit(CONTROLLER_EVENTS.CH_OMNI_ON_SEND, { channel: null })
}
}
/**
* @private
*/
_monoOn(channels = 1, channel) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
channels = Math.max(0, Math.min(16, Math.round(channels)))
if (channel !== undefined) {
channel = clamp(Math.round(channel), 1, 16)
const status = 0xb0 + (channel - 1)
this.connection.send([status, 126, channels])
this.emit(CONTROLLER_EVENTS.CH_MONO_ON_SEND, { channels, channel })
} else {
for (let ch = 1; ch <= 16; ch++) {
const status = 0xb0 + (ch - 1)
this.connection.send([status, 126, channels])
}
this.emit(CONTROLLER_EVENTS.CH_MONO_ON_SEND, { channels, channel: null })
}
}
/**
* @private
*/
_polyOn(channel) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
if (channel !== undefined) {
channel = clamp(Math.round(channel), 1, 16)
const status = 0xb0 + (channel - 1)
this.connection.send([status, 127, 0])
this.emit(CONTROLLER_EVENTS.CH_POLY_ON_SEND, { channel })
} else {
for (let ch = 1; ch <= 16; ch++) {
const status = 0xb0 + (ch - 1)
this.connection.send([status, 127, 0])
}
this.emit(CONTROLLER_EVENTS.CH_POLY_ON_SEND, { channel: null })
}
}
/**
* Send SysEx (System Exclusive) message
* @private
* @param {Array<number>} data - SysEx data bytes (0-127)
* @param {boolean} [includeWrapper=false] - If true, data already includes F0/F7
* @returns {void}
* @example
* this._sendSysEx([0x42, 0x30, 0x00, 0x7F]) // Send without wrapper
* this._sendSysEx([0xF0, 0x42, 0x30, 0xF7], true) // Send with wrapper
*/
_sendSysEx(data, includeWrapper = false) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
if (!this.options.sysex) {
console.warn("SysEx not enabled. Initialize with sysex: true")
return
}
this.connection.sendSysEx(data, includeWrapper)
this.emit(CONTROLLER_EVENTS.SYS_EX_SEND, { data, includeWrapper })
}
/**
* Send MIDI Timing Clock message (System Real-Time)
* @private
* @returns {void}
*/
_sendClock() {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
this.connection.send([0xf8])
}
/**
* Send MIDI Start message (System Real-Time)
* @private
* @returns {void}
*/
_sendStart() {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
this.connection.send([0xfa])
}
/**
* Send MIDI Continue message (System Real-Time)
* @private
* @returns {void}
*/
_sendContinue() {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
this.connection.send([0xfb])
}
/**
* Send MIDI Stop message (System Real-Time)
* @private
* @returns {void}
*/
_sendStop() {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
this.connection.send([0xfc])
}
/**
* Send MTC (MIDI Time Code) quarter frame message
* @private
* @param {number} data - MTC quarter frame data (0-127)
* @returns {void}
*/
_sendMTC(data) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
data = clamp(Math.round(data), 0, 127)
this.connection.send([0xf1, data])
}
/**
* Send Song Position Pointer message (System Common)
* @private
* @param {number} position - Song position in beats (0-16383)
* @returns {void}
*/
_sendSongPosition(position) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
position = clamp(Math.round(position), 0, 16383)
const lsb = position & 0x7f
const msb = (position >> 7) & 0x7f
this.connection.send([0xf2, lsb, msb])
}
/**
* Send Song Select message (System Common)
* @private
* @param {number} song - Song/sequence number (0-127)
* @returns {void}
*/
_sendSongSelect(song) {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
song = clamp(Math.round(song), 0, 127)
this.connection.send([0xf3, song])
}
/**
* Send Tune Request message (System Common)
* @private
* @returns {void}
*/
_sendTuneRequest() {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
this.connection.send([0xf6])
}
/**
* Send Active Sensing message (System Real-Time)
* @private
* @returns {void}
*/
_sendActiveSensing() {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
this.connection.send([0xfe])
}
/**
* Send System Reset message (System Real-Time)
* @private
* @returns {void}
*/
_sendSystemReset() {
if (!this.initialized) {
console.warn("MIDI not initialized. Call init() first.")
return
}
this.connection.send([0xff])
}
/**
* Handle incoming MIDI messages from input device
* @private
* @param {Object} event - MIDI message event
* @param {Array<number>} event.data - MIDI message bytes
* @param {number} event.midiwire - Timestamp
* @returns {void}
*/
_handleMIDIMessage(event) {
const [status, data1, data2] = event.data
const messageType = status & 0xf0
const channel = (status & 0x0f) + 1
if (status === 0xf8) {
this.emit(CONTROLLER_EVENTS.SYS_CLOCK_RECV, {
timestamp: event.midiwire,
})
return
}
if (status === 0xfa) {
this.emit(CONTROLLER_EVENTS.SYS_START_RECV, {
timestamp: event.midiwire,
})
return
}
if (status === 0xfb) {
this.emit(CONTROLLER_EVENTS.SYS_CONTINUE_RECV, {
timestamp: event.midiwire,
})
return
}
if (status === 0xfc) {
this.emit(CONTROLLER_EVENTS.SYS_STOP_RECV, {
timestamp: event.midiwire,
})
return
}
if (status === 0xfe) {
this.emit(CONTROLLER_EVENTS.SYS_ACT_SENSE_RECV, {
timestamp: event.midiwire,
})
return
}
if (status === 0xff) {
this.emit(CONTROLLER_EVENTS.SYS_RESET_RECV, {
timestamp: event.midiwire,
})
return
}
if (status === 0xf0) {
this.emit(CONTROLLER_EVENTS.SYS_EX_RECV, {
data: Array.from(event.data),
timestamp: event.midiwire,
})
return
}
if (status === 0xf1) {
this.emit(CONTROLLER_EVENTS.SYS_MTC_RECV, {
data: data1,
timestamp: event.midiwire,
})
return
}
if (status === 0xf2) {
const position = data1 + (data2 << 7)
this.emit(CONTROLLER_EVENTS.SYS_SONG_POS_RECV, {
position,
timestamp: event.midiwire,
})
return
}
if (status === 0xf3) {
this.emit(CONTROLLER_EVENTS.SYS_SONG_SEL_RECV, {
song: data1,
timestamp: event.midiwire,
})
return
}
if (status === 0xf6) {
this.emit(CONTROLLER_EVENTS.SYS_TUNE_REQ_RECV, {
timestamp: event.midiwire,
})
return
}
if (status === 0xf7) {
this.emit(CONTROLLER_EVENTS.MIDI_RAW, {
status,
data: [data1, data2],
channel,
timestamp: event.midiwire,
})
return
}
if (messageType === 0xb0) {
const key = `${channel}:${data1}`
this.state.controlChange.set(key, data2)
this.emit(CONTROLLER_EVENTS.CH_CC_RECV, {
cc: data1,
value: data2,
channel,
})
return
}
if (messageType === 0xc0) {
this.state.programChange.set(channel.toString(), data1)
this.emit(CONTROLLER_EVENTS.CH_PC_RECV, {
program: data1,
channel,
})
return
}
if (messageType === 0xe0) {
const value = data1 + (data2 << 7) // Combine LSB and MSB
this.state.pitchBend.set(channel.toString(), value)
this.emit(CONTROLLER_EVENTS.CH_PITCH_BEND_RECV, {
value,
channel,
})
return
}
if (messageType === 0xd0) {
this.state.monoPressure.set(channel.toString(), data1)
this.emit(CONTROLLER_EVENTS.CH_MONO_PRESS_RECV, {
pressure: data1,
channel,
})
return
}
if (messageType === 0xa0) {
const key = `${channel}:${data1}`
this.state.polyPressure.set(key, data2)
this.emit(CONTROLLER_EVENTS.CH_POLY_PRESS_RECV, {
note: data1,
pressure: data2,
channel,
})
return
}
if (messageType === 0x90 && data2 > 0) {
this.emit(CONTROLLER_EVENTS.CH_NOTE_ON_RECV, {
note: data1,
velocity: data2,
channel,
})
return
}
if (messageType === 0x80 || (messageType === 0x90 && data2 === 0)) {
this.emit(CONTROLLER_EVENTS.CH_NOTE_OFF_RECV, {
note: data1,
channel,
})
return
}
this.emit(CONTROLLER_EVENTS.MIDI_RAW, {
status,
data: [data1, data2],
channel,
timestamp: event.midiwire,
})
}
/**
* Get current state as a patch object
* @private
* @param {string} [name="Unnamed Patch"] - Patch name
* @returns {PatchData} Patch data object with channels, settings, and metadata
*/
_getPatch(name = "Unnamed Patch") {
const patch = {
name,
device: this._getCurrentOutput()?.name || null,
timestamp: new Date().toISOString(),
version: "1.0",
channels: {},
settings: {},
}
for (const [key, value] of this.state.controlChange.entries()) {
const [channel, cc] = key.split(":").map(Number)
if (!patch.channels[channel]) {
patch.channels[channel] = { ccs: {}, notes: {} }
}
patch.channels[channel].ccs[cc] = value
}
for (const [channelStr, value] of this.state.programChange.entries()) {
const channel = parseInt(channelStr, 10)
if (!patch.channels[channel]) {
patch.channels[channel] = { ccs: {}, notes: {} }
}
patch.channels[channel].program = value
}
for (const [channelStr, value] of this.state.pitchBend.entries()) {
const channel = parseInt(channelStr, 10)
if (!patch.channels[channel]) {
patch.channels[channel] = { ccs: {}, notes: {} }
}
patch.channels[channel].pitchBend = value
}
for (const [channelStr, value] of this.state.monoPressure.entries()) {
const channel = parseInt(channelStr, 10)
if (!patch.channels[channel]) {
patch.channels[channel] = { ccs: {}, notes: {} }
}
patch.channels[channel].monoPressure = value
}
for (const [key, value] of this.state.polyPressure.entries()) {
const [channelStr, note] = key.split(":").map(Number)
const channel = parseInt(channelStr, 10)
if (!patch.channels[channel]) {
patch.channels[channel] = { ccs: {}, notes: {} }
}
if (!patch.channels[channel].polyPressure) {
patch.channels[channel].polyPressure = {}
}
patch.channels[channel].polyPressure[note] = value
}
for (const [element, binding] of this.bindings.entries()) {
const { config } = binding
if (config.cc) {
const settingKey = `cc${config.cc}`
patch.settings[settingKey] = {
min: config.min,
max: config.max,
invert: config.invert || false,
is14Bit: config.is14Bit || false,
label: element.getAttribute?.("data-midi-label") || null,
elementId: element.id || null,
}
}
}
return patch
}
/**
* Apply a patch to the controller
* @private
* @param {PatchData} patch - Patch object to apply
* @returns {Promise<void>}
* @throws {MIDIValidationError} If patch format is invalid
*/
async _setPatch(patch) {
if (!patch || !patch.channels) {
throw new MIDIValidationError("Invalid patch format", "patch")
}
const version = patch.version || "1.0"
if (version === "1.0") {
await this._applyPatchV1(patch)
} else {
console.warn(`Unknown patch version: ${version}. Attempting to apply as v1.0`)
await this._applyPatchV1(patch)
}
this.emit(CONTROLLER_EVENTS.PATCH_LOADED, { patch })
}
/**
* Apply patch data for version 1.0 format
* @private
* @param {PatchData} patch - Patch object to apply
* @returns {Promise<void>}
*/
async _applyPatchV1(patch) {
for (const [channelStr, channelData] of Object.entries(patch.channels)) {
const channel = parseInt(channelStr, 10)
if (channelData.ccs) {
for (const [ccStr, value] of Object.entries(channelData.ccs)) {
const cc = parseInt(ccStr, 10)
this._sendCC(cc, value, channel)
}
}
if (channelData.program !== undefined) {
this._sendPC(channelData.program, channel)
}
if (channelData.pitchBend !== undefined) {
this._sendPitchBend(channelData.pitchBend, channel)
}
if (channelData.monoPressure !== undefined) {
this._sendMonoPressure(channelData.monoPressure, channel)
}
if (channelData.polyPressure) {
for (const [noteStr, pressure] of Object.entries(channelData.polyPressure)) {
const note = parseInt(noteStr, 10)
this._sendPolyPressure(note, pressure, channel)
}
}
if (channelData.notes) {
for (const [noteStr, velocity] of Object.entries(channelData.notes)) {
const note = parseInt(noteStr, 10)
if (velocity > 0) {
this._sendNoteOn(note, velocity, channel)
} else {
this._sendNoteOff(note, channel)
}
}
}
}
if (patch.settings) {
for (const [bindingKey, setting] of Object.entries(patch.settings)) {
for (const [element, binding] of this.bindings.entries()) {
if (binding.config.cc?.toString() === bindingKey.replace("cc", "")) {
if (element.min !== undefined && setting.min !== undefined) {
element.min = String(setting.min)
}
if (element.max !== undefined && setting.max !== undefined) {
element.max = String(setting.max)
}
}
}
}
}
for (const [element, binding] of this.bindings.entries()) {
const { config } = binding
if (config.cc !== undefined) {
const channel = config.channel || this.options.inputChannel
const channelData = patch.channels[channel]
if (channelData?.ccs) {
const ccValue = channelData.ccs[config.cc]
if (ccValue !== undefined) {
const min = config.min !== undefined ? config.min : parseFloat(element.getAttribute?.("min")) || 0
const max = config.max !== undefined ? config.max : parseFloat(element.getAttribute?.("max")) || 127
const invert = config.invert || false
let elementValue
if (invert) {
elementValue = max - (ccValue / 127) * (max - min)
} else {
elementValue = min + (ccValue / 127) * (max - min)
}
if (config.onInput && typeof config.onInput === "function") {
config.onInput(elementValue)
} else {
element.value = elementValue
element.dispatchEvent(new Event("input", { bubbles: true }))
}
}
}
}
}
}
/**
* Save a patch to localStorage
* @private
* @param {string} name - Patch name
* @param {PatchData} [patch] - Optional patch object (will use get() if not provided)
* @returns {string} Storage key used
*/
_savePatch(name, patch = null) {
const patchToSave = patch || this._getPatch(name)
const key = `midiwire_patch_${name}`
try {
localStorage.setItem(key, JSON.stringify(patchToSave))
this.emit(CONTROLLER_EVENTS.PATCH_SAVED, { name, patch: patchToSave })
return key
} catch (err) {
console.error("Failed to save patch:", err)
throw err
}
}
/**
* Load a patch from localStorage
* @private
* @param {string} name - Patch name
* @returns {PatchData|null} Patch object or null if not found
*/
_loadPatch(name) {
const key = `midiwire_patch_${name}`
try {
const stored = localStorage.getItem(key)
if (!stored) {
return null
}
const patch = JSON.parse(stored)
this.emit(CONTROLLER_EVENTS.PATCH_LOADED, { name, patch })
return patch
} catch (err) {
console.error("Failed to load patch:", err)
return null
}
}
/**
* Delete a patch from localStorage
* @private
* @param {string} name - Patch name
* @returns {boolean} Success status
*/
_deletePatch(name) {
const key = `midiwire_patch_${name}`
try {
localStorage.removeItem(key)
this.emit(CONTROLLER_EVENTS.PATCH_DELETED, { name })
return true
} catch (err) {
console.error("Failed to delete patch:", err)
return false
}
}
/**
* List all saved patches from localStorage
* @private
* @returns {Array<{name: string, patch: PatchData}>} Sorted array of patch objects
*/
_listPatches() {
const patches = []
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith("midiwire_patch_")) {
const name = key.replace("midiwire_patch_", "")
const patch = this._loadPatch(name)
if (patch) {
patches.push({ name, patch })
}
}
}
} catch (err) {
console.error("Failed to list patches:", err)
}
return patches.sort((a, b) => a.name.localeCompare(b.name))
}
}