diff --git a/pico-explorer/app.py b/pico-explorer/app.py index 49e6875..484e29a 100644 --- a/pico-explorer/app.py +++ b/pico-explorer/app.py @@ -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