PM_K-1 CircuitPython: add the lanes/pads view

Replace the single beat-dot row with a full pad grid: each lane is a row of step
pads coloured by dynamics (mute/normal/accent/ghost), with the playhead lit as it
plays (per-lane, so polymeter shows). Header (title/BPM/RUN/item) is compacted above
it; transport stays below. Pads are vectorio rects sharing one 8-colour palette and
recolour in place via color_index (cheap, tear-free); the grid only rebuilds on track
change. Caps at MAXLANES=5 rows (extra lanes still play). Verified by rendering the whole
displayio scene graph headless (layout + playhead lighting correct).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 21:56:27 -05:00
parent 47c19dea8c
commit c499910df4
2 changed files with 60 additions and 41 deletions

View file

@ -62,6 +62,9 @@ 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_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240
C_BTN = 0x1C222C C_BTN = 0x1C222C
LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)} LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)}
MAXLANES = 5 # lanes shown on the pad grid (extras still play)
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) # WS2812 RGB LED — self-contained via the core neopixel_write module (no external library)
class RGB: class RGB:
@ -174,7 +177,7 @@ def _parse_lane(tok):
for i in range(steps): for i in range(steps):
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) if i % sub == 0: levels.append(2 if (i // sub) in starts else 1)
else: levels.append(0) else: levels.append(0)
return {'sub': sub, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute} return {'sound': sound, 'sub': sub, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute}
def load_programs(): def load_programs():
try: try:
@ -278,9 +281,10 @@ class App:
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0) self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0)
self.programs = load_programs() self.programs = load_programs()
self.buttons = [] self.buttons = []
self.dirty = True; self.dots = [] self.dirty = True
self.dot_pal = displayio.Palette(3) self.pad_pal = displayio.Palette(8)
self.dot_pal[0] = C_DIM; self.dot_pal[1] = C_CYAN; self.dot_pal[2] = C_AMBER 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 = []
self._build_scene() self._build_scene()
self.load(0) self.load(0)
@ -292,16 +296,13 @@ class App:
def _build_scene(self): def _build_scene(self):
root = displayio.Group(); self.display.root_group = root root = displayio.Group(); self.display.root_group = root
root.append(rect(0, 0, WIDTH, HEIGHT, C_BG)) root.append(rect(0, 0, WIDTH, HEIGHT, C_BG))
root.append(rect(0, 42, WIDTH, 2, C_PANEL)) tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 8; root.append(tg)
# static title + BPM label root.append(rect(0, 38, WIDTH, 2, C_PANEL))
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 # dynamic groups
self.g_bpm = displayio.Group(); root.append(self.g_bpm) self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right)
self.g_dots = displayio.Group(); root.append(self.g_dots) self.g_run = displayio.Group(); root.append(self.g_run) # RUN / STOP (left)
self.g_run = displayio.Group(); root.append(self.g_run) self.g_name = displayio.Group(); root.append(self.g_name) # item index + name
self.g_idx = displayio.Group(); root.append(self.g_idx) self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes × step pads
self.g_name = displayio.Group(); root.append(self.g_name)
# buttons (rects static; labels in per-button groups so play can toggle) # 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] bw, bh = 96, 56; gap = (WIDTH - 3*bw)//4; xs = [gap, gap*2+bw, gap*3+bw*2]
self.btn_lbl = {} self.btn_lbl = {}
@ -335,8 +336,8 @@ class App:
n = len(self.programs); self.idx = i % n n = len(self.programs); self.idx = i % n
self.name, prog = self.programs[self.idx] self.name, prog = self.programs[self.idx]
self.bpm, self.lanes = parse_program(prog) self.bpm, self.lanes = parse_program(prog)
self.master = self.lanes[0]; self.beat = -1 self.master = self.lanes[0]
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_dots() self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid()
def _reset_clock(self): def _reset_clock(self):
now = time.monotonic_ns() now = time.monotonic_ns()
for L in self.lanes: for L in self.lanes:
@ -357,9 +358,9 @@ class App:
# ---------- transport ---------- # ---------- transport ----------
def toggle(self): def toggle(self):
self.running = not self.running self.running = not self.running
if self.running: self._reset_clock(); self.beat = -1 if self.running: self._reset_clock()
else: self.buz.duty_cycle = 0; self.led_off() else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads()
self.draw_status(); self.draw_dots(); self._label("play") self.draw_status(); self._label("play")
def set_bpm(self, v): def set_bpm(self, v):
v = max(30, min(300, v)) v = max(30, min(300, v))
if v != self.bpm: if v != self.bpm:
@ -368,7 +369,7 @@ class App:
self.draw_bpm() self.draw_bpm()
def goto(self, i): def goto(self, i):
was = self.running; self.load(i); self._label("play") was = self.running; self.load(i); self._label("play")
if was: self.running = True; self._reset_clock(); self.beat = -1 if was: self.running = True; self._reset_clock()
def tap(self): def tap(self):
now = time.monotonic() now = time.monotonic()
if not hasattr(self, '_taps'): self._taps = [] if not hasattr(self, '_taps'): self._taps = []
@ -383,18 +384,17 @@ class App:
now = time.monotonic_ns() now = time.monotonic_ns()
if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0 if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0
if self.running: if self.running:
fired = []; beat_hit = False fired = []
for L in self.lanes: for li, L in enumerate(self.lanes):
adv = False
while now >= L['next']: while now >= L['next']:
L['step'] = (L['step'] + 1) % L['steps'] L['step'] = (L['step'] + 1) % L['steps']
lvl = 0 if L['mute'] else L['levels'][L['step']] lvl = 0 if L['mute'] else L['levels'][L['step']]
if lvl > 0: fired.append(lvl) if lvl > 0: fired.append(lvl)
if L is self.master and L['step'] % L['sub'] == 0: beat_hit = True L['next'] += L['dur']; adv = True
L['next'] += L['dur'] if adv and li < len(self.lane_pads): self._move_playhead(li, L['step'])
if fired: if fired:
best = max(fired, key=lambda l: PRIO.get(l, 0)); self.click(best); self.flash(best) 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): if self.rgb != (0, 0, 0):
r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10 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.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0)
@ -442,28 +442,47 @@ class App:
# ---------- drawing ---------- # ---------- drawing ----------
def draw_bpm(self): def draw_bpm(self):
self._place(self.g_bpm, str(self.bpm), 0, 92, C_TXT, C_BG, FONT_L, right_edge=WIDTH-14) self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
def draw_status(self): def draw_status(self):
self._place(self.g_run, "RUN" if self.running else "STOP", 12, 244, 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) 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, "%d/%d %s" % (self.idx+1, len(self.programs), self.name[:18]),
self._place(self.g_name, self.name[:26], 12, 272, C_TXT, C_BG, FONT_M) 12, 112, C_TXT, C_BG, FONT_M)
def draw_dots(self):
m = self.master; bpb = max(1, m['steps'] // m['sub']) # ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ----------
if len(self.dots) != bpb: # rebuild only when the beat count changes def _padbase(self, L, s):
while len(self.g_dots): self.g_dots.pop() return 0 if L['mute'] else L['levels'][s]
self.dots = []; sz, sp = 18, 26; x0 = max(12, WIDTH - 12 - bpb*sp) def build_grid(self):
for i in range(bpb): while len(self.g_grid): self.g_grid.pop()
r = vectorio.Rectangle(pixel_shader=self.dot_pal, width=sz, height=sz, x=x0 + i*sp, y=200) self.lane_pads = []; self.lane_lit = []
self.g_dots.append(r); self.dots.append(r) n = min(len(self.lanes), MAXLANES)
for i in range(bpb): # otherwise just recolour (cheap, no tearing) top = 140; rowh = min(38, (294 - top) // max(1, n))
lvl = m['levels'][(i*m['sub']) % m['steps']]; on = self.running and i == self.beat for li in range(n):
self.dots[i].color_index = (2 if lvl == 2 else 1) if on else 0 L = self.lanes[li]; y = top + li * rowh
tg, w, h = make_text((L.get('sound', '') or '?')[:4], FONT_M, C_MUTE, C_BG)
tg.x = 10; tg.y = y + 2; self.g_grid.append(tg)
steps = L['steps']; px0 = 66; pw = (WIDTH - 10 - px0) // steps; ph = max(8, rowh - 10)
pads = []
for s in range(steps):
r = vectorio.Rectangle(pixel_shader=self.pad_pal, width=max(2, pw - 1), height=ph, x=px0 + s*pw, y=y)
r.color_index = self._padbase(L, s); self.g_grid.append(r); pads.append(r)
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 self.dirty = True
def run(self): def run(self):
if self.touch.addr is None: if self.touch.addr is None:
self._place(self.g_name, "touch: not found", 12, 272, C_AMBER, C_BG, FONT_M) print("GT911 touch not found")
while True: while True:
self.tick(); self.poll() self.tick(); self.poll()
# push a complete frame only when something changed (no mid-update tearing); # push a complete frame only when something changed (no mid-update tearing);