PM_K-1 0.0.16: BPM floor 30 -> 5; hamburger ☰ menu + Settings / Help / About
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) <noreply@anthropic.com>
This commit is contained in:
parent
12a31b87a8
commit
5153e35a52
2 changed files with 242 additions and 7 deletions
Binary file not shown.
249
pico-cp/app.py
249
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue