Merge Examples/RL tabs into single unified toolbar across all four UIs. Users can now freely mix supervised learning (Add Example + Train) with RL feedback (thumbs up/down) without switching modes. Param bar dragging is always enabled. Add Web MIDI input module (js/synth/midi-input.js) for external MIDI controllers — routes note on/off to C15 synth, CC 1/2 to joystick. Add gamepad module (js/ui/gamepad.js) with auto-detection, deadzone, and axis normalization across all UIs. Fix synth parameter sync: routeOutputs() now called after trainModel() in onThumbsUp and loadState to prevent stale params on first joystick move. Add separate Clear Examples button (dataset only, keeps weights).
157 lines
3.8 KiB
JavaScript
157 lines
3.8 KiB
JavaScript
// MIDI Input — receives notes and CCs from external MIDI controllers
|
|
// Uses Web MIDI API, routes note on/off to C15 bridge
|
|
|
|
export class MIDIInput {
|
|
constructor(bridge) {
|
|
this.bridge = bridge;
|
|
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; }
|
|
|
|
_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) {
|
|
this.bridge.noteOn(data1, data2 / 127);
|
|
} else {
|
|
// Velocity 0 = note off
|
|
this.bridge.noteOff(data1);
|
|
}
|
|
break;
|
|
|
|
case 0x80: // Note Off
|
|
this.bridge.noteOff(data1);
|
|
break;
|
|
|
|
case 0xb0: // Control Change
|
|
this._onCC?.(data1, data2 / 127);
|
|
break;
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
this.disable();
|
|
if (this.midiAccess) {
|
|
this.midiAccess.onstatechange = null;
|
|
}
|
|
this.midiAccess = null;
|
|
}
|
|
}
|