diff --git a/editor-beta.html b/editor-beta.html index c259553..7a74624 100644 --- a/editor-beta.html +++ b/editor-beta.html @@ -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 (_) {} } diff --git a/editor.html b/editor.html index 489ff94..de2bfef 100644 --- a/editor.html +++ b/editor.html @@ -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 (_) {} } diff --git a/pico-explorer/app.py b/pico-explorer/app.py index f3a0cb0..aaffd0b 100644 --- a/pico-explorer/app.py +++ b/pico-explorer/app.py @@ -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