PM_K-1 0.0.14: gapless seam + continuous ramp + MIDI Clock Out (master); speaker rename

Speaker rename (production device has a full audio circuit, not a buzzer):
- MUTE_BUZZER -> MUTE_SPEAKER, self.buz -> self.spk, P_BUZ -> P_SPK
- New SPEAKER_AUTO_MUTE flag (default True): mute the speaker when a MIDI host is
  detected (the old hardcoded behavior; now a setting).

Gapless seam between tracks (Continue):
- _prepare_next() pre-parses the next playlist item during the LAST bar so the swap is
  allocation-free. _do_advance() swaps lanes/bpm/bars/ramp/trainer in with
  lanes[0]['next'] = seam_t (the wall-clock time of the boundary step we just hit), no
  _reset_clock - the next tick fires step 0 of the new track exactly at the boundary.
  tick() breaks out at the seam so the old voice's boundary beat is NOT fired (it'd be
  the new track's step 0 a few ms later). Visuals (build_grid + draws) are deferred
  one display-refresh cycle behind the audio via _need_redraw, so the audio doesn't
  wait for them.

Continuous ramp:
- Replaced the bar-boundary set_bpm step with per-master-step linear interpolation:
  bpm = _ramp_base + amt * ((m_steps/mlen) % bars) / ramp.every (clamped 30..300).
  The integer-clamped bpm glides smoothly across the segment. draw_bpm() is now lazy
  (skips the bitmap alloc if the displayed integer hasn't changed), and the periodic
  meter tick in run() also redraws BPM so the big number follows the ramp.

MIDI Clock Out (master):
- New flags: MIDI_CHANNEL (default 10 = GM drum), MIDI_CLOCK_OUT (default OFF),
  MIDI_CLOCK_OUT_TRANSPORT (default ON). midi_send() now uses the configured channel.
  In tick(), when running + MIDI_CLOCK_OUT, stream 0xF8 at 24 PPQN with the interval
  computed live from self.bpm (so it follows the continuous ramp). toggle() sends
  0xFA on Start and 0xFC on Stop when transport is enabled.

Verified in harness: seam keeps lanes[0]['next'] = seam_t (no _reset_clock); ramp 80
glides via +0.25/step (visible as 80->81 in 4 master steps at rmp80/4/4); Clock Out
math sound (60/120/180 BPM -> 41.67/20.83/13.89 ms tick interval).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-30 07:11:19 -05:00
parent b1bb792df6
commit fd8446658d
4 changed files with 96 additions and 34 deletions

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VARASYS PM_K1 Kit — wiring, parts &amp; firmware (Raspberry Pi Pico build)</title>
<meta name="description" content="PM_K1 Kit — build a touchscreen polymeter metronome from a Raspberry Pi Pico on the 52Pi EP0172 breadboard kit (3.5in ST7796 captouch, joystick, RGB, buzzer). Pinout, parts list, and the precompiled CircuitPython firmware bundle." />
<meta name="description" content="PM_K1 Kit — build a touchscreen polymeter metronome from a Raspberry Pi Pico on the 52Pi EP0172 breadboard kit (3.5in ST7796 captouch, joystick, RGB, speaker). Pinout, parts list, and the precompiled CircuitPython firmware bundle." />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
(function(){ try{ var p = localStorage.getItem("metronome.theme");
@ -51,12 +51,12 @@
<p>This is the first member of the family you can actually build today from offtheshelf parts: a
<b>Raspberry Pi Pico</b> seated on the <b>52Pi EP0172 "Pico Breadboard Kit Plus"</b>, which carries a
3.5″ <b>ST7796</b> 320×480 capacitivetouch screen (<b>GT911</b>), a PSP <b>joystick</b>, a <b>WS2812 RGB</b>
LED, a <b>buzzer</b> and two buttons — all prewired, so you don't solder anything; you just seat the Pico
LED, a <b>speaker</b> and two buttons — all prewired, so you don't solder anything; you just seat the Pico
and drop the firmware bundle onto its USB drive.</p>
<p>It runs the same <b>polymeter engine</b> and the same <b>program strings</b> as the web editor: design a
groove on the site and <b>Save to device</b> over USBMIDI (or edit on the device's touchscreen — beats,
lanes, playlists), and it plays standalone. Tap the screen, nudge tempo with the joystick; the RGB shows
run/stop and the beat pulse, and the buzzer clicks. Powered over the Pico's USB.</p>
run/stop and the beat pulse, and the speaker clicks. Powered over the Pico's USB.</p>
</section>
<details class="spec" open>
@ -75,7 +75,7 @@
<tr><td class="part">PSP joystick X / Y</td><td>ADC0 (GP26) / ADC1 (GP27)</td></tr>
<tr><td class="part">Button A (play/stop) / Button B (tap)</td><td>GP15 / GP14</td></tr>
<tr><td class="part">WS2812 RGB LED</td><td>GP12</td></tr>
<tr><td class="part">Buzzer</td><td>GP13</td></tr>
<tr><td class="part">Speaker</td><td>GP13</td></tr>
</tbody>
</table>
</div>
@ -89,7 +89,7 @@
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
<tbody>
<tr><td class="part">Raspberry Pi Pico (or Pico W / Pico 2) <span class="spec">— the brain</span></td><td class="q">1</td><td class="c">5</td></tr>
<tr><td class="part">52Pi EP0172 "Pico Breadboard Kit Plus" <span class="spec">— 3.5″ ST7796 captouch, GT911, PSP joystick, WS2812 RGB, buzzer, 2 buttons, breadboard, acrylic panel</span></td><td class="q">1</td><td class="c">38</td></tr>
<tr><td class="part">52Pi EP0172 "Pico Breadboard Kit Plus" <span class="spec">— 3.5″ ST7796 captouch, GT911, PSP joystick, WS2812 RGB, speaker, 2 buttons, breadboard, acrylic panel</span></td><td class="q">1</td><td class="c">38</td></tr>
<tr><td class="part">USB cable <span class="spec">— power + flashing</span></td><td class="q">1</td><td class="c">2</td></tr>
<tr class="total"><td>Total (oneoff)</td><td class="q"></td><td class="c">≈ $45</td></tr>
</tbody>
@ -120,7 +120,7 @@
then the setlist <b></b> menu → <b>📟 Save to device</b>. It's pushed over USBMIDI and the device shows
<b>Saved ✓</b>. (Fallback for any browser: it downloads <code>programs.json</code> — boot holding A and drag it on.)</li>
<li><b>Play through your computer:</b> click <b>🎹 Device audio</b>, then press play on the device — the full
groove sounds through your speakers over USBMIDI, in sync; the screen shows a <b>MIDI</b> badge and the buzzer mutes.</li>
groove sounds through your speakers over USBMIDI, in sync; the screen shows a <b>MIDI</b> badge and the speaker mutes.</li>
<li><b>Practice log:</b> plays over 5 s appear at the bottom of the screen (time · BPM · duration · track); tap a row twice to delete.</li>
<li><b>Firmware updates:</b> ⋯ menu → <b>⬆ Update firmware</b> — it checks your version, pushes the latest over USBMIDI, and the device A/Bupdates with automatic rollback if a build won't boot.</li>
</ol>

View file

@ -6,7 +6,7 @@ It runs the same programstring language as <https://metronome.varasys.io>. Th
bricked (BOOTSEL → drag a MicroPython `.uf2` back).
What it does: drives the 3.5″ touchscreen with a lanes/pads display + antialiased text, plays the
buzzer + RGB beat light, **logs your practice to `/history.json`**, accepts new set lists **pushed
speaker + RGB beat light, **logs your practice to `/history.json`**, accepts new set lists **pushed
from the web editor over USBMIDI**, and plays through your **computer's speakers** over USBMIDI.
## Two poweron modes (set by `boot.py`)
@ -58,7 +58,7 @@ rare. And the Pico is unbrickable as the ultimate backstop.)
The Pico is a USBMIDI device and sends a note per click (GM drum note per lane, velocity by accent).
In the editor click **🎹 Device audio**, grant MIDI access, and press play *on the device* — the editor
voices the groove through its full synth, out your speakers, locked to the device's clock. While a host is
listening the screen shows a green **MIDI** badge and the **buzzer automutes** (the computer plays instead).
listening the screen shows a green **MIDI** badge and the **speaker automutes** (the computer plays instead).
The editor also syncs the device clock, so the practice log gets real wallclock timestamps.
## Playlists, editing & Continue
@ -88,7 +88,7 @@ The editor also syncs the device clock, so the practice log gets real wallclo
- The firmware **performs** ramps (tempo steps every N bars) and gap-trainer cycles (silent rest bars).
- **Touchscreen:** the bottom shows the **practice log for the current track** (time · BPM · duration · bars)
— newest first. Plays under 5 s aren't logged. **Tap a row to arm it (turns amber), tap again to delete.**
- **RGB LED** flashes the beat (amber accent / cyan normal / violet ghost); the **buzzer** clicks to match.
- **RGB LED** flashes the beat (amber accent / cyan normal / violet ghost); the **speaker** clicks to match.
- The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives powercycles.
## programs.json
@ -108,7 +108,7 @@ Each `prog` is a program string from the editor (tempo, lanes, patterns, `/2` su
- **Taps land wrong:** set `TOUCH_DEBUG = True`, read the raw coords over USB serial, then set
`TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y`.
- **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`.
- **Computer audio:** `MIDI_ENABLED` (default on); `MUTE_BUZZER` forces the buzzer off even standalone.
- **Computer audio:** `MIDI_ENABLED` (default on); `MUTE_SPEAKER` forces the speaker off even standalone.
- **LED too bright/dim:** `LED_BRIGHTNESS` (0..1, default 0.15).
- **Screen tearing:** SPI panels have no tearingeffect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast
to minimise it — lower only if unstable.

View file

@ -1,6 +1,6 @@
# VARASYS PolyMeter - PM_K-1 "Kit" firmware (CircuitPython edition)
# Raspberry Pi Pico (Pico / Pico W / Pico 2) on the 52Pi EP-0172 "Pico Breadboard Kit Plus":
# 3.5" ST7796 320x480 cap-touch (GT911), PSP joystick, WS2812 RGB, buzzer, 2 buttons.
# 3.5" ST7796 320x480 cap-touch (GT911), PSP joystick, WS2812 RGB, speaker, 2 buttons.
#
# WHY CIRCUITPYTHON: the board then mounts as a USB drive (CIRCUITPY) carrying this code, your
# tracks (programs.json) and a copy of the editor - edit on the web, "Save to device" writes
@ -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.13" # firmware version (the A/B updater pushes/compares this)
APP_VERSION = "0.0.14" # 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:
@ -46,7 +46,11 @@ except ImportError:
SPI_BAUD = 62_500_000 # faster SPI = smaller tearing window; drop to 40_000_000 if unstable
LED_BRIGHTNESS = 0.15 # WS2812 sits right next to you - keep it dim (0..1)
MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web editor's "Device audio")
MUTE_BUZZER = False # silence the on-board buzzer (e.g. when using computer audio)
MIDI_CHANNEL = 10 # 1..16 - GM channel 10 is the drum channel (what DAWs auto-route to drums)
MIDI_CLOCK_OUT = False # send 24 PPQN MIDI Clock so a DAW can slave its tempo to the metronome
MIDI_CLOCK_OUT_TRANSPORT = True # also send Start (0xFA) / Stop (0xFC) on play / stop (relevant if MIDI_CLOCK_OUT)
MUTE_SPEAKER = False # always silence the on-board speaker
SPEAKER_AUTO_MUTE = True # auto-mute the speaker when a MIDI host is listening (computer plays it instead)
WIDTH, HEIGHT = 320, 480
MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed.
INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative
@ -63,7 +67,7 @@ JOY_DEADZONE = 9000
# ----- pins (fixed by the EP-0172 board) -----
P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7
P_SDA, P_SCL = board.GP8, board.GP9
P_RGB, P_BUZ, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14
P_RGB, P_SPK, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14
P_JOYX, P_JOYY = board.GP26, board.GP27
# ----- BUILT-IN playlists: the standard defaults from the web editor, baked in here so they update with
@ -405,8 +409,8 @@ class App:
self._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler (clock + pushed programs)
self._fw = None # chunked firmware transfer: staging file handle
self.led = RGB(P_RGB)
self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0)
self.buz_off = 0
self.spk = pwmio.PWMOut(P_SPK, frequency=1600, variable_frequency=True, duty_cycle=0)
self.spk_off = 0
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
self._aPrev = True; self._bPrev = True
self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY)
@ -416,6 +420,8 @@ class App:
self.ramp = None; self.trainer = None; self._lastbar = -1; self._muted = False; self._ramp_base = 120
self._dirty = False; self._overlay = None; self._ovbtns = [] # on-device editing: unsaved edits + modal
self.continue_on = False; self._advance = False; self._grid = {} # auto-advance + pad hit-test geometry
self._next_pending = None; self._seam_t = 0; self._need_redraw = False # gapless seam between tracks
self._displayed_bpm = -1; self._clock_next = 0 # lazy BPM redraw + MIDI Clock Out tick scheduler
self.sl = 0; self.rebuild_setlists() # built-in playlists (baked) + user playlists (programs.json)
self.dirty = True
self.pad_pal = displayio.Palette(8) # 0-3 idle levels (mute/normal/accent/ghost), 4-7 the lit playhead
@ -506,6 +512,7 @@ class App:
self.bpm, self.lanes, self.bars, self.ramp, self.trainer = parse_program(prog)
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
self._dirty = False; self._overlay = None # fresh load -> no unsaved edits
self._next_pending = None; self._need_redraw = False # discard any prepared seam (user navigated away)
while len(self.g_overlay): self.g_overlay.pop() # dismiss any open modal
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train()
self.build_grid(); self.draw_log()
@ -706,9 +713,9 @@ class App:
# ---------- audio + light ----------
def click(self, level):
self.buz.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
self.buz.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000)
self.buz_off = time.monotonic_ns() + 22_000_000
self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
self.spk.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000)
self.spk_off = time.monotonic_ns() + 22_000_000
def _led_base(self):
return LED_RUN if self.running else LED_IDLE # dim red while playing / dim green when stopped
def flash(self, level):
@ -719,14 +726,23 @@ class App:
self.led.set(*self.rgb)
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
if self.midi is None: return
try: self.midi.write(bytes([0x90, note, vel])) # Note On (percussive - no Note Off needed)
try: self.midi.write(bytes([0x90 | ((MIDI_CHANNEL - 1) & 0x0F), note, vel])) # Note On, channel 1..16
except Exception: pass
# ---------- transport ----------
def toggle(self):
self.running = not self.running
if self.running: self._reset_clock(); self._start_play()
else: self.buz.duty_cycle = 0; self.reset_playheads(); self._log_play()
if self.running:
self._reset_clock(); self._start_play()
self._clock_next = time.monotonic_ns() # start MIDI Clock Out from zero
if MIDI_CLOCK_OUT and MIDI_CLOCK_OUT_TRANSPORT and self.midi is not None:
try: self.midi.write(bytes([0xFA])) # Start
except Exception: pass
else:
self.spk.duty_cycle = 0; self.reset_playheads(); self._log_play()
if MIDI_CLOCK_OUT and MIDI_CLOCK_OUT_TRANSPORT and self.midi is not None:
try: self.midi.write(bytes([0xFC])) # Stop
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))
@ -751,17 +767,25 @@ class App:
# ---------- scheduler ----------
def tick(self):
now = time.monotonic_ns()
if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0
if self.spk_off and now >= self.spk_off: self.spk.duty_cycle = 0; self.spk_off = 0
if self.running:
fired = []
for li, L in enumerate(self.lanes):
if self._advance: break # seam armed - skip remaining lanes for THIS tick
adv = False
while now >= L['next']:
L['step'] = (L['step'] + 1) % L['steps']
if li == 0:
self._m_steps += 1 # count master-lane steps -> bars
nb = self._m_steps // L['steps']
if nb != self._lastbar: self._lastbar = nb; self._on_new_bar(nb) # ramp + gap-trainer
if nb != self._lastbar: self._lastbar = nb; self._on_new_bar(nb)
if self._advance: break # seam armed - suppress this step's firing
if self.ramp and L['steps'] > 0: # CONTINUOUS ramp: interpolate bpm at every master step
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'])))
if new_bpm != self.bpm: self.bpm = new_bpm
lvl = 0 if L['mute'] else L['levels'][L['step']]
if lvl > 0:
fired.append(lvl)
@ -771,7 +795,8 @@ class App:
if adv and li < len(self.lane_pads): self._move_playhead(li, L['step'])
if fired and not self._muted:
best = max(fired, key=lambda l: PRIO.get(l, 0))
if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead
if not MUTE_SPEAKER and not (SPEAKER_AUTO_MUTE and self.midi_host):
self.click(best) # speaker silent if user muted it / auto-mute on + host present
self.flash(best)
base = self._led_base() # decay the beat pulse back down to the red running base
if self.rgb != base:
@ -780,18 +805,49 @@ class App:
b = base[2] + (self.rgb[2]-base[2])*7//10
if abs(r-base[0])+abs(g-base[1])+abs(b-base[2]) < 6: r, g, b = base
self.rgb = (r, g, b); self.led.set(r, g, b)
if self._advance: # Continue: roll to the next item at the segment end
if self._advance: # Continue: gapless swap to the prepared track at seam_t
self._advance = False
self.load((self.idx + 1) % len(self.setlists[self.sl]['items'])); self.led_rest()
self._do_advance()
# MIDI Clock Out (master): 24 PPQN; interval follows the live bpm (so continuous ramps carry through)
if self.running and MIDI_CLOCK_OUT and self.midi is not None:
while now >= self._clock_next:
try: self.midi.write(bytes([0xF8]))
except Exception: pass
self._clock_next += int(60_000_000_000 / max(1, self.bpm) / 24)
def _on_new_bar(self, bar):
# Pre-parse the next track during the LAST bar of this segment, so the swap at the seam is allocation-free
if self.bars and self.continue_on and self._next_pending is None and bar == self.bars - 1:
self._prepare_next()
if self.bars and bar > 0 and bar % self.bars == 0: # segment boundary
self._seg_start = time.monotonic() # timer resets with the bar counter
if self.ramp: self.set_bpm(self._ramp_base) # ramp restarts each segment
if self.continue_on: self._advance = True # Continue: roll to the next item
elif self.ramp and bar > 0 and bar % self.ramp['every'] == 0:
self.set_bpm(self.bpm + self.ramp['amt']) # mid-segment ramp step
if self.continue_on and self._next_pending is not None:
self._seam_t = self.lanes[0]['next'] # the wall-clock time of THIS boundary step
self._advance = True # tick() will swap to the prepared track
# Note: per-master-step continuous ramp handles the bpm reset implicitly (seg_bar wraps to 0)
t = self.trainer # gap trainer: silence during the rest bars
self._muted = bool(t and (t['play'] + t['mute']) and (bar % (t['play'] + t['mute'])) >= t['play'])
def _prepare_next(self): # parse the next playlist item into a side holder
items = self.setlists[self.sl]['items']
nxt = (self.idx + 1) % len(items)
if nxt == self.idx: return # 1-item playlist -> just loop, no swap
name, prog = items[nxt]
bpm, lanes, bars, ramp, trainer = parse_program(prog)
self._next_pending = {'lanes': lanes, 'bpm': bpm, 'bars': bars, 'ramp': ramp,
'trainer': trainer, 'name': name, 'idx': nxt}
def _do_advance(self): # gapless seam: swap the prepared track in at seam_t
n = self._next_pending
if n is None: return
self._next_pending = None
self.lanes = n['lanes']; self.bpm = n['bpm']; self.bars = n['bars']
self.ramp = n['ramp']; self.trainer = n['trainer']; self.name = n['name']; self.idx = n['idx']
self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False; self._m_steps = 0
self._dirty = False; self._overlay = None
while len(self.g_overlay): self.g_overlay.pop()
seam = self._seam_t
for L in self.lanes: L['next'] = seam; L['step'] = -1 # NEXT tick fires step 0 of the new track at seam_t
self._need_redraw = True # visuals (grid + draws) catch up on the next refresh
self._seg_start = time.monotonic() # reset the on-screen timer
self.led_rest()
# ---------- inputs ----------
def poll(self):
@ -833,14 +889,16 @@ class App:
host = bool(self.last_midi_in) and (nowms - self.last_midi_in) < 1.0
if host != self.midi_host:
self.midi_host = host
if host: self.buz.duty_cycle = 0 # silence the buzzer when the computer takes over
if host and SPEAKER_AUTO_MUTE: self.spk.duty_cycle = 0 # auto-mute when the computer takes over
self.led_rest(); self.draw_icons()
uc = bool(getattr(supervisor.runtime, "usb_connected", True)) # connected to a computer?
if uc != self.usb_conn:
self.usb_conn = uc; self.draw_icons()
# ---------- drawing ----------
def draw_bpm(self):
def draw_bpm(self): # lazy: skip the bitmap alloc if the displayed integer is unchanged
if self.bpm == self._displayed_bpm: return
self._displayed_bpm = self.bpm
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
def draw_status(self): # set-list tab (tap=switch) + CONT toggle, above the item title
sl = self.setlists[self.sl]
@ -1068,9 +1126,13 @@ class App:
except OSError: committed = True
while True:
self.tick(); self.poll()
if self._need_redraw: # post-seam: visuals catch up AFTER the audio swap
self._need_redraw = False
self.draw_bpm(); self.draw_status(); self.draw_train()
self.build_grid(); self.draw_log(); self.draw_meters()
tnow = time.monotonic()
if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter
self._uiNext = tnow + 0.25; self.draw_meters()
self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm() # bpm follows the continuous ramp
if not committed and tnow - boot > 5: # booted & ran fine for 5s -> confirm the update
try: os.remove("/trial")
except Exception: pass