PM_K-1 0.1.0: on-device lane editor - edit/add/remove lanes

Tap the instrument name -> a modal to change Sound (cycle the GM voices), Beats (1-12),
Subdivision (1-8), Swing, and Mute, plus + Lane / Remove (1..MAXLANES). Beats/sub changes
regenerate the lane's default accents; sound/swing/mute keep the pattern. Reuses the
existing dirty + Save/Revert + .mpy machinery (edits to a built-in save a copy to
"My edits"). The modal redraws live as you adjust; tap Done or outside to close.

Verified in harness: editor opens (13 hit-zones), sound cycles, beats/sub regen steps,
swing/mute toggle, add/remove lanes, the edited track serializes + round-trips, Done
closes; modal renders cleanly. app.mpy builds (C/v6).

This completes the Phase-2 editing set (beats + lanes + Continue + built-in/user split).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-29 14:44:04 -05:00
parent ecd1d2a189
commit dbc9fa7fdc
3 changed files with 82 additions and 9 deletions

View file

@ -67,9 +67,10 @@ The editor also syncs the device clock, so the practice log gets real wallclo
firmware. **Your own** playlists live in `programs.json` (synced from the editor's *Save to device*). firmware. **Your own** playlists live in `programs.json` (synced from the editor's *Save to device*).
- **Switch playlist:** tap the **set-list tab** (above the title; grey = built-in, cyan = yours). **Item:** - **Switch playlist:** tap the **set-list tab** (above the title; grey = built-in, cyan = yours). **Item:**
joystick left/right. joystick left/right.
- **Edit on the device:** **tap a beat** to cycle it (off → normal → accent → ghost). The title turns - **Edit on the device:** **tap a beat** to cycle it (off → normal → accent → ghost); **tap the instrument
**red** (unsaved); **tap the title** to **Save** or **Revert**. Editing a built-in saves a **copy** into name** for the **lane editor** (sound · beats · subdivision · swing · mute, plus **+ Lane / Remove**).
a *My edits* playlist (built-ins never change). Editing your own updates it in place. The title turns **red** (unsaved); **tap the title** to **Save** or **Revert**. Editing a built-in saves a
**copy** into a *My edits* playlist (built-ins never change). Editing your own updates it in place.
- **Continue (auto-advance):** tap **CONT** (top-right of the tab line) — when on, a playlist auto-advances - **Continue (auto-advance):** tap **CONT** (top-right of the tab line) — when on, a playlist auto-advances
to the next item at the end of each item's `b<n>` segment (turn it on for the Song playlist). to the next item at the end of each item's `b<n>` segment (turn it on for the Song playlist).

View file

@ -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.12" # firmware version (the A/B updater pushes/compares this) APP_VERSION = "0.1.0" # 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:
@ -115,6 +115,8 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare
"tomLow":41,"tom808":45,"tomMid":45,"tomHigh":48, "tambourine":54, "tomLow":41,"tom808":45,"tomMid":45,"tomHigh":48, "tambourine":54,
"cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":37} "cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":37}
GM_DEFAULT = 37 GM_DEFAULT = 37
SOUNDS = ["kick", "snare", "clap", "rim", "hatClosed", "hatOpen", "ride", "crash", # lane-editor sound cycle
"tomLow", "tomMid", "tomHigh", "cowbell", "woodblock", "claves", "tambourine", "beep"]
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 = 158 # top of the pad grid (leaves room for time/bar/ramp/tab rows) GRID_TOP = 158 # top of the pad grid (leaves room for time/bar/ramp/tab rows)
@ -598,11 +600,9 @@ class App:
self.dirty = True self.dirty = True
def _tap_overlay(self, tx, ty): def _tap_overlay(self, tx, ty):
if self._overlay == 'msg': self._close_overlay(); return if self._overlay == 'msg': self._close_overlay(); return
for x0, y0, x1, y1, act in self._ovbtns: for x0, y0, x1, y1, act in self._ovbtns: # each action manages the panel (lane edits redraw it live)
if x0 <= tx <= x1 and y0 <= ty <= y1: if x0 <= tx <= x1 and y0 <= ty <= y1: act(); return
while len(self.g_overlay): self.g_overlay.pop() # clear the panel, then run the action self._close_overlay() # tapped outside a button -> cancel / done
self._overlay = None; act(); self.dirty = True; return
self._close_overlay() # tapped outside -> cancel
def _handle_tap(self, tx, ty): def _handle_tap(self, tx, ty):
if self._overlay: self._tap_overlay(tx, ty); return if self._overlay: self._tap_overlay(tx, ty); return
if 112 <= ty <= 126: # set-list tab line if 112 <= ty <= 126: # set-list tab line
@ -614,7 +614,79 @@ class App:
return return
hit = self._grid_hit(tx, ty) hit = self._grid_hit(tx, ty)
if hit and hit[0] == 'beat': self._cycle_beat(hit[1], hit[2]); return if hit and hit[0] == 'beat': self._cycle_beat(hit[1], hit[2]); return
if hit and hit[0] == 'lane': self._show_laneedit(hit[1]); return # tap the instrument name -> lane editor
self._tap_log(tx, ty) # else the practice log self._tap_log(tx, ty) # else the practice log
# ---------- lane editor (tap the instrument name): sound / beats / sub / swing / mute + add / remove ----------
def _show_laneedit(self, li):
self._overlay = 'lane'; self._edit_li = li; self._draw_laneedit()
def _draw_laneedit(self):
li = self._edit_li; L = self.lanes[li]; g = self.g_overlay
while len(g): g.pop()
self._ovbtns = []
PX, PY, PW, RH = 14, 54, WIDTH - 28, 34
g.append(rect(PX, PY, PW, RH * 7 + 30, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN))
t, w, h = make_text("Edit lane %d of %d" % (li + 1, len(self.lanes)), FONT_S, C_MUTE, C_PANEL)
t.x = PX + 12; t.y = PY + 8; g.append(t)
y = [PY + 28]
def vrow(label, value, fn): # label + [<] value [>]; left tap = fn(-1), right = fn(+1)
yy = y[0]
lt, lw, lh = make_text(label, FONT_S, C_MUTE, C_PANEL); lt.x = PX + 12; lt.y = yy + 9; g.append(lt)
g.append(rect(PX + 108, yy + 3, 28, RH - 8, C_BTN))
at, aw, ah = make_text("<", FONT_M, C_CYAN, C_BTN); at.x = PX + 108 + 9; at.y = yy + 7; g.append(at)
vt, vw, vh = make_text(value, FONT_M, C_TXT, C_PANEL); vt.x = PX + 146; vt.y = yy + 5; g.append(vt)
g.append(rect(PX + PW - 36, yy + 3, 28, RH - 8, C_BTN))
gt, gw, gh = make_text(">", FONT_M, C_CYAN, C_BTN); gt.x = PX + PW - 36 + 9; gt.y = yy + 7; g.append(gt)
self._ovbtns.append((PX + 104, yy, PX + 140, yy + RH, lambda: fn(-1)))
self._ovbtns.append((PX + PW - 40, yy, PX + PW, yy + RH, lambda: fn(1)))
y[0] += RH
vrow("Sound", L['sound'][:9], self._edit_sound)
vrow("Beats", str(sum(L['groups'])), self._edit_beats)
vrow("Subdiv", str(L['sub']), self._edit_sub)
vrow("Swing", "on" if L['swing'] else "off", self._edit_swing)
vrow("Mute", "yes" if L['mute'] else "no", self._edit_mute)
yy = y[0] + 2; bw = (PW - 36) // 2 # + Lane | Remove
g.append(rect(PX + 12, yy, bw, RH - 6, C_BTN))
a, aw, ah = make_text("+ Lane", FONT_S, C_GREEN if len(self.lanes) < MAXLANES else C_DIM, C_BTN); a.x = PX + 22; a.y = yy + 8; g.append(a)
self._ovbtns.append((PX + 12, yy, PX + 12 + bw, yy + RH, self._edit_add))
g.append(rect(PX + PW - 12 - bw, yy, bw, RH - 6, C_BTN))
r, rw, rh = make_text("Remove", FONT_S, C_AMBER if len(self.lanes) > 1 else C_DIM, C_BTN); r.x = PX + PW - 12 - bw + 14; r.y = yy + 8; g.append(r)
self._ovbtns.append((PX + PW - 12 - bw, yy, PX + PW - 12, yy + RH, self._edit_remove))
yy += RH + 2
g.append(rect(PX + 12, yy, PW - 24, RH - 4, C_BTN))
d, dw, dh = make_text("Done", FONT_M, C_CYAN, C_BTN); d.x = PX + (PW - dw) // 2; d.y = yy + 5; g.append(d)
self._ovbtns.append((PX + 12, yy, PX + PW - 12, yy + RH, self._edit_done))
self.dirty = True
def _regen_levels(self, L): # default accents after a beats/sub change
sub = L['sub']; groups = L['groups']; starts = set(); acc = 0
for gp in groups: starts.add(acc); acc += gp
L['steps'] = sum(groups) * sub
L['levels'] = [(2 if (i // sub) in starts else 1) if i % sub == 0 else 0 for i in range(L['steps'])]
def _lane_dirty(self, structural):
if structural: self._regen_levels(self.lanes[self._edit_li])
self.build_grid()
if not self._dirty: self._dirty = True; self.draw_status()
self._draw_laneedit() # refresh the modal with the new values
def _edit_sound(self, d):
L = self.lanes[self._edit_li]; i = SOUNDS.index(L['sound']) if L['sound'] in SOUNDS else 0
L['sound'] = SOUNDS[(i + d) % len(SOUNDS)]; self._lane_dirty(False)
def _edit_beats(self, d):
L = self.lanes[self._edit_li]; L['groups'] = [max(1, min(12, sum(L['groups']) + d))]; self._lane_dirty(True)
def _edit_sub(self, d):
L = self.lanes[self._edit_li]; L['sub'] = max(1, min(8, L['sub'] + d)); self._lane_dirty(True)
def _edit_swing(self, d):
L = self.lanes[self._edit_li]; L['swing'] = not L['swing']; self._lane_dirty(False)
def _edit_mute(self, d):
L = self.lanes[self._edit_li]; L['mute'] = not L['mute']; self._lane_dirty(False)
def _edit_add(self):
if len(self.lanes) >= MAXLANES: return
self.lanes.insert(self._edit_li + 1, _parse_lane("beep:4")); self._edit_li += 1; self._lane_dirty(False)
def _edit_remove(self):
if len(self.lanes) <= 1: return
del self.lanes[self._edit_li]
if self._edit_li >= len(self.lanes): self._edit_li = len(self.lanes) - 1
self._lane_dirty(False)
def _edit_done(self):
self._close_overlay()
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