diff --git a/pm_e-2.html b/pm_e-2.html index 5a41635..d1b99cc 100644 --- a/pm_e-2.html +++ b/pm_e-2.html @@ -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 + + @@ -1242,7 +1245,7 @@ async function _ensureMidi() { // MIDI access WITH SysEx (needed to send/ _midiAccess.onstatechange = _wireMidi; _wireMidi(); 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 const el = $("devBadge"); if (!el) return; 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." : "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) => ``).join("") + : ``; + 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) 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(); }); $("updateFwBtn").addEventListener("click", () => { $("trayMenu").hidden = true; updateFirmware(); }); $("midiBtn").addEventListener("click", toggleDeviceAudio); +$("midiOutBtn").addEventListener("click", toggleMidiOut); $("devBadge").addEventListener("click", () => { _ensureMidi().then(updateDevBadge); }); $("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); }); $("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); }); diff --git a/src/engine.js b/src/engine.js index 375ac9c..8d6dab8 100644 --- a/src/engine.js +++ b/src/engine.js @@ -157,6 +157,9 @@ function scheduleMeterTick(m, time) { 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) 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); }