PM_X-1 0.0.5: clear logo/divider overlap + simpler always-on amp

User reported the y=28 divider crossed the bottom of the V in the VARASYS
logo. The logo is actually 29 px tall (gen_assets.py blob: w=156 h=29), so
positioned at y=8 it ran to y=37 - the divider sat ~halfway through the V.

Fix: logo now at y=4 (top-margin 4 px), divider at y=34 (~1 px clearance
below the logo bottom at y=33). Everything below the divider shifted down
6 px: BPM/time y=44, bar y=56, train y=64, tab y=78, title y=94, GRID_TOP=116,
LOG_TOP=224 with 5 rows (was 6).

Simpler amp control. 0.0.4's per-click _amp(True)/_amp(False) toggling was
both timing-sensitive (the brief 22 ms PWM burst would race with amp settling
time) and polarity-guessed. Replaced with: hold amp_en at the polarity-correct
"enable" value the whole time. One config flag (AMP_EN_ON_VALUE, default True)
controls polarity. If the user still hears no sound after dragging this on,
flip the flag to False and re-flash.

Also still check Settings -> Speaker. If a saved /settings.json from an earlier
build has speaker=auto, Live sync's heartbeat will silence the piezo - cycle
to "Always" with A in the Settings menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-30 23:28:09 -05:00
parent 5aac3ab172
commit 8fe9ade210

View file

@ -13,7 +13,7 @@
import board, busio, digitalio, 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.4" # firmware version (the A/B updater pushes/compares this)
APP_VERSION = "0.0.5" # firmware version (the A/B updater pushes/compares this)
DEVICE_ID = "X" # 'X' = Explorer, 'K' = 52Pi kit (per docs/livesync-protocol.md and the version reply)
try:
import rtc # set from the editor's clock SysEx so the log has real timestamps
@ -39,8 +39,9 @@ MUTE_SPEAKER = False # always silence the on-board piezo
SPEAKER_AUTO_MUTE = False # auto-mute the piezo when a MIDI host is listening. DEFAULT OFF on Explorer:
# Live sync sends a FULL heartbeat every 5s which would silence the piezo otherwise.
# Toggle to Auto in Settings if you ARE using "Device audio" in the editor.
AMP_EN_ACTIVE_HIGH = True # piezo amp enable polarity. If you HEAR sound from the piezo only when click()
# has just timed out (~22ms after a beat), flip this to False - your amp is active-low.
AMP_EN_ON_VALUE = True # digital value that ENABLES the piezo amp. The firmware just holds the pin at this
# value the whole time (no per-click toggling - too fiddly). If you HEAR NOTHING from
# the piezo, flip this to False - your board's amp is active-low.
DISPLAY_ROTATION = 270 # 0=native landscape; 90/270 = portrait. Hold the device with A/B/C
# on top: try 270 first; if upside-down try 90; 180 = flipped landscape.
@ -55,9 +56,9 @@ P_SDA, P_SCL = board.GP20, board.GP21 # QwSTEMMA (unuse
# call display.rotation = DISPLAY_ROTATION (above) to turn it portrait so the layout has the same
# shape as the PM_K-1 Kit's portrait UI but in 240x320 instead of 320x480.
WIDTH, HEIGHT = 240, 320
GRID_TOP = 110 # top of the pad grid (compact header + meters + title fit above)
GRID_TOP = 116 # top of the pad grid (header is 29-px-tall logo + clear gap = ~34 px)
MAXLANES = 4 # lanes visible on the pad grid (parser still accepts more; they just play silent)
LOG_TOP, LOG_ROWH, LOG_ROWS = 218, 14, 6 # footer practice log: rows below the grid like the Kit
LOG_TOP, LOG_ROWH, LOG_ROWS = 224, 14, 5 # footer practice log: rows below the grid like the Kit
MIN_LOG_SEC = 5 # don't log plays shorter than this
LOG_MENU_ROWS = 8 # log entries shown in the Practice-log menu screen (longer history view)
@ -342,7 +343,7 @@ class App:
self._fw = None; self._fw_n = 0; self._fw_pushing = False # chunked firmware transfer state + bus-quiet flag
self.spk = pwmio.PWMOut(P_AUDIO, frequency=1600, variable_frequency=True, duty_cycle=0)
self.amp_en = digitalio.DigitalInOut(P_AMPEN); self.amp_en.direction = digitalio.Direction.OUTPUT
self._amp(False) # amp off when no audio playing (saves power, kills hum)
self.amp_en.value = AMP_EN_ON_VALUE # hold the amp enabled the whole time; flip AMP_EN_ON_VALUE if no sound
self.spk_off = 0
# buttons - all active-low with internal pull-ups
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB); self.btnC = self._btn(P_BTNC)
@ -403,24 +404,25 @@ class App:
def _build_scene(self):
root = displayio.Group(); self.display.root_group = root
root.append(rect(0, 0, WIDTH, HEIGHT, C_BG))
# Header (y 0..28): VARASYS logo + version + (right edge) MIDI/USB badges + run dot
# Header (y 0..33): VARASYS logo is 29 px tall - position the logo with a 4 px top margin and put the
# divider at y=34 so it clears the V (the previous y=28 sat ~halfway through the V).
if LOGO:
tg, _p, lw, lh = make_glyph(LOGO, C_CYAN, C_BG); tg.x = 8; tg.y = 8; root.append(tg)
tg, _p, lw, lh = make_glyph(LOGO, C_CYAN, C_BG); tg.x = 8; tg.y = 4; root.append(tg)
lx = 8 + lw
else:
tg, w, h = make_text("VARASYS", FONT_M, C_CYAN, C_BG); tg.x = 8; tg.y = 8; root.append(tg)
lx = 8 + w
vtg, vw, vh = make_text("v" + APP_VERSION, FONT_S, C_DIM, C_BG); vtg.x = lx + 4; vtg.y = 10; root.append(vtg)
vtg, vw, vh = make_text("v" + APP_VERSION, FONT_S, C_DIM, C_BG); vtg.x = lx + 4; vtg.y = 20; root.append(vtg)
# Run dot + MIDI/USB icons packed at the right edge (replaces the Kit's hamburger; C button opens the menu)
self.run_dot_pal = displayio.Palette(1); self.run_dot_pal[0] = C_RUN_IDLE
self.run_dot = vectorio.Circle(pixel_shader=self.run_dot_pal, radius=4, x=WIDTH - 12, y=14)
self.run_dot = vectorio.Circle(pixel_shader=self.run_dot_pal, radius=4, x=WIDTH - 12, y=18)
root.append(self.run_dot)
x = WIDTH - 22 # icons live to the LEFT of the run dot
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 -= 6
tg, pal, w, h = make_glyph(asset, C_DIM, C_BG); x -= w; tg.x = x; tg.y = 6; x -= 6
root.append(tg); setattr(self, attr, pal)
root.append(rect(0, 28, WIDTH, 1, C_PANEL)) # header divider
root.append(rect(0, 34, WIDTH, 1, C_PANEL)) # header divider clears the 29-px logo
# Dynamic groups - layout order matches the Kit (BPM big at top-right + meters left;
# then ramp/trainer indicators; then setlist tab + CONT; then track title; then pad grid).
self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big BPM (right, y ~44)
@ -564,7 +566,7 @@ class App:
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._amp(False)
if MUTE_SPEAKER: self.spk.duty_cycle = 0
self._save_settings(); self._draw_settings()
def _adj_midi_out(self, d):
global MIDI_ENABLED
@ -713,13 +715,10 @@ class App:
self._seg_start = time.monotonic()
# ---------- audio + run-state indicator ----------
def _amp(self, on): # respect AMP_EN_ACTIVE_HIGH (flip in CONFIG if your amp is active-low)
self.amp_en.value = on if AMP_EN_ACTIVE_HIGH else not on
def click(self, level):
self._amp(True) # enable the amp briefly while we drive the piezo
def click(self, level): # amp is held enabled at boot; click just drives PWM
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 # silence + amp off scheduled in tick()
self.spk_off = time.monotonic_ns() + 22_000_000 # PWM is silenced after 22ms by tick()
def _set_run_dot(self):
self.run_dot_pal[0] = C_RUN_GO if self.running else C_RUN_IDLE
self.dirty = True
@ -862,7 +861,7 @@ class App:
try: self.midi.write(self._start_byte)
except Exception: pass
else:
self.spk.duty_cycle = 0; self._amp(False); self.reset_playheads(); self._log_play()
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(self._stop_byte)
except Exception: pass
@ -894,7 +893,7 @@ class App:
def tick(self):
now = time.monotonic_ns()
if self.spk_off and now >= self.spk_off:
self.spk.duty_cycle = 0; self.spk_off = 0; self._amp(False)
self.spk.duty_cycle = 0; self.spk_off = 0
if self._slaved and (now - self._clock_in_last_t) > 1_000_000_000: self._slaved = False
if self.running:
fired_best = 0; fired_prio = -1
@ -1127,7 +1126,7 @@ class App:
if host != self.midi_host:
self.midi_host = host
if host and SPEAKER_AUTO_MUTE:
self.spk.duty_cycle = 0; self._amp(False)
self.spk.duty_cycle = 0
self._set_run_dot(); self.draw_icons()
uc = bool(getattr(supervisor.runtime, "usb_connected", True))
if uc != self.usb_conn:
@ -1137,19 +1136,19 @@ class App:
def draw_bpm(self):
if self.bpm == self._displayed_bpm: return
self._displayed_bpm = self.bpm
self._place(self.g_bpm, str(self.bpm), 0, 38, C_TXT, C_BG, FONT_M, right_edge=WIDTH-8)
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_M, right_edge=WIDTH-8)
def draw_status(self):
sl = self.setlists[self.sl]
# setlist tab line at y=72; muted = built-in, cyan = your own
# setlist tab line at y=78; muted = built-in, cyan = your own
self._place(self.g_idx, "%s %d/%d" % (sl['title'][:13], self.idx + 1, len(sl['items'])),
6, 72, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S)
self._place(self.g_cont, "CONT", 0, 72, C_GREEN if self.continue_on else C_DIM, C_BG, FONT_S, right_edge=WIDTH-6)
# track title at y=88 (FONT_M; ~16 px tall, fits above GRID_TOP=110)
self._place(self.g_name, self.name[:22], 6, 88, C_TXT, C_BG, FONT_M)
6, 78, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S)
self._place(self.g_cont, "CONT", 0, 78, C_GREEN if self.continue_on else C_DIM, C_BG, FONT_S, right_edge=WIDTH-6)
# track title at y=94 (FONT_M; ~16 px tall, fits above GRID_TOP=116)
self._place(self.g_name, self.name[:22], 6, 94, C_TXT, C_BG, FONT_M)
def draw_train(self):
g = self.g_train
while len(g): g.pop()
x = 6; y = 58 # ramp / gap-trainer indicators below the meters row, above the setlist tab
x = 6; y = 64 # ramp / gap-trainer indicators below the meters row, above the setlist tab
if self.ramp:
up = self.ramp['amt'] >= 0
pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)]
@ -1184,9 +1183,9 @@ class App:
else:
ts = self._fmt_t(el); bs = "bar %s" % cur
if ts != self._lastTs:
self._place(self.g_time, ts, 6, 38, C_TXT, C_BG, FONT_S); self._lastTs = ts
self._place(self.g_time, ts, 6, 44, C_TXT, C_BG, FONT_S); self._lastTs = ts
if bs != self._lastBs:
self._place(self.g_bar, bs, 6, 50, C_MUTE, C_BG, FONT_S); self._lastBs = bs
self._place(self.g_bar, bs, 6, 56, C_MUTE, C_BG, FONT_S); self._lastBs = bs
# ---------- pad grid (chunked rebuild; per-pad chunks so audio interleaves) ----------
def _padbase(self, L, s):
@ -1346,7 +1345,7 @@ class App:
def _slave_stop(self):
if self.running:
self.running = False
self.spk.duty_cycle = 0; self._amp(False)
self.spk.duty_cycle = 0
self.reset_playheads(); self._log_play()
self._set_run_dot(); self.draw_meters()
self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False