PM_K-1 firmware: screen refinements (run-state bg tint, time/bar totals, per-track log, square/circle pads, separated track #)

App.py-only (ships over the one-click updater). APP_VERSION -> 0.0.3.

- Run/stop is now a background tint (gray running / near-black stopped) instead of
  STOP text, reclaiming the space.
- Running time + bar counter show "of total" when the track has a b<n> length:
  "1:23 of 2:00" and "bar N of 16" (bar cycles 1..N); total time derived from
  bars x master-beats-per-bar x 60/bpm. Parser now reads the b<n> token.
- Practice log is filtered to the current track (drops the redundant track column).
- Pads: squares for the main pulse, circles for subdivisions (was square + hollow
  outline); fewer vectorio shapes too.
- Track number set apart from the title (small + dim, right) so it no longer reads
  as part of the title.

On-device editing (tap instrument -> lane table; tap beat -> cycle state; dirty-name
-> confirm save/revert) is deferred to Phase 2, where "save" has a correct destination
(an edited built-in saves as a user copy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-29 09:41:57 -05:00
parent dec6c61fce
commit ca44aa833d
3 changed files with 70 additions and 65 deletions

View file

@ -62,7 +62,11 @@ The editor also syncs the device clock, so the practice log gets real wallclo
- **Joystick:** up/down = tempo, left/right = previous/next groove.
- **Button A (GP15):** play / stop. **Button B (GP14):** tap tempo.
- **Touchscreen:** the bottom of the screen shows the **practice log** (time · BPM · duration · track) —
- **Screen:** VARASYS logo + MIDI/USB status icons up top; the **background tints gray while running**
(black when stopped). Running time and bar count show **of the segment total** when the track has a
bar length (`b<n>`), e.g. `1:23 of 2:00` and `bar 3 of 16`. Main beats are **squares**, subdivisions
are **circles**, with vertical gridlines lining the beats up across lanes.
- **Touchscreen:** the bottom shows the **practice log for the current track** (time · BPM · duration) —
newest first. Plays under 5 s aren't logged. **Tap a row to arm it (turns amber), tap again to delete.**
- **RGB LED** flashes the beat (amber accent / cyan normal / violet ghost); the **buzzer** clicks to match.
- The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives powercycles.

View file

@ -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.2" # firmware version (the A/B updater pushes/compares this)
APP_VERSION = "0.0.3" # 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:
@ -91,7 +91,7 @@ MIN_LOG_SEC = 5 # don't log plays shorter t
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
C_RUNBG = 0x161D28 # background tint while running (vs near-black when stopped)
# WS2812 RGB LED - self-contained via the core neopixel_write module (no external library)
class RGB:
@ -195,17 +195,19 @@ PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PRIO = {2: 3, 1: 2, 3: 1}
def parse_program(s):
bpm = 120; lanes = []
bpm = 120; lanes = []; bars = 0
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 tok[0] == 'b' and tok[1:].isdigit(): # b<n> = segment length in bars (totals + Continue)
bars = int(tok[1:]); continue # (lane sounds like "beep:4" have a ':' -> not matched here)
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
return max(30, min(300, bpm)), lanes, bars
def _parse_lane(tok):
poly = '~' in tok; mute = '!' in tok
@ -337,24 +339,23 @@ class App:
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.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.bars = 0; self.rgb = (0, 0, 0)
self.programs = load_programs()
self.dirty = True
self.pad_pal = displayio.Palette(10) # 0-3 idle levels, 4-7 lit levels, 8 off-beat border, 9 hollow bg
self.pad_pal = displayio.Palette(8) # 0-3 idle levels (mute/normal/accent/ghost), 4-7 the lit playhead
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
self.ic_midi_pal = None; self.ic_usb_pal = None; self.bg_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()
self.play_start = None; self.play_bpm = 0; self.play_name = ""
self._armed = None; self.log_rows = []
self._build_scene()
self.load(0)
self.draw_log(); self.draw_icons(); self.draw_meters()
self.load(0) # load() also draws the (track-filtered) practice 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
@ -363,7 +364,8 @@ class App:
# ---------- scene graph ----------
def _build_scene(self):
root = displayio.Group(); self.display.root_group = root
root.append(rect(0, 0, WIDTH, HEIGHT, C_BG))
self.bg_pal = solid(C_BG) # recolored on play/stop (black <-> running gray)
root.append(vectorio.Rectangle(pixel_shader=self.bg_pal, width=WIDTH, height=HEIGHT, x=0, y=0))
# 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)
@ -377,14 +379,14 @@ class App:
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_time = displayio.Group(); root.append(self.g_time) # elapsed [of total] (left)
self.g_bar = displayio.Group(); root.append(self.g_bar) # bar [of total] (left)
self.g_name = displayio.Group(); root.append(self.g_name) # track title
self.g_idx = displayio.Group(); root.append(self.g_idx) # track number (dim, right) - set apart from the title
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)
# (no on-screen buttons - transport is the joystick + buttons A/B; touch deletes log rows)
# run/stop is shown by the background tint (black=stopped, gray=running); transport = joystick + buttons A/B
def _place(self, group, s, x, y, fg, bg, font, right_edge=None):
while len(group): group.pop()
@ -401,9 +403,9 @@ class App:
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.bpm, self.lanes, self.bars = parse_program(prog)
self.master = self.lanes[0]
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid()
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid(); self.draw_log()
def _step_dur(self, L, step):
beat = 60_000_000_000 / self.bpm
if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar
@ -441,17 +443,18 @@ class App:
self.running = not self.running
if self.running: self._reset_clock(); self._start_play()
else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads(); self._log_play()
self.draw_status()
self.draw_runbg(); self.draw_meters()
def set_bpm(self, v):
v = max(30, min(300, v))
if v != self.bpm:
self.bpm = v
self.draw_bpm()
self.draw_bpm(); self.draw_meters() # total time depends on bpm
def goto(self, i):
was = self.running
if was: self.running = False; self._log_play() # close out the track that was playing
self.load(i)
if was: self.running = True; self._reset_clock(); self._start_play()
self.draw_runbg(); self.draw_meters()
def tap(self):
now = time.monotonic()
if not hasattr(self, '_taps'): self._taps = []
@ -534,36 +537,42 @@ class App:
# ---------- drawing ----------
def draw_bpm(self):
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
def draw_status(self):
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, 120, C_TXT, C_BG, FONT_M)
def draw_status(self): # title (bright) + track number set apart (dim, right)
self._place(self.g_name, self.name[:22], 12, 116, C_TXT, C_BG, FONT_M)
self._place(self.g_idx, "%d/%d" % (self.idx + 1, len(self.programs)), 0, 120,
C_DIM, C_BG, FONT_S, right_edge=WIDTH - 12)
def draw_runbg(self): # run/stop indicator: tint the whole background
if self.bg_pal is not None: self.bg_pal[0] = C_RUNBG if self.running else C_BG
self.dirty = True
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)
def _fmt_t(self, s): # m:ss, or h:mm:ss past an hour
s = int(s)
return "%d:%02d:%02d" % (s // 3600, (s % 3600) // 60, s % 60) if s >= 3600 else "%d:%02d" % (s // 60, s % 60)
def draw_meters(self): # running time [of total] + bar [of total]; ~4x/s from run()
run = self.running and self.play_start is not None
mlen = self.lanes[0]['steps'] if self.lanes else 1
bpb = (self.lanes[0]['steps'] // max(1, self.lanes[0]['sub'])) if self.lanes else 4
el = (time.monotonic() - self.play_start) if run else 0
mbars = self._m_steps // max(1, mlen) # whole master bars elapsed
cur = ("%d" % ((mbars % self.bars + 1) if self.bars else (mbars + 1))) if run else "-" # cycle 1..N
if self.bars: # track has a length (b<n>): show "X of TOTAL"
ts = "%s of %s" % (self._fmt_t(el), self._fmt_t(self.bars * bpb * 60.0 / self.bpm))
bs = "bar %s of %d" % (cur, self.bars)
else:
ts = "0:00"; bs = "bar -"
ts = self._fmt_t(el); bs = "bar %s" % cur
if ts != self._lastTs:
self._place(self.g_time, ts, 12, 86, C_TXT, C_BG, FONT_M); self._lastTs = ts
self._place(self.g_time, ts, 12, 52, 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
self._place(self.g_bar, bs, 12, 84, C_MUTE, C_BG, FONT_M); 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 = []
@ -579,37 +588,28 @@ class App:
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']; stepw = max(1, usable // steps)
side = max(5, min(15, stepw - 1, rowh - 6)); inner = max(2, side - 4)
side = max(5, min(15, stepw - 1, rowh - 6)) # square edge for the main pulse
rad = max(2, min(side // 2, stepw // 2 - 1)) # smaller circle for subdivisions
pads = []
for s in range(steps):
base = self._padbase(L, s)
cxp = px0 + 6 + (s * usable) // steps # proportional -> beats line up across lanes
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))
if s % sub == 0: # main beat -> square
p = vectorio.Rectangle(pixel_shader=self.pad_pal, width=side, height=side,
x=cxp - side // 2, y=cy - side // 2)
else: # subdivision -> circle
p = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=cxp, y=cy)
p.color_index = self._padbase(L, s); self.g_grid.append(p); pads.append(p)
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): self._pad_idle(pads[prev])
if step < len(pads): self._pad_lit(pads[step])
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): self._pad_idle(pads[prev])
if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev)
self.lane_lit[li] = -1
self.dirty = True
@ -648,17 +648,18 @@ class App:
g = self.g_log
while len(g): g.pop()
self.log_rows = []
hdr, w, h = make_text("PRACTICE LOG", FONT_S, C_MUTE, C_BG); hdr.x = 10; hdr.y = LOG_TOP; g.append(hdr)
if not self.log:
tg, w, h = make_text("plays over 5s show here", FONT_S, C_DIM, C_BG); tg.x = 10; tg.y = LOG_TOP + LOG_ROWH; g.append(tg)
hdr, w, h = make_text("PRACTICE LOG - THIS TRACK", FONT_S, C_MUTE, C_BG); hdr.x = 10; hdr.y = LOG_TOP; g.append(hdr)
rows = [(i, e) for i, e in enumerate(self.log) if e.get("name") == self.name] # current track only
if not rows:
tg, w, h = make_text("no plays over 5s yet", FONT_S, C_DIM, C_BG); tg.x = 10; tg.y = LOG_TOP + LOG_ROWH; g.append(tg)
self.dirty = True; return
y = LOG_TOP + LOG_ROWH + 2
for idx in range(min(LOG_ROWS, len(self.log))):
e = self.log[idx]; armed = (idx == self._armed)
for k in range(min(LOG_ROWS, len(rows))):
oi, e = rows[k]; armed = (oi == self._armed) # oi = index into self.log (for delete)
dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60)
line = "%s%s %3d %5s %s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur, e["name"][:16])
line = "%s%s %3d bpm %s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur)
tg, w, h = make_text(line, FONT_S, C_AMBER if armed else C_TXT, C_BG); tg.x = 10; tg.y = y; g.append(tg)
self.log_rows.append((y - 2, y + LOG_ROWH - 2, idx))
self.log_rows.append((y - 2, y + LOG_ROWH - 2, oi))
y += LOG_ROWH
self.dirty = True
def _tap_log(self, x, ty):