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:
parent
d80c35984e
commit
305d9373d0
1 changed files with 57 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue