PM_X-1 0.0.3: compact 240x320 layout + piezo polarity flag + Pimoroni device filter

Three fixes the user reported on 0.0.2:

1. Layout still overlapped. 0.0.2 ported the Kit's pixel positions verbatim,
   but those were designed for 480 px of height; on 320 the same Y values
   stack on top of each other. This pass actually scales everything down:
   - BPM big number was FONT_L (~30 px tall) -> FONT_M (~16 px tall).
   - Time + bar meters were FONT_M -> FONT_S, tightly stacked at y=32/44
     instead of y=50/78.
   - Setlist tab + CONT y=66 (was 118); track title y=82 (was 134).
   - GRID_TOP=104 (was 138). Frees ~34 px more vertical room for the
     pad grid.
   - Modal panels: PX/PW shrunk to use less of the 240-wide canvas, RH
     22 (was 26), inter-line spacing 13-14 (was 14-16). Title strings
     trimmed ("Practice log" instead of "Practice log (this track)").

2. No sound from the piezo. Two likely causes:
   - SPEAKER_AUTO_MUTE was True. Live sync sends a FULL heartbeat over
     USB-MIDI every 5s; the firmware sees those bytes and treats it as
     "host listening" -> mutes the piezo. Default now False on Explorer
     (toggle to Auto in Settings if you ARE using "Device audio" in the
     editor).
   - AMP_EN polarity. Added AMP_EN_ACTIVE_HIGH config flag (default True)
     and a _amp(on) helper. If your specific board's amp is active-low,
     flip to False at the top of CONFIG.

3. Firmware push stalled at chunk 1. Editor's _isDevicePort() only matched
   "pico" / "circuitpython" / "usb_midi"; the Pimoroni Explorer reports
   under a different name, so the editor broadcast to all MIDI outputs
   and the ACK got lost in routing. Filter now also matches "pimoroni",
   "explorer", "rp2350", and "varasys" (future-proofing).

The .mpy build dropped from 29.8 KB to 29.3 KB (smaller font footprint plus
fewer modal hint strings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-30 22:05:56 -05:00
parent edb736c1d3
commit ea7bb9bfee
3 changed files with 51 additions and 36 deletions

View file

@ -1151,7 +1151,11 @@ async function loadFromDevice() {
let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null, _verCb = null;
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; }
function _isDevicePort(p) { const n = (p.name || "").toLowerCase(); return n.includes("pico") || n.includes("circuitpython") || n.includes("usb_midi"); }
function _isDevicePort(p) { // recognise PM_K-1 (Pico) and PM_X-1 (Pimoroni Explorer RP2350) USB-MIDI ports;
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") ||
n.includes("pimoroni") || n.includes("explorer") || n.includes("rp2350") || n.includes("varasys");
}
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);
for (const o of (dev.length ? dev : outs)) { try { o.send(bytes); } catch (_) {} }

View file

@ -1147,7 +1147,11 @@ async function loadFromDevice() {
let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null, _verCb = null;
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; }
function _isDevicePort(p) { const n = (p.name || "").toLowerCase(); return n.includes("pico") || n.includes("circuitpython") || n.includes("usb_midi"); }
function _isDevicePort(p) { // recognise PM_K-1 (Pico) and PM_X-1 (Pimoroni Explorer RP2350) USB-MIDI ports;
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") ||
n.includes("pimoroni") || n.includes("explorer") || n.includes("rp2350") || n.includes("varasys");
}
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);
for (const o of (dev.length ? dev : outs)) { try { o.send(bytes); } catch (_) {} }

View file

@ -13,7 +13,7 @@
import board, busio, digitalio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
APP_VERSION = "0.0.2" # firmware version (the A/B updater pushes/compares this)
APP_VERSION = "0.0.3" # firmware version (the A/B updater pushes/compares this)
DEVICE_ID = "X" # 'X' = Explorer, 'K' = 52Pi kit (per docs/livesync-protocol.md and the version reply)
try:
import rtc # set from the editor's clock SysEx so the log has real timestamps
@ -36,7 +36,11 @@ MIDI_CLOCK_OUT_TRANSPORT = True
MIDI_CLOCK_IN = False # follow an external 24 PPQN clock
MIDI_CLOCK_IN_TRANSPORT = True
MUTE_SPEAKER = False # always silence the on-board piezo
SPEAKER_AUTO_MUTE = True # auto-mute the piezo when a MIDI host is listening
SPEAKER_AUTO_MUTE = False # auto-mute the piezo when a MIDI host is listening. DEFAULT OFF on Explorer:
# Live sync sends a FULL heartbeat every 5s which would silence the piezo otherwise.
# Toggle to Auto in Settings if you ARE using "Device audio" in the editor.
AMP_EN_ACTIVE_HIGH = True # piezo amp enable polarity. If you HEAR sound from the piezo only when click()
# has just timed out (~22ms after a beat), flip this to False - your amp is active-low.
DISPLAY_ROTATION = 270 # 0=native landscape; 90/270 = portrait. Hold the device with A/B/C
# on top: try 270 first; if upside-down try 90; 180 = flipped landscape.
@ -51,10 +55,10 @@ P_SDA, P_SCL = board.GP20, board.GP21 # QwSTEMMA (unuse
# call display.rotation = DISPLAY_ROTATION (above) to turn it portrait so the layout has the same
# shape as the PM_K-1 Kit's portrait UI but in 240x320 instead of 320x480.
WIDTH, HEIGHT = 240, 320
GRID_TOP = 138 # top of the pad grid (header + meters + title fit above)
GRID_TOP = 104 # top of the pad grid (compact header + meters + title fit above)
MAXLANES = 6 # lanes shown on the pad grid (parser still accepts more; they just play silent visually)
MIN_LOG_SEC = 5 # don't log plays shorter than this
LOG_MENU_ROWS = 8 # log entries shown in the Practice-log menu screen (more vertical room in portrait)
LOG_MENU_ROWS = 8 # log entries shown in the Practice-log menu screen
# ----- BUILT-IN playlists: same defaults as the Kit so the two firmwares feel identical -----
BUILTIN_SETLISTS = [
@ -337,7 +341,7 @@ class App:
self._fw = None; self._fw_n = 0 # chunked firmware transfer state
self.spk = pwmio.PWMOut(P_AUDIO, frequency=1600, variable_frequency=True, duty_cycle=0)
self.amp_en = digitalio.DigitalInOut(P_AMPEN); self.amp_en.direction = digitalio.Direction.OUTPUT
self.amp_en.value = False # amp off when no audio playing (saves power, kills hum)
self._amp(False) # amp off when no audio playing (saves power, kills hum)
self.spk_off = 0
# buttons - all active-low with internal pull-ups
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB); self.btnC = self._btn(P_BTNC)
@ -495,7 +499,7 @@ class App:
def _draw_menu(self):
g = self.g_overlay
while len(g): g.pop()
PX, PY, PW, RH = 24, 36, WIDTH - 48, 26
PX, PY, PW, RH = 18, 32, WIDTH - 36, 22
rows = (
("Continue: " + ("on" if self.continue_on else "off"), None, self._menu_toggle_continue),
("Settings >", None, self._show_settings),
@ -527,7 +531,7 @@ class App:
def _draw_settings(self):
g = self.g_overlay
while len(g): g.pop()
PX, PY, PW, RH = 14, 30, WIDTH - 28, 26
PX, PY, PW, RH = 10, 28, WIDTH - 20, 22
sm = "Off" if MUTE_SPEAKER else ("Auto" if SPEAKER_AUTO_MUTE else "Always")
rows = (
("Speaker", sm, self._adj_speaker),
@ -557,7 +561,7 @@ class App:
cur = "off" if MUTE_SPEAKER else ("auto" if SPEAKER_AUTO_MUTE else "always")
i = (modes.index(cur) + d) % 3
MUTE_SPEAKER = (modes[i] == "off"); SPEAKER_AUTO_MUTE = (modes[i] == "auto")
if MUTE_SPEAKER: self.spk.duty_cycle = 0; self.amp_en.value = False
if MUTE_SPEAKER: self.spk.duty_cycle = 0; self._amp(False)
self._save_settings(); self._draw_settings()
def _adj_midi_out(self, d):
global MIDI_ENABLED
@ -584,9 +588,9 @@ class App:
def _draw_help(self):
g = self.g_overlay
while len(g): g.pop()
PX, PY, PW = 12, 30, WIDTH - 24
PX, PY, PW = 10, 26, WIDTH - 20
title, lines = HELP_PAGES[self._help_page]
PH = 26 + 14 * len(lines) + 22
PH = 22 + 13 * len(lines) + 18
g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN))
t, w, h = make_text(title, FONT_M, C_TXT, C_PANEL); t.x = PX + 10; t.y = PY + 5; g.append(t)
pi, piw, pih = make_text("%d / %d" % (self._help_page + 1, len(HELP_PAGES)), FONT_S, C_DIM, C_PANEL)
@ -624,13 +628,13 @@ class App:
)
g = self.g_overlay
while len(g): g.pop()
PX, PY, PW = 24, 28, WIDTH - 48; PH = 12 + 16 * len(lines) + 22
PX, PY, PW = 20, 26, WIDTH - 40; PH = 10 + 14 * len(lines) + 20
g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN))
yy = PY + 8
for text, col in lines:
if col is not None:
lt, lw, lh = make_text(text, FONT_S, col, C_PANEL); lt.x = PX + 14; lt.y = yy; g.append(lt)
yy += 16
lt, lw, lh = make_text(text, FONT_S, col, C_PANEL); lt.x = PX + 12; lt.y = yy; g.append(lt)
yy += 14
hint, hw, hh = make_text("C = close", FONT_S, C_DIM, C_PANEL)
hint.x = PX + 14; hint.y = PY + PH - 14; g.append(hint)
self.dirty = True
@ -642,21 +646,21 @@ class App:
def _draw_log_modal(self):
g = self.g_overlay
while len(g): g.pop()
PX, PY, PW = 8, 28, WIDTH - 16; PH = 12 + LOG_MENU_ROWS * 16 + 28
PX, PY, PW = 6, 26, WIDTH - 12; PH = 12 + LOG_MENU_ROWS * 14 + 22
g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN))
t, w, h = make_text("Practice log (this track)", FONT_M, C_TXT, C_PANEL); t.x = PX + 10; t.y = PY + 5; g.append(t)
t, w, h = make_text("Practice log", FONT_M, C_TXT, C_PANEL); t.x = PX + 10; t.y = PY + 4; g.append(t)
rows = [(i, e) for i, e in enumerate(self.log) if e.get("name") == self.name]
if not rows:
tg, w, h = make_text("no plays over 5s yet", FONT_S, C_DIM, C_PANEL); tg.x = PX + 14; tg.y = PY + 32; g.append(tg)
tg, w, h = make_text("no plays over 5s yet", FONT_S, C_DIM, C_PANEL); tg.x = PX + 12; tg.y = PY + 28; g.append(tg)
else:
top = self._log_scroll; yy = PY + 28
top = self._log_scroll; yy = PY + 24
for k in range(min(LOG_MENU_ROWS, len(rows) - top)):
_oi, e = rows[top + k]
dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60)
bars = e.get("bars", 0); bstr = (" %dbar" % bars) if bars else ""
line = "%s %3dbpm %s%s" % (e.get("t", "--:--"), e["bpm"], dur, bstr)
lt, lw, lh = make_text(line, FONT_S, C_TXT, C_PANEL); lt.x = PX + 14; lt.y = yy; g.append(lt)
yy += 16
lt, lw, lh = make_text(line, FONT_S, C_TXT, C_PANEL); lt.x = PX + 12; lt.y = yy; g.append(lt)
yy += 14
hint, hw, hh = make_text("X/Z scroll, C close", FONT_S, C_DIM, C_PANEL)
hint.x = PX + 10; hint.y = PY + PH - 14; g.append(hint)
self.dirty = True
@ -706,11 +710,13 @@ class App:
self._seg_start = time.monotonic()
# ---------- audio + run-state indicator ----------
def _amp(self, on): # respect AMP_EN_ACTIVE_HIGH (flip in CONFIG if your amp is active-low)
self.amp_en.value = on if AMP_EN_ACTIVE_HIGH else not on
def click(self, level):
self.amp_en.value = True # enable the amp briefly while we drive the piezo
self._amp(True) # enable the amp briefly while we drive the piezo
self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
self.spk.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000)
self.spk_off = time.monotonic_ns() + 22_000_000 # silence + amp_en off scheduled in tick()
self.spk_off = time.monotonic_ns() + 22_000_000 # silence + amp off scheduled in tick()
def _set_run_dot(self):
self.run_dot_pal[0] = C_RUN_GO if self.running else C_RUN_IDLE
self.dirty = True
@ -853,7 +859,7 @@ class App:
try: self.midi.write(self._start_byte)
except Exception: pass
else:
self.spk.duty_cycle = 0; self.amp_en.value = False; self.reset_playheads(); self._log_play()
self.spk.duty_cycle = 0; self._amp(False); self.reset_playheads(); self._log_play()
if MIDI_CLOCK_OUT and MIDI_CLOCK_OUT_TRANSPORT and self.midi is not None:
try: self.midi.write(self._stop_byte)
except Exception: pass
@ -885,7 +891,7 @@ class App:
def tick(self):
now = time.monotonic_ns()
if self.spk_off and now >= self.spk_off:
self.spk.duty_cycle = 0; self.spk_off = 0; self.amp_en.value = False
self.spk.duty_cycle = 0; self.spk_off = 0; self._amp(False)
if self._slaved and (now - self._clock_in_last_t) > 1_000_000_000: self._slaved = False
if self.running:
fired_best = 0; fired_prio = -1
@ -1118,7 +1124,7 @@ class App:
if host != self.midi_host:
self.midi_host = host
if host and SPEAKER_AUTO_MUTE:
self.spk.duty_cycle = 0; self.amp_en.value = False
self.spk.duty_cycle = 0; self._amp(False)
self._set_run_dot(); self.draw_icons()
uc = bool(getattr(supervisor.runtime, "usb_connected", True))
if uc != self.usb_conn:
@ -1128,19 +1134,20 @@ class App:
def draw_bpm(self):
if self.bpm == self._displayed_bpm: return
self._displayed_bpm = self.bpm
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-10)
# Smaller than the Kit's BPM: FONT_M instead of FONT_L. Kit's FONT_L was ~30px tall at 480 - too big proportionally at 320.
self._place(self.g_bpm, str(self.bpm), 0, 30, C_TXT, C_BG, FONT_M, right_edge=WIDTH-8)
def draw_status(self):
sl = self.setlists[self.sl]
# setlist tab line at y=118 (matches the Kit's spacing); muted = built-in, cyan = your own
# setlist tab line at y=66; muted = built-in, cyan = your own
self._place(self.g_idx, "%s %d/%d" % (sl['title'][:13], self.idx + 1, len(sl['items'])),
10, 118, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S)
self._place(self.g_cont, "CONT", 0, 118, C_GREEN if self.continue_on else C_DIM, C_BG, FONT_S, right_edge=WIDTH-10)
# track title at y=134 (FONT_M; ~16 px tall, fits above GRID_TOP=138)
self._place(self.g_name, self.name[:20], 10, 134, C_TXT, C_BG, FONT_M)
6, 66, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S)
self._place(self.g_cont, "CONT", 0, 66, C_GREEN if self.continue_on else C_DIM, C_BG, FONT_S, right_edge=WIDTH-6)
# track title at y=82 (FONT_M; ~16 px tall, fits above GRID_TOP=104)
self._place(self.g_name, self.name[:22], 6, 82, C_TXT, C_BG, FONT_M)
def draw_train(self):
g = self.g_train
while len(g): g.pop()
x = 10; y = 100 # ramp / gap-trainer indicators on a single row above the setlist tab
x = 6; y = 52 # ramp / gap-trainer indicators below the meters row, above the setlist tab
if self.ramp:
up = self.ramp['amt'] >= 0
pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)]
@ -1175,9 +1182,9 @@ class App:
else:
ts = self._fmt_t(el); bs = "bar %s" % cur
if ts != self._lastTs:
self._place(self.g_time, ts, 10, 50, C_TXT, C_BG, FONT_M); self._lastTs = ts
self._place(self.g_time, ts, 6, 32, C_TXT, C_BG, FONT_S); self._lastTs = ts
if bs != self._lastBs:
self._place(self.g_bar, bs, 10, 78, C_MUTE, C_BG, FONT_M); self._lastBs = bs
self._place(self.g_bar, bs, 6, 44, C_MUTE, C_BG, FONT_S); self._lastBs = bs
# ---------- pad grid (chunked rebuild; per-pad chunks so audio interleaves) ----------
def _padbase(self, L, s):
@ -1318,7 +1325,7 @@ class App:
def _slave_stop(self):
if self.running:
self.running = False
self.spk.duty_cycle = 0; self.amp_en.value = False
self.spk.duty_cycle = 0; self._amp(False)
self.reset_playheads(); self._log_play()
self._set_run_dot(); self.draw_meters()
self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False