pm-kit: on-screen swinging pendulum graphic, synced to the motor's beat phase

Draw a swinging pendulum on the ST7796 that mirrors the physical stepper arm.
Inverted-metronome style (pivot near the bottom, weighted bob swinging up top),
shown over the practice-log area while playing and swapped back to the log when
stopped (the log is for post-session review, not mid-play).

- _build_scene: a hidden g_pend group (stand + pivot + arm Polygon + bob Circle).
- draw_pendulum(now) computes the bob from the SAME swing phase the motor uses
  (_pend_beat0 / _pend_dir / _beat_ns), so screen and arm move identically and it
  follows tempo ramps. Animated ~30fps from run(); the gated refresh renders it.
- _pend_service now advances the swing clock even when no motor is wired, so the
  graphic works standalone (STEPPER_ENABLED=False still animates the screen).
- tick() toggles g_pend/g_log visibility on the play<->stop transition.

Pure ASCII; conformance 47/47; build.sh precompiles app.mpy fine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-05 21:07:53 -05:00
parent 305d9373d0
commit 0eb38f1c1e

View file

@ -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