diff --git a/pico-cp/app.py b/pico-cp/app.py index d23f827..cd088b8 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -16,7 +16,7 @@ # # Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md. -import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor +import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor, math supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart APP_VERSION = "0.0.24" # firmware version (the A/B updater pushes/compares this) DEVICE_ID = "K" # 'K' = 52Pi kit, 'X' = Pimoroni Explorer (per docs/livesync-protocol.md and the version reply) @@ -163,6 +163,12 @@ MAXLANES = 5 # lanes shown on the pad gr GRID_TOP = 158 # top of the pad grid (leaves room for time/bar/ramp/tab rows) LOG_TOP, LOG_ROWH, LOG_ROWS = 302, 16, 9 # practice-history log area (below the pad grid) MIN_LOG_SEC = 5 # don't log plays shorter than this +# On-screen pendulum (drawn over the log area while playing): inverted-metronome style - pivot near +# the bottom, weighted bob swinging up top. Mirrors the physical stepper arm (same beat phase). +PEND_PX = WIDTH // 2 # pivot x (screen centre) +PEND_PY = HEIGHT - 16 # pivot y (near the bottom edge) +PEND_LEN = 140 # arm length (px) +PEND_THETA = 0.66 # half-swing angle in radians (~38 deg) 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) @@ -527,6 +533,7 @@ class App: self.spk_off = 0 self.pend = Pendulum(P_STEP) if STEPPER_ENABLED else None # beat-synced pendulum arm (optional) self._pend_beat0 = 0; self._pend_dir = 1; self._pend_last = 0; self._pend_on = False + self._pendNext = 0.0 # ~30fps cadence for the on-screen pendulum self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB) self._aPrev = True; self._bPrev = True self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY) @@ -613,6 +620,15 @@ class App: 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) + self.g_pend = displayio.Group(); root.append(self.g_pend) # swinging pendulum, shown over the log while playing + self.g_pend.append(rect(PEND_PX - 26, PEND_PY + 4, 52, 4, C_PANEL)) # little stand/base under the pivot + self._pend_arm = vectorio.Polygon(pixel_shader=solid(C_DIM), + points=[(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (PEND_PX, PEND_PY - PEND_LEN)], x=0, y=0) + self.g_pend.append(self._pend_arm) + self.g_pend.append(vectorio.Circle(pixel_shader=solid(C_PANEL), radius=7, x=PEND_PX, y=PEND_PY)) # pivot + self._pend_bob = vectorio.Circle(pixel_shader=solid(C_CYAN), radius=13, x=PEND_PX, y=PEND_PY - PEND_LEN) + self.g_pend.append(self._pend_bob) + self.g_pend.hidden = True # only visible while running self.g_overlay = displayio.Group(); root.append(self.g_overlay) # modal (save/revert) - drawn on top # run/stop shows on the RGB LED; tap beats to edit, tap the title to save/revert, tap the tab to switch lists @@ -1073,24 +1089,43 @@ class App: self._seg_start = time.monotonic() # and the on-screen timer (resets with the bar counter) self._pend_beat0 = now; self._pend_dir = 1 # restart the pendulum swing, aligned to the beat clock - def _pend_service(self, now): # swing the arm in time with the beat (called each tick) - p = self.pend - if p is None or not p.ok: return + def _pend_service(self, now): # advance the swing clock + drive the arm (called each tick while running) beat = self._beat_ns if beat <= 0: return - self._pend_on = True while now - self._pend_beat0 >= beat: # reach an extreme exactly on each beat, then reverse self._pend_beat0 += beat; self._pend_dir = -self._pend_dir + p = self.pend + if p is None or not p.ok: return # no motor wired -> clock still advanced for the screen graphic max_arc = (STEPPER_MAX_RATE * beat) // 1_000_000_000 # cap the arc to what the motor can sweep in a beat arc = STEPPER_ARC if STEPPER_ARC < max_arc else max_arc if arc < 1: arc = 1 frac = (now - self._pend_beat0) / beat - if frac < 0.0: frac = 0.0 - elif frac > 1.0: frac = 1.0 + if frac > 1.0: frac = 1.0 + elif frac < 0.0: frac = 0.0 desired = int(frac * arc) if self._pend_dir > 0 else int((1.0 - frac) * arc) if desired != p.pos and (now - self._pend_last) >= (1_000_000_000 // STEPPER_MAX_RATE): p.step_toward(desired); self._pend_last = now + def _pend_show(self, on): # swap: pendulum visible while playing, log when stopped + try: + self.g_pend.hidden = not on; self.g_log.hidden = on + except Exception: + pass + self.dirty = True + + def draw_pendulum(self, now): # move the on-screen arm to match the beat-swing phase + beat = self._beat_ns + if beat <= 0: return + frac = (now - self._pend_beat0) / beat + if frac > 1.0: frac = 1.0 + elif frac < 0.0: frac = 0.0 + n = frac if self._pend_dir > 0 else (1.0 - frac) # 0..1 across the swing (matches the motor) + ang = (n * 2.0 - 1.0) * PEND_THETA + bx = PEND_PX + int(PEND_LEN * math.sin(ang)); by = PEND_PY - int(PEND_LEN * math.cos(ang)) + self._pend_bob.x = bx; self._pend_bob.y = by + self._pend_arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (bx, by)] + self.dirty = True + # ---------- audio + light ---------- def click(self, level): self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600) @@ -1411,9 +1446,11 @@ class App: except Exception: pass self._clock_next += tick_ns if self.running: # pendulum: swing while playing, free-wheel-off when stopped + if not self._pend_on: # just started -> show the pendulum over the log + self._pend_on = True; self._pend_show(True) self._pend_service(now) - elif self._pend_on: - self._pend_on = False + elif self._pend_on: # just stopped -> restore the log, de-energize the motor + self._pend_on = False; self._pend_show(False) if self.pend is not None and self.pend.ok: self.pend.release() def _end_plan(self): # Per-track playback flow. Returns None to loop forever, else (fire_bars, action) where action is @@ -1875,6 +1912,8 @@ class App: tnow = time.monotonic() if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm() # bpm follows the continuous ramp + if self.running and tnow >= self._pendNext: # ~30fps: animate the on-screen pendulum + self._pendNext = tnow + 0.03; self.draw_pendulum(time.monotonic_ns()) if self._sync_armed and tnow >= self._sync_heartbeat_next: self._sync_broadcast_full() # periodic FULL: device is the convergence authority if not committed and tnow - boot > 5: # booted & ran fine for 5s -> confirm the update