/* MIDI pedal learning. No MIDI access until explicitly enabled. */ (function(root) { 'use strict'; const actions = { toggle: 'Play / Pause', next: 'Next in setlist', repeat: 'Repeat current section', loop: 'Set / toggle A–B loop', panic: 'Panic — fade to silence' }; function decode(data) { if (!data || data.length < 3) return null; const type = data[0] & 240, channel = data[0] & 15; if (![128,144,176].includes(type)) return null; return { key: `${type === 176 ? 'cc' : 'note'}:${channel}:${data[1]}`, down: type === 176 ? data[2] >= 64 : type === 144 && data[2] > 0 }; } function createGate() { const held = new Set(); return { accept(port, message) { const key = `${port}:${message.key}`; const was = held.has(key); if (message.down) held.add(key); else held.delete(key); return message.down && !was; }, reset() { held.clear(); } }; } let adapter, access, learning = '', status = '', panelRefresh = () => {}, panicTimer; const gate = createGate(); function receive(event) { if (!adapter.settings().midiEnabled) return; const message = decode(event.data); if (!message || !gate.accept(event.target?.id || '', message)) return; const settings = adapter.settings(); if (learning) { const mappings = { ...(settings.midiMappings || {}) }; for (const [action, key] of Object.entries(mappings)) if (key === message.key) delete mappings[action]; mappings[learning] = message.key; settings.midiMappings = mappings; learning = ''; adapter.persist(); status = 'Mapping saved'; panelRefresh(); return; } const action = Object.keys(actions).find(action => settings.midiMappings?.[action] === message.key); if (action) adapter.action(action); } function attach() { gate.reset(); for (const input of access.inputs.values()) input.onmidimessage = input.state === 'disconnected' ? null : receive; status = `${[...access.inputs.values()].filter(p => p.state !== 'disconnected').length} MIDI input(s) connected`; panelRefresh(); } async function enable(on) { adapter.settings().midiEnabled = on; adapter.persist(); learning = ''; gate.reset(); if (!on) { if (access) for (const input of access.inputs.values()) input.onmidimessage = null; status = 'MIDI is off'; panelRefresh(); return; } try { access ||= await root.navigator.requestMIDIAccess({ sysex: false }); access.onstatechange = attach; attach(); } catch (error) { adapter.settings().midiEnabled = false; adapter.persist(); status = `MIDI access unavailable: ${error.message}`; panelRefresh(); } } function fade({ volume, setVolume, pause, restore }) { clearInterval(panicTimer); const start = Date.now(); panicTimer = setInterval(() => { const fraction = Math.max(0, 1 - (Date.now() - start) / 1000); setVolume(volume * fraction); if (!fraction) { clearInterval(panicTimer); pause(); restore(); } }, 25); } function configure(options) { adapter = options; root.SettingsSections.register({ id: 'midi', title: 'MIDI pedalboard', cluster: 'Live & worship', summary: () => adapter.settings().midiEnabled ? 'On · pedal mappings' : 'Off', render(container) { const doc = container.ownerDocument; if (!root.navigator.requestMIDIAccess) { const p = doc.createElement('p'); p.textContent = 'Web MIDI is unavailable in this browser. iPhone and iPad browsers do not support Web MIDI.'; container.append(p); return; } const label = doc.createElement('label'); label.className = 'set-row'; label.textContent = 'Enable MIDI pedalboard'; const toggle = doc.createElement('input'); toggle.type = 'checkbox'; toggle.checked = !!adapter.settings().midiEnabled; toggle.onchange = () => enable(toggle.checked); label.append(toggle); const info = doc.createElement('p'); info.setAttribute('role', 'status'); const rows = doc.createElement('div'); container.append(label, info, rows); const paint = () => { toggle.checked = !!adapter.settings().midiEnabled; info.textContent = learning ? `Press the pedal for ${actions[learning]}` : status || 'Enable MIDI to connect a pedalboard.'; rows.replaceChildren(); for (const [action, title] of Object.entries(actions)) { const row = doc.createElement('div'); row.className = 'download-job'; const text = doc.createElement('b'); text.textContent = `${title} · ${adapter.settings().midiMappings?.[action] || 'Unassigned'}`; const buttons = doc.createElement('div'); for (const name of ['Learn', 'Clear']) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = name; b.disabled = name === 'Learn' && !adapter.settings().midiEnabled; b.setAttribute('aria-label', `${name} MIDI ${title}`); b.onclick = () => { if (name === 'Learn') { learning = learning === action ? '' : action; gate.reset(); } else { delete adapter.settings().midiMappings?.[action]; adapter.persist(); } paint(); }; buttons.append(b); } row.append(text, buttons); rows.append(row); } }; panelRefresh = paint; paint(); return () => { if (panelRefresh === paint) panelRefresh = () => {}; learning = ''; }; } }); if (adapter.settings().midiEnabled && root.navigator.requestMIDIAccess) enable(true); } const api = { configure, decode, createGate, fade }; if (typeof module !== 'undefined') module.exports = api; else root.MidiPedals = api; })(typeof window !== 'undefined' ? window : globalThis);