PM_K-1 0.0.7: perform tempo ramps + gap trainer, show their indicators, log bars

Device parser now reads the rmp<start>/<amt>/<every> and tr<play>/<mute> tokens it
previously ignored, and the firmware performs them:
- Tempo ramp: steps bpm by <amt> every <every> bars (resets to the start at each b<n>
  segment boundary). Shows an amber ramp arrow + "+amt/everyb" (up/down by sign; no
  starting bpm, per request).
- Gap trainer: cycles <play> audible bars then <mute> silent bars (no click/MIDI/LED;
  playheads keep moving). Shows a play|rest symbol + "play/muteb".
- Practice log entries now record + show bars played.

Verified in the CPython harness: ramp 92->96->100->104->108 (+4 every 2 bars), gap
mute cycle play,play,mute,mute, and the on-screen ramp indicator renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-29 11:43:54 -05:00
parent 5e71df6b17
commit 88104e3d5c
3 changed files with 69 additions and 22 deletions

View file

@ -62,12 +62,16 @@ The editor also syncs the device clock, so the practice log gets real wallclo
- **Joystick:** up/down = tempo, left/right = previous/next groove.
- **Button A (GP15):** play / stop. **Button B (GP14):** tap tempo.
- **Screen:** VARASYS logo + MIDI/USB status icons up top; the **background tints gray while running**
(black when stopped). Running time and bar count show **of the segment total** when the track has a
bar length (`b<n>`), e.g. `1:23 of 2:00` and `bar 3 of 16`. Main beats are **squares**, subdivisions
are **circles**, with vertical gridlines lining the beats up across lanes.
- **Touchscreen:** the bottom shows the **practice log for the current track** (time · BPM · duration) —
newest first. Plays under 5 s aren't logged. **Tap a row to arm it (turns amber), tap again to delete.**
- **Screen:** VARASYS logo + firmware version + MIDI/USB status icons up top. Running time and bar count
show **of the segment total** when the track has a bar length (`b<n>`), e.g. `1:23 of 2:00` and `bar 3
of 16`. A track with a tempo ramp (`rmp`) shows a **ramp arrow + amount/every-bars** (e.g. `+4/2b`); a
gap-trainer track (`tr`) shows a **play|rest symbol + bars** (e.g. `2/2b`). Main beats are **squares**,
subdivisions are **circles**, with vertical gridlines lining the beats up across lanes.
- **RGB LED = run state:** dim **green** when stopped ("on"), dim **red** while playing, with the beat
pulsing brighter on top. (The screen background stays black — recoloring it forces a full-screen repaint.)
- The firmware **performs** ramps (tempo steps every N bars) and gap-trainer cycles (silent rest bars).
- **Touchscreen:** the bottom shows the **practice log for the current track** (time · BPM · duration · bars)
— newest first. Plays under 5 s aren't logged. **Tap a row to arm it (turns amber), tap again to delete.**
- **RGB LED** flashes the beat (amber accent / cyan normal / violet ghost); the **buzzer** clicks to match.
- The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives powercycles.

View file

