diff --git a/build.sh b/build.sh index 2a6b795..3b60e88 100755 --- a/build.sh +++ b/build.sh @@ -42,7 +42,7 @@ 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", "app.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", - "README.md", "protect-firmware.sh"): + "logo.bin", "midi.bin", "usb.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 print("zipped pm_k1_circuitpy.zip") diff --git a/pico-cp/README.md b/pico-cp/README.md index 3a994a7..0732b83 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -24,8 +24,11 @@ from the web editor over USB‑MIDI**, and plays through your **computer's speak ( — Pico 2 / W builds also fine). A `CIRCUITPY` drive appears. 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.) + `programs.json`, `font_s.bin` / `font_m.bin` / `font_l.bin`, `logo.bin` / `midi.bin` / `usb.bin` (the + on-screen logo + MIDI/USB status icons), `editor.html` (offline editor), and the helper scripts. + (`code.py` is a tiny stable loader; `app.py` is what firmware updates replace. The `.bin` assets — like + the fonts — ride in the bundle, since the one-click updater only pushes `app.py`; if a `.bin` is missing + the firmware just falls back to text and never fails to boot.) 3. **Power‑cycle** (so `boot.py` takes effect). It boots into appliance mode and runs. ## Program it from the web (push over USB‑MIDI) diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index cfd5ae9..d8a8698 100644 Binary files a/pico-cp/__pycache__/app.cpython-312.pyc and b/pico-cp/__pycache__/app.cpython-312.pyc differ diff --git a/pico-cp/app.py b/pico-cp/app.py index a82d214..e6ee32d 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -18,7 +18,7 @@ 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 = "0.0.1" # firmware version (the A/B updater pushes/compares this) +APP_VERSION = "0.0.2" # 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: @@ -85,10 +85,13 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare 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) +GRID_TOP = 150 # top of the pad grid (leaves room for stopwatch/bar) 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 +C_GRID = 0x1A2330 # faint vertical beat gridlines (beats line up across lanes) +PAD_OUTLINE = 0x33414F # off-beat hollow-square border when idle # WS2812 RGB LED - self-contained via the core neopixel_write module (no external library) class RGB: @@ -162,6 +165,31 @@ def make_text(s, font, fg, bg): pen += adv return displayio.TileGrid(bmp, pixel_shader=pal), w, h +# ---- single-image alpha assets (logo, status icons) — blit like a one-off glyph; see gen_assets.py ---- +def load_alpha(path): + try: + with open(path, "rb") as f: blob = f.read() + return (blob[0], blob[1], blob) # (w, h, bytes); pixels start at offset 2 + except Exception: + return None # missing/corrupt -> caller falls back to text (no crash) +def make_glyph(asset, fg, bg): + w, h, blob = asset + gc.collect() + bmp = displayio.Bitmap(w, h, 16); pal = displayio.Palette(16) + for i in range(16): pal[i] = _blend(bg, fg, i) + for k in range(w * h): + byte = blob[2 + (k >> 1)] + nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF) + if nib: bmp[k % w, k // w] = nib + return displayio.TileGrid(bmp, pixel_shader=pal), pal, w, h +def _recolor(pal, fg, bg): # re-tint a stored asset palette in place (tear-free) + for i in range(16): pal[i] = _blend(bg, fg, i) + +LOGO = load_alpha("/logo.bin") # VARASYS wordmark (no tagline) +ICON_MIDI = load_alpha("/midi.bin") # DIN-5: green when a MIDI host is listening +ICON_USB = load_alpha("/usb.bin") # trident: lit when USB-connected to a computer +gc.collect() + # ============================== 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} @@ -312,9 +340,13 @@ class App: 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) + self.pad_pal = displayio.Palette(10) # 0-3 idle levels, 4-7 lit levels, 8 off-beat border, 9 hollow bg for i in range(4): self.pad_pal[i] = PAD_DIM[i]; self.pad_pal[i + 4] = PAD_LIT[i] + self.pad_pal[8] = PAD_OUTLINE; self.pad_pal[9] = C_BG self.lane_pads = []; self.lane_lit = [] + self.usb_conn = False; self._m_steps = 0 # USB-connected state; master-lane steps (for the bar counter) + self._uiNext = 0.0; self._lastTs = None; self._lastBs = None # throttle the stopwatch/bar redraw + self.ic_midi_pal = None; self.ic_usb_pal = None # 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() @@ -322,7 +354,7 @@ class App: self._armed = None; self.log_rows = [] self._build_scene() self.load(0) - self.draw_log() + self.draw_log(); self.draw_icons(); self.draw_meters() def _btn(self, pin): d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP @@ -332,13 +364,23 @@ class App: 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) + # header: VARASYS logo (left, no tagline) + MIDI / USB status icons (right) + if LOGO: + tg, _p, lw, lh = make_glyph(LOGO, C_CYAN, C_BG); tg.x = 10; tg.y = 9; root.append(tg) + else: + tg, w, h = make_text("VARASYS", FONT_M, C_CYAN, C_BG); tg.x = 10; tg.y = 8; root.append(tg) + x = WIDTH - 12 + for asset, attr in ((ICON_USB, "ic_usb_pal"), (ICON_MIDI, "ic_midi_pal")): + if asset: + tg, pal, w, h = make_glyph(asset, C_DIM, C_BG); x -= w; tg.x = x; tg.y = 8; x -= 8 + root.append(tg); setattr(self, attr, pal) 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_time = displayio.Group(); root.append(self.g_time) # stopwatch (m:ss, left) + self.g_bar = displayio.Group(); root.append(self.g_bar) # bar counter (right) 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) @@ -376,6 +418,7 @@ class App: now = time.monotonic_ns() for L in self.lanes: L['next'] = now; L['step'] = -1 + self._m_steps = 0 # restart the bar count # ---------- audio + light ---------- def click(self, level): @@ -428,6 +471,7 @@ class App: adv = False while now >= L['next']: L['step'] = (L['step'] + 1) % L['steps'] + if li == 0: self._m_steps += 1 # count master-lane steps -> bars lvl = 0 if L['mute'] else L['levels'][L['step']] if lvl > 0: fired.append(lvl) @@ -482,7 +526,10 @@ class App: 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() + self.led_off(); self.draw_icons() + uc = bool(getattr(supervisor.runtime, "usb_connected", True)) # connected to a computer? + if uc != self.usb_conn: + self.usb_conn = uc; self.draw_icons() # ---------- drawing ---------- def draw_bpm(self): @@ -491,42 +538,78 @@ class App: 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) + 12, 120, C_TXT, C_BG, FONT_M) + def draw_icons(self): # recolor the MIDI/USB icons by state (tear-free palette swap) + if self.ic_midi_pal is not None: + _recolor(self.ic_midi_pal, C_GREEN if self.midi_host else C_DIM, C_BG) + if self.ic_usb_pal is not None: + _recolor(self.ic_usb_pal, C_CYAN if self.usb_conn else C_DIM, C_BG) + self.dirty = True + def draw_meters(self): # stopwatch (m:ss) + bar counter; called ~4x/s from run() + if self.running and self.play_start is not None: + el = int(time.monotonic() - self.play_start) + ts = "%d:%02d" % (el // 60, el % 60) + mlen = self.lanes[0]['steps'] if self.lanes else 1 + bs = "bar %d" % (self._m_steps // max(1, mlen) + 1) + else: + ts = "0:00"; bs = "bar -" + if ts != self._lastTs: + self._place(self.g_time, ts, 12, 86, C_TXT, C_BG, FONT_M); self._lastTs = ts + if bs != self._lastBs: + self._place(self.g_bar, bs, 0, 92, C_MUTE, C_BG, FONT_M, right_edge=WIDTH-12); self._lastBs = bs # ---------- 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 _sq(self, cx, cy, side, ci): # a centred square pad sharing pad_pal + r = vectorio.Rectangle(pixel_shader=self.pad_pal, width=side, height=side, x=cx - side // 2, y=cy - side // 2) + r.color_index = ci; return r 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)) + top = GRID_TOP; rowh = min(40, ((LOG_TOP - 10) - top) // max(1, n)) + px0 = 60; usable = WIDTH - 8 - px0 - 12; gridh = n * rowh + # vertical gridlines at the master lane's beats, full height -> beats line up across lanes + m = self.lanes[0]; mbeats = max(1, m['steps'] // max(1, m['sub'])) + for bcol in range(mbeats): + self.g_grid.append(rect(px0 + 6 + (bcol * usable) // mbeats, top, 1, gridh, C_GRID)) 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) + steps = L['steps']; sub = L['sub']; stepw = max(1, usable // steps) + side = max(5, min(15, stepw - 1, rowh - 6)); inner = max(2, side - 4) pads = [] for s in range(steps): - rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision + base = self._padbase(L, s) 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) + if s % sub == 0: # main beat -> filled square + sq = self._sq(cxp, cy, side, base); self.g_grid.append(sq) + pads.append(("fill", (sq,), base)) + else: # off-beat -> hollow outline square + out = self._sq(cxp, cy, side, base if base else 8) + ins = self._sq(cxp, cy, inner, 9) # bg fill = the hollow centre + self.g_grid.append(out); self.g_grid.append(ins) + pads.append(("out", (out, ins), base)) self.lane_pads.append(pads); self.lane_lit.append(-1) self.dirty = True + def _pad_idle(self, pad): + kind, shapes, base = pad + if kind == "fill": shapes[0].color_index = base + else: shapes[0].color_index = base if base else 8; shapes[1].color_index = 9 # ring + hollow centre + def _pad_lit(self, pad): + kind, shapes, base = pad + for sh in shapes: sh.color_index = base + 4 # fill the square (lit level) regardless of shape 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 + if 0 <= prev < len(pads): self._pad_idle(pads[prev]) + if step < len(pads): self._pad_lit(pads[step]) 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) + if 0 <= prev < len(pads): self._pad_idle(pads[prev]) self.lane_lit[li] = -1 self.dirty = True @@ -635,7 +718,10 @@ class App: 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 + tnow = time.monotonic() + if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter + self._uiNext = tnow + 0.25; self.draw_meters() + if not committed and tnow - boot > 5: # booted & ran fine for 5s -> confirm the update try: os.remove("/trial") except Exception: pass committed = True diff --git a/pico-cp/gen_assets.py b/pico-cp/gen_assets.py new file mode 100644 index 0000000..869a5c1 --- /dev/null +++ b/pico-cp/gen_assets.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# Generate the on-screen bitmap assets the CircuitPython firmware (app.py) blits: +# logo.bin - the VARASYS wordmark (no tagline), tinted brand cyan at the top +# midi.bin - a 5-pin DIN icon (lights green when a MIDI host is listening) +# usb.bin - the USB "trident" icon (lights when the device is USB-connected) +# +# Each file is a single 4-bit-alpha image with a 2-byte header (matches the font packing +# in gen_font.py, just without the glyph metrics table): +# byte 0 = width, byte 1 = height, then ((w*h+1)//2) bytes of 4-bit alpha, +# row-major, two pixels per byte (first pixel = high nibble). +# app.py's load_alpha()/make_glyph() decode it and blend bg->fg per pixel (smooth). +# +# Re-run after changing the logo/icons: python3 pico-cp/gen_assets.py +# Writes pico-cp/{logo,midi,usb}.bin and /tmp/assets_verify.png (eyeball it). + +import math, pathlib +from PIL import Image, ImageDraw + +HERE = pathlib.Path(__file__).parent +LOGO_PNG = pathlib.Path.home() / "src/varasys_logo/For using with dark colored background/VARASYS Limited.png" +SS = 6 # supersample factor for the drawn icons (downscaled -> anti-aliased) + + +def pack(coverage_img): + """coverage_img: 'L' image (0..255 alpha). -> bytes(w, h, packed 4-bit alpha).""" + w, h = coverage_img.size + assert w <= 255 and h <= 255, "asset too large for the 1-byte dims (%dx%d)" % (w, h) + px = coverage_img.load() + nib = [] + for y in range(h): + for x in range(w): + nib.append(px[x, y] >> 4) # 8-bit -> 4-bit alpha + if len(nib) % 2: + nib.append(0) + out = bytearray([w, h]) + for i in range(0, len(nib), 2): + out.append((nib[i] << 4) | nib[i + 1]) + return bytes(out) + + +def make_logo(target_w=156): + img = Image.open(LOGO_PNG).convert("RGBA") + r, g, b, a = img.split() + # Coverage = alpha if the PNG is genuinely transparent, else brightness (cyan on a flat bg). + if a.getextrema()[0] < 250: + cov = a + else: + cov = img.convert("L") + bbox = cov.getbbox() + if bbox: + cov = cov.crop(bbox) + w, h = cov.size + th = max(1, round(target_w * h / w)) + cov = cov.resize((target_w, th), Image.LANCZOS) + return pack(cov) + + +def make_midi(size=22): + cw = size * SS + img = Image.new("L", (cw, cw), 0) + d = ImageDraw.Draw(img) + cx = cy = cw / 2 + R = cw * 0.44 + lw = max(2, int(cw * 0.07)) + d.ellipse([cx - R, cy - R, cx + R, cy + R], outline=255, width=lw) # connector shell + # 5 pins in the DIN fan (one top-centre, a flanking pair above, a wider pair below) + ring = R * 0.60 + pr = cw * 0.075 + pins = [(0.0, -1.0), (-0.72, -0.55), (0.72, -0.55), (-0.95, 0.18), (0.95, 0.18)] + for fx, fy in pins: + x = cx + fx * ring + y = cy + fy * ring + d.ellipse([x - pr, y - pr, x + pr, y + pr], fill=255) + return pack(img.resize((size, size), Image.LANCZOS)) + + +def make_usb(size=22): + cw = size * SS + img = Image.new("L", (cw, cw), 0) + d = ImageDraw.Draw(img) + cx = cw / 2 + lw = max(2, int(cw * 0.075)) + top, bot = cw * 0.10, cw * 0.90 + d.line([(cx, top + cw * 0.10), (cx, bot)], fill=255, width=lw) # shaft + # arrowhead at the top + ah = cw * 0.13 + d.polygon([(cx, top), (cx - ah, top + ah * 1.4), (cx + ah, top + ah * 1.4)], fill=255) + # base plug (filled circle at the bottom) + br = cw * 0.10 + d.ellipse([cx - br, bot - br, cx + br, bot + br], fill=255) + # left branch -> small filled circle + ly = cw * 0.46 + lx = cx - cw * 0.26 + d.line([(cx, ly + cw * 0.10), (lx, ly)], fill=255, width=lw) + cr = cw * 0.085 + d.ellipse([lx - cr, ly - cr, lx + cr, ly + cr], fill=255) + # right branch -> small filled square + ry = cw * 0.34 + rx = cx + cw * 0.26 + d.line([(cx, ry + cw * 0.10), (rx, ry)], fill=255, width=lw) + sq = cw * 0.08 + d.rectangle([rx - sq, ry - sq, rx + sq, ry + sq], fill=255) + return pack(img.resize((size, size), Image.LANCZOS)) + + +def unpack_to_img(blob, fg=(10, 179, 247), bg=(6, 9, 14)): + """Decode like app.py would, for the verify sheet.""" + w, h = blob[0], blob[1] + im = Image.new("RGB", (w, h), bg) + for k in range(w * h): + byte = blob[2 + (k >> 1)] + nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF) + t = nib * 17 + col = tuple((bg[i] * (255 - t) + fg[i] * t) // 255 for i in range(3)) + im.putpixel((k % w, k // w), col) + return im + + +def main(): + assets = {"logo": make_logo(), "midi": make_midi(), "usb": make_usb()} + for name, blob in assets.items(): + (HERE / (name + ".bin")).write_bytes(blob) + print("wrote %s.bin %dx%d %d bytes" % (name, blob[0], blob[1], len(blob))) + # verify sheet on a dark panel-like background + pad = 12 + imgs = [unpack_to_img(b) for b in assets.values()] + W = max(i.width for i in imgs) + pad * 2 + H = sum(i.height for i in imgs) + pad * (len(imgs) + 1) + sheet = Image.new("RGB", (W, H), (6, 9, 14)) + y = pad + for i in imgs: + sheet.paste(i, (pad, y)) + y += i.height + pad + sheet.save("/tmp/assets_verify.png") + print("verify -> /tmp/assets_verify.png") + + +if __name__ == "__main__": + main() diff --git a/pico-cp/logo.bin b/pico-cp/logo.bin new file mode 100644 index 0000000..21eb897 Binary files /dev/null and b/pico-cp/logo.bin differ diff --git a/pico-cp/midi.bin b/pico-cp/midi.bin new file mode 100644 index 0000000..02f1cc5 Binary files /dev/null and b/pico-cp/midi.bin differ diff --git a/pico-cp/usb.bin b/pico-cp/usb.bin new file mode 100644 index 0000000..dd86644 Binary files /dev/null and b/pico-cp/usb.bin differ