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:
parent
5e71df6b17
commit
88104e3d5c
3 changed files with 69 additions and 22 deletions
|
|
@ -62,12 +62,16 @@ The editor also syncs the device clock, so the practice log gets real wall‑clo
|
||||||
|
|
||||||
- **Joystick:** up/down = tempo, left/right = previous/next groove.
|
- **Joystick:** up/down = tempo, left/right = previous/next groove.
|
||||||
- **Button A (GP15):** play / stop. **Button B (GP14):** tap tempo.
|
- **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**
|
- **Screen:** VARASYS logo + firmware version + MIDI/USB status icons up top. Running time and bar count
|
||||||
(black when stopped). Running time and bar count show **of the segment total** when the track has a
|
show **of the segment total** when the track has a bar length (`b<n>`), e.g. `1:23 of 2:00` and `bar 3
|
||||||
bar length (`b<n>`), e.g. `1:23 of 2:00` and `bar 3 of 16`. Main beats are **squares**, subdivisions
|
of 16`. A track with a tempo ramp (`rmp`) shows a **ramp arrow + amount/every-bars** (e.g. `+4/2b`); a
|
||||||
are **circles**, with vertical gridlines lining the beats up across lanes.
|
gap-trainer track (`tr`) shows a **play|rest symbol + bars** (e.g. `2/2b`). Main beats are **squares**,
|
||||||
- **Touchscreen:** the bottom shows the **practice log for the current track** (time · BPM · duration) —
|
subdivisions are **circles**, with vertical gridlines lining the beats up across lanes.
|
||||||
newest first. Plays under 5 s aren't logged. **Tap a row to arm it (turns amber), tap again to delete.**
|
- **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.
|
- **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 power‑cycles.
|
- The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives power‑cycles.
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
|
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
|
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:
|
try:
|
||||||
import rtc # set from the editor's clock SysEx so the log has real timestamps
|
import rtc # set from the editor's clock SysEx so the log has real timestamps
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
@ -87,7 +87,7 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare
|
||||||
GM_DEFAULT = 37
|
GM_DEFAULT = 37
|
||||||
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
|
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
|
||||||
MAXLANES = 5 # lanes shown on the pad grid (extras still play)
|
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)
|
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
|
MIN_LOG_SEC = 5 # don't log plays shorter than this
|
||||||
PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost
|
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}
|
PRIO = {2: 3, 1: 2, 3: 1}
|
||||||
|
|
||||||
def parse_program(s):
|
def parse_program(s):
|
||||||
bpm = 120; lanes = []; bars = 0
|
bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None
|
||||||
for tok in s.strip().split(';'):
|
for tok in s.strip().split(';'):
|
||||||
tok = tok.strip()
|
tok = tok.strip()
|
||||||
if not tok: continue
|
if not tok: continue
|
||||||
|
|
@ -204,11 +204,23 @@ def parse_program(s):
|
||||||
bpm = int(tok[1:]); continue
|
bpm = int(tok[1:]); continue
|
||||||
if tok[0] == 'b' and tok[1:].isdigit(): # b<n> = segment length in bars (totals + 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)
|
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
|
if ':' not in tok: continue
|
||||||
lane = _parse_lane(tok)
|
lane = _parse_lane(tok)
|
||||||
if lane: lanes.append(lane)
|
if lane: lanes.append(lane)
|
||||||
if not lanes: lanes = [_parse_lane("beep:4")]
|
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):
|
def _parse_lane(tok):
|
||||||
poly = '~' in tok; mute = '!' in tok
|
poly = '~' in tok; mute = '!' in tok
|
||||||
|
|
@ -342,6 +354,7 @@ class App:
|
||||||
self._joyNext = 0
|
self._joyNext = 0
|
||||||
self._touchDown = False; self._touchSeen = 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.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.programs = load_programs()
|
||||||
self.dirty = True
|
self.dirty = True
|
||||||
self.pad_pal = displayio.Palette(8) # 0-3 idle levels (mute/normal/accent/ghost), 4-7 the lit playhead
|
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_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_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_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_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_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
|
self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads
|
||||||
|
|
@ -407,9 +421,10 @@ class App:
|
||||||
def load(self, i):
|
def load(self, i):
|
||||||
n = len(self.programs); self.idx = i % n
|
n = len(self.programs); self.idx = i % n
|
||||||
self.name, prog = self.programs[self.idx]
|
self.name, prog = self.programs[self.idx]
|
||||||
self.bpm, self.lanes, self.bars = parse_program(prog)
|
self.bpm, self.lanes, self.bars, self.ramp, self.trainer = parse_program(prog)
|
||||||
self.master = self.lanes[0]
|
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.build_grid(); self.draw_log()
|
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train()
|
||||||
|
self.build_grid(); self.draw_log()
|
||||||
def _step_dur(self, L, step):
|
def _step_dur(self, L, step):
|
||||||
beat = 60_000_000_000 / self.bpm
|
beat = 60_000_000_000 / self.bpm
|
||||||
if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar
|
if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar
|
||||||
|
|
@ -480,14 +495,18 @@ class App:
|
||||||
adv = False
|
adv = False
|
||||||
while now >= L['next']:
|
while now >= L['next']:
|
||||||
L['step'] = (L['step'] + 1) % L['steps']
|
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']]
|
lvl = 0 if L['mute'] else L['levels'][L['step']]
|
||||||
if lvl > 0:
|
if lvl > 0:
|
||||||
fired.append(lvl)
|
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
|
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 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))
|
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
|
if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead
|
||||||
self.flash(best)
|
self.flash(best)
|
||||||
|
|
@ -498,6 +517,12 @@ class App:
|
||||||
b = base[2] + (self.rgb[2]-base[2])*7//10
|
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
|
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)
|
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 ----------
|
# ---------- inputs ----------
|
||||||
def poll(self):
|
def poll(self):
|
||||||
|
|
@ -547,9 +572,25 @@ class App:
|
||||||
def draw_bpm(self):
|
def draw_bpm(self):
|
||||||
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
|
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)
|
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_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, 120,
|
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)
|
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)
|
def draw_icons(self): # recolor the MIDI/USB icons by state (tear-free palette swap)
|
||||||
if self.ic_midi_pal is not None:
|
if self.ic_midi_pal is not None:
|
||||||
_recolor(self.ic_midi_pal, C_GREEN if self.midi_host else C_DIM, C_BG)
|
_recolor(self.ic_midi_pal, C_GREEN if self.midi_host else C_DIM, C_BG)
|
||||||
|
|
@ -572,9 +613,9 @@ class App:
|
||||||
else:
|
else:
|
||||||
ts = self._fmt_t(el); bs = "bar %s" % cur
|
ts = self._fmt_t(el); bs = "bar %s" % cur
|
||||||
if ts != self._lastTs:
|
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:
|
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) ----------
|
# ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ----------
|
||||||
def _padbase(self, L, s):
|
def _padbase(self, L, s):
|
||||||
|
|
@ -645,9 +686,10 @@ class App:
|
||||||
if self.play_start is None: return
|
if self.play_start is None: return
|
||||||
dur = int(time.monotonic() - self.play_start); self.play_start = None
|
dur = int(time.monotonic() - self.play_start); self.play_start = None
|
||||||
if dur < MIN_LOG_SEC: return # skip plays under 5 seconds
|
if dur < MIN_LOG_SEC: return # skip plays under 5 seconds
|
||||||
|
mlen = self.lanes[0]['steps'] if self.lanes else 1
|
||||||
t = time.localtime()
|
t = time.localtime()
|
||||||
self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
|
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
|
del self.log[200:]; self._armed = None
|
||||||
self._save_log(); self.draw_log()
|
self._save_log(); self.draw_log()
|
||||||
def draw_log(self):
|
def draw_log(self):
|
||||||
|
|
@ -663,7 +705,8 @@ class App:
|
||||||
for k in range(min(LOG_ROWS, len(rows))):
|
for k in range(min(LOG_ROWS, len(rows))):
|
||||||
oi, e = rows[k]; armed = (oi == self._armed) # oi = index into self.log (for delete)
|
oi, e = rows[k]; armed = (oi == self._armed) # oi = index into self.log (for delete)
|
||||||
dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60)
|
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)
|
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))
|
self.log_rows.append((y - 2, y + LOG_ROWH - 2, oi))
|
||||||
y += LOG_ROWH
|
y += LOG_ROWH
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue