Files

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;