/** * 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;