diff --git a/pico-cp/README.md b/pico-cp/README.md index 0732b83..4ac4cdb 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -62,7 +62,11 @@ The editor also syncs the device clock, so the practice log gets real wall‑clo - **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`), 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 power‑cycles. diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index d8a8698..ef0b6d6 100644 Binary files a/pico-cp/__pycache__/app.cpython-312.pyc and b/pico-cp/__pycache__/app.cpython-312.pyc differ diff --git a/pico-cp/app.py b/pico-cp/app.py index e6ee32d..d1762cb 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -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 = 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): 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):