pm-kit: beat-pendulum on PIO too (unified PIO stepper, drop bit-bang driver)
Move the play-mode pendulum onto the same PIO/DMA stepper as jog: at each beat the CPU just reverses direction and sets the sweep rate (rate = STEPPER_ARC / beat, capped at STEPPER_MAX_RATE -> auto-shrink); the state machine sweeps continuously between beats, so the pendulum stays smooth even while the screen redraws its graphic. _pend_start kicks the first sweep on play; stop de-energizes via off(). self.pend is now a single PioStepper shared by play + jog (jog reuses it instead of recreating). Removed the superseded bit-bang Pendulum class and the unused _pend_last. README updated (pendulum motion is PIO-driven). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f2cf3e3ed9
commit
76392ab20f
2 changed files with 24 additions and 52 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue