Add learnable MIDI pedal controls with hot-plug support
This commit is contained in:
@@ -4543,6 +4543,7 @@ const Notes = (() => {
|
|||||||
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
|
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
|
||||||
jumpChapter,
|
jumpChapter,
|
||||||
openChapterPicker,
|
openChapterPicker,
|
||||||
|
repeatSection: () => { const cs = chapters().filter(c => c.t <= now()).sort((a, b) => a.t - b.t); Player.seek(abA ?? cs.at(-1)?.t ?? 0); },
|
||||||
chapterAt: (t) => { const c = chapters().slice().sort((a, b) => a.t - b.t); let k = -1; c.forEach((x, i) => { if (x.t <= t) k = i; }); return k < 0 ? null : { n: k + 1, of: c.length, title: c[k].title || '' }; },
|
chapterAt: (t) => { const c = chapters().slice().sort((a, b) => a.t - b.t); let k = -1; c.forEach((x, i) => { if (x.t <= t) k = i; }); return k < 0 ? null : { n: k + 1, of: c.length, title: c[k].title || '' }; },
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@@ -12448,6 +12449,13 @@ async function boot() {
|
|||||||
LyricsWindow.configure({ prepare: () => { if (!isServiceModeOpen()) Notes.stageLyrics.set(true); }, view: () => isServiceModeOpen() && data.settings.serviceLyrics ? $('serviceLyrics') : $('stageLyrics'), line: () => $('serviceLyricsList')?.querySelector('.active')?.textContent || $('stageLyricsList')?.querySelector('.active')?.textContent || current?.meta?.title || 'Nothing playing', toggle: () => { Player.toggle(); updatePlayBtn(); }, next: playNext, refresh: () => { Notes.stageLyrics.render(true); Notes.serviceRefresh(); }, toast });
|
LyricsWindow.configure({ prepare: () => { if (!isServiceModeOpen()) Notes.stageLyrics.set(true); }, view: () => isServiceModeOpen() && data.settings.serviceLyrics ? $('serviceLyrics') : $('stageLyrics'), line: () => $('serviceLyricsList')?.querySelector('.active')?.textContent || $('stageLyricsList')?.querySelector('.active')?.textContent || current?.meta?.title || 'Nothing playing', toggle: () => { Player.toggle(); updatePlayBtn(); }, next: playNext, refresh: () => { Notes.stageLyrics.render(true); Notes.serviceRefresh(); }, toast });
|
||||||
EQ.renderButton();
|
EQ.renderButton();
|
||||||
EQ.apply();
|
EQ.apply();
|
||||||
|
MidiPedals.configure({ settings: () => data.settings, persist, action: action => {
|
||||||
|
if (action === 'toggle') { Player.toggle(); updatePlayBtn(); }
|
||||||
|
else if (action === 'next') playNext();
|
||||||
|
else if (action === 'repeat') Notes.repeatSection?.();
|
||||||
|
else if (action === 'loop') { if (abA === null) setAbA(); else if (abB === null) setAbB(); else clearAb(); }
|
||||||
|
else if (action === 'panic') MidiPedals.fade({ volume: Player.soundEl.volume, setVolume: v => { Player.soundEl.volume = v; }, pause: () => { Player.pause(); updatePlayBtn(); }, restore: () => Player.applyVolume() });
|
||||||
|
} });
|
||||||
applyAppearance();
|
applyAppearance();
|
||||||
updateProfileStatus();
|
updateProfileStatus();
|
||||||
updateLoopRepeatButtons();
|
updateLoopRepeatButtons();
|
||||||
|
|||||||
@@ -657,6 +657,7 @@
|
|||||||
<script src="downloads.js"></script>
|
<script src="downloads.js"></script>
|
||||||
<script src="party-dj.js"></script>
|
<script src="party-dj.js"></script>
|
||||||
<script src="lyrics-window.js"></script>
|
<script src="lyrics-window.js"></script>
|
||||||
|
<script src="midi.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
37
frontend/midi.js
Normal file
37
frontend/midi.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/* 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);
|
||||||
4
frontend/midi.test.js
Normal file
4
frontend/midi.test.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
const { test } = require('node:test'), assert = require('node:assert/strict');
|
||||||
|
const { decode, createGate } = require('./midi');
|
||||||
|
test('MIDI note-off and zero velocity release notes; CC uses switch threshold', () => { assert.deepEqual(decode([0x92, 60, 80]), { key: 'note:2:60', down: true }); assert.equal(decode([0x82, 60, 64]).down, false); assert.equal(decode([0x92, 60, 0]).down, false); assert.equal(decode([0xb0, 64, 63]).down, false); assert.equal(decode([0xb0, 64, 64]).down, true); assert.equal(decode([0xf8]), null); });
|
||||||
|
test('held pedals trigger once, releases re-arm, different devices remain independent', () => { const gate = createGate(), down = decode([0xb0, 64, 127]), up = decode([0xb0, 64, 0]); assert.equal(gate.accept('one', down), true); assert.equal(gate.accept('one', down), false); assert.equal(gate.accept('two', down), true); gate.accept('one', up); assert.equal(gate.accept('one', down), true); gate.reset(); assert.equal(gate.accept('two', down), true); });
|
||||||
@@ -86,6 +86,7 @@ const SHELL = [
|
|||||||
'/downloads.js',
|
'/downloads.js',
|
||||||
'/party-dj.js',
|
'/party-dj.js',
|
||||||
'/lyrics-window.js',
|
'/lyrics-window.js',
|
||||||
|
'/midi.js',
|
||||||
'/settings-sections.css',
|
'/settings-sections.css',
|
||||||
'/app.js',
|
'/app.js',
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
|
|||||||
Reference in New Issue
Block a user