pm_e-2: MIDI out — drive external gear (drum module / e-kit) from the editor

engine.js: add an opt-in per-hit hook in scheduleMeterTick — onMeterHit(sound,
time, lvl) — called only if a page defines it (no-op everywhere else). Lets a
page emit MIDI per scheduled hit, in lockstep with the audio scheduler.

pm_e-2.html: a "🎛 MIDI out" header toggle + output-port picker. When on, each
groove hit is sent as a GM drum Note-On (channel 10; note from SOUND_GM e.g.
kick*->36, snare*->38, hat*->42; velocity by accent/normal/ghost) to the chosen
port via output.send([..], ts) with a timestamp derived from the hits audio time
(performance.now() + (time - audioCtx.currentTime)*1000) for tight sync; a note-off
follows 60ms later. Port list prefers a non-PM output (the external gear) and
refreshes on MIDI connect/disconnect. Independent of the local synth + Device
audio; goes green (blue) when on. Web MIDI (Chrome/Edge/Firefox).

Conformance suite still green (engine.js change is in the scheduler, not the codec).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-04 12:41:01 -05:00
parent 964dee01d6
commit 34e0d24aad
2 changed files with 53 additions and 1 deletions

View file

@ -298,6 +298,9 @@
title="Device link (USB-MIDI). Click to connect — Chrome, Edge or Firefox. Turns green and shows the name while a PM device (PM_K-1 / PM_X-1 / PM_G-1) is plugged in; ◎ means none is detected. This only reports the connection — it does not make sound on its own.">◎ connect device</span> title="Device link (USB-MIDI). Click to connect — Chrome, Edge or Firefox. Turns green and shows the name while a PM device (PM_K-1 / PM_X-1 / PM_G-1) is plugged in; ◎ means none is detected. This only reports the connection — it does not make sound on its own.">◎ connect device</span>
<button id="midiBtn" class="devctrl" <button id="midiBtn" class="devctrl"
title="Device audio — on/off switch. When ON, the notes a connected device sends over USB-MIDI are played through THIS computer's speakers (the device drives the sound, locked to its clock). You can switch it on before plugging in — it doesn't need a device to toggle; plug one in while it's on and it sounds through the computer.">🎹 Device audio</button> title="Device audio — on/off switch. When ON, the notes a connected device sends over USB-MIDI are played through THIS computer's speakers (the device drives the sound, locked to its clock). You can switch it on before plugging in — it doesn't need a device to toggle; plug one in while it's on and it sounds through the computer.">🎹 Device audio</button>
<button id="midiOutBtn" class="devctrl"
title="MIDI out — on/off switch. When ON, the groove is sent as MIDI notes to external gear (a drum module / e-kit) on the output port chosen at right: GM drum notes on channel 10, scheduled tightly in sync with playback. Independent of the local synth and of Device audio. Web MIDI · Chrome/Edge/Firefox.">🎛 MIDI out</button>
<select id="midiOutSel" class="devctrl" title="MIDI output port to drive (your drum module / e-kit)" hidden></select>
<button id="helpBtn" title="keyboard shortcuts (?)">?</button> <button id="helpBtn" title="keyboard shortcuts (?)">?</button>
</div> </div>
</div> </div>
@ -1242,7 +1245,7 @@ async function _ensureMidi() { // MIDI access WITH SysEx (needed to send/
_midiAccess.onstatechange = _wireMidi; _wireMidi(); _midiAccess.onstatechange = _wireMidi; _wireMidi();
return true; return true;
} }
function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); updateDevBadge(); } function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); updateDevBadge(); populateMidiOutPorts(); }
function updateDevBadge() { // header badge: lights green while a PM_K-1 / PM_X-1 is connected over USB-MIDI function updateDevBadge() { // header badge: lights green while a PM_K-1 / PM_X-1 is connected over USB-MIDI
const el = $("devBadge"); if (!el) return; const el = $("devBadge"); if (!el) return;
const dev = _midiAccess ? [..._midiOutputs(), ..._midiInputs()].filter(_isDevicePort) : []; const dev = _midiAccess ? [..._midiOutputs(), ..._midiInputs()].filter(_isDevicePort) : [];
@ -1291,6 +1294,51 @@ async function toggleDeviceAudio() {
? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green per note." ? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green per note."
: "Armed, but no MIDI input yet. Plug in the PM_K-1 (CircuitPython firmware) — it connects automatically."); : "Armed, but no MIDI input yet. Plug in the PM_K-1 (CircuitPython firmware) — it connects automatically.");
} }
/* MIDI OUT: send the groove as GM drum notes to a chosen output port, to drive external gear
(a drum module / e-kit). Hooks engine.js's per-hit `onMeterHit`, scheduled in sync with audio. */
const SOUND_GM = {
kick:36, kick808:36, kick909:36, snare:38, snare808:38, snare909:38,
clap:39, clap808:39, clap909:39, rim:37,
hatClosed:42, hat808:42, hat909:42, hatOpen:46, openHat808:46,
ride:51, ride909:51, crash:49, crash909:49,
tomLow:41, tom808:45, tomMid:45, tomHigh:48, tambourine:54,
cowbell:56, cowbell808:56, woodblock:76, jamblock:76, claves:75, beep:37,
};
let _midiOutOn = false;
function _midiOutTarget() {
const sel = $("midiOutSel"); if (!sel) return null;
return _midiOutputs().find((o) => o.id === sel.value) || null;
}
function populateMidiOutPorts() {
const sel = $("midiOutSel"); if (!sel) return;
const outs = _midiOutputs(), prev = sel.value;
sel.innerHTML = outs.length
? outs.map((o) => `<option value="${o.id}">${(o.name || "output").slice(0, 22)}</option>`).join("")
: `<option value="">(no MIDI outputs)</option>`;
if (outs.some((o) => o.id === prev)) sel.value = prev; // keep prior choice
else { const ext = outs.find((o) => !_isDevicePort(o)); sel.value = ((ext || outs[0] || {}).id) || ""; } // prefer external gear
}
// engine.js calls this per scheduled hit (sound name, audio-context time, level 1/2/3)
function onMeterHit(sound, time, lvl) {
if (!_midiOutOn) return;
const out = _midiOutTarget(); if (!out) return;
const note = SOUND_GM[sound]; if (note == null) return;
const vel = lvl === 2 ? 112 : lvl === 3 ? 45 : 90; // accent / ghost / normal
const ts = (audioCtx ? performance.now() + (time - audioCtx.currentTime) * 1000 : performance.now());
try { out.send([0x99, note, vel], ts); out.send([0x89, note, 0], ts + 60); } catch (_) {} // note-on, then off (ch10)
}
function updateMidiOutBtn() {
const b = $("midiOutBtn"), sel = $("midiOutSel"); if (!b) return;
if (_midiOutOn) { b.style.color = "#7ab8ff"; b.style.borderColor = "#7ab8ff"; if (sel) sel.hidden = false; }
else { b.style.color = "var(--muted)"; b.style.borderColor = "var(--edge)"; if (sel) sel.hidden = true; }
}
async function toggleMidiOut() {
if (_midiOutOn) { _midiOutOn = false; updateMidiOutBtn(); return; }
if (!(await _ensureMidi())) return alert("Driving external MIDI gear needs the Web MIDI API — use Chrome, Edge, or Firefox.");
populateMidiOutPorts();
if (!_midiOutputs().length) return alert("No MIDI output ports found. Connect your drum module / e-kit (USB-MIDI) and try again.");
_midiOutOn = true; updateMidiOutBtn();
}
function _queryDeviceVersion() { // ask the device its firmware version (SysEx 0x02 -> reply 0x03) function _queryDeviceVersion() { // ask the device its firmware version (SysEx 0x02 -> reply 0x03)
return new Promise((res) => { _verCb = res; _send([0xF0, 0x7D, 0x02, 0xF7]); setTimeout(() => { if (_verCb) { _verCb = null; res(null); } }, 1500); }); return new Promise((res) => { _verCb = res; _send([0xF0, 0x7D, 0x02, 0xF7]); setTimeout(() => { if (_verCb) { _verCb = null; res(null); } }, 1500); });
} }
@ -1719,6 +1767,7 @@ $("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true
$("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); }); $("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); });
$("updateFwBtn").addEventListener("click", () => { $("trayMenu").hidden = true; updateFirmware(); }); $("updateFwBtn").addEventListener("click", () => { $("trayMenu").hidden = true; updateFirmware(); });
$("midiBtn").addEventListener("click", toggleDeviceAudio); $("midiBtn").addEventListener("click", toggleDeviceAudio);
$("midiOutBtn").addEventListener("click", toggleMidiOut);
$("devBadge").addEventListener("click", () => { _ensureMidi().then(updateDevBadge); }); $("devBadge").addEventListener("click", () => { _ensureMidi().then(updateDevBadge); });
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); }); $("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); }); $("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });

View file

@ -157,6 +157,9 @@ function scheduleMeterTick(m, time) {
if (!lvl) return; if (!lvl) return;
const lin = m.gainDb ? Math.pow(10, m.gainDb / 20) : 1; // per-lane dB gain → linear, applied at schedule time (no stutter) const lin = m.gainDb ? Math.pow(10, m.gainDb / 20) : 1; // per-lane dB gain → linear, applied at schedule time (no stutter)
playInstrument(m.sound, time, (lvl === 2 ? 1.0 : lvl === 3 ? 0.25 : 0.6) * lin); playInstrument(m.sound, time, (lvl === 2 ? 1.0 : lvl === 3 ? 0.25 : 0.6) * lin);
// opt-in per-hit hook (a page may define onMeterHit to e.g. emit MIDI out to external gear);
// (sound name, audio-context time of the hit, dynamic level 1/2/3). No-op on pages that don't set it.
if (typeof onMeterHit === "function") onMeterHit(m.sound, time, lvl);
} }
function refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); } function refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); }