diff --git a/README.md b/README.md index 2be39e2..94af3a5 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,9 @@ flashing steps. Firmware lives in **`pico/`**: set lists **pushed from the editor over USB‑MIDI** (with a universal download‑and‑drag fallback), and plays **out your computer's speakers over USB‑MIDI** (the editor's **🎹 Device audio**). By default the firmware owns the drive (read‑only to the computer, so it's protected); hold **button A** at power‑on for - editor mode (drive writable). The MicroPython build stays the simple, no‑computer option. + editor mode (drive writable). **Firmware updates are one click** from the editor (⋯ → Update firmware) — + pushed over USB‑MIDI as an A/B update with automatic rollback. The MicroPython build stays the simple, + no‑computer option. ## Keyboard shortcuts diff --git a/build.sh b/build.sh index 374aa36..2a6b795 100755 --- a/build.sh +++ b/build.sh @@ -37,9 +37,11 @@ pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) print("copied embed.js") pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_text()) # PM_K-1 firmware, downloadable print("copied pico-main.py") +pathlib.Path("dist/pico-cp-app.py").write_text(pathlib.Path("pico-cp/app.py").read_text()) # served for the editor's A/B firmware updater +print("copied pico-cp-app.py") import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY) with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z: - for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", + for f in ("code.py", "app.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", "README.md", "protect-firmware.sh"): z.write("pico-cp/" + f, f) z.write("dist/editor.html", "editor.html") # offline copy of the editor, on the drive diff --git a/deploy.sh b/deploy.sh index 40a7dff..bc05d79 100755 --- a/deploy.sh +++ b/deploy.sh @@ -49,6 +49,7 @@ done cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)" cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle +cp "$DIST_DIR/pico-cp-app.py" "$DEST_DIR/pico-cp-app.py"; echo " pico-cp-app.py ($(stat -c '%s' "$DEST_DIR/pico-cp-app.py") bytes)" # PM_K-1 firmware for the editor's A/B updater rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/) # info-*.html are first-class pages again: each form factor has a lean widget page diff --git a/editor.html b/editor.html index 1f51cc8..3cf3ee5 100644 --- a/editor.html +++ b/editor.html @@ -349,6 +349,7 @@ + @@ -1128,7 +1129,7 @@ async function loadFromDevice() { /* 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. */ -let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null; +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 _send(bytes) { for (const o of _midiOutputs()) { try { o.send(bytes); } catch (_) {} } } @@ -1145,8 +1146,10 @@ async function _ensureMidi() { // MIDI access WITH SysEx (needed to send/ function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); } function onDeviceMidi(e) { const d = e.data; if (!d) return; - if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply to a program push - if (_saveCb) { const cb = _saveCb; _saveCb = null; cb(d[2] === 0x7F); } // 0x7F ACK / 0x7E NAK + if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply + const cmd = d[2]; + if (cmd === 0x03 && _verCb) { const cb = _verCb; _verCb = null; cb(String.fromCharCode(...d.slice(3, d.length - 1))); } // version + else if ((cmd === 0x7F || cmd === 0x7E) && _saveCb) { const cb = _saveCb; _saveCb = null; cb(cmd === 0x7F); } // ACK/NAK return; } if (_midiOn && (d[0] & 0xf0) === 0x90 && d.length >= 3 && d[2] > 0) { // Note On -> voice it @@ -1179,6 +1182,32 @@ 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."); } +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); }); +} +async function updateFirmware() { // A/B firmware update over USB-MIDI, with a version check + if (!(await _ensureMidi()) || !_midiOutputs().length) + return alert("Connect the PM_K-1 (Chrome/Edge/Firefox), then try again."); + const dev = await _queryDeviceVersion(); + let src; + try { src = await (await fetch("/pico-cp-app.py", { cache: "no-store" })).text(); } + catch (e) { return alert("Couldn't fetch the latest firmware from the site."); } + const m = src.match(/APP_VERSION\s*=\s*["']([^"']+)["']/); const latest = m && m[1]; + if (!latest) return alert("Couldn't read the latest firmware version."); + const upToDate = dev && dev === latest; + if (!confirm("Device firmware: " + (dev || "unknown") + "\nLatest: " + latest + + (upToDate ? "\n\nYou're up to date. Re-install anyway?" + : "\n\nUpdate now? The device reboots, runs the new build, and auto-rolls-back if it fails to start."))) return; + const bytes = [0xF0, 0x7D, 0x20]; + for (let i = 0; i < src.length; i++) bytes.push(src.charCodeAt(i) & 0x7F); // app.py is ASCII + bytes.push(0xF7); + const p = new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, 5000); }); + _send(bytes); + const ok = await p; + alert(ok === true ? "Update sent ✓ — the device is rebooting into the new build (v" + latest + "). It auto-confirms after a few seconds, or rolls back if it won't start." + : ok === false ? "The device is in editor mode (read-only to the updater). Reboot it normally (don't hold A) and try again." + : "No acknowledgement from the device. Make sure it's connected and not in editor mode — or drag app.py onto the drive in editor mode."); +} // Apply a shared link on load. Returns true if it set the metronome state. function applyHashShare() { @@ -1405,6 +1434,7 @@ $("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $( $("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; }); $("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); }); $("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); }); +$("updateFwBtn").addEventListener("click", () => { $("trayMenu").hidden = true; updateFirmware(); }); $("midiBtn").addEventListener("click", toggleDeviceAudio); $("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); }); $("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); }); diff --git a/info-kit.html b/info-kit.html index 2cd9c94..7f211e0 100644 --- a/info-kit.html +++ b/info-kit.html @@ -152,6 +152,7 @@
  • Play through your computer: click 🎹 Device audio, then press play on the device — the full groove sounds through your speakers over USB‑MIDI, in sync; the screen shows a MIDI badge and the buzzer mutes.
  • Practice log: plays over 5 s appear at the bottom of the screen (time · BPM · duration · track); tap a row twice to delete.
  • +
  • Firmware updates: ⋯ menu → ⬆ Update firmware — it checks your version, pushes the latest over USB‑MIDI, and the device A/B‑updates with automatic rollback if a build won't boot.
  • diff --git a/pico-cp/README.md b/pico-cp/README.md index 3768677..3a994a7 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -23,8 +23,9 @@ from the web editor over USB‑MIDI**, and plays through your **computer's speak 1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, drop the CircuitPython `.uf2` onto `RPI‑RP2` ( — Pico 2 / W builds also fine). A `CIRCUITPY` drive appears. -2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py`, `programs.json`, - `font_s.bin` / `font_m.bin` / `font_l.bin`, `editor.html` (offline editor), and the helper scripts. +2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py` (loader) + `app.py` (the application), + `programs.json`, `font_s.bin` / `font_m.bin` / `font_l.bin`, `editor.html` (offline editor), and the + helper scripts. (`code.py` is a tiny stable loader; `app.py` is what firmware updates replace.) 3. **Power‑cycle** (so `boot.py` takes effect). It boots into appliance mode and runs. ## Program it from the web (push over USB‑MIDI) @@ -36,6 +37,16 @@ acknowledges — the editor shows **Saved ✓**. **📥 Load from device** reads *Universal fallback (any browser / OS, even Safari):* Save to device **downloads** `programs.json` when no device answers — boot the Pico in **editor mode** (hold A) and drag the file onto the `CIRCUITPY` drive. +## Firmware updates (one‑click, A/B with auto‑rollback) + +`code.py` is a small stable **loader**; the application is `app.py` (it carries `APP_VERSION`). To update: +the editor's ⋯ menu → **⬆ Update firmware…** queries the device's version, fetches the latest from the site, +shows *device vs latest*, and on confirm **pushes the new `app.py` over USB‑MIDI**. The device installs it to +a **trial slot** (keeping the old build as `app.bak`) and reboots; if the new build **doesn't boot, the loader +automatically rolls back** to `app.bak`. A build that runs cleanly for ~5 s is confirmed. No BOOTSEL, no +dragging. (Updating CircuitPython *itself* still uses BOOTSEL + a `.uf2`, but that's rare. And the Pico is +unbrickable as the ultimate backstop.) + ## Play through the computer's speakers The Pico is a USB‑MIDI device and sends a note per click (GM drum note per lane, velocity by accent). diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..cfd5ae9 Binary files /dev/null and b/pico-cp/__pycache__/app.cpython-312.pyc differ diff --git a/pico-cp/__pycache__/code.cpython-312.pyc b/pico-cp/__pycache__/code.cpython-312.pyc index c2baf00..29e08de 100644 Binary files a/pico-cp/__pycache__/code.cpython-312.pyc and b/pico-cp/__pycache__/code.cpython-312.pyc differ diff --git a/pico-cp/app.py b/pico-cp/app.py new file mode 100644 index 0000000..10cfa07 --- /dev/null +++ b/pico-cp/app.py @@ -0,0 +1,648 @@ +# VARASYS PolyMeter - PM_K-1 "Kit" firmware (CircuitPython edition) +# Raspberry Pi Pico (Pico / Pico W / Pico 2) on the 52Pi EP-0172 "Pico Breadboard Kit Plus": +# 3.5" ST7796 320x480 cap-touch (GT911), PSP joystick, WS2812 RGB, buzzer, 2 buttons. +# +# WHY CIRCUITPYTHON: the board then mounts as a USB drive (CIRCUITPY) carrying this code, your +# tracks (programs.json) and a copy of the editor - edit on the web, "Save to device" writes +# programs.json here, and CircuitPython auto-reloads with the new grooves. It also sends USB-MIDI +# (a note per click) so the web editor can play it out the computer's speakers ("Device audio"). +# Runs the SAME program strings as metronome.varasys.io. +# +# INSTALL: flash CircuitPython (https://circuitpython.org/board/raspberry_pi_pico/), then copy +# this file as code.py plus programs.json onto the CIRCUITPY drive. It runs on boot. +# +# Fallback: the simpler MicroPython firmware (pico/main.py) is always available - BOOTSEL + +# drag a MicroPython .uf2 to go back. The Pico cannot be bricked. +# +# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md. + +import board, busio, digitalio, analogio, 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 = "1.0.0" # firmware version (the A/B updater pushes/compares this) +try: + import rtc # set from the editor's clock SysEx so the log has real timestamps +except ImportError: + rtc = None +try: # CircuitPython 9.x + from fourwire import FourWire + from busdisplay import BusDisplay +except ImportError: # CircuitPython 8.x + from displayio import FourWire + from displayio import Display as BusDisplay +try: + import neopixel_write # core module on RP2040 - drives WS2812 with no external library +except ImportError: + neopixel_write = None +try: + import usb_midi # default-enabled on RP2040 - sends a MIDI note per click to the computer +except ImportError: + usb_midi = None + +# ============================== CONFIG (tweak if needed) ============================== +SPI_BAUD = 62_500_000 # faster SPI = smaller tearing window; drop to 40_000_000 if unstable +LED_BRIGHTNESS = 0.15 # WS2812 sits right next to you - keep it dim (0..1) +MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web editor's "Device audio") +MUTE_BUZZER = False # silence the on-board buzzer (e.g. when using computer audio) +WIDTH, HEIGHT = 320, 480 +MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed. +INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative +# Touch (GT911) - flip if taps land wrong: +TOUCH_SWAP_XY = False +TOUCH_INVERT_X = False +TOUCH_INVERT_Y = False +TOUCH_DEBUG = False +# Joystick: +JOY_INVERT_X = False +JOY_INVERT_Y = False +JOY_DEADZONE = 9000 + +# ----- pins (fixed by the EP-0172 board) ----- +P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7 +P_SDA, P_SCL = board.GP8, board.GP9 +P_RGB, P_BUZ, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14 +P_JOYX, P_JOYY = board.GP26, board.GP27 + +# ----- baked default grooves (used only if programs.json is missing/bad) ----- +DEFAULT_PROGRAMS = [ + ("Four on the floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"), + ("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"), + ("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"), + ("5 over 4", "t100;kick:4;claves:5~"), + ("Straight click", "t120;beep:4"), +] + +# ============================== COLOURS (0xRRGGBB; displayio handles 565) ============================== +C_BG, C_PANEL, C_TXT, C_MUTE = 0x06090E, 0x1C222C, 0xC7D0DB, 0x788494 +C_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240 +C_BTN = 0x1C222C +LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)} +# voice -> General-MIDI note (USB-MIDI bridge), and level -> MIDI velocity +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} +GM_DEFAULT = 37 +MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost +MAXLANES = 5 # lanes shown on the pad grid (extras still play) +LOG_TOP, LOG_ROWH, LOG_ROWS = 302, 16, 9 # practice-history log area (below the pad grid) +MIN_LOG_SEC = 5 # don't log plays shorter than this +PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost +PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost + +# WS2812 RGB LED - self-contained via the core neopixel_write module (no external library) +class RGB: + def __init__(self, pin): + self.ok = neopixel_write is not None + if self.ok: + self.io = digitalio.DigitalInOut(pin); self.io.direction = digitalio.Direction.OUTPUT + self.buf = bytearray(3) + def set(self, r, g, b): + if not self.ok: return + # WS2812 wants GRB order; scale down so it isn't blinding + self.buf[0] = int(g * LED_BRIGHTNESS); self.buf[1] = int(r * LED_BRIGHTNESS); self.buf[2] = int(b * LED_BRIGHTNESS) + try: neopixel_write.neopixel_write(self.io, self.buf) + except Exception: self.ok = False + +# ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ============================== +def load_font(path): + with open(path, "rb") as f: + blob = f.read() + count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {} + for _ in range(count): + cp = (blob[p] << 8) | blob[p+1]; w = blob[p+2]; h = blob[p+3] + xoff = blob[p+4]; xoff = xoff - 256 if xoff > 127 else xoff + top = blob[p+5]; adv = blob[p+6]; p += 7 + glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2 + return (glyphs, blob) + +FONT_S = load_font("/font_s.bin") # small - pad-grid lane labels +FONT_M = load_font("/font_m.bin") # labels / buttons +FONT_L = load_font("/font_l.bin") # big BPM +gc.collect() + +def _blend(bg, fg, i): + t = i * 17 + r = (((bg >> 16) & 0xFF)*(255-t) + ((fg >> 16) & 0xFF)*t) // 255 + g = (((bg >> 8) & 0xFF)*(255-t) + ((fg >> 8) & 0xFF)*t) // 255 + b = ((bg & 0xFF)*(255-t) + (fg & 0xFF)*t) // 255 + return (r << 16) | (g << 8) | b + +def make_text(s, font, fg, bg): + """Render a string into a displayio TileGrid (anti-aliased via a 16-step blend palette).""" + glyphs, blob = font + w = 0; top0 = 999; bot = 0 + for c in s: + g = glyphs.get(ord(c)) + if not g: continue + w += g[4] + if g[1]: + if g[3] < top0: top0 = g[3] + if g[3] + g[1] > bot: bot = g[3] + g[1] + if top0 == 999: top0 = 0 + w = max(1, w); h = max(1, bot - top0) + gc.collect() + bmp = displayio.Bitmap(w, h, 16) + pal = displayio.Palette(16) + for i in range(16): pal[i] = _blend(bg, fg, i) + pen = 0 + for c in s: + g = glyphs.get(ord(c)) + if not g: continue + gw, gh, xoff, gtop, adv, off = g + for j in range(gh): + row = (gtop - top0) + j + for i in range(gw): + k = j * gw + i + byte = blob[off + (k >> 1)] + nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF) + if nib: + x = pen + xoff + i + if 0 <= x < w and 0 <= row < h: bmp[x, row] = nib + pen += adv + return displayio.TileGrid(bmp, pixel_shader=pal), w, h + +# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ============================== +PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0} +PRIO = {2: 3, 1: 2, 3: 1} + +def parse_program(s): + bpm = 120; lanes = [] + for tok in s.strip().split(';'): + tok = tok.strip() + if not tok: continue + if tok[0] == 't' and tok[1:].isdigit(): + bpm = int(tok[1:]); continue + if ':' not in tok: continue + lane = _parse_lane(tok) + if lane: lanes.append(lane) + if not lanes: lanes = [_parse_lane("beep:4")] + return max(30, min(300, bpm)), lanes + +def _parse_lane(tok): + poly = '~' in tok; mute = '!' in tok + tok = tok.replace('~', '').replace('!', '') + if '@' in tok: tok = tok.split('@')[0] + sound, _, rest = tok.partition(':') + pattern = None + if '=' in rest: rest, _, pattern = rest.partition('=') + sub = 1; swing = False + if '/' in rest: + rest, _, sd = rest.partition('/') + swing = sd.endswith('s'); sd = sd.rstrip('s') # "/2s" = swung eighths + sub = int(sd) if sd.isdigit() else 1 + groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4] + beats = sum(groups); starts = set(); acc = 0 + for gp in groups: starts.add(acc); acc += gp + steps = beats * sub + if pattern: + levels = [PAT.get(ch, 0) for ch in pattern] + if len(levels) < steps: levels += [0] * (steps - len(levels)) + steps = len(levels) + else: + levels = [] + for i in range(steps): + if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) + else: levels.append(0) + return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute} + +def load_programs(): + try: + with open("/programs.json") as f: + d = json.load(f) + progs = [(p["name"], p["prog"]) for p in d["programs"]] + if progs: return progs + except Exception as e: + print("programs.json:", e) + return DEFAULT_PROGRAMS + +# ============================== GT911 TOUCH ============================== +class GT911: + def __init__(self, i2c): + self.i2c = i2c; self.addr = None + while not i2c.try_lock(): pass + try: found = i2c.scan() + finally: i2c.unlock() + for a in (0x5D, 0x14): + if a in found: self.addr = a; break + if self.addr is None and found: self.addr = found[0] + def _rd(self, reg, n): + b = bytearray(n) + while not self.i2c.try_lock(): pass + try: + self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF])) + self.i2c.readfrom_into(self.addr, b) + finally: self.i2c.unlock() + return b + def _wr(self, reg, val): + while not self.i2c.try_lock(): pass + try: self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF, val])) + finally: self.i2c.unlock() + def read(self): + if self.addr is None: return None + try: st = self._rd(0x814E, 1)[0] + except OSError: return None + if not (st & 0x80): return None + n = st & 0x0F; pt = None + if n >= 1: + b = self._rd(0x8150, 4); tx = b[0] | (b[1] << 8); ty = b[2] | (b[3] << 8) + pt = self._map(tx, ty) + try: self._wr(0x814E, 0) + except OSError: pass + return pt + def _map(self, tx, ty): + if TOUCH_DEBUG: print("touch raw", tx, ty) + if TOUCH_SWAP_XY: tx, ty = ty, tx + if TOUCH_INVERT_X: tx = WIDTH - 1 - tx + if TOUCH_INVERT_Y: ty = HEIGHT - 1 - ty + if 0 <= tx < WIDTH and 0 <= ty < HEIGHT: return (tx, ty) + return None + +# ============================== DISPLAY SETUP ============================== +def st7796_init(): + inv = b'\x21\x00' if INVERT_COLORS else b'\x20\x00' + return ( + b'\x01\x80\x78' # SWRESET + 120ms + b'\x11\x80\x78' # SLPOUT + 120ms + b'\xF0\x01\xC3' b'\xF0\x01\x96' # command-set unlock + + bytes([0x36, 0x01, MADCTL]) + + b'\x3A\x01\x55' # 16bpp + b'\xB4\x01\x01' + b'\xB6\x03\x80\x02\x3B' + b'\xE8\x08\x40\x8A\x00\x00\x29\x19\xA5\x33' + b'\xC1\x01\x06' b'\xC2\x01\xA7' + b'\xC5\x81\x18\x78' # VCOM + 120ms + b'\xE0\x0E\xF0\x09\x0B\x06\x04\x15\x2F\x54\x42\x3C\x17\x14\x18\x1B' + b'\xE1\x0E\xE0\x09\x0B\x06\x04\x03\x2B\x43\x42\x3B\x16\x14\x17\x1B' + b'\xF0\x01\x3C' b'\xF0\x81\x69\x78' # lock + 120ms + + inv + + b'\x29\x80\x32' # DISPON + 50ms + ) + +def make_display(): + displayio.release_displays() + spi = busio.SPI(clock=P_SCK, MOSI=P_MOSI) + bus = FourWire(spi, command=P_DC, chip_select=P_CS, reset=P_RST, baudrate=SPI_BAUD) + return BusDisplay(bus, st7796_init(), width=WIDTH, height=HEIGHT, auto_refresh=False) + +def solid(color): + p = displayio.Palette(1); p[0] = color; return p + +def rect(x, y, w, h, color): + return vectorio.Rectangle(pixel_shader=solid(color), width=w, height=h, x=x, y=y) + +# ============================== APP ============================== +class App: + def __init__(self): + self.display = make_display() + self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000) + 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_in = usb_midi.ports[0] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 0) else None + self._mbuf = bytearray(64); self.midi_host = False; self.last_midi_in = 0.0 + self._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler (clock + pushed programs) + self.led = RGB(P_RGB) + self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0) + self.buz_off = 0 + self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB) + self._aPrev = True; self._bPrev = True + self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY) + self._joyNext = 0 + self._touchDown = False; self._touchSeen = 0 + self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0) + self.programs = load_programs() + self.dirty = True + self.pad_pal = displayio.Palette(8) + for i in range(4): self.pad_pal[i] = PAD_DIM[i]; self.pad_pal[i + 4] = PAD_LIT[i] + self.lane_pads = []; self.lane_lit = [] + # practice history - persisted to /history.json (next to programs.json) when we own the filesystem + self.can_write = self._probe_write() + self.log = self._load_log() + self.play_start = None; self.play_bpm = 0; self.play_name = "" + self._armed = None; self.log_rows = [] + self._build_scene() + self.load(0) + self.draw_log() + + def _btn(self, pin): + d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP + return d + + # ---------- scene graph ---------- + def _build_scene(self): + root = displayio.Group(); self.display.root_group = root + root.append(rect(0, 0, WIDTH, HEIGHT, C_BG)) + tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 8; root.append(tg) + root.append(rect(0, 38, WIDTH, 2, C_PANEL)) + # dynamic groups + 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_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 x step pads + root.append(rect(0, LOG_TOP - 6, WIDTH, 2, C_PANEL)) # divider above the history log + self.g_log = displayio.Group(); root.append(self.g_log) # practice history (tap a row to delete) + # (no on-screen buttons - transport is the joystick + buttons A/B; touch deletes log rows) + + def _place(self, group, s, x, y, fg, bg, font, right_edge=None): + while len(group): group.pop() + self.dirty = True + if not s: return + tg, w, h = make_text(s, font, fg, bg) + tg.x = (right_edge - w) if right_edge is not None else x; tg.y = y; group.append(tg) + def _center(self, group, s, cx, cy, fg, bg, font): + while len(group): group.pop() + tg, w, h = make_text(s, font, fg, bg); tg.x = cx - w//2; tg.y = cy - h//2; group.append(tg) + self.dirty = True + + # ---------- program ---------- + def load(self, i): + n = len(self.programs); self.idx = i % n + self.name, prog = self.programs[self.idx] + self.bpm, self.lanes = parse_program(prog) + self.master = self.lanes[0] + self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid() + def _step_dur(self, L, step): + beat = 60_000_000_000 / self.bpm + if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar + m = self.lanes[0]; master_bar = beat * (m['steps'] // m['sub']) + return int(master_bar / L['steps']) + sub = L['sub'] + if L['swing'] and sub % 2 == 0: # swing even subdivisions: long-short (2:1) pairs + pair = beat / (sub // 2) + return int(pair * 2 / 3) if (step % sub) % 2 == 0 else int(pair / 3) + return int(beat / sub) # straight: a step = one beat / subdivision + def _reset_clock(self): + now = time.monotonic_ns() + for L in self.lanes: + L['next'] = now; L['step'] = -1 + + # ---------- audio + light ---------- + def click(self, level): + self.buz.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600) + self.buz.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000) + self.buz_off = time.monotonic_ns() + 22_000_000 + def flash(self, level): + self.rgb = LEVEL_RGB.get(level, (0, 150, 255)) + self.led.set(*self.rgb) + def led_off(self): + self.rgb = (0, 0, 0) + self.led.set(0, 0, 0) + def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer + if self.midi is None: return + try: self.midi.write(bytes([0x90, note, vel])) # Note On (percussive - no Note Off needed) + except Exception: pass + + # ---------- transport ---------- + def toggle(self): + self.running = not self.running + if self.running: self._reset_clock(); self._start_play() + else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads(); self._log_play() + self.draw_status() + def set_bpm(self, v): + v = max(30, min(300, v)) + if v != self.bpm: + self.bpm = v + self.draw_bpm() + def goto(self, i): + was = self.running + if was: self.running = False; self._log_play() # close out the track that was playing + self.load(i) + if was: self.running = True; self._reset_clock(); self._start_play() + def tap(self): + now = time.monotonic() + if not hasattr(self, '_taps'): self._taps = [] + self._taps = [t for t in self._taps if now - t < 2.4] + self._taps.append(now) + if len(self._taps) >= 2: + span = (self._taps[-1] - self._taps[0]) / (len(self._taps) - 1) + if span > 0: self.set_bpm(round(60 / span)) + + # ---------- scheduler ---------- + def tick(self): + now = time.monotonic_ns() + if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0 + if self.running: + fired = [] + for li, L in enumerate(self.lanes): + adv = False + while now >= L['next']: + L['step'] = (L['step'] + 1) % L['steps'] + lvl = 0 if L['mute'] else L['levels'][L['step']] + if lvl > 0: + fired.append(lvl) + self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane + L['next'] += self._step_dur(L, L['step']); adv = True + if adv and li < len(self.lane_pads): self._move_playhead(li, L['step']) + if fired: + best = max(fired, key=lambda l: PRIO.get(l, 0)) + if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead + self.flash(best) + if self.rgb != (0, 0, 0): + r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10 + self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0) + self.led.set(*self.rgb) + + # ---------- inputs ---------- + def poll(self): + a = self.btnA.value + if (not a) and self._aPrev: self.toggle() + self._aPrev = a + b = self.btnB.value + if (not b) and self._bPrev: self.tap() + self._bPrev = b + now = time.monotonic_ns() + if now >= self._joyNext: + x = self.jx.value - 32768; y = self.jy.value - 32768 + if JOY_INVERT_X: x = -x + if JOY_INVERT_Y: y = -y + if abs(y) > JOY_DEADZONE: + self.set_bpm(self.bpm + (1 if y > 0 else -1) * (5 if abs(y) > 26000 else 1)) + self._joyNext = now + 70_000_000 + elif abs(x) > JOY_DEADZONE: + self.goto(self.idx + (1 if x > 0 else -1)); self._joyNext = now + 350_000_000; return + else: + self._joyNext = now + 20_000_000 + pt = self.touch.read() + nowms = time.monotonic() + if pt: + self._touchSeen = nowms + if not self._touchDown: + self._touchDown = True; self._tap_log(pt[0], pt[1]) + elif self._touchDown and (nowms - self._touchSeen) > 0.14: + self._touchDown = False + # USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs) + if self.midi_in is not None: + try: n = self.midi_in.readinto(self._mbuf) + except Exception: n = 0 + if n: + self.last_midi_in = nowms + self._feed_midi(self._mbuf, n) + 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() + + # ---------- drawing ---------- + def draw_bpm(self): + self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12) + def draw_status(self): + self._place(self.g_run, "RUN" if self.running else "STOP", 12, 48, + 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]), + 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) ---------- + def _padbase(self, L, s): + return 0 if L['mute'] else L['levels'][s] + def build_grid(self): + while len(self.g_grid): self.g_grid.pop() + self.lane_pads = []; self.lane_lit = [] + n = min(len(self.lanes), MAXLANES) + top = 140; rowh = min(40, (296 - top) // max(1, n)) + for li in range(n): + L = self.lanes[li]; y = top + li * rowh; cy = y + rowh // 2 + tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG) + tg.x = 8; tg.y = cy - h // 2; self.g_grid.append(tg) + steps = L['steps']; sub = L['sub']; px0 = 60 + usable = WIDTH - 8 - px0 - 12; stepw = max(1, usable // steps) + r_big = max(2, min(6, stepw // 2, (rowh - 8) // 2)); r_sml = max(2, r_big - 2) + pads = [] + for s in range(steps): + rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision + cxp = px0 + 6 + (s * usable) // steps # proportional -> beats line up across lanes + c = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=cxp, y=cy) + c.color_index = self._padbase(L, s); self.g_grid.append(c); pads.append(c) + self.lane_pads.append(pads); self.lane_lit.append(-1) + self.dirty = True + def _move_playhead(self, li, step): + pads = self.lane_pads[li]; prev = self.lane_lit[li] + if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev) + if step < len(pads): pads[step].color_index = self._padbase(self.lanes[li], step) + 4 + self.lane_lit[li] = step; self.dirty = True + def reset_playheads(self): + for li, pads in enumerate(self.lane_pads): + prev = self.lane_lit[li] + if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev) + self.lane_lit[li] = -1 + self.dirty = True + + # ---------- practice history (saved to /history.json, next to programs.json) ---------- + def _probe_write(self): + try: + with open("/.wtest", "w") as f: f.write("1") + try: os.remove("/.wtest") + except Exception: pass + return True + except OSError: + return False # editor mode: the computer owns the FS + def _load_log(self): + try: + with open("/history.json") as f: return json.load(f).get("log", []) + except Exception: + return [] + def _save_log(self): + if not self.can_write: return + try: + with open("/history.json", "w") as f: json.dump({"log": self.log[:200]}, f) + except OSError: + self.can_write = False + def _start_play(self): + self.play_start = time.monotonic(); self.play_bpm = self.bpm; self.play_name = self.name + def _log_play(self): + if self.play_start is None: return + dur = int(time.monotonic() - self.play_start); self.play_start = None + if dur < MIN_LOG_SEC: return # skip plays under 5 seconds + t = time.localtime() + self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm, + "dur": dur, "name": self.play_name}) + del self.log[200:]; self._armed = None + self._save_log(); self.draw_log() + def draw_log(self): + g = self.g_log + while len(g): g.pop() + self.log_rows = [] + hdr, w, h = make_text("PRACTICE LOG", FONT_S, C_MUTE, C_BG); hdr.x = 10; hdr.y = LOG_TOP; g.append(hdr) + if not self.log: + tg, w, h = make_text("plays over 5s show here", FONT_S, C_DIM, C_BG); tg.x = 10; tg.y = LOG_TOP + LOG_ROWH; g.append(tg) + self.dirty = True; return + y = LOG_TOP + LOG_ROWH + 2 + for idx in range(min(LOG_ROWS, len(self.log))): + e = self.log[idx]; armed = (idx == self._armed) + dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60) + line = "%s%s %3d %5s %s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur, e["name"][:16]) + tg, w, h = make_text(line, FONT_S, C_AMBER if armed else C_TXT, C_BG); tg.x = 10; tg.y = y; g.append(tg) + self.log_rows.append((y - 2, y + LOG_ROWH - 2, idx)) + y += LOG_ROWH + self.dirty = True + def _tap_log(self, x, ty): + for y0, y1, idx in self.log_rows: + if y0 <= ty <= y1: + if self._armed == idx: del self.log[idx]; self._armed = None; self._save_log(); self.draw_log() # confirm delete + else: self._armed = idx; self.draw_log() # arm (tap again) + return + if self._armed is not None: self._armed = None; self.draw_log() # tapped elsewhere -> cancel + + # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs) ---------- + def _feed_midi(self, buf, n): + for i in range(n): + b = buf[i] + if b == 0xF0: self._sx = bytearray(); self._sxon = True + elif b == 0xF7: + if self._sxon: self._handle_sysex(self._sx) + self._sxon = False + elif b >= 0xF8: pass # real-time (e.g. Active Sensing 0xFE) - ignore + elif self._sxon: + if len(self._sx) < 60000: self._sx.append(b) # big enough for a pushed firmware (app.py) + else: self._sxon = False # overflow guard + def _handle_sysex(self, sx): + if len(sx) < 2 or sx[0] != 0x7D: return # 0x7D = our (educational) manufacturer id + cmd = sx[1] + if cmd == 0x01 and len(sx) >= 8 and rtc is not None: # set clock: yr-2000, mo, dd, hh, mm, ss + try: rtc.RTC().datetime = time.struct_time((2000 + sx[2], sx[3], sx[4], sx[5], sx[6], sx[7], 0, -1, -1)) + except Exception: pass + elif cmd == 0x02: # version query -> reply 0x03 + APP_VERSION + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x03]) + APP_VERSION.encode() + bytes([0xF7])) + elif cmd == 0x10: # write /programs.json pushed from the editor, then reload + try: + with open("/programs.json", "wb") as f: f.write(bytes(sx[2:])) + self.programs = load_programs(); self.idx = min(self.idx, len(self.programs) - 1) + self.load(self.idx) + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F, 0xF7])) # ACK ok + except OSError: + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: read-only (editor mode) + elif cmd == 0x20: # A/B firmware update: install new app.py to the trial slot + try: + data = bytes(sx[2:]) + try: os.remove("/app.bak") + except OSError: pass + os.rename("/app.py", "/app.bak") # keep the current build as the rollback + with open("/app.py", "wb") as f: f.write(data) + open("/trial", "w").close() # arm the trial; the loader reverts if it won't boot + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F, 0xF7])) # ACK -> rebooting + time.sleep(0.3); supervisor.reload() + except OSError: + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: read-only (editor mode) + + def run(self): + if self.touch.addr is None: + print("GT911 touch not found") + boot = time.monotonic() + try: os.stat("/trial"); committed = False # we're a freshly-pushed build on trial + except OSError: committed = True + while True: + self.tick(); self.poll() + if not committed and time.monotonic() - boot > 5: # booted & ran fine for 5s -> confirm the update + try: os.remove("/trial") + except Exception: pass + committed = True + # push a complete frame only when something changed (no mid-update tearing); + # capped at the display's refresh rate, so dirty regions stay small and quick + if self.dirty and self.display.refresh(): + self.dirty = False + time.sleep(0.0005) + +App().run() diff --git a/pico-cp/code.py b/pico-cp/code.py index 3f9dd87..3ea730c 100644 --- a/pico-cp/code.py +++ b/pico-cp/code.py @@ -1,626 +1,23 @@ -# VARASYS PolyMeter — PM_K-1 "Kit" firmware (CircuitPython edition) -# Raspberry Pi Pico (Pico / Pico W / Pico 2) on the 52Pi EP-0172 "Pico Breadboard Kit Plus": -# 3.5" ST7796 320x480 cap-touch (GT911), PSP joystick, WS2812 RGB, buzzer, 2 buttons. +# code.py - PM_K-1 A/B firmware loader (stable; rarely changes). # -# WHY CIRCUITPYTHON: the board then mounts as a USB drive (CIRCUITPY) carrying this code, your -# tracks (programs.json) and a copy of the editor — edit on the web, "Save to device" writes -# programs.json here, and CircuitPython auto-reloads with the new grooves. It also sends USB-MIDI -# (a note per click) so the web editor can play it out the computer's speakers ("Device audio"). -# Runs the SAME program strings as metronome.varasys.io. -# -# INSTALL: flash CircuitPython (https://circuitpython.org/board/raspberry_pi_pico/), then copy -# this file as code.py plus programs.json onto the CIRCUITPY drive. It runs on boot. -# -# Fallback: the simpler MicroPython firmware (pico/main.py) is always available — BOOTSEL + -# drag a MicroPython .uf2 to go back. The Pico cannot be bricked. -# -# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md. +# The real application lives in app.py; app.bak holds the previous known-good build. The web editor +# pushes a new app.py to a "trial" slot over USB-MIDI; this loader runs it, and if the new build +# fails to boot it AUTOMATICALLY ROLLS BACK to app.bak. (The Pico is also unbrickable: BOOTSEL -> +# drag a CircuitPython .uf2.) app.py clears the /trial marker once it has run healthily for ~5s. +import supervisor, os +supervisor.runtime.autoreload = False # updates reboot explicitly; never auto-restart on our own writes + +def _trial(): + try: os.stat("/trial"); return True + except OSError: return False -import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor -supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart try: - import rtc # set from the editor's clock SysEx so the log has real timestamps -except ImportError: - rtc = None -try: # CircuitPython 9.x - from fourwire import FourWire - from busdisplay import BusDisplay -except ImportError: # CircuitPython 8.x - from displayio import FourWire - from displayio import Display as BusDisplay -try: - import neopixel_write # core module on RP2040 — drives WS2812 with no external library -except ImportError: - neopixel_write = None -try: - import usb_midi # default-enabled on RP2040 — sends a MIDI note per click to the computer -except ImportError: - usb_midi = None - -# ============================== CONFIG (tweak if needed) ============================== -SPI_BAUD = 62_500_000 # faster SPI = smaller tearing window; drop to 40_000_000 if unstable -LED_BRIGHTNESS = 0.15 # WS2812 sits right next to you — keep it dim (0..1) -MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web editor's "Device audio") -MUTE_BUZZER = False # silence the on-board buzzer (e.g. when using computer audio) -WIDTH, HEIGHT = 320, 480 -MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed. -INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative -# Touch (GT911) — flip if taps land wrong: -TOUCH_SWAP_XY = False -TOUCH_INVERT_X = False -TOUCH_INVERT_Y = False -TOUCH_DEBUG = False -# Joystick: -JOY_INVERT_X = False -JOY_INVERT_Y = False -JOY_DEADZONE = 9000 - -# ----- pins (fixed by the EP-0172 board) ----- -P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7 -P_SDA, P_SCL = board.GP8, board.GP9 -P_RGB, P_BUZ, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14 -P_JOYX, P_JOYY = board.GP26, board.GP27 - -# ----- baked default grooves (used only if programs.json is missing/bad) ----- -DEFAULT_PROGRAMS = [ - ("Four on the floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"), - ("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"), - ("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"), - ("5 over 4", "t100;kick:4;claves:5~"), - ("Straight click", "t120;beep:4"), -] - -# ============================== COLOURS (0xRRGGBB; displayio handles 565) ============================== -C_BG, C_PANEL, C_TXT, C_MUTE = 0x06090E, 0x1C222C, 0xC7D0DB, 0x788494 -C_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240 -C_BTN = 0x1C222C -LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)} -# voice -> General-MIDI note (USB-MIDI bridge), and level -> MIDI velocity -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} -GM_DEFAULT = 37 -MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost -MAXLANES = 5 # lanes shown on the pad grid (extras still play) -LOG_TOP, LOG_ROWH, LOG_ROWS = 302, 16, 9 # practice-history log area (below the pad grid) -MIN_LOG_SEC = 5 # don't log plays shorter than this -PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost -PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost - -# WS2812 RGB LED — self-contained via the core neopixel_write module (no external library) -class RGB: - def __init__(self, pin): - self.ok = neopixel_write is not None - if self.ok: - self.io = digitalio.DigitalInOut(pin); self.io.direction = digitalio.Direction.OUTPUT - self.buf = bytearray(3) - def set(self, r, g, b): - if not self.ok: return - # WS2812 wants GRB order; scale down so it isn't blinding - self.buf[0] = int(g * LED_BRIGHTNESS); self.buf[1] = int(r * LED_BRIGHTNESS); self.buf[2] = int(b * LED_BRIGHTNESS) - try: neopixel_write.neopixel_write(self.io, self.buf) - except Exception: self.ok = False - -# ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ============================== -def load_font(path): - with open(path, "rb") as f: - blob = f.read() - count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {} - for _ in range(count): - cp = (blob[p] << 8) | blob[p+1]; w = blob[p+2]; h = blob[p+3] - xoff = blob[p+4]; xoff = xoff - 256 if xoff > 127 else xoff - top = blob[p+5]; adv = blob[p+6]; p += 7 - glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2 - return (glyphs, blob) - -FONT_S = load_font("/font_s.bin") # small — pad-grid lane labels -FONT_M = load_font("/font_m.bin") # labels / buttons -FONT_L = load_font("/font_l.bin") # big BPM -gc.collect() - -def _blend(bg, fg, i): - t = i * 17 - r = (((bg >> 16) & 0xFF)*(255-t) + ((fg >> 16) & 0xFF)*t) // 255 - g = (((bg >> 8) & 0xFF)*(255-t) + ((fg >> 8) & 0xFF)*t) // 255 - b = ((bg & 0xFF)*(255-t) + (fg & 0xFF)*t) // 255 - return (r << 16) | (g << 8) | b - -def make_text(s, font, fg, bg): - """Render a string into a displayio TileGrid (anti-aliased via a 16-step blend palette).""" - glyphs, blob = font - w = 0; top0 = 999; bot = 0 - for c in s: - g = glyphs.get(ord(c)) - if not g: continue - w += g[4] - if g[1]: - if g[3] < top0: top0 = g[3] - if g[3] + g[1] > bot: bot = g[3] + g[1] - if top0 == 999: top0 = 0 - w = max(1, w); h = max(1, bot - top0) - gc.collect() - bmp = displayio.Bitmap(w, h, 16) - pal = displayio.Palette(16) - for i in range(16): pal[i] = _blend(bg, fg, i) - pen = 0 - for c in s: - g = glyphs.get(ord(c)) - if not g: continue - gw, gh, xoff, gtop, adv, off = g - for j in range(gh): - row = (gtop - top0) + j - for i in range(gw): - k = j * gw + i - byte = blob[off + (k >> 1)] - nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF) - if nib: - x = pen + xoff + i - if 0 <= x < w and 0 <= row < h: bmp[x, row] = nib - pen += adv - return displayio.TileGrid(bmp, pixel_shader=pal), w, h - -# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ============================== -PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0} -PRIO = {2: 3, 1: 2, 3: 1} - -def parse_program(s): - bpm = 120; lanes = [] - for tok in s.strip().split(';'): - tok = tok.strip() - if not tok: continue - if tok[0] == 't' and tok[1:].isdigit(): - bpm = int(tok[1:]); continue - if ':' not in tok: continue - lane = _parse_lane(tok) - if lane: lanes.append(lane) - if not lanes: lanes = [_parse_lane("beep:4")] - return max(30, min(300, bpm)), lanes - -def _parse_lane(tok): - poly = '~' in tok; mute = '!' in tok - tok = tok.replace('~', '').replace('!', '') - if '@' in tok: tok = tok.split('@')[0] - sound, _, rest = tok.partition(':') - pattern = None - if '=' in rest: rest, _, pattern = rest.partition('=') - sub = 1; swing = False - if '/' in rest: - rest, _, sd = rest.partition('/') - swing = sd.endswith('s'); sd = sd.rstrip('s') # "/2s" = swung eighths - sub = int(sd) if sd.isdigit() else 1 - groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4] - beats = sum(groups); starts = set(); acc = 0 - for gp in groups: starts.add(acc); acc += gp - steps = beats * sub - if pattern: - levels = [PAT.get(ch, 0) for ch in pattern] - if len(levels) < steps: levels += [0] * (steps - len(levels)) - steps = len(levels) - else: - levels = [] - for i in range(steps): - if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) - else: levels.append(0) - return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute} - -def load_programs(): - try: - with open("/programs.json") as f: - d = json.load(f) - progs = [(p["name"], p["prog"]) for p in d["programs"]] - if progs: return progs - except Exception as e: - print("programs.json:", e) - return DEFAULT_PROGRAMS - -# ============================== GT911 TOUCH ============================== -class GT911: - def __init__(self, i2c): - self.i2c = i2c; self.addr = None - while not i2c.try_lock(): pass - try: found = i2c.scan() - finally: i2c.unlock() - for a in (0x5D, 0x14): - if a in found: self.addr = a; break - if self.addr is None and found: self.addr = found[0] - def _rd(self, reg, n): - b = bytearray(n) - while not self.i2c.try_lock(): pass + import app # runs the application (app.py ends with App().run()) +except Exception: + if _trial(): # a freshly-pushed build crashed on startup -> roll back try: - self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF])) - self.i2c.readfrom_into(self.addr, b) - finally: self.i2c.unlock() - return b - def _wr(self, reg, val): - while not self.i2c.try_lock(): pass - try: self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF, val])) - finally: self.i2c.unlock() - def read(self): - if self.addr is None: return None - try: st = self._rd(0x814E, 1)[0] - except OSError: return None - if not (st & 0x80): return None - n = st & 0x0F; pt = None - if n >= 1: - b = self._rd(0x8150, 4); tx = b[0] | (b[1] << 8); ty = b[2] | (b[3] << 8) - pt = self._map(tx, ty) - try: self._wr(0x814E, 0) - except OSError: pass - return pt - def _map(self, tx, ty): - if TOUCH_DEBUG: print("touch raw", tx, ty) - if TOUCH_SWAP_XY: tx, ty = ty, tx - if TOUCH_INVERT_X: tx = WIDTH - 1 - tx - if TOUCH_INVERT_Y: ty = HEIGHT - 1 - ty - if 0 <= tx < WIDTH and 0 <= ty < HEIGHT: return (tx, ty) - return None - -# ============================== DISPLAY SETUP ============================== -def st7796_init(): - inv = b'\x21\x00' if INVERT_COLORS else b'\x20\x00' - return ( - b'\x01\x80\x78' # SWRESET + 120ms - b'\x11\x80\x78' # SLPOUT + 120ms - b'\xF0\x01\xC3' b'\xF0\x01\x96' # command-set unlock - + bytes([0x36, 0x01, MADCTL]) + - b'\x3A\x01\x55' # 16bpp - b'\xB4\x01\x01' - b'\xB6\x03\x80\x02\x3B' - b'\xE8\x08\x40\x8A\x00\x00\x29\x19\xA5\x33' - b'\xC1\x01\x06' b'\xC2\x01\xA7' - b'\xC5\x81\x18\x78' # VCOM + 120ms - b'\xE0\x0E\xF0\x09\x0B\x06\x04\x15\x2F\x54\x42\x3C\x17\x14\x18\x1B' - b'\xE1\x0E\xE0\x09\x0B\x06\x04\x03\x2B\x43\x42\x3B\x16\x14\x17\x1B' - b'\xF0\x01\x3C' b'\xF0\x81\x69\x78' # lock + 120ms - + inv + - b'\x29\x80\x32' # DISPON + 50ms - ) - -def make_display(): - displayio.release_displays() - spi = busio.SPI(clock=P_SCK, MOSI=P_MOSI) - bus = FourWire(spi, command=P_DC, chip_select=P_CS, reset=P_RST, baudrate=SPI_BAUD) - return BusDisplay(bus, st7796_init(), width=WIDTH, height=HEIGHT, auto_refresh=False) - -def solid(color): - p = displayio.Palette(1); p[0] = color; return p - -def rect(x, y, w, h, color): - return vectorio.Rectangle(pixel_shader=solid(color), width=w, height=h, x=x, y=y) - -# ============================== APP ============================== -class App: - def __init__(self): - self.display = make_display() - self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000) - 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_in = usb_midi.ports[0] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 0) else None - self._mbuf = bytearray(64); self.midi_host = False; self.last_midi_in = 0.0 - self._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler (clock + pushed programs) - self.led = RGB(P_RGB) - self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0) - self.buz_off = 0 - self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB) - self._aPrev = True; self._bPrev = True - self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY) - self._joyNext = 0 - self._touchDown = False; self._touchSeen = 0 - self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0) - self.programs = load_programs() - self.dirty = True - self.pad_pal = displayio.Palette(8) - for i in range(4): self.pad_pal[i] = PAD_DIM[i]; self.pad_pal[i + 4] = PAD_LIT[i] - self.lane_pads = []; self.lane_lit = [] - # practice history — persisted to /history.json (next to programs.json) when we own the filesystem - self.can_write = self._probe_write() - self.log = self._load_log() - self.play_start = None; self.play_bpm = 0; self.play_name = "" - self._armed = None; self.log_rows = [] - self._build_scene() - self.load(0) - self.draw_log() - - def _btn(self, pin): - d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP - return d - - # ---------- scene graph ---------- - def _build_scene(self): - root = displayio.Group(); self.display.root_group = root - root.append(rect(0, 0, WIDTH, HEIGHT, C_BG)) - tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 8; root.append(tg) - root.append(rect(0, 38, WIDTH, 2, C_PANEL)) - # dynamic groups - 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_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 - root.append(rect(0, LOG_TOP - 6, WIDTH, 2, C_PANEL)) # divider above the history log - self.g_log = displayio.Group(); root.append(self.g_log) # practice history (tap a row to delete) - # (no on-screen buttons — transport is the joystick + buttons A/B; touch deletes log rows) - - def _place(self, group, s, x, y, fg, bg, font, right_edge=None): - while len(group): group.pop() - self.dirty = True - if not s: return - tg, w, h = make_text(s, font, fg, bg) - tg.x = (right_edge - w) if right_edge is not None else x; tg.y = y; group.append(tg) - def _center(self, group, s, cx, cy, fg, bg, font): - while len(group): group.pop() - tg, w, h = make_text(s, font, fg, bg); tg.x = cx - w//2; tg.y = cy - h//2; group.append(tg) - self.dirty = True - - # ---------- program ---------- - def load(self, i): - n = len(self.programs); self.idx = i % n - self.name, prog = self.programs[self.idx] - self.bpm, self.lanes = parse_program(prog) - self.master = self.lanes[0] - self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid() - def _step_dur(self, L, step): - beat = 60_000_000_000 / self.bpm - if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar - m = self.lanes[0]; master_bar = beat * (m['steps'] // m['sub']) - return int(master_bar / L['steps']) - sub = L['sub'] - if L['swing'] and sub % 2 == 0: # swing even subdivisions: long–short (2:1) pairs - pair = beat / (sub // 2) - return int(pair * 2 / 3) if (step % sub) % 2 == 0 else int(pair / 3) - return int(beat / sub) # straight: a step = one beat / subdivision - def _reset_clock(self): - now = time.monotonic_ns() - for L in self.lanes: - L['next'] = now; L['step'] = -1 - - # ---------- audio + light ---------- - def click(self, level): - self.buz.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600) - self.buz.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000) - self.buz_off = time.monotonic_ns() + 22_000_000 - def flash(self, level): - self.rgb = LEVEL_RGB.get(level, (0, 150, 255)) - self.led.set(*self.rgb) - def led_off(self): - self.rgb = (0, 0, 0) - self.led.set(0, 0, 0) - def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer - if self.midi is None: return - try: self.midi.write(bytes([0x90, note, vel])) # Note On (percussive — no Note Off needed) + os.remove("/app.py"); os.rename("/app.bak", "/app.py"); os.remove("/trial") except Exception: pass - - # ---------- transport ---------- - def toggle(self): - self.running = not self.running - if self.running: self._reset_clock(); self._start_play() - else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads(); self._log_play() - self.draw_status() - def set_bpm(self, v): - v = max(30, min(300, v)) - if v != self.bpm: - self.bpm = v - self.draw_bpm() - def goto(self, i): - was = self.running - if was: self.running = False; self._log_play() # close out the track that was playing - self.load(i) - if was: self.running = True; self._reset_clock(); self._start_play() - def tap(self): - now = time.monotonic() - if not hasattr(self, '_taps'): self._taps = [] - self._taps = [t for t in self._taps if now - t < 2.4] - self._taps.append(now) - if len(self._taps) >= 2: - span = (self._taps[-1] - self._taps[0]) / (len(self._taps) - 1) - if span > 0: self.set_bpm(round(60 / span)) - - # ---------- scheduler ---------- - def tick(self): - now = time.monotonic_ns() - if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0 - if self.running: - fired = [] - for li, L in enumerate(self.lanes): - adv = False - while now >= L['next']: - L['step'] = (L['step'] + 1) % L['steps'] - lvl = 0 if L['mute'] else L['levels'][L['step']] - if lvl > 0: - fired.append(lvl) - self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane - L['next'] += self._step_dur(L, L['step']); adv = True - if adv and li < len(self.lane_pads): self._move_playhead(li, L['step']) - if fired: - best = max(fired, key=lambda l: PRIO.get(l, 0)) - if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead - self.flash(best) - if self.rgb != (0, 0, 0): - r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10 - self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0) - self.led.set(*self.rgb) - - # ---------- inputs ---------- - def poll(self): - a = self.btnA.value - if (not a) and self._aPrev: self.toggle() - self._aPrev = a - b = self.btnB.value - if (not b) and self._bPrev: self.tap() - self._bPrev = b - now = time.monotonic_ns() - if now >= self._joyNext: - x = self.jx.value - 32768; y = self.jy.value - 32768 - if JOY_INVERT_X: x = -x - if JOY_INVERT_Y: y = -y - if abs(y) > JOY_DEADZONE: - self.set_bpm(self.bpm + (1 if y > 0 else -1) * (5 if abs(y) > 26000 else 1)) - self._joyNext = now + 70_000_000 - elif abs(x) > JOY_DEADZONE: - self.goto(self.idx + (1 if x > 0 else -1)); self._joyNext = now + 350_000_000; return - else: - self._joyNext = now + 20_000_000 - pt = self.touch.read() - nowms = time.monotonic() - if pt: - self._touchSeen = nowms - if not self._touchDown: - self._touchDown = True; self._tap_log(pt[0], pt[1]) - elif self._touchDown and (nowms - self._touchSeen) > 0.14: - self._touchDown = False - # USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs) - if self.midi_in is not None: - try: n = self.midi_in.readinto(self._mbuf) - except Exception: n = 0 - if n: - self.last_midi_in = nowms - self._feed_midi(self._mbuf, n) - 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() - - # ---------- drawing ---------- - def draw_bpm(self): - self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12) - def draw_status(self): - self._place(self.g_run, "RUN" if self.running else "STOP", 12, 48, - 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]), - 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) ---------- - def _padbase(self, L, s): - return 0 if L['mute'] else L['levels'][s] - def build_grid(self): - while len(self.g_grid): self.g_grid.pop() - self.lane_pads = []; self.lane_lit = [] - n = min(len(self.lanes), MAXLANES) - top = 140; rowh = min(40, (296 - top) // max(1, n)) - for li in range(n): - L = self.lanes[li]; y = top + li * rowh; cy = y + rowh // 2 - tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG) - tg.x = 8; tg.y = cy - h // 2; self.g_grid.append(tg) - steps = L['steps']; sub = L['sub']; px0 = 60 - usable = WIDTH - 8 - px0 - 12; stepw = max(1, usable // steps) - r_big = max(2, min(6, stepw // 2, (rowh - 8) // 2)); r_sml = max(2, r_big - 2) - pads = [] - for s in range(steps): - rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision - cxp = px0 + 6 + (s * usable) // steps # proportional → beats line up across lanes - c = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=cxp, y=cy) - c.color_index = self._padbase(L, s); self.g_grid.append(c); pads.append(c) - self.lane_pads.append(pads); self.lane_lit.append(-1) - self.dirty = True - def _move_playhead(self, li, step): - pads = self.lane_pads[li]; prev = self.lane_lit[li] - if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev) - if step < len(pads): pads[step].color_index = self._padbase(self.lanes[li], step) + 4 - self.lane_lit[li] = step; self.dirty = True - def reset_playheads(self): - for li, pads in enumerate(self.lane_pads): - prev = self.lane_lit[li] - if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev) - self.lane_lit[li] = -1 - self.dirty = True - - # ---------- practice history (saved to /history.json, next to programs.json) ---------- - def _probe_write(self): - try: - with open("/.wtest", "w") as f: f.write("1") - try: os.remove("/.wtest") - except Exception: pass - return True - except OSError: - return False # editor mode: the computer owns the FS - def _load_log(self): - try: - with open("/history.json") as f: return json.load(f).get("log", []) - except Exception: - return [] - def _save_log(self): - if not self.can_write: return - try: - with open("/history.json", "w") as f: json.dump({"log": self.log[:200]}, f) - except OSError: - self.can_write = False - def _start_play(self): - self.play_start = time.monotonic(); self.play_bpm = self.bpm; self.play_name = self.name - def _log_play(self): - if self.play_start is None: return - dur = int(time.monotonic() - self.play_start); self.play_start = None - if dur < MIN_LOG_SEC: return # skip plays under 5 seconds - t = time.localtime() - self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm, - "dur": dur, "name": self.play_name}) - del self.log[200:]; self._armed = None - self._save_log(); self.draw_log() - def draw_log(self): - g = self.g_log - while len(g): g.pop() - self.log_rows = [] - hdr, w, h = make_text("PRACTICE LOG", FONT_S, C_MUTE, C_BG); hdr.x = 10; hdr.y = LOG_TOP; g.append(hdr) - if not self.log: - tg, w, h = make_text("plays over 5s show here", FONT_S, C_DIM, C_BG); tg.x = 10; tg.y = LOG_TOP + LOG_ROWH; g.append(tg) - self.dirty = True; return - y = LOG_TOP + LOG_ROWH + 2 - for idx in range(min(LOG_ROWS, len(self.log))): - e = self.log[idx]; armed = (idx == self._armed) - dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60) - line = "%s%s %3d %5s %s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur, e["name"][:16]) - tg, w, h = make_text(line, FONT_S, C_AMBER if armed else C_TXT, C_BG); tg.x = 10; tg.y = y; g.append(tg) - self.log_rows.append((y - 2, y + LOG_ROWH - 2, idx)) - y += LOG_ROWH - self.dirty = True - def _tap_log(self, x, ty): - for y0, y1, idx in self.log_rows: - if y0 <= ty <= y1: - if self._armed == idx: del self.log[idx]; self._armed = None; self._save_log(); self.draw_log() # confirm delete - else: self._armed = idx; self.draw_log() # arm (tap again) - return - if self._armed is not None: self._armed = None; self.draw_log() # tapped elsewhere -> cancel - - # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs) ---------- - def _feed_midi(self, buf, n): - for i in range(n): - b = buf[i] - if b == 0xF0: self._sx = bytearray(); self._sxon = True - elif b == 0xF7: - if self._sxon: self._handle_sysex(self._sx) - self._sxon = False - elif b >= 0xF8: pass # real-time (e.g. Active Sensing 0xFE) — ignore - elif self._sxon: - if len(self._sx) < 6000: self._sx.append(b) - else: self._sxon = False # overflow guard - def _handle_sysex(self, sx): - if len(sx) < 2 or sx[0] != 0x7D: return # 0x7D = our (educational) manufacturer id - cmd = sx[1] - if cmd == 0x01 and len(sx) >= 8 and rtc is not None: # set clock: yr-2000, mo, dd, hh, mm, ss - try: rtc.RTC().datetime = time.struct_time((2000 + sx[2], sx[3], sx[4], sx[5], sx[6], sx[7], 0, -1, -1)) - except Exception: pass - elif cmd == 0x10: # write /programs.json pushed from the editor, then reload - try: - with open("/programs.json", "wb") as f: f.write(bytes(sx[2:])) - self.programs = load_programs(); self.idx = min(self.idx, len(self.programs) - 1) - self.load(self.idx) - if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F, 0xF7])) # ACK ok - except OSError: - if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: read-only (editor mode) - - def run(self): - if self.touch.addr is None: - print("GT911 touch not found") - while True: - self.tick(); self.poll() - # push a complete frame only when something changed (no mid-update tearing); - # capped at the display's refresh rate, so dirty regions stay small and quick - if self.dirty and self.display.refresh(): - self.dirty = False - time.sleep(0.0005) - -App().run() + supervisor.reload() # reboot into the restored known-good build + else: + raise # the active build failed unexpectedly (rare) -> on-screen traceback