midi-out: 24-PPQN clock + Start/Stop; shared src/midiout.js; mirror into editor.html
- Extract MIDI-out into a shared partial src/midiout.js (one copy, no drift); both editors @BUILD:include it. The page wires three transport hooks: midiOutStart(t0) in start(), midiOutStop() in stop(), midiOutClock(ahead) at end of scheduler(); engine.js calls onMeterHit() per hit. - Clock-out: a "clock" checkbox (default on) appears with the port picker. When on: MIDI Start (0xFA) at the downbeat, 24-PPQN clock (0xF8) scheduled across the audio look-ahead window (timestamped, tracks tempo/ramp, stays tight), Stop (0xFC) on stop. Guarded against starve-looping at extreme tempos. - Mirror the feature into editor.html (PM_E-1): header .devctrl pills, the include, _wireMidi port refresh, transport hooks, listener, and the _isDevicePort fix (recognizes PM_G-1 Grid etc.). Conformance suite still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
34e0d24aad
commit
0bec13abab
3 changed files with 103 additions and 49 deletions
28
editor.html
28
editor.html
|
|
@ -257,6 +257,11 @@
|
||||||
.menu button { text-align:left; }
|
.menu button { text-align:left; }
|
||||||
/* embed mode: drop the header + legend, keep the editor */
|
/* embed mode: drop the header + legend, keep the editor */
|
||||||
[data-embed] .appheader, [data-embed] .kbd-legend { display:none !important; }
|
[data-embed] .appheader, [data-embed] .kbd-legend { display:none !important; }
|
||||||
|
/* device-link badge + MIDI-out controls in the header — matching pills (state via colour in JS) */
|
||||||
|
.devctrl{ font:inherit; font-size:12px; line-height:1.5; padding:3px 10px; border-radius:7px;
|
||||||
|
border:1px solid var(--edge); background:transparent; color:var(--muted);
|
||||||
|
white-space:nowrap; cursor:pointer; }
|
||||||
|
.devctrl:hover{ border-color:var(--muted); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -272,7 +277,13 @@
|
||||||
</a>
|
</a>
|
||||||
<h1 style="margin:0">PM_E‑1 <span style="font-weight:400; opacity:.75">PolyMeter Editor</span></h1>
|
<h1 style="margin:0">PM_E‑1 <span style="font-weight:400; opacity:.75">PolyMeter Editor</span></h1>
|
||||||
<div class="appheader-ctrls" style="display:flex; align-items:center; gap:10px">
|
<div class="appheader-ctrls" style="display:flex; align-items:center; gap:10px">
|
||||||
<span id="devBadge" title="USB-MIDI link to a PM_K-1 / PM_X-1 (Chrome/Edge/Firefox). Click to connect." style="cursor:pointer; font-size:12px; padding:3px 9px; border-radius:7px; border:1px solid var(--edge); color:var(--muted); white-space:nowrap">◎ connect device</span>
|
<span id="devBadge" class="devctrl"
|
||||||
|
title="Device link (USB-MIDI). Click to connect — Chrome, Edge or Firefox. Turns green and shows the name while a PM device is plugged in; ◎ means none is detected.">◎ connect device</span>
|
||||||
|
<button id="midiOutBtn" class="devctrl"
|
||||||
|
title="MIDI out — on/off. When ON, the groove is sent as MIDI notes (GM drums, channel 10) to external gear (a drum module / e-kit) on the output port chosen at right, in sync with playback. Optional 24-PPQN clock locks the gear's tempo. 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>
|
||||||
|
<label id="midiClkWrap" class="devctrl" hidden style="display:inline-flex; align-items:center; gap:5px"
|
||||||
|
title="Also send 24-PPQN MIDI clock + Start/Stop on this port, so the gear's tempo/sequencer locks to the editor."><input type="checkbox" id="midiClkChk" checked style="margin:0"> clock</label>
|
||||||
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
|
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -543,6 +554,7 @@ function scheduler() {
|
||||||
}
|
}
|
||||||
// Boundary reached → swap to the new segment seamlessly (scheduler keeps running).
|
// Boundary reached → swap to the new segment seamlessly (scheduler keeps running).
|
||||||
if (pendingSwitch && masterBeatTime >= pendingSwitch.atTime - 1e-9) performCutover(pendingSwitch);
|
if (pendingSwitch && masterBeatTime >= pendingSwitch.atTime - 1e-9) performCutover(pendingSwitch);
|
||||||
|
midiOutClock(ahead); // schedule 24-PPQN MIDI clock ticks across the same look-ahead window
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
|
|
@ -555,10 +567,12 @@ function start() {
|
||||||
const t0 = audioCtx.currentTime + 0.08;
|
const t0 = audioCtx.currentTime + 0.08;
|
||||||
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; }
|
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; }
|
||||||
masterBeat = 0; masterBeatTime = t0; muteWindows = [];
|
masterBeat = 0; masterBeatTime = t0; muteWindows = [];
|
||||||
|
midiOutStart(t0); // MIDI clock Start (if MIDI out + clock on)
|
||||||
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
|
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
|
||||||
scheduler(); syncStartBtn();
|
scheduler(); syncStartBtn();
|
||||||
}
|
}
|
||||||
function stop() {
|
function stop() {
|
||||||
|
midiOutStop(); // MIDI clock Stop (if MIDI out + clock on)
|
||||||
state.running = false;
|
state.running = false;
|
||||||
clearInterval(schedulerTimer); schedulerTimer = null;
|
clearInterval(schedulerTimer); schedulerTimer = null;
|
||||||
pendingSwitch = null; segBarCount = 0; // drop any armed switch so it can't fire on next start
|
pendingSwitch = null; segBarCount = 0; // drop any armed switch so it can't fire on next start
|
||||||
|
|
@ -1217,10 +1231,14 @@ async function loadFromDevice() {
|
||||||
let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null, _verCb = null;
|
let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null, _verCb = null;
|
||||||
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
|
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
|
||||||
function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; }
|
function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; }
|
||||||
function _isDevicePort(p) { // recognise PM_K-1 (Pico) and PM_X-1 (Pimoroni Explorer RP2350) USB-MIDI ports;
|
function _isDevicePort(p) { // recognise the PM devices' USB-MIDI ports by name;
|
||||||
const n = (p.name || "").toLowerCase(); // anything unrecognised triggers a broadcast to all outputs - bad for ACK routing.
|
const n = (p.name || "").toLowerCase(); // anything unrecognised triggers a broadcast to all outputs - bad for ACK routing.
|
||||||
return n.includes("pico") || n.includes("circuitpython") || n.includes("usb_midi") ||
|
return n.includes("pico") || n.includes("circuitpython") || n.includes("usb_midi") ||
|
||||||
n.includes("pimoroni") || n.includes("explorer") || n.includes("rp2350") || n.includes("varasys");
|
n.includes("pimoroni") || n.includes("explorer") || n.includes("rp2350") || n.includes("varasys") ||
|
||||||
|
// native Rust firmware enumerates as e.g. "PM_G-1 Grid" / "PM_K-1" / "PM_X-1" (VARASYS / PolyMeter)
|
||||||
|
n.includes("pm_g") || n.includes("pm_k") || n.includes("pm_x") ||
|
||||||
|
n.includes("pm-g") || n.includes("pm-k") || n.includes("pm-x") ||
|
||||||
|
n.includes("grid") || n.includes("polymeter");
|
||||||
}
|
}
|
||||||
function _send(bytes) { // send only to the PM_K-1 (not loopback ports like "Midi Through", which just echo)
|
function _send(bytes) { // send only to the PM_K-1 (not loopback ports like "Midi Through", which just echo)
|
||||||
const outs = _midiOutputs(), dev = outs.filter(_isDevicePort);
|
const outs = _midiOutputs(), dev = outs.filter(_isDevicePort);
|
||||||
|
|
@ -1236,7 +1254,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) : [];
|
||||||
|
|
@ -1281,6 +1299,7 @@ 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.");
|
||||||
}
|
}
|
||||||
|
/*@BUILD:include:src/midiout.js@*/
|
||||||
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); });
|
||||||
}
|
}
|
||||||
|
|
@ -1630,6 +1649,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(); });
|
||||||
|
|
|
||||||
51
pm_e-2.html
51
pm_e-2.html
|
|
@ -301,6 +301,8 @@
|
||||||
<button id="midiOutBtn" class="devctrl"
|
<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>
|
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>
|
<select id="midiOutSel" class="devctrl" title="MIDI output port to drive (your drum module / e-kit)" hidden></select>
|
||||||
|
<label id="midiClkWrap" class="devctrl" hidden style="display:inline-flex; align-items:center; gap:5px"
|
||||||
|
title="Also send 24-PPQN MIDI clock + Start/Stop on this port, so the gear's tempo/sequencer locks to the editor."><input type="checkbox" id="midiClkChk" checked style="margin:0"> clock</label>
|
||||||
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
|
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -587,6 +589,7 @@ function scheduler() {
|
||||||
}
|
}
|
||||||
// Boundary reached → swap to the new segment seamlessly (scheduler keeps running).
|
// Boundary reached → swap to the new segment seamlessly (scheduler keeps running).
|
||||||
if (pendingSwitch && masterBeatTime >= pendingSwitch.atTime - 1e-9) performCutover(pendingSwitch);
|
if (pendingSwitch && masterBeatTime >= pendingSwitch.atTime - 1e-9) performCutover(pendingSwitch);
|
||||||
|
midiOutClock(ahead); // schedule 24-PPQN MIDI clock ticks across the same look-ahead window
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
|
|
@ -599,10 +602,12 @@ function start() {
|
||||||
const t0 = audioCtx.currentTime + 0.08;
|
const t0 = audioCtx.currentTime + 0.08;
|
||||||
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; }
|
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; }
|
||||||
masterBeat = 0; masterBeatTime = t0; muteWindows = [];
|
masterBeat = 0; masterBeatTime = t0; muteWindows = [];
|
||||||
|
midiOutStart(t0); // MIDI clock Start (if MIDI out + clock on)
|
||||||
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
|
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
|
||||||
scheduler(); syncStartBtn();
|
scheduler(); syncStartBtn();
|
||||||
}
|
}
|
||||||
function stop() {
|
function stop() {
|
||||||
|
midiOutStop(); // MIDI clock Stop (if MIDI out + clock on)
|
||||||
state.running = false;
|
state.running = false;
|
||||||
clearInterval(schedulerTimer); schedulerTimer = null;
|
clearInterval(schedulerTimer); schedulerTimer = null;
|
||||||
pendingSwitch = null; segBarCount = 0; // drop any armed switch so it can't fire on next start
|
pendingSwitch = null; segBarCount = 0; // drop any armed switch so it can't fire on next start
|
||||||
|
|
@ -1294,51 +1299,7 @@ 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
|
/*@BUILD:include:src/midiout.js@*/
|
||||||
(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); });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
src/midiout.js
Normal file
73
src/midiout.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
/* MIDI OUT — send the groove as MIDI to external gear (a drum module / e-kit), in sync with
|
||||||
|
playback, plus an optional 24-PPQN MIDI clock + Start/Stop so the gear's tempo locks to the editor.
|
||||||
|
Shared by editor.html + pm_e-2.html (one copy → no drift).
|
||||||
|
|
||||||
|
Relies on page globals: $, audioCtx, state, _midiOutputs(), _ensureMidi(), _isDevicePort().
|
||||||
|
The page wires three transport hooks: midiOutStart(t0) in start(), midiOutStop() in stop(),
|
||||||
|
midiOutClock(aheadTime) at the end of scheduler(); engine.js calls onMeterHit() per scheduled hit. */
|
||||||
|
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, _midiClkNext = 0;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// schedule `bytes` at audio-context time `audioTime`, converted to the Web-MIDI (performance.now) clock
|
||||||
|
function _midiOutSendAt(bytes, audioTime) {
|
||||||
|
const out = _midiOutTarget(); if (!out) return;
|
||||||
|
const ts = (typeof audioCtx !== "undefined" && audioCtx)
|
||||||
|
? performance.now() + (audioTime - audioCtx.currentTime) * 1000 : performance.now();
|
||||||
|
try { out.send(bytes, ts); } catch (_) {}
|
||||||
|
}
|
||||||
|
// engine.js per-hit hook: GM drum Note-On (ch10) at the hit's audio time, then a Note-Off 60 ms later
|
||||||
|
function onMeterHit(sound, time, lvl) {
|
||||||
|
if (!_midiOutOn) return;
|
||||||
|
const note = SOUND_GM[sound]; if (note == null) return;
|
||||||
|
const vel = lvl === 2 ? 112 : lvl === 3 ? 45 : 90; // accent / ghost / normal
|
||||||
|
_midiOutSendAt([0x99, note, vel], time);
|
||||||
|
_midiOutSendAt([0x89, note, 0], time + 0.06);
|
||||||
|
}
|
||||||
|
function _clockOn() { const c = $("midiClkChk"); return _midiOutOn && !!c && c.checked; }
|
||||||
|
function midiOutStart(t0) { if (_clockOn()) { _midiOutSendAt([0xFA], t0); _midiClkNext = t0; } } // MIDI Start
|
||||||
|
function midiOutStop() {
|
||||||
|
if (_clockOn() && typeof audioCtx !== "undefined" && audioCtx) _midiOutSendAt([0xFC], audioCtx.currentTime); // MIDI Stop
|
||||||
|
}
|
||||||
|
// schedule 24-PPQN clock ticks up to `aheadTime` (called from the page's look-ahead scheduler)
|
||||||
|
function midiOutClock(aheadTime) {
|
||||||
|
if (!_clockOn() || !state.running || typeof audioCtx === "undefined" || !audioCtx) return;
|
||||||
|
const tickDur = (60 / state.bpm) / 24;
|
||||||
|
let guard = 0;
|
||||||
|
while (_midiClkNext < aheadTime && guard++ < 512) { _midiOutSendAt([0xF8], _midiClkNext); _midiClkNext += tickDur; }
|
||||||
|
if (_midiClkNext < aheadTime) _midiClkNext = aheadTime; // never starve-loop if tickDur is tiny
|
||||||
|
}
|
||||||
|
function updateMidiOutBtn() {
|
||||||
|
const b = $("midiOutBtn"), sel = $("midiOutSel"), clk = $("midiClkWrap"); if (!b) return;
|
||||||
|
b.style.color = _midiOutOn ? "#7ab8ff" : "var(--muted)";
|
||||||
|
b.style.borderColor = _midiOutOn ? "#7ab8ff" : "var(--edge)";
|
||||||
|
if (sel) sel.hidden = !_midiOutOn;
|
||||||
|
if (clk) clk.hidden = !_midiOutOn;
|
||||||
|
}
|
||||||
|
async function toggleMidiOut() {
|
||||||
|
if (_midiOutOn) { midiOutStop(); _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();
|
||||||
|
if (state.running && typeof audioCtx !== "undefined" && audioCtx) midiOutStart(audioCtx.currentTime + 0.05); // armed mid-play → Start now
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue