2026-03-22 10:01:50 +01:00
|
|
|
// MIDI Input — receives notes and CCs from external MIDI controllers
|
2026-04-03 19:20:29 +02:00
|
|
|
// Uses Web MIDI API, routes note on/off through a SynthEngine interface
|
2026-03-22 10:01:50 +01:00
|
|
|
|
|
|
|
|
export class MIDIInput {
|
2026-04-03 19:20:29 +02:00
|
|
|
constructor(engine) {
|
|
|
|
|
this.engine = engine;
|
2026-03-22 10:01:50 +01:00
|
|
|
this.enabled = false;
|
|
|
|
|
this.midiAccess = null;
|
|
|
|
|
this.selectedInputId = null;
|
|
|
|
|
this.activeInput = null;
|
|
|
|
|
this._onStatusChange = null;
|
|
|
|
|
this._onInputsChange = null;
|
|
|
|
|
this._onCC = null; // optional: (ccNumber, value01) => void
|
|
|
|
|
this._boundOnMessage = this._onMessage.bind(this);
|
|
|
|
|
this._boundOnStateChange = this._onStateChange.bind(this);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set onStatusChange(fn) { this._onStatusChange = fn; }
|
|
|
|
|
set onInputsChange(fn) { this._onInputsChange = fn; }
|
|
|
|
|
set onCC(fn) { this._onCC = fn; }
|
|
|
|
|
|
2026-04-03 19:20:29 +02:00
|
|
|
/** Hot-swap the engine (e.g. when the user switches synth engines). */
|
|
|
|
|
setEngine(engine) { this.engine = engine; }
|
|
|
|
|
|
2026-03-22 10:01:50 +01:00
|
|
|
_status(msg) {
|
|
|
|
|
console.log('[MIDI]', msg);
|
|
|
|
|
this._onStatusChange?.(msg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Request MIDI access. Returns true if available. */
|
|
|
|
|
async init() {
|
|
|
|
|
if (this.midiAccess) return true;
|
|
|
|
|
|
|
|
|
|
if (!navigator.requestMIDIAccess) {
|
|
|
|
|
this._status('Web MIDI not supported');
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.midiAccess = await navigator.requestMIDIAccess({ sysex: false });
|
|
|
|
|
this.midiAccess.onstatechange = this._boundOnStateChange;
|
|
|
|
|
this._status('MIDI available');
|
|
|
|
|
return true;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this._status(`MIDI access denied: ${err.message}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Get list of available MIDI inputs as [{id, name}] */
|
|
|
|
|
getInputs() {
|
|
|
|
|
if (!this.midiAccess) return [];
|
|
|
|
|
const inputs = [];
|
|
|
|
|
for (const [id, input] of this.midiAccess.inputs) {
|
|
|
|
|
inputs.push({ id, name: input.name || `MIDI Input ${id}` });
|
|
|
|
|
}
|
|
|
|
|
return inputs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Select a specific MIDI input by id, or null for all inputs */
|
|
|
|
|
selectInput(inputId) {
|
|
|
|
|
// Disconnect previous
|
|
|
|
|
this._disconnectInput();
|
|
|
|
|
|
|
|
|
|
this.selectedInputId = inputId;
|
|
|
|
|
|
|
|
|
|
if (!this.enabled) return;
|
|
|
|
|
this._connectInput();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Enable MIDI input routing to the synth */
|
|
|
|
|
enable() {
|
|
|
|
|
if (this.enabled) return;
|
|
|
|
|
this.enabled = true;
|
|
|
|
|
this._connectInput();
|
|
|
|
|
this._status('MIDI enabled');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Disable MIDI input routing */
|
|
|
|
|
disable() {
|
|
|
|
|
if (!this.enabled) return;
|
|
|
|
|
this.enabled = false;
|
|
|
|
|
this._disconnectInput();
|
|
|
|
|
this._status('MIDI disabled');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggle() {
|
|
|
|
|
if (this.enabled) this.disable();
|
|
|
|
|
else this.enable();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_connectInput() {
|
|
|
|
|
if (!this.midiAccess || !this.enabled) return;
|
|
|
|
|
|
|
|
|
|
if (this.selectedInputId === null) {
|
|
|
|
|
// Listen on all inputs
|
|
|
|
|
for (const [, input] of this.midiAccess.inputs) {
|
|
|
|
|
input.onmidimessage = this._boundOnMessage;
|
|
|
|
|
}
|
|
|
|
|
this.activeInput = null;
|
|
|
|
|
} else {
|
|
|
|
|
const input = this.midiAccess.inputs.get(this.selectedInputId);
|
|
|
|
|
if (input) {
|
|
|
|
|
input.onmidimessage = this._boundOnMessage;
|
|
|
|
|
this.activeInput = input;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_disconnectInput() {
|
|
|
|
|
if (!this.midiAccess) return;
|
|
|
|
|
for (const [, input] of this.midiAccess.inputs) {
|
|
|
|
|
input.onmidimessage = null;
|
|
|
|
|
}
|
|
|
|
|
this.activeInput = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_onStateChange(e) {
|
|
|
|
|
// MIDI device connected/disconnected
|
|
|
|
|
this._onInputsChange?.(this.getInputs());
|
|
|
|
|
|
|
|
|
|
if (this.enabled) {
|
|
|
|
|
// Reconnect in case the selected device was re-plugged
|
|
|
|
|
this._disconnectInput();
|
|
|
|
|
this._connectInput();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_onMessage(e) {
|
|
|
|
|
const [status, data1, data2] = e.data;
|
|
|
|
|
const type = status & 0xf0;
|
|
|
|
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 0x90: // Note On
|
|
|
|
|
if (data2 > 0) {
|
2026-04-03 19:20:29 +02:00
|
|
|
this.engine.noteOn(data1, data2 / 127);
|
2026-03-22 10:01:50 +01:00
|
|
|
} else {
|
|
|
|
|
// Velocity 0 = note off
|
2026-04-03 19:20:29 +02:00
|
|
|
this.engine.noteOff(data1);
|
2026-03-22 10:01:50 +01:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 0x80: // Note Off
|
2026-04-03 19:20:29 +02:00
|
|
|
this.engine.noteOff(data1);
|
2026-03-22 10:01:50 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 0xb0: // Control Change
|
|
|
|
|
this._onCC?.(data1, data2 / 127);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
|
this.disable();
|
|
|
|
|
if (this.midiAccess) {
|
|
|
|
|
this.midiAccess.onstatechange = null;
|
|
|
|
|
}
|
|
|
|
|
this.midiAccess = null;
|
|
|
|
|
}
|
|
|
|
|
}
|