import { EventEmitter } from "./EventEmitter.js"
import { MIDIAccessError, MIDIConnectionError, MIDIDeviceError, MIDIValidationError } from "./errors.js"
/**
* Connection event constants for device connection/disconnection events.
* Use these constants when listening for MIDI device state changes.
*
* @namespace CONNECTION_EVENTS
* @property {string} DEVICE_CHANGE="device-change" - Emitted when any MIDI device state changes (connect/disconnect)
* @property {string} IN_DEV_CONNECTED="in-dev-connected" - Emitted when an input device is connected
* @property {string} IN_DEV_DISCONNECTED="in-dev-disconnected" - Emitted when an input device is disconnected
* @property {string} OUT_DEV_CONNECTED="out-dev-connected" - Emitted when an output device is connected
* @property {string} OUT_DEV_DISCONNECTED="out-dev-disconnected" - Emitted when an output device is disconnected
*
* @example
* connection.on(CONNECTION_EVENTS.DEVICE_CHANGE, ({ port, state, type, device }) => {
* console.log(`${device.name} ${state}`);
* });
*
* connection.on(CONNECTION_EVENTS.OUT_DEV_CONNECTED, ({ device }) => {
* console.log(`Output connected: ${device.name}`);
* });
*
* @example
* // Using the short alias (CONN)
* connection.on(CONN.DEVICE_CHANGE, ({ device, state }) => {
* console.log(`${device.name} ${state}`);
* });
*/
export const CONNECTION_EVENTS = {
DEVICE_CHANGE: "device-change",
IN_DEV_CONNECTED: "in-dev-connected",
IN_DEV_DISCONNECTED: "in-dev-disconnected",
OUT_DEV_CONNECTED: "out-dev-connected",
OUT_DEV_DISCONNECTED: "out-dev-disconnected",
}
/**
* Low-level Web MIDI API connection handler. Manages:
* - MIDI device access/request
* - Device enumeration (inputs/outputs)
* - Device connection/disconnection
* - Hotplug detection and events
* - Raw MIDI message sending/receiving
* - SysEx message handling
* - Connection state tracking
*
* NOTE: Typically used internally by MIDIController. Most applications
* should use MIDIController instead for higher-level APIs.
* @extends EventEmitter
*
* @example
* // Basic usage
* const connection = new MIDIConnection({ sysex: true });
* await connection.requestAccess();
* await connection.connect("My MIDI Device");
* connection.send([0x90, 60, 100]); // Note on
*
* @example
* // Listening for device changes
* connection.on(CONNECTION_EVENTS.DEVICE_CHANGE, ({ device, state }) => {
* console.log(`${device.name} is now ${state}`);
* });
*/
export class MIDIConnection extends EventEmitter {
/**
* @param {Object} options
* @param {boolean} [options.sysex=false] - Request SysEx access
*/
constructor(options = {}) {
super()
this.options = {
sysex: false,
...options,
}
this.midiAccess = null
this.output = null
this.input = null
}
/**
* Request MIDI access from the browser. Sets up hotplug detection and
* emits events when devices connect/disconnect.
*
* @returns {Promise<void>}
* @throws {MIDIAccessError} If MIDI is not supported or access is denied
*
* @emits CONNECTION_EVENTS.DEVICE_CHANGE - When any MIDI device state changes
* @emits CONNECTION_EVENTS.IN_DEV_CONNECTED - When an input device connects
* @emits CONNECTION_EVENTS.IN_DEV_DISCONNECTED - When an input device disconnects
* @emits CONNECTION_EVENTS.OUT_DEV_CONNECTED - When an output device connects
* @emits CONNECTION_EVENTS.OUT_DEV_DISCONNECTED - When an output device disconnects
*
* @example
* // Request basic MIDI access
* await connection.requestAccess();
*
* @example
* // Request with SysEx support
* const connection = new MIDIConnection({ sysex: true });
* await connection.requestAccess();
*/
async requestAccess() {
if (!navigator.requestMIDIAccess) {
throw new MIDIAccessError("Web MIDI API is not supported in this browser", "unsupported")
}
try {
this.midiAccess = await navigator.requestMIDIAccess({
sysex: this.options.sysex,
})
this.midiAccess.onstatechange = (event) => {
const port = event.port
const state = event.port.state
this.emit(CONNECTION_EVENTS.DEVICE_CHANGE, {
port,
state,
type: port.type,
device: {
id: port.id,
name: port.name,
manufacturer: port.manufacturer || "Unknown",
},
})
if (state === "disconnected") {
if (port.type === "input") {
this.emit(CONNECTION_EVENTS.IN_DEV_DISCONNECTED, { device: port })
if (this.input && this.input.id === port.id) {
this.input = null
}
} else if (port.type === "output") {
this.emit(CONNECTION_EVENTS.OUT_DEV_DISCONNECTED, { device: port })
if (this.output && this.output.id === port.id) {
this.output = null
}
}
} else if (state === "connected") {
if (port.type === "input") {
this.emit(CONNECTION_EVENTS.IN_DEV_CONNECTED, { device: port })
} else if (port.type === "output") {
this.emit(CONNECTION_EVENTS.OUT_DEV_CONNECTED, { device: port })
}
}
}
} catch (err) {
if (err.name === "SecurityError") {
throw new MIDIAccessError("MIDI access denied. SysEx requires user permission.", "denied")
}
throw new MIDIAccessError(`Failed to get MIDI access: ${err.message}`, "failed")
}
}
/**
* Connect to a MIDI output device. Can connect by index, name, or ID.
*
* @param {string|number} [device] - Device name, ID, or index (defaults to first available)
* @returns {Promise<void>}
* @throws {MIDIConnectionError} If MIDI access not initialized
* @throws {MIDIDeviceError} If device not found or index out of range
*
* @example
* // Connect to first available device
* await connection.connect();
*
* @example
* // Connect by index
* await connection.connect(0);
*
* @example
* // Connect by name
* await connection.connect("My MIDI Keyboard");
*
* @example
* // Connect by device ID
* await connection.connect("input-12345");
*/
async connect(device) {
if (!this.midiAccess) {
throw new MIDIConnectionError("MIDI access not initialized. Call requestAccess() first.")
}
const outputs = Array.from(this.midiAccess.outputs.values())
if (outputs.length === 0) {
throw new MIDIDeviceError("No MIDI output devices available", "output")
}
if (device === undefined) {
this.output = outputs[0]
return
}
if (typeof device === "number") {
if (device < 0 || device >= outputs.length) {
throw new MIDIDeviceError(`Output index ${device} out of range (0-${outputs.length - 1})`, "output", device)
}
this.output = outputs[device]
return
}
this.output = outputs.find((output) => output.name === device || output.id === device)
if (!this.output) {
const availableNames = outputs.map((o) => o.name).join(", ")
throw new MIDIDeviceError(`MIDI output "${device}" not found. Available: ${availableNames}`, "output", device)
}
}
/**
* Connect to a MIDI input device for receiving messages
* @param {string|number} [device] - Device name, ID, or index (defaults to first available)
* @param {Function} onMessage - Callback for incoming MIDI messages
* @returns {Promise<void>}
* @throws {MIDIConnectionError} If MIDI access not initialized
* @throws {MIDIValidationError} If onMessage is not a function
* @throws {MIDIDeviceError} If device not found or index out of range
*/
async connectInput(device, onMessage) {
if (!this.midiAccess) {
throw new MIDIConnectionError("MIDI access not initialized. Call requestAccess() first.")
}
if (typeof onMessage !== "function") {
throw new MIDIValidationError("onMessage callback must be a function", "callback")
}
const inputs = Array.from(this.midiAccess.inputs.values())
if (inputs.length === 0) {
throw new MIDIDeviceError("No MIDI input devices available", "input")
}
if (this.input) {
this.input.onmidimessage = null
}
if (device === undefined) {
this.input = inputs[0]
} else if (typeof device === "number") {
if (device < 0 || device >= inputs.length) {
throw new MIDIDeviceError(`Input index ${device} out of range (0-${inputs.length - 1})`, "input", device)
}
this.input = inputs[device]
} else {
this.input = inputs.find((input) => input.name === device || input.id === device)
if (!this.input) {
const availableNames = inputs.map((i) => i.name).join(", ")
throw new MIDIDeviceError(`MIDI input "${device}" not found. Available: ${availableNames}`, "input", device)
}
}
this.input.onmidimessage = (event) => {
onMessage(event)
}
}
/**
* Disconnect from current output
*/
disconnectOutput() {
this.output = null
}
/**
* Disconnect from current input
*/
disconnectInput() {
if (this.input) {
this.input.onmidimessage = null
this.input = null
}
}
/**
* Disconnect from both output and input
*/
disconnect() {
this.disconnectOutput()
this.disconnectInput()
}
/**
* Check if currently connected to an output
* @returns {boolean}
*/
isConnected() {
return this.output !== null
}
/**
* Get current output device info
* @returns {Object|null}
*/
getCurrentOutput() {
if (!this.output) return null
return {
id: this.output.id,
name: this.output.name,
manufacturer: this.output.manufacturer || "Unknown",
}
}
/**
* Get current input device info
* @returns {Object|null}
*/
getCurrentInput() {
if (!this.input) return null
return {
id: this.input.id,
name: this.input.name,
manufacturer: this.input.manufacturer || "Unknown",
}
}
/**
* Get all available MIDI outputs
* @returns {Array<{id: string, name: string, manufacturer: string}>}
*/
getOutputs() {
if (!this.midiAccess) return []
const outputs = []
this.midiAccess.outputs.forEach((output) => {
if (output.state === "connected") {
outputs.push({
id: output.id,
name: output.name,
manufacturer: output.manufacturer || "Unknown",
})
}
})
return outputs
}
/**
* Get all available MIDI inputs
* @returns {Array<{id: string, name: string, manufacturer: string}>}
*/
getInputs() {
if (!this.midiAccess) return []
const inputs = []
this.midiAccess.inputs.forEach((input) => {
if (input.state === "connected") {
inputs.push({
id: input.id,
name: input.name,
manufacturer: input.manufacturer || "Unknown",
})
}
})
return inputs
}
/**
* Send a MIDI message to the connected output. Automatically converts
* Arrays to Uint8Array for Web MIDI API compatibility.
*
* @param {Uint8Array|Array<number>} message - MIDI message bytes (e.g., [0x90, 60, 100])
* @param {number} [timestamp=performance.now()] - Optional timestamp for scheduled sending
* @returns {void}
*
* @example
* // Send a note on message (channel 1, note C4, velocity 100)
* connection.send([0x90, 60, 100]);
*
* @example
* // Send a note off message
* connection.send([0x80, 60, 0]);
*
* @example
* // Send a control change
* connection.send([0xB0, 7, 64]); // Volume to 64
*/
send(message, timestamp = null) {
if (!this.output) {
console.warn("No MIDI output connected. Call connect() first.")
return
}
try {
const data = new Uint8Array(message)
if (timestamp === null) {
this.output.send(data)
} else {
this.output.send(data, timestamp)
}
} catch (err) {
console.error("Failed to send MIDI message:", err)
}
}
/**
* Send a System Exclusive (SysEx) message. Requires sysex: true in constructor options.
*
* @param {Array<number>} data - SysEx data bytes (without F0/F7 wrapper)
* @param {boolean} [includeWrapper=false] - If true, wraps data with F0/F7 bytes
* @returns {void}
*
* @example
* // Send a basic SysEx message (will be wrapped with F0/F7)
* connection.sendSysEx([0x41, 0x10, 0x42, 0x12, 0x40, 0x00, 0x7F]);
*
* @example
* // Send pre-wrapped SysEx message
* connection.sendSysEx([0xF0, 0x41, 0x10, 0x42, 0xF7], false);
*/
sendSysEx(data, includeWrapper = false) {
if (!this.options.sysex) {
console.warn("SysEx not enabled. Initialize with sysex: true")
return
}
let message
if (includeWrapper) {
message = [0xf0, ...data, 0xf7]
} else {
message = data
}
this.send(message)
}
}