From 5153e35a5238a01ab2e1ab8d421283b7881b7960 Mon Sep 17 00:00:00 2001 From: Me Here Date: Sat, 30 May 2026 07:50:55 -0500 Subject: [PATCH] =?UTF-8?q?PM=5FK-1=200.0.16:=20BPM=20floor=2030=20->=205;?= =?UTF-8?q?=20hamburger=20=E2=98=B0=20menu=20+=20Settings=20/=20Help=20/?= =?UTF-8?q?=20About?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BPM lower bound dropped 30 -> 5 (engine + ramp + slave-clock-in interval filter; pure clamp choice, no engine reason for the higher floor). Very slow practice is now possible. Sub-musical (<5) sits below the slave-decay timeout so wasn't worth pursuing. Hamburger menu (☰ at the far right of the header, 3 thin recolorable rects; tap zone covers the corner). Reuses the existing overlay / _ovbtns pattern - no new framework. - Main menu (_show_menu): Save edits / Revert edits (dimmed when not _dirty) / Continue on-off (live) / Settings > / Help > / About / Done. - Settings sub-modal: LED brightness 5..50% in 5% steps, Speaker mode (Auto / Always / Off, combining MUTE_SPEAKER + SPEAKER_AUTO_MUTE), MIDI Out on/off, MIDI Channel 1..16, Clock Out on/off, Clock In on/off. < value > adjusters reusing the lane-editor row pattern. Each tap calls _save_settings(); the modal redraws live. - Help sub-modal: 3 paginated pages (Transport & Nav / Editing / Status & Hardware) with < Done > nav. - About sub-modal: version, free RAM, uptime, CircuitPython version, site URL. Defensive about gc.mem_free and sys.implementation (CircuitPython-specific). - Persistence to /settings.json: read in __init__ (_load_settings overrides defaults), written on every change (settings are small + infrequent). NAKs silently if read-only. Verified in the harness: all 4 modals render, settings.json round-trips (channel 10->11, clock_out off->on persisted), tap zones populate as expected. Co-Authored-By: Claude Opus 4.7 (1M context) --- pico-cp/__pycache__/app.cpython-312.pyc | Bin 97074 -> 116845 bytes pico-cp/app.py | 249 +++++++++++++++++++++++- 2 files changed, 242 insertions(+), 7 deletions(-) diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index 50a24c002a19ca848ffcfd9b0eae349e137257a0..e64e6438ae461628f2d33a99bff9f4368e37b7c0 100644 GIT binary patch delta 29245 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 delta 14583 zcma)j34ByVwtm;`B`Xjh5E2MWSer-yQ9xud32R6~2w@8_O((ZWLOR{Cx&tHz1A?M} z2$pzJL2(^H1+>*TI*Q=FgUH}q_cuBt&iHqGIu5h=Ki|1YcarD({a(-TrS>{?>YP)j zPF=s=9rxHz&hBq?>y~Jv-y}0*?a#gUbnho#+%xjrkn;X|V)+0)sXSBH$_G+<5d9rY ze}~ZDEczQ6Bz#(no~uvR`|r1xFW2+R%k}*775dDTcAKqyW%;V|-%y!HpH;qEpIz?N z=ag6IbIU6=+Z>xdZ>vo&cu2#%)K=H$Z?%>CG@H+-7d|A~O4o3{D4MV0d~q~iO?i95 z+IQ9XiUDQ%0vchhUP4%_FC_HqrG$0*BEkT*^y_8Oox9Cgt zW%_d6#{Vnd?<$v+Lm3tw(pS*%VSOcGgT6}tjqcG`>)!p2@^yLzm95t+%Qxscr5be~ zVG}QY4K2PZy7;T~YN~10YX~>$YYDH`YYAJp?EpVDu8TI_M3ilM$YPLfxB4zEQx!YY z#3HrEIrg&Otha5jFSo51Lv6m4VK(U!HeHnWU2fa0?X3FMx-_nJq9q?KOe0*}ZVqOiy0vyg75rif1ia zB$rW*TmV>vHVLyfRQl?}HNk+}A(x{>NK;gqq9Wv=++h026)3iceBq|J(m8oKl(h#7wJWYZ>=+!-If`PEyf<}?#j7zkX0!qs6fI2{pDjASErUJ=IfKCvKBLKPdvnY0X zD*V2HF1J&~Z`GawN5rbg6`5byQv&GI7mx-hP?ru_!*8#2qtOs08 zU^>FV`f;YcA{aJBr8RX6%Iy_(^`^bv>zCJaw%!*owW{^>uUc;6j#boCUUPF+e%mVR zytO`0*ta1p4^#b-$g->?O{_9jZ58Qi?Un$9_${d@feQk?O9DBRSkhe_O z9om<#rkzO|a`4gzhaGbqt2&i5?UZ9$P0=8IHyaH1o{((u>N01zK}a$=U2@)+`mxECN2`Ev4ZA)Zx@Q+(Z^pCK?3 zTB-gzB}a@@8JA~gu-tu!<}8aJA!V&hc`lbDBa|<8Yo?1x_vFuI>S^nLqGD65iw*em z<)egK4Vjvo!}R}w>fLtJu{Kx}uvYvdN;FdxGRtBecTepjMyLm-zDB}OKkZ-L_EWo9 zZR7NzV!S#qeQM?nD93>pH7X&0t*=qqscfA3Y5E8kEMKKfWj13QOLeIJr)>=8qC9)Tj9a z#Kn<+=P$EQf{m7gXm-=NCfE?rP2ue<@e*dasr#I=%f^q-@px(iHDQluUS#JSUF*iy z6LypoeX08QyvxS1$+t@%S^R)yN4uDPkJOtI+6I&Q!pNF}JnbSTA{#{5*LnS&jgQ^4 zUPgPYm&+sX6*{yOn3$T9%|tuze4YBTcw8<|uF-NY!cba$m?Z>Bl7Zhn@n3_p6gq%#!i5htcm9%&9)OqV`)R~2K^RQ>d#Dg{TS5^V40pOe* zgMr2Y*xX_OS%V66vJ{{{$^diK4W+~ASUFNUNTl)kl@tn3nsnJjX1kn;cIxv*r5%N& zq^v-QThtwkoBDB&WVU#EO{m`QZItEIOsspcltW0||el5%R({)0~A_rsgi#<2Nphr8Ic=hoL7tG9~2@&Ag$P+_4qdwWOTEdR!}q}k?K&C?L* zHp+RUIS1t`qPY~x!PfAPTf!;eUlsbnP2pd&ZS7m+kPZ^>7`bh(ZqpT)>hD#etI@tZCU%&vW4hS z3MeBmo%4!I7MIB-NIOaz{C-ngTw*%vDMc&b>B`YK6$5tk667{2X^Q7bE5d=}O4ZIG z7vzx?k*<9nXB-_S#zcntv$g5r5!9c5dcqnlJSv(?T%)bgX4+PfOSzI(v$5O8?#bF#W3*gqjzcUpOMx`6ZMI(<}O;k z$P$PPD8G{6JcyOg5NqYrfPu(@9quf7i+V0RwI941)0OYZU!GfRotmtUhgGkJQL4CM zlRZT)SEm~Wj)^KL8QE}^sjVZ2a$U96

6Gc*9}IE>I{1XLRp4%RIfLu2|P6?nP|) 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 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