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:
parent
ecd1d2a189
commit
dbc9fa7fdc
3 changed files with 82 additions and 9 deletions
|
|
@ -67,9 +67,10 @@ The editor also syncs the device clock, so the practice log gets real wall‑clo
|
|||
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:**
|
||||
joystick left/right.
|
||||
- **Edit on the device:** **tap a beat** to cycle it (off → normal → accent → ghost). 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.
|
||||
- **Edit on the device:** **tap a beat** to cycle it (off → normal → accent → ghost); **tap the instrument
|
||||
name** for the **lane editor** (sound · beats · subdivision · swing · mute, plus **+ Lane / Remove**).
|
||||
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
|
||||
to the next item at the end of each item's `b<n>` segment (turn it on for the Song playlist).
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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.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:
|
||||
import rtc # set from the editor's clock SysEx so the log has real timestamps
|
||||
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,
|
||||
"cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":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
|
||||
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)
|
||||
|
|
@ -598,11 +600,9 @@ class App:
|
|||
self.dirty = True
|
||||
def _tap_overlay(self, tx, ty):
|
||||
if self._overlay == 'msg': self._close_overlay(); return
|
||||
for x0, y0, x1, y1, act in self._ovbtns:
|
||||
if x0 <= tx <= x1 and y0 <= ty <= y1:
|
||||
while len(self.g_overlay): self.g_overlay.pop() # clear the panel, then run the action
|
||||
self._overlay = None; act(); self.dirty = True; return
|
||||
self._close_overlay() # tapped outside -> cancel
|
||||
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: act(); return
|
||||
self._close_overlay() # tapped outside a button -> cancel / done
|
||||
def _handle_tap(self, tx, ty):
|
||||
if self._overlay: self._tap_overlay(tx, ty); return
|
||||
if 112 <= ty <= 126: # set-list tab line
|
||||
|
|
@ -614,7 +614,79 @@ class App:
|
|||
return
|
||||
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] == 'lane': self._show_laneedit(hit[1]); return # tap the instrument name -> lane editor
|
||||
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):
|
||||
beat = 60_000_000_000 / self.bpm
|
||||
if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar
|
||||
|
|
|
|||
Loading…
Reference in a new issue