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*).
|
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).
|
||||||
|
|
||||||
|
|
|
||||||
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.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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue