diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index 50a24c0..e64e643 100644 Binary files a/pico-cp/__pycache__/app.cpython-312.pyc and b/pico-cp/__pycache__/app.cpython-312.pyc differ diff --git a/pico-cp/app.py b/pico-cp/app.py index b84ee50..b403369 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -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.15" # firmware version (the A/B updater pushes/compares this) +APP_VERSION = "0.0.16" # 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: @@ -123,6 +123,35 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare GM_DEFAULT = 37 SOUNDS = ["kick", "snare", "clap", "rim", "hatClosed", "hatOpen", "ride", "crash", # lane-editor sound cycle "tomLow", "tomMid", "tomHigh", "cowbell", "woodblock", "claves", "tambourine", "beep"] +HELP_PAGES = ( # paginated on-device help (rendered in _draw_help) + ("Transport & Navigation", ( + "Joystick up/down: tempo +/-1 (5 if held)", + "Joystick left/right: prev/next track", + "Button A: play / stop", + "Button B: tap tempo", + "Tap set-list tab: switch playlist", + "Tap CONT (top of tab): auto-advance", + "Tap hamburger: this menu", + )), + ("Editing", ( + "Tap a beat: off -> normal -> accent -> ghost", + "Tap an instrument name: lane editor", + "Lane editor: sound / beats / sub / swing /", + " mute, plus + Lane / Remove", + "Title turns red: unsaved edits", + "Tap red title: Save or Revert", + "Built-in edits save into 'My edits'", + )), + ("Status & Hardware", ( + "MIDI badge green: laptop listening", + "USB badge cyan: connected to a computer", + "RGB LED: green=stop / red=play + pulse", + "Squares = main beats, circles = subs", + "Ramp arrow: track has a tempo ramp", + "Gap symbol: silent rest bars", + "Practice log: time / BPM / dur / bars", + )), +) 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) @@ -259,7 +288,7 @@ def parse_program(s): 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, ramp, trainer + return max(5, min(300, bpm)), lanes, bars, ramp, trainer def _parse_lane(tok): poly = '~' in tok; mute = '!' in tok @@ -437,6 +466,7 @@ class App: self.ic_midi_pal = None; self.ic_usb_pal = None # practice history - persisted to /history.json (next to programs.json) when we own the filesystem self.can_write = self._probe_write() + self._load_settings() # /settings.json overrides the module-level defaults self.log = self._load_log() self.play_start = None; self.play_bpm = 0; self.play_name = "" self._armed = None; self.log_rows = [] @@ -460,7 +490,12 @@ class App: tg, w, h = make_text("VARASYS", FONT_M, C_CYAN, C_BG); tg.x = 10; tg.y = 8; root.append(tg) lx = 10 + w vtg, vw, vh = make_text("v" + APP_VERSION, FONT_S, C_DIM, C_BG); vtg.x = lx + 6; vtg.y = 8; root.append(vtg) - x = WIDTH - 12 + # Hamburger menu (3 thin rects) at the far right; tap zone is generous so it's easy to hit. + mx = WIDTH - 30 # left edge of the icon (18 px wide x 14 px tall total) + for dy in (10, 16, 22): + root.append(rect(mx, dy, 18, 2, C_MUTE)) + self._menu_bbox = (mx - 8, 0, WIDTH, 32) + x = mx - 8 # MIDI/USB icons start LEFT of the hamburger for asset, attr in ((ICON_USB, "ic_usb_pal"), (ICON_MIDI, "ic_midi_pal")): if asset: tg, pal, w, h = make_glyph(asset, C_DIM, C_BG); x -= w; tg.x = x; tg.y = 8; x -= 8 @@ -615,6 +650,8 @@ class App: self._close_overlay() # tapped outside a button -> cancel / done def _handle_tap(self, tx, ty): if self._overlay: self._tap_overlay(tx, ty); return + x0, y0, x1, y1 = self._menu_bbox # hamburger ☰ -> main menu + if x0 <= tx <= x1 and y0 <= ty <= y1: self._show_menu(); return if 112 <= ty <= 126: # set-list tab line if tx > WIDTH - 56: self.toggle_continue() # right end = CONT (auto-advance) toggle else: self.switch_setlist(1) @@ -697,6 +734,204 @@ class App: self._lane_dirty(False) def _edit_done(self): self._close_overlay() + # ---------- hamburger ☰ menu (main) + sub-modals (Settings / Help / About) ---------- + def _show_menu(self): + self._overlay = 'menu'; self._draw_menu() + def _draw_menu(self): + g = self.g_overlay + while len(g): g.pop() + self._ovbtns = [] + PX, PY, PW, RH = 24, 70, WIDTH - 48, 34 + rows = ( + ("Save edits", C_GREEN if self._dirty else C_DIM, self._save_edit if self._dirty else None), + ("Revert edits", C_AMBER if self._dirty else C_DIM, self._revert if self._dirty else None), + ("Continue: " + ("on" if self.continue_on else "off"), C_CYAN if self.continue_on else C_TXT, self._menu_toggle_continue), + ("Settings >", C_TXT, self._show_settings), + ("Help >", C_TXT, self._show_help), + ("About", C_TXT, self._show_about), + ) + PH = 38 + len(rows) * RH + RH + 8 + g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN)) + t, w, h = make_text("Menu", FONT_M, C_TXT, C_PANEL); t.x = PX + 14; t.y = PY + 12; g.append(t) + for i, (label, col, act) in enumerate(rows): + yy = PY + 38 + i * RH + g.append(rect(PX + 10, yy, PW - 20, RH - 4, C_BTN)) + tt, tw, th = make_text(label, FONT_M, col, C_BTN); tt.x = PX + 20; tt.y = yy + 6; g.append(tt) + if act: self._ovbtns.append((PX + 10, yy, PX + PW - 10, yy + RH, act)) + yy = PY + 38 + len(rows) * RH + 4 + g.append(rect(PX + 10, yy, PW - 20, RH - 4, C_BTN)) + dt, dw, dh = make_text("Done", FONT_M, C_CYAN, C_BTN); dt.x = PX + (PW - dw) // 2; dt.y = yy + 6; g.append(dt) + self._ovbtns.append((PX + 10, yy, PX + PW - 10, yy + RH, self._close_overlay)) + self.dirty = True + def _menu_toggle_continue(self): + self.continue_on = not self.continue_on; self.draw_status(); self._draw_menu() + + # ---------- Settings sub-modal (LED / Speaker / MIDI Out / Channel / Clock Out / Clock In) ---------- + def _show_settings(self): + self._overlay = 'settings'; self._draw_settings() + def _draw_settings(self): + g = self.g_overlay + while len(g): g.pop() + self._ovbtns = [] + PX, PY, PW, RH = 14, 50, WIDTH - 28, 32 + sm = "Off" if MUTE_SPEAKER else ("Auto" if SPEAKER_AUTO_MUTE else "Always") + rows = ( + ("LED", "%d%%" % int(LED_BRIGHTNESS * 100 + 0.5), self._adj_led), + ("Speaker", sm, self._adj_speaker), + ("MIDI Out", "on" if MIDI_ENABLED else "off", self._adj_midi_out), + ("Channel", str(MIDI_CHANNEL), self._adj_midi_ch), + ("Clock Out", "on" if MIDI_CLOCK_OUT else "off", self._adj_clock_out), + ("Clock In", "on" if MIDI_CLOCK_IN else "off", self._adj_clock_in), + ) + PH = 30 + len(rows) * RH + RH + 8 + g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN)) + t, w, h = make_text("Settings", FONT_S, C_MUTE, C_PANEL); t.x = PX + 12; t.y = PY + 8; g.append(t) + for i, (label, value, fn) in enumerate(rows): + yy = PY + 26 + i * RH + 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 + 6; g.append(at) + vt, vw, vh = make_text(value, FONT_M, C_TXT, C_PANEL); vt.x = PX + 150; vt.y = yy + 4; 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 + 6; g.append(gt) + self._ovbtns.append((PX + 104, yy, PX + 140, yy + RH, lambda f=fn: f(-1))) + self._ovbtns.append((PX + PW - 40, yy, PX + PW, yy + RH, lambda f=fn: f(1))) + yy = PY + 26 + len(rows) * RH + 4 + g.append(rect(PX + 12, yy + 2, PW - 24, RH - 4, C_BTN)) + dt, dw, dh = make_text("Done", FONT_M, C_CYAN, C_BTN); dt.x = PX + (PW - dw) // 2; dt.y = yy + 5; g.append(dt) + self._ovbtns.append((PX + 12, yy, PX + PW - 12, yy + RH, self._close_overlay)) + self.dirty = True + def _adj_led(self, d): + global LED_BRIGHTNESS + v = LED_BRIGHTNESS + d * 0.05 + if v < 0.05: v = 0.05 + if v > 0.50: v = 0.50 + LED_BRIGHTNESS = round(v * 100) / 100.0 + self.led.set(*self.rgb); self._save_settings(); self._draw_settings() + def _adj_speaker(self, d): + global MUTE_SPEAKER, SPEAKER_AUTO_MUTE + modes = ("auto", "always", "off") + cur = "off" if MUTE_SPEAKER else ("auto" if SPEAKER_AUTO_MUTE else "always") + i = (modes.index(cur) + d) % 3 + MUTE_SPEAKER = (modes[i] == "off"); SPEAKER_AUTO_MUTE = (modes[i] == "auto") + if MUTE_SPEAKER: self.spk.duty_cycle = 0 + self._save_settings(); self._draw_settings() + def _adj_midi_out(self, d): + global MIDI_ENABLED + MIDI_ENABLED = not MIDI_ENABLED; self._save_settings(); self._draw_settings() + def _adj_midi_ch(self, d): + global MIDI_CHANNEL + MIDI_CHANNEL = ((MIDI_CHANNEL - 1 + d) % 16) + 1 + self._save_settings(); self._draw_settings() + def _adj_clock_out(self, d): + global MIDI_CLOCK_OUT + MIDI_CLOCK_OUT = not MIDI_CLOCK_OUT + if MIDI_CLOCK_OUT: self._clock_next = time.monotonic_ns() + self._save_settings(); self._draw_settings() + def _adj_clock_in(self, d): + global MIDI_CLOCK_IN + MIDI_CLOCK_IN = not MIDI_CLOCK_IN + if not MIDI_CLOCK_IN: self._slaved = False + self._save_settings(); self._draw_settings() + + # ---------- Help sub-modal (paginated) ---------- + def _show_help(self): + self._overlay = 'help'; self._help_page = 0; self._draw_help() + def _draw_help(self): + g = self.g_overlay + while len(g): g.pop() + self._ovbtns = [] + PX, PY, PW = 14, 50, WIDTH - 28 + title, lines = HELP_PAGES[self._help_page] + PH = 38 + 18 * len(lines) + 60 + g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN)) + t, w, h = make_text(title, FONT_M, C_TXT, C_PANEL); t.x = PX + 12; t.y = PY + 8; g.append(t) + pi, piw, pih = make_text("%d / %d" % (self._help_page + 1, len(HELP_PAGES)), FONT_S, C_DIM, C_PANEL) + pi.x = PX + PW - piw - 12; pi.y = PY + 12; g.append(pi) + yy = PY + 36 + for ln in lines: + lt, lw, lh = make_text(ln[:42], FONT_S, C_TXT, C_PANEL); lt.x = PX + 12; lt.y = yy; g.append(lt) + yy += 16 + # Nav: < (prev) | Done | > (next) + by = PY + PH - 38; bh = 32; bw = (PW - 36) // 3 + for i, (lbl, col, act) in enumerate(( + ("<", C_CYAN if self._help_page > 0 else C_DIM, + self._help_prev if self._help_page > 0 else None), + ("Done", C_CYAN, self._close_overlay), + (">", C_CYAN if self._help_page < len(HELP_PAGES) - 1 else C_DIM, + self._help_next if self._help_page < len(HELP_PAGES) - 1 else None))): + bx = PX + 12 + i * (bw + 6) + g.append(rect(bx, by, bw, bh, C_BTN)) + lt, lw, lh = make_text(lbl, FONT_M, col, C_BTN); lt.x = bx + (bw - lw) // 2; lt.y = by + 6; g.append(lt) + if act: self._ovbtns.append((bx, by, bx + bw, by + bh, act)) + self.dirty = True + def _help_prev(self): + self._help_page = max(0, self._help_page - 1); self._draw_help() + def _help_next(self): + self._help_page = min(len(HELP_PAGES) - 1, self._help_page + 1); self._draw_help() + + # ---------- About sub-modal ---------- + def _show_about(self): + self._overlay = 'about'; self._draw_about() + def _draw_about(self): + import sys + gc.collect() + try: free = gc.mem_free() + except Exception: free = 0 # mem_free is CircuitPython-only + try: cp_ver = "%d.%d.%d" % sys.implementation.version[:3] + except Exception: cp_ver = "?" + up_min = int(time.monotonic()) // 60 + lines = ( + "VARASYS PolyMeter", + "PM_K-1 Kit", + "", + "Firmware: v" + APP_VERSION, + "Free RAM: %d KB" % (free // 1024), + "Uptime: %dm" % up_min, + "CircuitPython: " + cp_ver, + "", + "metronome.varasys.io", + ) + g = self.g_overlay + while len(g): g.pop() + self._ovbtns = [] + PX, PY, PW = 24, 90, WIDTH - 48; PH = 30 + 18 * len(lines) + 50 + g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN)) + yy = PY + 16 + for i, ln in enumerate(lines): + col = C_CYAN if i == 0 else (C_TXT if ln and i != 8 else C_DIM) + lt, lw, lh = make_text(ln, FONT_S, col, C_PANEL); lt.x = PX + 14; lt.y = yy; g.append(lt) + yy += 18 + by = PY + PH - 38 + g.append(rect(PX + 12, by, PW - 24, 32, C_BTN)) + dt, dw, dh = make_text("Done", FONT_M, C_CYAN, C_BTN); dt.x = PX + (PW - dw) // 2; dt.y = by + 6; g.append(dt) + self._ovbtns.append((PX + 12, by, PX + PW - 12, by + 32, self._close_overlay)) + self.dirty = True + + # ---------- Settings persistence (/settings.json) ---------- + def _load_settings(self): + global LED_BRIGHTNESS, MUTE_SPEAKER, SPEAKER_AUTO_MUTE, MIDI_ENABLED, MIDI_CHANNEL, MIDI_CLOCK_OUT, MIDI_CLOCK_IN + try: + with open("/settings.json") as f: d = json.load(f) + except Exception: return + try: + LED_BRIGHTNESS = float(d.get("led_brightness", LED_BRIGHTNESS)) + sm = d.get("speaker", "auto") + MUTE_SPEAKER = (sm == "off"); SPEAKER_AUTO_MUTE = (sm == "auto") + MIDI_ENABLED = bool(d.get("midi_out", MIDI_ENABLED)) + MIDI_CHANNEL = max(1, min(16, int(d.get("midi_channel", MIDI_CHANNEL)))) + MIDI_CLOCK_OUT = bool(d.get("clock_out", MIDI_CLOCK_OUT)) + MIDI_CLOCK_IN = bool(d.get("clock_in", MIDI_CLOCK_IN)) + except Exception as e: print("settings:", e) + def _save_settings(self): + if not self.can_write: return + sm = "off" if MUTE_SPEAKER else ("auto" if SPEAKER_AUTO_MUTE else "always") + d = {"led_brightness": LED_BRIGHTNESS, "speaker": sm, "midi_out": MIDI_ENABLED, + "midi_channel": MIDI_CHANNEL, "clock_out": MIDI_CLOCK_OUT, "clock_in": MIDI_CLOCK_IN} + try: + with open("/settings.json", "w") as f: json.dump(d, f) + except OSError: self.can_write = False + 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 @@ -748,7 +983,7 @@ class App: except Exception: pass self.led_rest(); self.draw_meters() # LED shows run state: red running / green stopped def set_bpm(self, v): - v = max(30, min(300, v)) + v = max(5, min(300, v)) if v != self.bpm: self.bpm = v self.draw_bpm(); self.draw_meters() # total time depends on bpm @@ -789,7 +1024,7 @@ class App: mlen = L['steps'] bar_pos = self._m_steps / mlen seg_bar = (bar_pos % self.bars) if self.bars else bar_pos - new_bpm = max(30, min(300, int(self._ramp_base + seg_bar / self.ramp['every'] * self.ramp['amt']))) + new_bpm = max(5, min(300, int(self._ramp_base + seg_bar / self.ramp['every'] * self.ramp['amt']))) if new_bpm != self.bpm: self.bpm = new_bpm lvl = 0 if L['mute'] else L['levels'][L['step']] if lvl > 0: @@ -1083,10 +1318,10 @@ class App: interval = now_ns - self._clock_in_last_t self._clock_in_last_t = now_ns # reject out-of-range intervals (30..300 BPM at 24 PPQN -> 8.33..83.3 ms per tick) - if interval < 8_300_000 or interval > 83_400_000: return + if interval < 8_300_000 or interval > 500_000_000: return if self._clock_in_avg == 0: self._clock_in_avg = interval else: self._clock_in_avg = (self._clock_in_avg * 7 + interval) // 8 # exponential smoothing, alpha = 1/8 - new_bpm = max(30, min(300, int(60_000_000_000 // (self._clock_in_avg * 24)))) + new_bpm = max(5, min(300, int(60_000_000_000 // (self._clock_in_avg * 24)))) if new_bpm != self.bpm: self.bpm = new_bpm self._slaved = True def _slave_start(self): # master sent Start (or Continue) -> start playback