diff --git a/pico-cp/README.md b/pico-cp/README.md index c0c44eb..6971070 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -104,8 +104,10 @@ The Kit can drive a **physical metronome pendulum**: a 4-input unipolar stepper on the EP‑0172 kit. On the custom PM_K‑1 board GP19/20/21 are already taken by SIG/CLIP LED + ground‑lift, so the production pendulum will need a pin reassignment.)* - **Motion:** the arm reaches an extreme **exactly on each beat**, then reverses; it reads the live beat - clock, so it follows tempo ramps. Coils **de‑energize when stopped**. The on‑screen pendulum (shown over - the practice log while playing) mirrors the arm exactly — and animates even with **no motor wired**. + clock, so it follows tempo ramps. The step pulses run on **PIO + DMA** (hardware‑timed), so the arm stays + smooth even while the screen redraws the pendulum graphic. Coils **de‑energize when stopped**. The on‑screen + pendulum (shown over the practice log while playing) mirrors the arm exactly — and animates even with **no + motor wired**. - **Config (top of `code.py`):** - `STEPPER_ENABLED` — off leaves the four pins free. - `PEND_SWING_DEG` — total swing arc end‑to‑end, in degrees (default **120**). Single source of truth: @@ -126,7 +128,6 @@ The Kit can drive a **physical metronome pendulum**: a 4-input unipolar stepper (hardware‑timed on a state machine), so the motor stays smooth even while the screen redraws — there's no CPU step loop to stall. *Tuning:* hold to spin; raise `STEPPER_MAX_RATE` until the motor skips, then back off; if it stalls *starting*, lower `STEPPER_ACCEL` / `STEPPER_JOG_START`. Power‑cycle (no buttons) to exit. - *(The beat‑pendulum during play still uses the simple step loop for now; it moves to the PIO driver next.)* ## programs.json diff --git a/pico-cp/app.py b/pico-cp/app.py index 834b056..5055719 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -197,35 +197,6 @@ class RGB: # 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 spin(self, cw): # one free half-step either way (jog/test mode) - self.phase += 1 if cw else -1; self._write() - def release(self): # de-energize all coils (cool + quiet when idle) - for d in self.io: d.value = False - def deinit(self): # free the 4 pins so PIO can claim them (jog mode) - for d in self.io: - try: d.deinit() - except Exception: pass - self.io = []; self.ok = False - # PIO-driven stepper: the step pulses come from a PIO state machine fed by DMA (a looping # background_write), so they keep flowing on dedicated hardware even while the CPU does a display # refresh or a GC pause - which is what made the bit-bang version jumpy. One 32-bit word packs the 8 @@ -589,8 +560,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.pend = PioStepper(P_STEP[0]) if STEPPER_ENABLED else None # PIO-driven stepper, shared by play + jog + self._pend_beat0 = 0; self._pend_dir = 1; 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 @@ -1156,23 +1127,24 @@ class App: 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 + if self.running: self._pend_start() # kick off the first sweep immediately on play - def _pend_service(self, now): # advance the swing clock + drive the arm (called each tick while running) + def _pend_service(self, now): # PIO pendulum: reverse + reset the sweep rate on each beat beat = self._beat_ns if beat <= 0: return - 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 + if now - self._pend_beat0 >= beat: # crossed a beat -> the arm reverses ON the beat + while now - self._pend_beat0 >= beat: # (advance the clock even with no motor, for the graphic) + self._pend_beat0 += beat; self._pend_dir = -self._pend_dir + self._pend_start() + + def _pend_start(self): # aim the PIO stepper to sweep one arc over one beat 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 > 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 + if p is None or not p.ok: return + beat = self._beat_ns + if beat <= 0: return + rate = STEPPER_ARC / (beat / 1.0e9) # half-steps/sec to cover the swing in one beat + if rate > STEPPER_MAX_RATE: rate = float(STEPPER_MAX_RATE) # cap -> auto-shrinks the swing at fast tempi + p.set_rate(rate); p.run(self._pend_dir > 0) # PIO sweeps continuously; we only flip it each beat def _pend_show(self, on): # swap: pendulum visible while playing, log when stopped try: @@ -1228,10 +1200,9 @@ class App: bob.x = bx; bob.y = by arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (bx, by)] self.display.refresh() - # Free the bit-bang coil pins, then drive the motor from PIO: the pulses come from the state - # machine + DMA, so a display refresh or GC pause can't stall them (that was the jumpiness). - if self.pend is not None: self.pend.deinit() - st = PioStepper(P_STEP[0]) + # Reuse the shared PIO stepper (play + jog use the same one); make a fresh one if disabled. + st = self.pend if (self.pend is not None and self.pend.ok) else PioStepper(P_STEP[0]) + st.off() # ensure it starts idle (coils off) if not st.ok: t, w, h = make_text("PIO unavailable", FONT_S, C_RED, C_BG); t.x = 12; t.y = 212; self.g_overlay.append(t) self.display.refresh() @@ -1585,7 +1556,7 @@ class App: self._pend_service(now) 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() + if self.pend is not None and self.pend.ok: self.pend.off() 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