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:
parent
47c19dea8c
commit
c499910df4
2 changed files with 60 additions and 41 deletions
Binary file not shown.
101
pico-cp/code.py
101
pico-cp/code.py
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue