pm-kit: beat-synced pendulum stepper on the CircuitPython Kit (display-model motion test)

Add an optional physical pendulum to pico-cp/app.py: a 4-input unipolar stepper
(e.g. ULN2003 on the EP-0172's free GP18-21) swung as a metronome arm in time
with the beat. First motion-feedback test for the display-model Kit.

- New Pendulum driver class (half-step 8-phase, non-blocking step_toward + release).
- _pend_service(now) derives the swing from the live _beat_ns: it reverses
  direction at each beat boundary so the arm hits an extreme exactly on the beat,
  and auto-shrinks the arc when STEPPER_MAX_RATE can't sweep the full travel in a
  beat. Reading _beat_ns live means it follows tempo ramps for free.
- Hooked into tick(): swings while running, de-energizes the coils once on stop
  (covers all stop paths). Swing phase re-aligns to the clock in _reset_clock.
- Config knobs (STEPPER_ENABLED/ARC/MAX_RATE) + P_STEP pins at the top; disabled
  cleanly leaves the pins free.

Stays pure ASCII (USB-MIDI push requirement); conformance suite still 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 20:59:12 -05:00
parent d80c35984e
commit 305d9373d0

View file

@ -66,12 +66,17 @@ TOUCH_DEBUG = False
JOY_INVERT_X = False JOY_INVERT_X = False
JOY_INVERT_Y = False JOY_INVERT_Y = False
JOY_DEADZONE = 9000 JOY_DEADZONE = 9000
# Pendulum stepper (optional): a 4-input unipolar motor (e.g. ULN2003) swung in time with the beat.
STEPPER_ENABLED = True # set False if no motor is wired (the pins just stay free)
STEPPER_ARC = 120 # half-steps for one end-to-end swing (= one beat). Tune to your arm's travel.
STEPPER_MAX_RATE = 900 # max half-steps/sec the motor can follow; auto-shrinks the arc at fast tempi
# ----- pins (fixed by the EP-0172 board) ----- # ----- pins (fixed by the EP-0172 board) -----
P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7 P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7
P_SDA, P_SCL = board.GP8, board.GP9 P_SDA, P_SCL = board.GP8, board.GP9
P_RGB, P_SPK, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14 P_RGB, P_SPK, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14
P_JOYX, P_JOYY = board.GP26, board.GP27 P_JOYX, P_JOYY = board.GP26, board.GP27
P_STEP = (board.GP18, board.GP19, board.GP20, board.GP21) # pendulum stepper IN1..IN4 (free pins on the EP-0172)
# ----- BUILT-IN playlists: the standard defaults from the web editor, baked in here so they update with # ----- BUILT-IN playlists: the standard defaults from the web editor, baked in here so they update with
# firmware and the user can't change/delete them. User playlists live separately in programs.json # firmware and the user can't change/delete them. User playlists live separately in programs.json
@ -177,6 +182,32 @@ class RGB:
try: neopixel_write.neopixel_write(self.io, self.buf) try: neopixel_write.neopixel_write(self.io, self.buf)
except Exception: self.ok = False except Exception: self.ok = False
# Pendulum stepper - a 4-input unipolar motor (e.g. ULN2003) driven as a metronome arm. Half-step
# 8-phase sequence (smoothest). Non-blocking: the app steps it toward a beat-derived target each loop.
HALF_SEQ = ((1, 0, 0, 0), (1, 1, 0, 0), (0, 1, 0, 0), (0, 1, 1, 0),
(0, 0, 1, 0), (0, 0, 1, 1), (0, 0, 0, 1), (1, 0, 0, 1))
class Pendulum:
def __init__(self, pins):
self.io = []
try:
for p in pins:
d = digitalio.DigitalInOut(p); d.direction = digitalio.Direction.OUTPUT; d.value = False
self.io.append(d)
self.ok = len(self.io) == 4
except Exception:
self.ok = False
self.phase = 0 # index into HALF_SEQ (advances +/-1 per half-step)
self.pos = 0 # arm position in half-steps from the 'home' extreme
def _write(self):
pat = HALF_SEQ[self.phase & 7]
self.io[0].value = bool(pat[0]); self.io[1].value = bool(pat[1])
self.io[2].value = bool(pat[2]); self.io[3].value = bool(pat[3])
def step_toward(self, target): # one half-step toward target
if target > self.pos: self.phase += 1; self.pos += 1; self._write()
elif target < self.pos: self.phase -= 1; self.pos -= 1; self._write()
def release(self): # de-energize all coils (cool + quiet when idle)
for d in self.io: d.value = False
# ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ============================== # ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ==============================
def load_font(path): def load_font(path):
with open(path, "rb") as f: with open(path, "rb") as f:
@ -494,6 +525,8 @@ class App:
self.led = RGB(P_RGB) self.led = RGB(P_RGB)
self.spk = pwmio.PWMOut(P_SPK, frequency=1600, variable_frequency=True, duty_cycle=0) self.spk = pwmio.PWMOut(P_SPK, frequency=1600, variable_frequency=True, duty_cycle=0)
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_beat0 = 0; self._pend_dir = 1; self._pend_last = 0; self._pend_on = False
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)
@ -1038,6 +1071,25 @@ class App:
L['next'] = now; L['step'] = -1 L['next'] = now; L['step'] = -1
self._m_steps = 0 # restart the bar count self._m_steps = 0 # restart the bar count
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
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
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
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
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
# ---------- audio + light ---------- # ---------- audio + light ----------
def click(self, level): def click(self, level):
@ -1358,6 +1410,11 @@ class App:
try: self.midi.write(clk) try: self.midi.write(clk)
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
self._pend_service(now)
elif self._pend_on:
self._pend_on = False
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
# 'stop' or a signed int goto offset. Explicit end= governs; otherwise the global Continue toggle # 'stop' or a signed int goto offset. Explicit end= governs; otherwise the global Continue toggle