PM_K-1 CircuitPython: circle pad grid, small labels, dimmer LED, faster SPI

From on-board feedback (works well; minor tweaks):
  - Pad grid uses circles now: big circle on each beat (division), small on the
    subdivisions (vectorio.Circle — native, no extra cost), coloured/lit as before.
  - Lane labels use a new small font (font_s.bin, ~12px via gen_font.py) so they're
    half-size and show more of the voice name (e.g. 'hatClos').
  - LED was blinding -> LED_BRIGHTNESS scale (default 0.15) applied on every write.
  - Residual tearing -> SPI back to 62.5 MHz (vendor speed; smaller tear window on a
    panel with no tearing-effect pin). Both are CONFIG flags.
Verified by rendering the full scene headless. font_s.bin added to gen_font.py + bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 22:07:00 -05:00
parent 6421e525da
commit ec43c694a1
8 changed files with 32 additions and 13 deletions

View file

@ -39,7 +39,7 @@ pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_t
print("copied pico-main.py") print("copied pico-main.py")
import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY) 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: with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
for f in ("code.py", "programs.json", "font_m.bin", "font_l.bin", "README.md"): for f in ("code.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", "README.md"):
z.write("pico-cp/" + f, f) z.write("pico-cp/" + f, f)
print("zipped pm_k1_circuitpy.zip") print("zipped pm_k1_circuitpy.zip")
PY PY

View file

@ -18,7 +18,7 @@ same programstring language as <https://metronome.varasys.io>.
2. **Copy everything from the bundle** onto `CIRCUITPY` (draganddrop — it's a normal drive now): 2. **Copy everything from the bundle** onto `CIRCUITPY` (draganddrop — it's a normal drive now):
- `code.py` (this firmware — runs on boot) - `code.py` (this firmware — runs on boot)
- `programs.json` (your grooves) - `programs.json` (your grooves)
- `font_m.bin`, `font_l.bin` (the antialiased fonts — kept as files to save RAM) - `font_s.bin`, `font_m.bin`, `font_l.bin` (the antialiased fonts — kept as files to save RAM)
3. It starts immediately. Editing `programs.json` (or resaving it from the editor) makes CircuitPython 3. It starts immediately. Editing `programs.json` (or resaving it from the editor) makes CircuitPython
**autoreload** with the new tracks. **autoreload** with the new tracks.
@ -45,6 +45,9 @@ Each `prog` is a program string from the web editor. Add/replace entries and sav
- **Taps land wrong:** set `TOUCH_DEBUG = True`, watch the serial output, then set - **Taps land wrong:** set `TOUCH_DEBUG = True`, watch the serial output, then set
`TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y`. `TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y`.
- **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`. - **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`.
- **LED too bright / too dim:** change `LED_BRIGHTNESS` (0..1, default 0.15).
- **Screen tearing:** the SPI panel has no tearing-effect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast
to minimise it — lower it only if the display is unstable.
- **Screen blank / garbled:** the panel lot may differ; drop `SPI_BAUD`, and if it's a 240×320 ILI9341 - **Screen blank / garbled:** the panel lot may differ; drop `SPI_BAUD`, and if it's a 240×320 ILI9341
instead of the 320×480 ST7796, the init/size need changing (this targets the 320×480 you have). instead of the 320×480 ST7796, the init/size need changing (this targets the 320×480 you have).
- **RGB LED** is driven by the core `neopixel_write` module — no library to install. If it stays dark, - **RGB LED** is driven by the core `neopixel_write` module — no library to install. If it stays dark,

View file

@ -28,7 +28,8 @@ except ImportError:
neopixel_write = None neopixel_write = None
# ============================== CONFIG (tweak if needed) ============================== # ============================== CONFIG (tweak if needed) ==============================
SPI_BAUD = 40_000_000 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)
WIDTH, HEIGHT = 320, 480 WIDTH, HEIGHT = 320, 480
MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed. 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 INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative
@ -75,7 +76,8 @@ class RGB:
self.buf = bytearray(3) self.buf = bytearray(3)
def set(self, r, g, b): def set(self, r, g, b):
if not self.ok: return if not self.ok: return
self.buf[0] = g; self.buf[1] = r; self.buf[2] = b # WS2812 wants GRB order # 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) try: neopixel_write.neopixel_write(self.io, self.buf)
except Exception: self.ok = False except Exception: self.ok = False
@ -91,8 +93,9 @@ def load_font(path):
glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2 glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2
return (glyphs, blob) return (glyphs, blob)
FONT_M = load_font("/font_m.bin") FONT_S = load_font("/font_s.bin") # small — pad-grid lane labels
FONT_L = load_font("/font_l.bin") FONT_M = load_font("/font_m.bin") # labels / buttons
FONT_L = load_font("/font_l.bin") # big BPM
gc.collect() gc.collect()
def _blend(bg, fg, i): def _blend(bg, fg, i):
@ -456,16 +459,18 @@ class App:
while len(self.g_grid): self.g_grid.pop() while len(self.g_grid): self.g_grid.pop()
self.lane_pads = []; self.lane_lit = [] self.lane_pads = []; self.lane_lit = []
n = min(len(self.lanes), MAXLANES) n = min(len(self.lanes), MAXLANES)
top = 140; rowh = min(38, (294 - top) // max(1, n)) top = 140; rowh = min(40, (296 - top) // max(1, n))
for li in range(n): for li in range(n):
L = self.lanes[li]; y = top + li * rowh L = self.lanes[li]; y = top + li * rowh; cy = y + rowh // 2
tg, w, h = make_text((L.get('sound', '') or '?')[:4], FONT_M, C_MUTE, C_BG) tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG)
tg.x = 10; tg.y = y + 2; self.g_grid.append(tg) tg.x = 8; tg.y = cy - h // 2; self.g_grid.append(tg)
steps = L['steps']; px0 = 66; pw = (WIDTH - 10 - px0) // steps; ph = max(8, rowh - 10) steps = L['steps']; sub = L['sub']; px0 = 60; pw = (WIDTH - 8 - px0) // steps
r_big = max(3, min((rowh - 4) // 2, (pw - 2) // 2)); r_sml = max(2, r_big * 6 // 10)
pads = [] pads = []
for s in range(steps): 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) rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision
r.color_index = self._padbase(L, s); self.g_grid.append(r); pads.append(r) c = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=px0 + s*pw + pw//2, 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.lane_pads.append(pads); self.lane_lit.append(-1)
self.dirty = True self.dirty = True
def _move_playhead(self, li, step): def _move_playhead(self, li, step):

BIN
pico-cp/font_s.bin Normal file

Binary file not shown.

1
pico/_font_l.b64 Normal file

File diff suppressed because one or more lines are too long

1
pico/_font_m.b64 Normal file

File diff suppressed because one or more lines are too long

View file

@ -20,6 +20,7 @@ ASCII = "".join(chr(c) for c in range(0x20, 0x7F))
SYMBOLS = "◀▶■" # ◀ ▶ ■ (prev / play / stop, anti-aliased) SYMBOLS = "◀▶■" # ◀ ▶ ■ (prev / play / stop, anti-aliased)
M_CHARS = ASCII + SYMBOLS M_CHARS = ASCII + SYMBOLS
L_CHARS = "0123456789 " L_CHARS = "0123456789 "
S_CHARS = ASCII # small font for the pad-grid lane labels (CircuitPython)
def build(size, chars): def build(size, chars):
font = ImageFont.truetype(FONT, size) font = ImageFont.truetype(FONT, size)
@ -88,12 +89,20 @@ def render(draw_img, font, s, x, y, fg, bg):
pen += adv pen += adv
return pen return pen
blob_s = build(12, S_CHARS)
blob_m = build(22, M_CHARS) blob_m = build(22, M_CHARS)
blob_l = build(78, L_CHARS) blob_l = build(78, L_CHARS)
# base64 (baked into the MicroPython firmware, pico/main.py — FONT_M / FONT_L)
(HERE / "_font_m.b64").write_text(base64.b64encode(blob_m).decode()) (HERE / "_font_m.b64").write_text(base64.b64encode(blob_m).decode())
(HERE / "_font_l.b64").write_text(base64.b64encode(blob_l).decode()) (HERE / "_font_l.b64").write_text(base64.b64encode(blob_l).decode())
print("FONT_M %d bytes -> %d b64" % (len(blob_m), len(base64.b64encode(blob_m)))) print("FONT_M %d bytes -> %d b64" % (len(blob_m), len(base64.b64encode(blob_m))))
print("FONT_L %d bytes -> %d b64" % (len(blob_l), len(base64.b64encode(blob_l)))) print("FONT_L %d bytes -> %d b64" % (len(blob_l), len(base64.b64encode(blob_l))))
# binary blobs (read at boot by the CircuitPython firmware, pico-cp/code.py)
CP = HERE.parent / "pico-cp"
(CP / "font_s.bin").write_bytes(blob_s)
(CP / "font_m.bin").write_bytes(blob_m)
(CP / "font_l.bin").write_bytes(blob_l)
print("wrote pico-cp/font_{s,m,l}.bin (%d / %d / %d bytes)" % (len(blob_s), len(blob_m), len(blob_l)))
# verification image on a dark bg (565 colours like the firmware) # verification image on a dark bg (565 colours like the firmware)
def c565(r, g, b): return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) def c565(r, g, b): return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)