import { UserAPI } from "../API"; import { AppSettings } from "../AppSettings"; export type MidiNoteEvent = { note: number; velocity: number; channel: number; timestamp: number; } export type MidiCCEvent = { control: number; value: number; channel: number; timestamp: number; } export class MidiConnection { /** * Wrapper class for Web MIDI API. Provides methods for sending MIDI messages. * * * @param midiAccess - Web MIDI API access object * @param midiOutputs - Array of MIDI output objects * @param currentOutputIndex - Index of the currently selected MIDI output * @param scheduledNotes - Object containing scheduled notes. Keys are note numbers and values are timeout IDs. */ /* Midi output */ private api: UserAPI; private settings: AppSettings; private midiAccess: MIDIAccess | null = null; public midiOutputs: MIDIOutput[] = []; private currentOutputIndex: number = 0; private scheduledNotes: { [noteNumber: number]: number } = {}; // { noteNumber: timeoutId } /* Midi input */ public midiInputs: MIDIInput[] = []; private currentInputIndex: number|undefined = undefined; public bufferLength: number = 512; // 32*16 public noteInputBuffer: MidiNoteEvent[] = []; public ccInputBuffer: MidiCCEvent[] = []; public activeNotes: MidiNoteEvent[] = []; public stickyNotes: MidiNoteEvent[] = []; /* MIDI clock stuff */ private midiClockInputIndex: number|undefined = undefined; private midiClockInput?: MIDIInput|undefined = undefined; private lastTimestamp: number = 0; private midiClockDelta: number = 0; private lastBPM: number; private roundedBPM: number = 0; private clockBuffer: number[] = []; private clockBufferLength = 24; private clockTicks = 0; private clockErrorCount = 0; private skipOnError = 0; constructor(api: UserAPI, settings: AppSettings) { this.api = api; this.settings = settings; this.lastBPM = api.bpm(); this.roundedBPM = this.lastBPM; this.initializeMidiAccess(); } private async initializeMidiAccess(): Promise { /** * Initializes Web MIDI API access and populates the list of MIDI outputs. * * @returns Promise */ try { this.midiAccess = await navigator.requestMIDIAccess(); this.midiOutputs = Array.from(this.midiAccess.outputs.values()); if (this.midiOutputs.length === 0) { console.warn("No MIDI outputs available."); this.currentOutputIndex = -1; } this.midiInputs = Array.from(this.midiAccess.inputs.values()); if (this.midiInputs.length === 0) { console.warn("No MIDI inputs available."); } else { this.updateInputSelects(); } } catch (error) { console.error("Failed to initialize MIDI:", error); } } public getCurrentMidiPort(): string | null { /** * Returns the name of the currently selected MIDI output. * * @returns Name of the currently selected MIDI output or null if no MIDI output is selected or available. */ if ( this.midiOutputs.length > 0 && this.currentOutputIndex >= 0 && this.currentOutputIndex < this.midiOutputs.length ) { return this.midiOutputs[this.currentOutputIndex].name; } else { console.error("No MIDI output selected or available."); return null; } } public sendStartMessage(): void { /** * Sends a MIDI Start message to the currently selected MIDI output and MIDI clock is not used */ if(!this.midiClockInput) { const output = this.midiOutputs[this.currentOutputIndex]; if (output) { output.send([0xfa]); // Send MIDI Start message } } } public sendStopMessage(): void { /** * Sends a MIDI Stop message to the currently selected MIDI output and MIDI clock is not used */ if(!this.midiClockInput) { const output = this.midiOutputs[this.currentOutputIndex]; if (output) { output.send([0xfc]); // Send MIDI Stop message } } } public getCurrentMidiPortIndex(): number { /** * Returns the index of the currently selected MIDI output. * * @returns Index of the currently selected MIDI output or -1 if no MIDI output is selected or available. */ if ( this.midiOutputs.length > 0 && this.currentOutputIndex >= 0 && this.currentOutputIndex < this.midiOutputs.length ) { return this.currentOutputIndex; } else { console.error("No MIDI output selected or available."); return -1; } } public setMidiClock(inputName: string|number): void { /** * Sets the MIDI input to use for MIDI clock messages. * * @param inputName Name of the MIDI input to use for MIDI clock messages */ const inputIndex = this.getMidiInputIndex(inputName); if (inputIndex !== -1) { this.midiClockInputIndex = inputIndex; this.midiClockInput = this.midiInputs[inputIndex]; this.registerMidiInputListener(inputIndex); } else { this.midiClockInput = undefined; } } public updateInputSelects() { /** * Updates the MIDI clock input select element with the available MIDI inputs. */ if(this.midiInputs.length > 0) { const midiClockSelect = document.getElementById("midi-clock-input") as HTMLSelectElement; const midiInputSelect = document.getElementById("default-midi-input") as HTMLSelectElement; midiClockSelect.innerHTML = ""; midiInputSelect.innerHTML = ""; // Set Midi clock as Internal by default const defaultOption = document.createElement("option"); defaultOption.value = "-1"; defaultOption.text = "Internal"; midiClockSelect.appendChild(defaultOption); // Set default input as None by default const defaultInputOption = document.createElement("option"); defaultInputOption.value = "-1"; defaultInputOption.text = "None"; midiInputSelect.appendChild(defaultInputOption); // Add MIDI inputs to clock select input and default midi input this.midiInputs.forEach((input, index) => { const option = document.createElement("option"); option.value = index.toString(); option.text = input.name || index.toString(); midiClockSelect.appendChild(option); midiInputSelect.appendChild(option.cloneNode(true)); }); if(this.settings.midi_clock_input) { const clockMidiInputIndex = this.getMidiInputIndex(this.settings.midi_clock_input); midiClockSelect.value = clockMidiInputIndex.toString(); if(clockMidiInputIndex > 0) { this.midiClockInput = this.midiInputs[clockMidiInputIndex]; this.registerMidiInputListener(clockMidiInputIndex); } } else { midiClockSelect.value = "-1"; } if(this.settings.default_midi_input) { const defaultMidiInputIndex = this.getMidiInputIndex(this.settings.default_midi_input); midiInputSelect.value = defaultMidiInputIndex.toString(); if(defaultMidiInputIndex > 0) { this.currentInputIndex = defaultMidiInputIndex; this.registerMidiInputListener(defaultMidiInputIndex); } } else { midiInputSelect.value = "-1"; } // Add midi clock listener midiClockSelect.addEventListener("change", (event) => { const value = (event.target as HTMLSelectElement).value; if(value === "-1") { if(this.midiClockInput && this.midiClockInputIndex!=this.currentInputIndex) this.midiClockInput.onmidimessage = null; this.midiClockInput = undefined; this.settings.midi_clock_input = undefined; } else { const clockInputIndex = parseInt(value); this.midiClockInputIndex = clockInputIndex; if(this.midiClockInput && this.midiClockInputIndex!=this.currentInputIndex) this.midiClockInput.onmidimessage = null; this.midiClockInput = this.midiInputs[clockInputIndex]; this.registerMidiInputListener(clockInputIndex); this.settings.midi_clock_input = this.midiClockInput.name || undefined; } }); // Add mini input listener midiInputSelect.addEventListener("change", (event) => { const value = (event.target as HTMLSelectElement).value; if(value === "-1") { if(this.currentInputIndex && this.currentInputIndex!=this.midiClockInputIndex) this.unregisterMidiInputListener(this.currentInputIndex); this.currentInputIndex = undefined; this.settings.default_midi_input = undefined; } else { if(this.currentInputIndex && this.currentInputIndex!=this.midiClockInputIndex) this.unregisterMidiInputListener(this.currentInputIndex); this.currentInputIndex = parseInt(value); this.registerMidiInputListener(this.currentInputIndex); this.settings.default_midi_input = this.midiInputs[this.currentInputIndex].name || undefined; } }); } } public registerMidiInputListener(inputIndex: number): void { /** * Register midi input listener and store last value as global parameter named channel_{number} */ if(inputIndex !== undefined) { const input = this.midiInputs[inputIndex]; if(input && !input.onmidimessage) { input.onmidimessage = (event: Event) => { const message = event as MIDIMessageEvent; /* MIDI CLOCK */ if(input.name === this.settings.midi_clock_input) { if (message.data[0] === 0xf8) { if(this.skipOnError>0) { this.skipOnError -= 1; } else { this.onMidiClock(event.timeStamp); } } else if(message.data[0] === 0xfa) { console.log("MIDI start received"); this.api.stop(); this.api.play(); } else if(message.data[0] === 0xfc) { console.log("MIDI stop received"); this.api.pause(); } else if(message.data[0] === 0xfb) { console.log("MIDI continue received"); this.api.play(); } else if(message.data[0] === 0xfe) { console.log("MIDI active sensing received"); } } /* DEFAULT MIDI INPUT */ if(input.name === this.settings.default_midi_input) { // If message is one of note ons if(message.data[0] >= 0x90 && message.data[0] <= 0x9F) { const channel = message.data[0] - 0x90 + 1; const note = message.data[1]; const velocity = message.data[2]; this.api.variable(`channel_${channel}_note`, note); this.api.variable(`channel_${channel}_velocity`, velocity); if(this.settings.midi_channels_scripts) this.api.script(channel); //console.log(`NOTE: ${note} VELOCITY: ${velocity} CHANNEL: ${channel}`); this.pushToMidiInputBuffer({note, velocity, channel, timestamp: event.timeStamp}); this.activeNotes.push({note, velocity, channel, timestamp: event.timeStamp}); const sticky = this.removeFromStickyNotes(note, channel); if(!sticky) this.stickyNotes.push({note, velocity, channel, timestamp: event.timeStamp}); } // If note off if(message.data[0] >= 0x80 && message.data[0] <= 0x8F) { const channel = message.data[0] - 0x80 + 1; const note = message.data[1]; this.removeFromActiveNotes(note, channel); } // If message is one of CCs if(message.data[0]>=0xB0 && message.data[0]<=0xBF) { const channel = message.data[0] - 0xB0 + 1; const control = message.data[1]; const value = message.data[2]; this.api.variable(`channel_${channel}_control`, control); this.api.variable(`channel_${channel}_value`, value); //console.log(`CC: ${control} VALUE: ${value} CHANNEL: ${channel}`); this.pushToMidiCCBuffer({control, value, channel, timestamp: event.timeStamp}); } } } } } } /* Methods for handling active midi notes */ public removeFromActiveNotes(note: number, channel: number): void { const index = this.activeNotes.findIndex((e) => e.note===note && e.channel===channel); if(index>=0) this.activeNotes.splice(index, 1); } public removeFromStickyNotes(note: number, channel: number): boolean { const index = this.stickyNotes.findIndex((e) => e.note===note && e.channel===channel); if(index>=0) { this.stickyNotes.splice(index, 1); return true; } else { return false; } } public stickyNotesFromChannel(channel: number): MidiNoteEvent[] { return this.stickyNotes.filter((e) => e.channel===channel); } public activeNotesFromChannel(channel: number): MidiNoteEvent[] { return this.activeNotes.filter((e) => e.channel===channel); } public killActiveNotes(): void { this.activeNotes = []; } public killActiveNotesFromChannel(channel: number): void { this.activeNotes = this.activeNotes.filter((e) => e.channel!==channel); } /* Methods for handling midi input buffers */ private pushToMidiInputBuffer(event: MidiNoteEvent): void { this.noteInputBuffer.push(event); if(this.noteInputBuffer.length>this.bufferLength) { this.noteInputBuffer.shift(); } } private pushToMidiCCBuffer(event: MidiCCEvent): void { this.ccInputBuffer.push(event); if(this.ccInputBuffer.length>this.bufferLength) { this.ccInputBuffer.shift(); } } public shiftNoteFromBuffer(): MidiNoteEvent|undefined { const event = this.noteInputBuffer.shift(); if(event) return event; else return undefined; } public popNoteFromBuffer(): MidiNoteEvent|undefined { const event = this.noteInputBuffer.pop(); if(event) return event; else return undefined; } public popCCFromBuffer(): MidiCCEvent|undefined { const event = this.ccInputBuffer.pop(); if(event) return event; else return undefined; } public shiftCCFromBuffer(): MidiCCEvent|undefined { const event = this.ccInputBuffer.shift(); if(event) return event; else return undefined; } public findNoteFromBufferInChannel(channel: number|undefined) { const index = this.noteInputBuffer.findIndex((e) => e.channel===channel); if(index>=0) { const event = this.noteInputBuffer[index]; this.noteInputBuffer.splice(index, 1); return event; } else { return undefined; } } public findCCFromBufferInChannel(channel: number|undefined) { const index = this.ccInputBuffer.findIndex((e) => e.channel===channel); if(index>=0) { const event = this.ccInputBuffer[index]; this.ccInputBuffer.splice(index, 1); return event; } else { return undefined; } } public unregisterMidiInputListener(inputIndex: number): void { /** * Unregister midi input listener */ if(inputIndex !== undefined) { const input = this.midiInputs[inputIndex]; if(input) { input.onmidimessage = null; } } } public onMidiClock(timestamp: number): void { /** * Called when a MIDI clock message is received. */ this.clockTicks += 1; if(this.lastTimestamp > 0) { if(this.lastTimestamp===timestamp) { // This is error handling for odd MIDI clock messages with the same timestamp this.clockErrorCount+=1; } else { if(this.clockErrorCount>0) { console.log("Timestamp error count: ", this.clockErrorCount); console.log("Current timestamp: ", timestamp); console.log("Last timestamp: ", this.lastTimestamp); console.log("Last delta: ", this.midiClockDelta); console.log("Current delta: ", timestamp - this.lastTimestamp); console.log("BPMs", this.clockBuffer); this.clockErrorCount = 0; /* I dont know why this happens. But when it does, deltas for the following messages are off. So skipping ~ quarted of clock resolution usually helps */ this.skipOnError = this.settings.midi_clock_ppqn/4; timestamp = 0; // timestamp 0 == lastTimestamp 0 } else { this.midiClockDelta = timestamp - this.lastTimestamp; this.lastBPM = 60 * (1000 / this.midiClockDelta / this.settings.midi_clock_ppqn); this.clockBuffer.push(this.lastBPM); if(this.clockBuffer.length>this.clockBufferLength) this.clockBuffer.shift(); const estimatedBPM = this.estimatedBPM(); if(estimatedBPM !== this.roundedBPM) { console.log("Esimated BPM: ", estimatedBPM); this.api.bpm(estimatedBPM); this.roundedBPM = estimatedBPM; } } } } this.lastTimestamp = timestamp; } public estimatedBPM(): number { /** * Returns the estimated BPM based on the last 24 MIDI clock messages. * * @returns Estimated BPM */ const sum = this.clockBuffer.reduce((a, b) => a + b); return Math.round(sum / this.clockBuffer.length); } public sendMidiClock(): void { /** * Sends a single MIDI clock message to the currently selected MIDI output. */ if(!this.midiClockInput) { const output = this.midiOutputs[this.currentOutputIndex]; if (output) { output.send([0xf8]); // Send a single MIDI clock message } } } public switchMidiOutput(outputName: string): boolean { /** * Switches the currently selected MIDI output. * * @param outputName Name of the MIDI output to switch to * @returns True if the MIDI output was found and switched to, false otherwise */ const index = this.getMidiOutputIndex(outputName); if (index !== -1) { this.currentOutputIndex = index; return true; } else { return false; } } public getMidiOutputIndex(output: string | number): number { /** * Returns the index of the MIDI output with the specified name. * * @param outputName Name of the MIDI output * @returns Index of the new MIDI output or current output if new is not valid * */ if (typeof output === "number") { if (output < 0 || output >= this.midiOutputs.length) { console.error( `Invalid MIDI output index. Index must be in the range 0-${ this.midiOutputs.length - 1 }.` ); return this.currentOutputIndex; } else { return output; } } else { const index = this.midiOutputs.findIndex((o) => o.name === output); if (index !== -1) { return index; } else { console.error(`MIDI output "${output}" not found.`); return this.currentOutputIndex; } } } public getMidiInputIndex(input: string | number): number { /** * Returns the index of the MIDI input with the specified name. * * @param input Name or index of the MIDI input * @returns Index of the new MIDI input or -1 if not valid * */ if (typeof input === "number") { if (input < 0 || input >= this.midiInputs.length) { console.error( `Invalid MIDI input index. Index must be in the range 0-${ this.midiInputs.length - 1 }.` ); return -1; } else { return input; } } else { const index = this.midiInputs.findIndex((o) => o.name === input); if (index !== -1) { return index; } else { console.error(`MIDI input "${input}" not found.`); return -1; } } } public listMidiOutputs(): string { /** * Lists all available MIDI outputs to the console. */ let final_string = "Available MIDI Outputs: "; this.midiOutputs.forEach((output, index) => { final_string += `(${index + 1}) ${output.name} `; }); return final_string; } public sendMidiNote( noteNumber: number, channel: number, velocity: number, duration: number, port: number | string = this.currentOutputIndex, bend: number | undefined = undefined ): void { /** * Sending a MIDI Note on/off message with the same note number and channel. Automatically manages * the note off message after the specified duration. * * @param noteNumber MIDI note number (0-127) * @param channel MIDI channel (0-15) * @param velocity MIDI velocity (0-127) * @param duration Duration in milliseconds * */ if (typeof port === "string") port = this.getMidiOutputIndex(port); const output = this.midiOutputs[port]; noteNumber = Math.min(Math.max(noteNumber, 0), 127); if (output) { const noteOnMessage = [0x90 + channel, noteNumber, velocity]; const noteOffMessage = [0x80 + channel, noteNumber, 0]; // Send Note On output.send(noteOnMessage); if (bend) this.sendPitchBend(bend, channel, port); // Schedule Note Off const timeoutId = setTimeout(() => { output.send(noteOffMessage); if (bend) this.sendPitchBend(8192, channel, port); delete this.scheduledNotes[noteNumber]; }, (duration - 0.02) * 1000); this.scheduledNotes[noteNumber] = timeoutId; } else { console.error("MIDI output not available."); } } public sendSysExMessage(message: number[]): void { /** * Sends a SysEx message to the currently selected MIDI output. * * @param message Array of SysEx message bytes * * @example * // Send a SysEx message to set the pitch bend range to 12 semitones * sendSysExMessage([0xF0, 0x43, 0x10, 0x4C, 0x08, 0x00, 0x01, 0x00, 0x02, 0xF7]); */ const output = this.midiOutputs[this.currentOutputIndex]; if (output) { output.send(message); } else { console.error("MIDI output not available."); } } public sendPitchBend( value: number, channel: number, port: number | string = this.currentOutputIndex ): void { /** * Sends a MIDI Pitch Bend message to the currently selected MIDI output. * * @param value MIDI pitch bend value (0-16383) * @param channel MIDI channel (0-15) * */ if (value < 0 || value > 16383) { console.error( "Invalid pitch bend value. Value must be in the range 0-16383." ); } if (channel < 0 || channel > 15) { console.error("Invalid MIDI channel. Channel must be in the range 0-15."); } if (typeof port === "string") port = this.getMidiOutputIndex(port); const output = this.midiOutputs[port]; if (output) { const lsb = value & 0x7f; const msb = (value >> 7) & 0x7f; output.send([0xe0 | channel, lsb, msb]); } else { console.error("MIDI output not available."); } } public sendProgramChange(programNumber: number, channel: number): void { /** * Sends a MIDI Program Change message to the currently selected MIDI output. * * @param programNumber MIDI program number (0-127) * @param channel MIDI channel (0-15) * * @example * // Send a Program Change message to select program 1 on channel 1 * sendProgramChange(0, 0); */ const output = this.midiOutputs[this.currentOutputIndex]; if (output) { output.send([0xc0 + channel, programNumber]); // Program Change } else { console.error("MIDI output not available."); } } public sendMidiControlChange( controlNumber: number, value: number, channel: number ): void { /** * Sends a MIDI Control Change message to the currently selected MIDI output. * * @param controlNumber MIDI control number (0-127) * @param value MIDI control value (0-127) * @param channel MIDI channel (0-15) */ const output = this.midiOutputs[this.currentOutputIndex]; if (output) { output.send([0xb0 + channel, controlNumber, value]); // Control Change } else { console.error("MIDI output not available."); } } public panic(): void { /** * Sends a Note Off message for all scheduled notes. */ const output = this.midiOutputs[this.currentOutputIndex]; if (output) { for (const noteNumber in this.scheduledNotes) { const timeoutId = this.scheduledNotes[noteNumber]; clearTimeout(timeoutId); output.send([0x80, parseInt(noteNumber), 0]); // Note Off } this.scheduledNotes = {}; } else { console.error("MIDI output not available."); } } }