From 305d9373d01ec5d6949110134dfa219e7ae737c8 Mon Sep 17 00:00:00 2001 From: Me Here Date: Fri, 5 Jun 2026 20:59:12 -0500 Subject: [PATCH] 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) --- pico-cp/app.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/pico-cp/app.py b/pico-cp/app.py index c2656b9..d23f827 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -66,12 +66,17 @@ TOUCH_DEBUG = False JOY_INVERT_X = False JOY_INVERT_Y = False 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) ----- 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_RGB, P_SPK, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14 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 # 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) 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) ============================== def load_font(path): with open(path, "rb") as f: @@ -494,6 +525,8 @@ class App: self.led = RGB(P_RGB) self.spk = pwmio.PWMOut(P_SPK, frequency=1600, variable_frequency=True, duty_cycle=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._aPrev = True; self._bPrev = True self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY) @@ -1038,6 +1071,25 @@ class App: L['next'] = now; L['step'] = -1 self._m_steps = 0 # restart the bar count 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 ---------- def click(self, level): @@ -1358,6 +1410,11 @@ class App: try: self.midi.write(clk) except Exception: pass 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): # 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