171 lines
5.2 KiB
JavaScript
171 lines
5.2 KiB
JavaScript
/**
|
|
* XZBT Control Bus & Local MIDI Controller
|
|
*
|
|
* XZBTControlBus is a small registry of named, settable console targets (ranges,
|
|
* toggles and triggers). Anything that wants to drive the console from outside the
|
|
* DOM -- MIDI here, potentially other transports later -- talks to the bus rather
|
|
* than reaching into app.js internals.
|
|
*
|
|
* XZBTMidiController maps Web MIDI CC / note messages onto those targets, with a
|
|
* MIDI-learn flow and bindings persisted to localStorage. Web MIDI is a browser
|
|
* API, not an external dependency: the offline single-file build is unaffected.
|
|
*/
|
|
|
|
class XZBTControlBus {
|
|
constructor() {
|
|
this.targets = new Map();
|
|
this.listeners = new Set();
|
|
}
|
|
register(id, spec) {
|
|
this.targets.set(id, { id, ...spec });
|
|
}
|
|
set(id, value, source = 'external') {
|
|
const t = this.targets.get(id);
|
|
if (!t) return false;
|
|
let v = value;
|
|
if (t.type === 'range') {
|
|
v = Math.max(t.min, Math.min(t.max, Number(value)));
|
|
if (t.step) v = Math.round(v / t.step) * t.step;
|
|
}
|
|
if (t.apply) t.apply(v, source);
|
|
this.listeners.forEach(fn => fn({ id, value: v, source, target: t }));
|
|
return true;
|
|
}
|
|
trigger(id, source = 'external') {
|
|
const t = this.targets.get(id);
|
|
if (!t) return false;
|
|
if (t.apply) t.apply(true, source);
|
|
this.listeners.forEach(fn => fn({ id, value: true, source, target: t }));
|
|
return true;
|
|
}
|
|
list() {
|
|
return [...this.targets.values()];
|
|
}
|
|
}
|
|
window.XZBTControlBus = XZBTControlBus;
|
|
|
|
class XZBTMidiController {
|
|
constructor(bus) {
|
|
this.bus = bus;
|
|
this.access = null;
|
|
this.bindings = {};
|
|
this.learningTarget = null;
|
|
this.ui = {};
|
|
this.storageKey = 'xzbt-midi-bindings-v02';
|
|
}
|
|
bindUI() {
|
|
const g = id => document.getElementById(id);
|
|
this.ui = {
|
|
status: g('midi-device-status'),
|
|
enable: g('btn-midi-enable'),
|
|
clear: g('btn-midi-clear'),
|
|
target: g('midi-target-select'),
|
|
learn: g('btn-midi-learn'),
|
|
bindings: g('midi-bindings')
|
|
};
|
|
if (!this.ui.enable) return;
|
|
this.load();
|
|
this.refreshTargets();
|
|
this.render();
|
|
this.ui.enable.onclick = () => this.enable();
|
|
this.ui.clear.onclick = () => {
|
|
this.bindings = {};
|
|
this.save();
|
|
this.render();
|
|
};
|
|
this.ui.learn.onclick = () => {
|
|
this.learningTarget = this.ui.target.value || null;
|
|
this.ui.learn.classList.toggle('midi-learn-active', !!this.learningTarget);
|
|
this.ui.learn.textContent = this.learningTarget
|
|
? 'MOVE A MIDI CONTROL NOW...'
|
|
: 'SELECT A TARGET FIRST';
|
|
};
|
|
}
|
|
refreshTargets() {
|
|
if (!this.ui.target) return;
|
|
this.ui.target.innerHTML = '';
|
|
this.bus.list().forEach(t => {
|
|
const o = document.createElement('option');
|
|
o.value = t.id;
|
|
o.textContent = t.label || t.id;
|
|
this.ui.target.appendChild(o);
|
|
});
|
|
}
|
|
async enable() {
|
|
if (!navigator.requestMIDIAccess) {
|
|
this.ui.status.textContent = 'WEB MIDI IS NOT AVAILABLE IN THIS BROWSER.';
|
|
return;
|
|
}
|
|
try {
|
|
this.access = await navigator.requestMIDIAccess({ sysex: false });
|
|
this.attach();
|
|
this.access.onstatechange = () => this.attach();
|
|
this.ui.status.textContent = `MIDI ONLINE - ${[...this.access.inputs.values()].map(x => x.name).join(', ') || 'NO INPUT DEVICES'}`;
|
|
} catch (e) {
|
|
this.ui.status.textContent = `MIDI ERROR - ${e.message || e}`;
|
|
}
|
|
}
|
|
attach() {
|
|
if (!this.access) return;
|
|
for (const input of this.access.inputs.values())
|
|
input.onmidimessage = e => this.onMessage(e, input);
|
|
}
|
|
keyFor(input, status, d1) {
|
|
const cmd = status & 0xf0,
|
|
ch = (status & 0x0f) + 1;
|
|
return `${input.id}|${cmd}|${ch}|${d1}`;
|
|
}
|
|
onMessage(e, input) {
|
|
const [status, d1, d2] = e.data,
|
|
cmd = status & 0xf0;
|
|
if (cmd !== 0xb0 && cmd !== 0x90 && cmd !== 0x80) return;
|
|
const key = this.keyFor(input, status, d1);
|
|
if (this.learningTarget) {
|
|
this.bindings[key] = {
|
|
target: this.learningTarget,
|
|
inputName: input.name || 'MIDI',
|
|
cmd,
|
|
channel: (status & 15) + 1,
|
|
data1: d1
|
|
};
|
|
this.learningTarget = null;
|
|
this.ui.learn.classList.remove('midi-learn-active');
|
|
this.ui.learn.textContent = 'MIDI LEARN: SELECT TARGET, THEN MOVE CONTROL';
|
|
this.save();
|
|
this.render();
|
|
return;
|
|
}
|
|
const b = this.bindings[key];
|
|
if (!b) return;
|
|
const t = this.bus.targets.get(b.target);
|
|
if (!t) return;
|
|
if (t.type === 'action') {
|
|
if (cmd === 0x90 && d2 > 0) this.bus.trigger(b.target, 'midi');
|
|
} else {
|
|
const norm = d2 / 127;
|
|
this.bus.set(b.target, t.min + norm * (t.max - t.min), 'midi');
|
|
}
|
|
}
|
|
save() {
|
|
try {
|
|
localStorage.setItem(this.storageKey, JSON.stringify(this.bindings));
|
|
} catch (_) {}
|
|
}
|
|
load() {
|
|
try {
|
|
this.bindings = JSON.parse(localStorage.getItem(this.storageKey) || '{}') || {};
|
|
} catch (_) {
|
|
this.bindings = {};
|
|
}
|
|
}
|
|
render() {
|
|
if (!this.ui.bindings) return;
|
|
const rows = Object.values(this.bindings).map(
|
|
b =>
|
|
`${b.inputName} CH${b.channel} ${b.cmd === 0xb0 ? 'CC' : 'NOTE'} ${b.data1} -> ${b.target}`
|
|
);
|
|
this.ui.bindings.textContent = rows.join('\n') || 'NO MIDI BINDINGS';
|
|
}
|
|
}
|
|
window.XZBTMidiController = XZBTMidiController;
|