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:
parent
305d9373d0
commit
0eb38f1c1e
1 changed files with 48 additions and 9 deletions
|
|
@ -16,7 +16,7 @@
|
||||||
#
|
#
|
||||||
# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md.
|
# 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
|
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)
|
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)
|
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)
|
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)
|
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
|
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_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost
|
||||||
PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead 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)
|
C_GRID = 0x1A2330 # faint vertical beat gridlines (beats line up across lanes)
|
||||||
|
|
@ -527,6 +533,7 @@ class App:
|
||||||
self.spk_off = 0
|
self.spk_off = 0
|
||||||
self.pend = Pendulum(P_STEP) if STEPPER_ENABLED else None # beat-synced pendulum arm (optional)
|
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._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.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
|
||||||
self._aPrev = True; self._bPrev = True
|
self._aPrev = True; self._bPrev = True
|
||||||
self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY)
|
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
|
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
|
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_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
|
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
|
# 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._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
|
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)
|
def _pend_service(self, now): # advance the swing clock + drive the arm (called each tick while running)
|
||||||
p = self.pend
|
|
||||||
if p is None or not p.ok: return
|
|
||||||
beat = self._beat_ns
|
beat = self._beat_ns
|
||||||
if beat <= 0: return
|
if beat <= 0: return
|
||||||
self._pend_on = True
|
|
||||||
while now - self._pend_beat0 >= beat: # reach an extreme exactly on each beat, then reverse
|
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
|
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
|
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
|
arc = STEPPER_ARC if STEPPER_ARC < max_arc else max_arc
|
||||||
if arc < 1: arc = 1
|
if arc < 1: arc = 1
|
||||||
frac = (now - self._pend_beat0) / beat
|
frac = (now - self._pend_beat0) / beat
|
||||||
if frac < 0.0: frac = 0.0
|
if frac > 1.0: frac = 1.0
|
||||||
elif 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)
|
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):
|
if desired != p.pos and (now - self._pend_last) >= (1_000_000_000 // STEPPER_MAX_RATE):
|
||||||
p.step_toward(desired); self._pend_last = now
|
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 ----------
|
# ---------- audio + light ----------
|
||||||
def click(self, level):
|
def click(self, level):
|
||||||
self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
|
self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
|
||||||
|
|
@ -1411,9 +1446,11 @@ class App:
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
self._clock_next += tick_ns
|
self._clock_next += tick_ns
|
||||||
if self.running: # pendulum: swing while playing, free-wheel-off when stopped
|
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)
|
self._pend_service(now)
|
||||||
elif self._pend_on:
|
elif self._pend_on: # just stopped -> restore the log, de-energize the motor
|
||||||
self._pend_on = False
|
self._pend_on = False; self._pend_show(False)
|
||||||
if self.pend is not None and self.pend.ok: self.pend.release()
|
if self.pend is not None and self.pend.ok: self.pend.release()
|
||||||
def _end_plan(self):
|
def _end_plan(self):
|
||||||
# Per-track playback flow. Returns None to loop forever, else (fire_bars, action) where action is
|
# 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()
|
tnow = time.monotonic()
|
||||||
if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter
|
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
|
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:
|
if self._sync_armed and tnow >= self._sync_heartbeat_next:
|
||||||
self._sync_broadcast_full() # periodic FULL: device is the convergence authority
|
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
|
if not committed and tnow - boot > 5: # booted & ran fine for 5s -> confirm the update
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue