PM_K-1: on-screen MIDI indicator + auto-mute buzzer when a host is listening

The editor's 'Device audio' now sends a MIDI Active-Sensing heartbeat (0xFE, every 250ms)
to the device while on. The firmware reads usb_midi.ports[0]; while it hears the heartbeat
(<1s) it shows a green 'MIDI' badge top-right and silences the buzzer (the computer plays);
~1s after it stops, it reverts to the buzzer and hides the badge. Manual MUTE_BUZZER still
works. Verified headless: host detected -> MIDI shown + buzzer duty 0; timeout -> reverts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 23:38:53 -05:00
parent 6b508ab86e
commit cc56741483
3 changed files with 25 additions and 4 deletions

View file

@ -1117,8 +1117,14 @@ async function loadFromDevice() {
/* Device audio (Phase 3): a connected PM_K-1 sends a USB-MIDI note per click; we voice it through /* Device audio (Phase 3): a connected PM_K-1 sends a USB-MIDI note per click; we voice it through
this page's synth, so the device drives sound out the computer's speakers, locked to its clock. */ this page's synth, so the device drives sound out the computer's speakers, locked to its clock. */
let _midiAccess = null, _midiOn = false, _midiFlash = 0; let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0;
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; } function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
function _heartbeat(on) { // tell the device a host is listening, so it shows "MIDI" + mutes its buzzer
clearInterval(_midiBeat); _midiBeat = 0;
if (on) _midiBeat = setInterval(() => {
if (_midiAccess) for (const out of _midiAccess.outputs.values()) { try { out.send([0xFE]); } catch (_) {} } // Active Sensing
}, 250);
}
function onDeviceMidi(e) { function onDeviceMidi(e) {
const d = e.data; if (!d || d.length < 3) return; const d = e.data; if (!d || d.length < 3) return;
if ((d[0] & 0xf0) === 0x90 && d[2] > 0) { // Note On if ((d[0] & 0xf0) === 0x90 && d[2] > 0) { // Note On
@ -1136,12 +1142,12 @@ function updateMidiBtn() {
b.classList.add("primary"); b.classList.add("primary");
} }
async function toggleDeviceAudio() { async function toggleDeviceAudio() {
if (_midiOn) { _midiOn = false; _bindMidi(); updateMidiBtn(); return; } if (_midiOn) { _midiOn = false; _heartbeat(false); _bindMidi(); updateMidiBtn(); return; }
if (!navigator.requestMIDIAccess) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome or Edge."); if (!navigator.requestMIDIAccess) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome or Edge.");
try { if (!_midiAccess) { _midiAccess = await navigator.requestMIDIAccess(); _midiAccess.onstatechange = () => { _bindMidi(); updateMidiBtn(); }; } } try { if (!_midiAccess) { _midiAccess = await navigator.requestMIDIAccess(); _midiAccess.onstatechange = () => { _bindMidi(); updateMidiBtn(); }; } }
catch (e) { return alert("MIDI access was denied."); } catch (e) { return alert("MIDI access was denied."); }
ensureAudio(); if (audioCtx && audioCtx.state === "suspended") audioCtx.resume(); ensureAudio(); if (audioCtx && audioCtx.state === "suspended") audioCtx.resume();
_midiOn = true; _bindMidi(); updateMidiBtn(); _midiOn = true; _heartbeat(true); _bindMidi(); updateMidiBtn();
const names = _midiInputs().map((i) => i.name || "MIDI"); const names = _midiInputs().map((i) => i.name || "MIDI");
alert(names.length alert(names.length
? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green on each note received." ? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green on each note received."

View file

@ -289,6 +289,8 @@ class App:
self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000) self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000)
self.touch = GT911(self.i2c) self.touch = GT911(self.i2c)
self.midi = usb_midi.ports[1] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 1) else None self.midi = usb_midi.ports[1] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 1) else None
self.midi_in = usb_midi.ports[0] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 0) else None
self._mbuf = bytearray(16); self.midi_host = False; self.last_midi_in = 0.0
self.led = RGB(P_RGB) self.led = RGB(P_RGB)
self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0) self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0)
self.buz_off = 0 self.buz_off = 0
@ -321,6 +323,7 @@ class App:
self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right) self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right)
self.g_run = displayio.Group(); root.append(self.g_run) # RUN / STOP (left) self.g_run = displayio.Group(); root.append(self.g_run) # RUN / STOP (left)
self.g_name = displayio.Group(); root.append(self.g_name) # item index + name self.g_name = displayio.Group(); root.append(self.g_name) # item index + name
self.g_midi = displayio.Group(); root.append(self.g_midi) # "MIDI" indicator (top-right) when a host is listening
self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes × step pads self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes × step pads
# buttons (rects static; labels in per-button groups so play can toggle) # buttons (rects static; labels in per-button groups so play can toggle)
bw, bh = 96, 56; gap = (WIDTH - 3*bw)//4; xs = [gap, gap*2+bw, gap*3+bw*2] bw, bh = 96, 56; gap = (WIDTH - 3*bw)//4; xs = [gap, gap*2+bw, gap*3+bw*2]
@ -426,7 +429,7 @@ class App:
if adv and li < len(self.lane_pads): self._move_playhead(li, L['step']) if adv and li < len(self.lane_pads): self._move_playhead(li, L['step'])
if fired: if fired:
best = max(fired, key=lambda l: PRIO.get(l, 0)) best = max(fired, key=lambda l: PRIO.get(l, 0))
if not MUTE_BUZZER: self.click(best) if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead
self.flash(best) self.flash(best)
if self.rgb != (0, 0, 0): if self.rgb != (0, 0, 0):
r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10 r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10
@ -461,6 +464,16 @@ class App:
self._touchDown = True; self.hit(pt[0], pt[1]) self._touchDown = True; self.hit(pt[0], pt[1])
elif self._touchDown and (nowms - self._touchSeen) > 0.14: elif self._touchDown and (nowms - self._touchSeen) > 0.14:
self._touchDown = False self._touchDown = False
# MIDI host present? the editor sends a heartbeat (Active Sensing) while "Device audio" is on
if self.midi_in is not None:
try:
if self.midi_in.readinto(self._mbuf): self.last_midi_in = nowms
except Exception: pass
host = bool(self.last_midi_in) and (nowms - self.last_midi_in) < 1.0
if host != self.midi_host:
self.midi_host = host
if host: self.buz.duty_cycle = 0 # silence the buzzer when the computer takes over
self.led_off(); self.draw_midi()
def hit(self, x, y): def hit(self, x, y):
for bx, by, bw, bh, key in self.buttons: for bx, by, bw, bh, key in self.buttons:
@ -481,6 +494,8 @@ class App:
C_GREEN if self.running else C_MUTE, C_BG, FONT_M) C_GREEN if self.running else C_MUTE, C_BG, FONT_M)
self._place(self.g_name, "%d/%d %s" % (self.idx+1, len(self.programs), self.name[:18]), self._place(self.g_name, "%d/%d %s" % (self.idx+1, len(self.programs), self.name[:18]),
12, 112, C_TXT, C_BG, FONT_M) 12, 112, C_TXT, C_BG, FONT_M)
def draw_midi(self):
self._place(self.g_midi, "MIDI" if self.midi_host else "", 0, 12, C_GREEN, C_BG, FONT_M, right_edge=WIDTH-12)
# ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ---------- # ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ----------
def _padbase(self, L, s): def _padbase(self, L, s):