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 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 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) DEVICE_ID = "X" # 'X' = Explorer, 'K' = 52Pi kit (per docs/livesync-protocol.md and the version reply)
try: try:
import rtc # set from the editor's clock SysEx so the log has real timestamps 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: 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. # 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. # 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() AMP_EN_ON_VALUE = True # digital value that ENABLES the piezo amp. The firmware just holds the pin at this
# has just timed out (~22ms after a beat), flip this to False - your amp is active-low. # 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 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. # 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 # 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. # shape as the PM_K-1 Kit's portrait UI but in 240x320 instead of 320x480.
WIDTH, HEIGHT = 240, 320 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) 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 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) 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._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.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_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 self.spk_off = 0
# buttons - all active-low with internal pull-ups # 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) 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): def _build_scene(self):
root = displayio.Group(); self.display.root_group = root root = displayio.Group(); self.display.root_group = root
root.append(rect(0, 0, WIDTH, HEIGHT, C_BG)) 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: 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 lx = 8 + lw
else: else:
tg, w, h = make_text("VARASYS", FONT_M, C_CYAN, C_BG); tg.x = 8; tg.y = 8; root.append(tg) tg, w, h = make_text("VARASYS", FONT_M, C_CYAN, C_BG); tg.x = 8; tg.y = 8; root.append(tg)
lx = 8 + w 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) # 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_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) root.append(self.run_dot)
x = WIDTH - 22 # icons live to the LEFT of the 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")): for asset, attr in ((ICON_USB, "ic_usb_pal"), (ICON_MIDI, "ic_midi_pal")):
if asset: 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(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; # 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). # 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) 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") cur = "off" if MUTE_SPEAKER else ("auto" if SPEAKER_AUTO_MUTE else "always")
i = (modes.index(cur) + d) % 3 i = (modes.index(cur) + d) % 3
MUTE_SPEAKER = (modes[i] == "off"); SPEAKER_AUTO_MUTE = (modes[i] == "auto") 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() self._save_settings(); self._draw_settings()
def _adj_midi_out(self, d): def _adj_midi_out(self, d):
global MIDI_ENABLED global MIDI_ENABLED
@ -713,13 +715,10 @@ class App:
self._seg_start = time.monotonic() self._seg_start = time.monotonic()
# ---------- audio + run-state indicator ---------- # ---------- audio + run-state indicator ----------
def _amp(self, on): # respect AMP_EN_ACTIVE_HIGH (flip in CONFIG if your amp is active-low) def click(self, level): # amp is held enabled at boot; click just drives PWM
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
self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600) 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.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): def _set_run_dot(self):
self.run_dot_pal[0] = C_RUN_GO if self.running else C_RUN_IDLE self.run_dot_pal[0] = C_RUN_GO if self.running else C_RUN_IDLE
self.dirty = True self.dirty = True
@ -862,7 +861,7 @@ class App:
try: self.midi.write(self._start_byte) try: self.midi.write(self._start_byte)
except Exception: pass except Exception: pass
else: 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: if MIDI_CLOCK_OUT and MIDI_CLOCK_OUT_TRANSPORT and self.midi is not None:
try: self.midi.write(self._stop_byte) try: self.midi.write(self._stop_byte)
except Exception: pass except Exception: pass
@ -894,7 +893,7 @@ class App:
def tick(self): def tick(self):
now = time.monotonic_ns() now = time.monotonic_ns()
if self.spk_off and now >= self.spk_off: 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._slaved and (now - self._clock_in_last_t) > 1_000_000_000: self._slaved = False
if self.running: if self.running:
fired_best = 0; fired_prio = -1 fired_best = 0; fired_prio = -1
@ -1127,7 +1126,7 @@ class App:
if host != self.midi_host: if host != self.midi_host:
self.midi_host = host self.midi_host = host
if host and SPEAKER_AUTO_MUTE: 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() self._set_run_dot(); self.draw_icons()
uc = bool(getattr(supervisor.runtime, "usb_connected", True)) uc = bool(getattr(supervisor.runtime, "usb_connected", True))
if uc != self.usb_conn: if uc != self.usb_conn:
@ -1137,19 +1136,19 @@ class App:
def draw_bpm(self): def draw_bpm(self):
if self.bpm == self._displayed_bpm: return if self.bpm == self._displayed_bpm: return
self._displayed_bpm = self.bpm 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): def draw_status(self):
sl = self.setlists[self.sl] 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'])), 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) 6, 78, 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) 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=88 (FONT_M; ~16 px tall, fits above GRID_TOP=110) # track title at y=94 (FONT_M; ~16 px tall, fits above GRID_TOP=116)
self._place(self.g_name, self.name[:22], 6, 88, C_TXT, C_BG, FONT_M) self._place(self.g_name, self.name[:22], 6, 94, C_TXT, C_BG, FONT_M)
def draw_train(self): def draw_train(self):
g = self.g_train g = self.g_train
while len(g): g.pop() 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: if self.ramp:
up = self.ramp['amt'] >= 0 up = self.ramp['amt'] >= 0
pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)] pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)]
@ -1184,9 +1183,9 @@ class App:
else: else:
ts = self._fmt_t(el); bs = "bar %s" % cur ts = self._fmt_t(el); bs = "bar %s" % cur
if ts != self._lastTs: 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: 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) ---------- # ---------- pad grid (chunked rebuild; per-pad chunks so audio interleaves) ----------
def _padbase(self, L, s): def _padbase(self, L, s):
@ -1346,7 +1345,7 @@ class App:
def _slave_stop(self): def _slave_stop(self):
if self.running: if self.running:
self.running = False self.running = False
self.spk.duty_cycle = 0; self._amp(False) self.spk.duty_cycle = 0
self.reset_playheads(); self._log_play() self.reset_playheads(); self._log_play()
self._set_run_dot(); self.draw_meters() self._set_run_dot(); self.draw_meters()
self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False