@ -18,7 +18,7 @@
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
APP_VERSION = "0.0.6" # firmware version (the A/B updater pushes/compares this)
APP_VERSION = "0.0.7" # firmware version (the A/B updater pushes/compares this)
try:
import rtc # set from the editor's clock SysEx so the log has real timestamps
except ImportError:
@ -87,7 +87,7 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare
GM_DEFAULT = 37
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
MAXLANES = 5 # lanes shown on the pad grid (extras still play)
GRID_TOP = 150 # top of the pad grid (leaves room for stopwatch/bar)
GRID_TOP = 158 # top of the pad grid (leaves room for time/bar/ramp rows)
LOG_TOP, LOG_ROWH, LOG_ROWS = 302, 16, 9 # practice-history log area (below the pad grid)
MIN_LOG_SEC = 5 # don't log plays shorter than this
PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost
@ -196,7 +196,7 @@ PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PRIO = {2: 3, 1: 2, 3: 1}
def parse_program(s):
bpm = 120; lanes = []; bars = 0
bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None
for tok in s.strip().split(';'):
tok = tok.strip()
if not tok: continue
@ -204,11 +204,23 @@ def parse_program(s):
bpm = int(tok[1:]); continue
if tok[0] == 'b' and tok[1:].isdigit(): # b<n> = segment length in bars (totals + Continue)
bars = int(tok[1:]); continue # (lane sounds like "beep:4" have a ':' -> not matched here)
if tok.startswith('rmp'): # rmp<start>/<amount>/<everyBars> tempo ramp (amount may be -)
p = tok[3:].split('/')
if len(p) == 3:
try: ramp = {'amt': int(p[1]), 'every': max(1, int(p[2]))}
except ValueError: pass
continue
if tok.startswith('tr') and '/' in tok and ':' not in tok: # tr<play>/<mute> gap trainer (bars)
p = tok[2:].split('/')
if len(p) == 2:
try: trainer = {'play': max(0, int(p[0])), 'mute': max(0, int(p[1]))}
except ValueError: pass
continue
if ':' not in tok: continue
lane = _parse_lane(tok)
if lane: lanes.append(lane)
if not lanes: lanes = [_parse_lane("beep:4")]
return max(30, min(300, bpm)), lanes, bars
return max(30, min(300, bpm)), lanes, bars, ramp, trainer
def _parse_lane(tok):
poly = '~' in tok; mute = '!' in tok
@ -342,6 +354,7 @@ class App:
self._joyNext = 0
self._touchDown = False; self._touchSeen = 0
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.bars = 0; self.rgb = (0, 0, 0)
self.ramp = None; self.trainer = None; self._lastbar = -1; self._muted = False; self._ramp_base = 120
self.programs = load_programs()
self.dirty = True
self.pad_pal = displayio.Palette(8) # 0-3 idle levels (mute/normal/accent/ghost), 4-7 the lit playhead
@ -385,6 +398,7 @@ class App:
self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right)
self.g_time = displayio.Group(); root.append(self.g_time) # elapsed [of total] (left)
self.g_bar = displayio.Group(); root.append(self.g_bar) # bar [of total] (left)
self.g_train = displayio.Group(); root.append(self.g_train) # ramp / gap-trainer indicators
self.g_name = displayio.Group(); root.append(self.g_name) # track title
self.g_idx = displayio.Group(); root.append(self.g_idx) # track number (dim, right) - set apart from the title
self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads
@ -407,9 +421,10 @@ class App:
def load(self, i):
n = len(self.programs); self.idx = i % n
self.name, prog = self.programs[self.idx]
self.bpm, self.lanes, self.bars = parse_program(prog)
self.master = self.lanes[0]
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid(); self.draw_log()
self.bpm, self.lanes, self.bars, self.ramp, self.trainer = parse_program(prog)
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train()
self.build_grid(); self.draw_log()
def _step_dur(self, L, step):
beat = 60_000_000_000 / self.bpm
if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar
@ -480,14 +495,18 @@ class App:
adv = False
while now >= L['next']:
L['step'] = (L['step'] + 1) % L['steps']
if li == 0: self._m_steps += 1 # count master-lane steps -> bars
if li == 0:
self._m_steps += 1 # count master-lane steps -> bars
nb = self._m_steps // L['steps']
if nb != self._lastbar: self._lastbar = nb; self._on_new_bar(nb) # ramp + gap-trainer
lvl = 0 if L['mute'] else L['levels'][L['step']]
if lvl > 0:
fired.append(lvl)
self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane
if not self._muted: # gap trainer: silent during the rest bars
self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90))
L['next'] += self._step_dur(L, L['step']); adv = True
if adv and li < len(self.lane_pads): self._move_playhead(li, L['step'])
if fired:
if fired and not self._muted:
best = max(fired, key=lambda l: PRIO.get(l, 0))
if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead
self.flash(best)
@ -498,6 +517,12 @@ class App:
b = base[2] + (self.rgb[2]-base[2])*7//10
if abs(r-base[0])+abs(g-base[1])+abs(b-base[2]) < 6: r, g, b = base
self.rgb = (r, g, b); self.led.set(r, g, b)
def _on_new_bar(self, bar):
if self.ramp and bar > 0: # tempo ramp: reset each segment, else step every N bars
if self.bars and bar % self.bars == 0: self.set_bpm(self._ramp_base)
elif bar % self.ramp['every'] == 0: self.set_bpm(self.bpm + self.ramp['amt'])
t = self.trainer # gap trainer: silence during the rest bars of each cycle
self._muted = bool(t and (t['play'] + t['mute']) and (bar % (t['play'] + t['mute'])) >= t['play'])
# ---------- inputs ----------
def poll(self):
@ -547,9 +572,25 @@ class App:
def draw_bpm(self):
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
def draw_status(self): # title (bright) + track number set apart (dim, right)
self._place(self.g_name, self.name[:22], 12, 116, C_TXT, C_BG, FONT_M)
self._place(self.g_idx, "%d/%d" % (self.idx + 1, len(self.programs)), 0, 120,
self._place(self.g_name, self.name[:22], 12, 130, C_TXT, C_BG, FONT_M)
self._place(self.g_idx, "%d/%d" % (self.idx + 1, len(self.programs)), 0, 134,
C_DIM, C_BG, FONT_S, right_edge=WIDTH - 12)
def draw_train(self): # ramp + gap-trainer indicators (symbol + params), when set
g = self.g_train
while len(g): g.pop()
x = 12; y = 104
if self.ramp:
up = self.ramp['amt'] >= 0
pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)] # rising / falling ramp
g.append(vectorio.Polygon(pixel_shader=solid(C_AMBER), points=pts, x=x, y=y)); x += 16
a = self.ramp['amt']; lbl = ("+%d" % a if a >= 0 else "%d" % a) + "/%db" % self.ramp['every']
tg, w, h = make_text(lbl, FONT_S, C_AMBER, C_BG); tg.x = x; tg.y = y; g.append(tg); x += w + 14
if self.trainer:
g.append(rect(x, y, 4, 9, C_CYAN)); g.append(rect(x + 6, y, 4, 9, C_DIM)) # play | rest
x += 14
tg, w, h = make_text("%d/%db" % (self.trainer['play'], self.trainer['mute']), FONT_S, C_CYAN, C_BG)
tg.x = x; tg.y = y; g.append(tg)
self.dirty = True
def draw_icons(self): # recolor the MIDI/USB icons by state (tear-free palette swap)
if self.ic_midi_pal is not None:
_recolor(self.ic_midi_pal, C_GREEN if self.midi_host else C_DIM, C_BG)
@ -572,9 +613,9 @@ class App:
else:
ts = self._fmt_t(el); bs = "bar %s" % cur
if ts != self._lastTs:
self._place(self.g_time, ts, 12, 52, C_TXT, C_BG, FONT_M); self._lastTs = ts
self._place(self.g_time, ts, 12, 50, C_TXT, C_BG, FONT_M); self._lastTs = ts
if bs != self._lastBs:
self._place(self.g_bar, bs, 12, 84, C_MUTE, C_BG, FONT_M); self._lastBs = bs
self._place(self.g_bar, bs, 12, 78, C_MUTE, C_BG, FONT_M); self._lastBs = bs
# ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ----------
def _padbase(self, L, s):
@ -645,9 +686,10 @@ class App:
if self.play_start is None: return
dur = int(time.monotonic() - self.play_start); self.play_start = None
if dur < MIN_LOG_SEC: return # skip plays under 5 seconds
mlen = self.lanes[0]['steps'] if self.lanes else 1
t = time.localtime()
self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
"dur": dur, "name": self.play_name})
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name})
del self.log[200:]; self._armed = None
self._save_log(); self.draw_log()
def draw_log(self):
@ -663,7 +705,8 @@ class App:
for k in range(min(LOG_ROWS, len(rows))):
oi, e = rows[k]; armed = (oi == self._armed) # oi = index into self.log (for delete)
dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60)
line = "%s%s %3d bpm %s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur)
bars = e.get("bars", 0); bstr = (" %dbar" % bars) if bars else ""
line = "%s%s %3dbpm %s%s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur, bstr)
tg, w, h = make_text(line, FONT_S, C_AMBER if armed else C_TXT, C_BG); tg.x = 10; tg.y = y; g.append(tg)
self.log_rows.append((y - 2, y + LOG_ROWH - 2, oi))
y += LOG_ROWH