metronome/pico-cp/code.py
Me Here ffededd05b PM_K-1 CircuitPython: self-contained RGB LED + fix screen tearing
From on-board feedback (memory + colours now good):
  - LED: drive the WS2812 via the core neopixel_write module (no neopixel library to
    install) — a tiny RGB class. Self-contained: it works straight from the bundle.
  - Tearing: switch displayio to auto_refresh=False and push a complete frame only when
    the scene changed (dirty flag, capped at the panel's refresh rate) so updates are
    never shown mid-paint. Beat dots now recolour in place (vectorio color_index) instead
    of being rebuilt every beat, shrinking the dirty region.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:43:48 -05:00

475 lines
21 KiB
Python

# 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. (USB-MIDI audio out
# to the computer comes in a later phase.) 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
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
# ============================== CONFIG (tweak if needed) ==============================
SPI_BAUD = 40_000_000
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)}
# 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
self.buf[0] = g; self.buf[1] = r; self.buf[2] = b # WS2812 wants GRB order
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_M = load_font("/font_m.bin")
FONT_L = load_font("/font_l.bin")
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
if '/' in rest:
rest, _, sd = rest.partition('/'); sd = sd.rstrip('s')
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 {'sub': sub, '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.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.buttons = []
self.dirty = True; self.dots = []
self.dot_pal = displayio.Palette(3)
self.dot_pal[0] = C_DIM; self.dot_pal[1] = C_CYAN; self.dot_pal[2] = C_AMBER
self._build_scene()
self.load(0)
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))
root.append(rect(0, 42, WIDTH, 2, C_PANEL))
# static title + BPM label
tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 12; root.append(tg)
tg, w, h = make_text("BPM", FONT_M, C_MUTE, C_BG); tg.x = 12; tg.y = 120; root.append(tg)
# dynamic groups
self.g_bpm = displayio.Group(); root.append(self.g_bpm)
self.g_dots = displayio.Group(); root.append(self.g_dots)
self.g_run = displayio.Group(); root.append(self.g_run)
self.g_idx = displayio.Group(); root.append(self.g_idx)
self.g_name = displayio.Group(); root.append(self.g_name)
# buttons (rects static; labels in per-button groups so play can toggle)
bw, bh = 96, 56; gap = (WIDTH - 3*bw)//4; xs = [gap, gap*2+bw, gap*3+bw*2]
self.btn_lbl = {}
rows = [(300, ("prev", "play", "next")), (372, ("minus", "tap", "plus"))]
for y, keys in rows:
for x, key in zip(xs, keys):
root.append(rect(x, y, bw, bh, C_BTN))
root.append(rect(x, y, bw, 2, C_PANEL)); root.append(rect(x, y+bh-2, bw, 2, C_PANEL))
lg = displayio.Group(); root.append(lg); self.btn_lbl[key] = (lg, x+bw//2, y+bh//2)
self.buttons.append((x, y, bw, bh, key))
self._label(key)
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
def _label(self, key):
sym = {"prev": "◀◀", "next": "▶▶", "minus": "-", "plus": "+", "tap": "TAP",
"play": "" if self.running else ""}[key]
lg, cx, cy = self.btn_lbl[key]
self._center(lg, sym, cx, cy, C_GREEN if key == "play" else C_TXT, C_BTN, FONT_M)
# ---------- 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.beat = -1
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_dots()
def _reset_clock(self):
now = time.monotonic_ns()
for L in self.lanes:
L['next'] = now; L['step'] = -1; L['dur'] = int(60_000_000_000 / self.bpm / L['sub'])
# ---------- 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)
# ---------- transport ----------
def toggle(self):
self.running = not self.running
if self.running: self._reset_clock(); self.beat = -1
else: self.buz.duty_cycle = 0; self.led_off()
self.draw_status(); self.draw_dots(); self._label("play")
def set_bpm(self, v):
v = max(30, min(300, v))
if v != self.bpm:
self.bpm = v
for L in self.lanes: L['dur'] = int(60_000_000_000 / self.bpm / L['sub'])
self.draw_bpm()
def goto(self, i):
was = self.running; self.load(i); self._label("play")
if was: self.running = True; self._reset_clock(); self.beat = -1
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 = []; beat_hit = False
for L in self.lanes:
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)
if L is self.master and L['step'] % L['sub'] == 0: beat_hit = True
L['next'] += L['dur']
if fired:
best = max(fired, key=lambda l: PRIO.get(l, 0)); self.click(best); self.flash(best)
if beat_hit:
self.beat = self.master['step'] // self.master['sub']; self.draw_dots()
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.hit(pt[0], pt[1])
elif self._touchDown and (nowms - self._touchSeen) > 0.14:
self._touchDown = False
def hit(self, x, y):
for bx, by, bw, bh, key in self.buttons:
if bx <= x <= bx+bw and by <= y <= by+bh:
if key == 'play': self.toggle()
elif key == 'prev': self.goto(self.idx - 1)
elif key == 'next': self.goto(self.idx + 1)
elif key == 'minus': self.set_bpm(self.bpm - 1)
elif key == 'plus': self.set_bpm(self.bpm + 1)
elif key == 'tap': self.tap()
return
# ---------- drawing ----------
def draw_bpm(self):
self._place(self.g_bpm, str(self.bpm), 0, 92, C_TXT, C_BG, FONT_L, right_edge=WIDTH-14)
def draw_status(self):
self._place(self.g_run, "RUN" if self.running else "STOP", 12, 244,
C_GREEN if self.running else C_MUTE, C_BG, FONT_M)
self._place(self.g_idx, "%d/%d" % (self.idx+1, len(self.programs)), 0, 244, C_MUTE, C_BG, FONT_M, right_edge=WIDTH-12)
self._place(self.g_name, self.name[:26], 12, 272, C_TXT, C_BG, FONT_M)
def draw_dots(self):
m = self.master; bpb = max(1, m['steps'] // m['sub'])
if len(self.dots) != bpb: # rebuild only when the beat count changes
while len(self.g_dots): self.g_dots.pop()
self.dots = []; sz, sp = 18, 26; x0 = max(12, WIDTH - 12 - bpb*sp)
for i in range(bpb):
r = vectorio.Rectangle(pixel_shader=self.dot_pal, width=sz, height=sz, x=x0 + i*sp, y=200)
self.g_dots.append(r); self.dots.append(r)
for i in range(bpb): # otherwise just recolour (cheap, no tearing)
lvl = m['levels'][(i*m['sub']) % m['steps']]; on = self.running and i == self.beat
self.dots[i].color_index = (2 if lvl == 2 else 1) if on else 0
self.dirty = True
def run(self):
if self.touch.addr is None:
self._place(self.g_name, "touch: not found", 12, 272, C_AMBER, C_BG, FONT_M)
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()