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:
parent
b1bb792df6
commit
fd8446658d
4 changed files with 96 additions and 34 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>VARASYS PM_K‑1 Kit — wiring, parts & firmware (Raspberry Pi Pico build)</title>
|
<title>VARASYS PM_K‑1 Kit — wiring, parts & firmware (Raspberry Pi Pico build)</title>
|
||||||
<meta name="description" content="PM_K‑1 Kit — build a touchscreen polymeter metronome from a Raspberry Pi Pico on the 52Pi EP‑0172 breadboard kit (3.5in ST7796 cap‑touch, joystick, RGB, buzzer). Pinout, parts list, and the precompiled CircuitPython firmware bundle." />
|
<meta name="description" content="PM_K‑1 Kit — build a touchscreen polymeter metronome from a Raspberry Pi Pico on the 52Pi EP‑0172 breadboard kit (3.5in ST7796 cap‑touch, 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@">
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||||
<script>
|
<script>
|
||||||
(function(){ try{ var p = localStorage.getItem("metronome.theme");
|
(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 off‑the‑shelf parts: a
|
<p>This is the first member of the family you can actually build today from off‑the‑shelf parts: a
|
||||||
<b>Raspberry Pi Pico</b> seated on the <b>52Pi EP‑0172 "Pico Breadboard Kit Plus"</b>, which carries a
|
<b>Raspberry Pi Pico</b> seated on the <b>52Pi EP‑0172 "Pico Breadboard Kit Plus"</b>, which carries a
|
||||||
3.5″ <b>ST7796</b> 320×480 capacitive‑touch screen (<b>GT911</b>), a PSP <b>joystick</b>, a <b>WS2812 RGB</b>
|
3.5″ <b>ST7796</b> 320×480 capacitive‑touch screen (<b>GT911</b>), a PSP <b>joystick</b>, a <b>WS2812 RGB</b>
|
||||||
LED, a <b>buzzer</b> and two buttons — all pre‑wired, so you don't solder anything; you just seat the Pico
|
LED, a <b>speaker</b> and two buttons — all pre‑wired, so you don't solder anything; you just seat the Pico
|
||||||
and drop the firmware bundle onto its USB drive.</p>
|
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
|
<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 USB‑MIDI (or edit on the device's touchscreen — beats,
|
groove on the site and <b>Save to device</b> over USB‑MIDI (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
|
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>
|
</section>
|
||||||
|
|
||||||
<details class="spec" open>
|
<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">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">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">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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
|
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
|
||||||
<tbody>
|
<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">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 EP‑0172 "Pico Breadboard Kit Plus" <span class="spec">— 3.5″ ST7796 cap‑touch, 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 EP‑0172 "Pico Breadboard Kit Plus" <span class="spec">— 3.5″ ST7796 cap‑touch, 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><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 (one‑off)</td><td class="q"></td><td class="c">≈ $45</td></tr>
|
<tr class="total"><td>Total (one‑off)</td><td class="q"></td><td class="c">≈ $45</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
then the set‑list <b>⋯</b> menu → <b>📟 Save to device</b>. It's pushed over USB‑MIDI and the device shows
|
then the set‑list <b>⋯</b> menu → <b>📟 Save to device</b>. It's pushed over USB‑MIDI 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>
|
<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
|
<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 USB‑MIDI, in sync; the screen shows a <b>MIDI</b> badge and the buzzer mutes.</li>
|
groove sounds through your speakers over USB‑MIDI, 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>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 USB‑MIDI, and the device A/B‑updates with automatic rollback if a build won't boot.</li>
|
<li><b>Firmware updates:</b> ⋯ menu → <b>⬆ Update firmware</b> — it checks your version, pushes the latest over USB‑MIDI, and the device A/B‑updates with automatic rollback if a build won't boot.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ It runs the same program‑string language as <https://metronome.varasys.io>. Th
|
||||||
bricked (BOOTSEL → drag a MicroPython `.uf2` back).
|
bricked (BOOTSEL → drag a MicroPython `.uf2` back).
|
||||||
|
|
||||||
What it does: drives the 3.5″ touchscreen with a lanes/pads display + anti‑aliased text, plays the
|
What it does: drives the 3.5″ touchscreen with a lanes/pads display + anti‑aliased 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 USB‑MIDI**, and plays through your **computer's speakers** over USB‑MIDI.
|
from the web editor over USB‑MIDI**, and plays through your **computer's speakers** over USB‑MIDI.
|
||||||
|
|
||||||
## Two power‑on modes (set by `boot.py`)
|
## Two power‑on modes (set by `boot.py`)
|
||||||
|
|
@ -58,7 +58,7 @@ rare. And the Pico is unbrickable as the ultimate backstop.)
|
||||||
The Pico is a USB‑MIDI device and sends a note per click (GM drum note per lane, velocity by accent).
|
The Pico is a USB‑MIDI 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
|
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
|
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 auto‑mutes** (the computer plays instead).
|
listening the screen shows a green **MIDI** badge and the **speaker auto‑mutes** (the computer plays instead).
|
||||||
The editor also syncs the device clock, so the practice log gets real wall‑clock timestamps.
|
The editor also syncs the device clock, so the practice log gets real wall‑clock timestamps.
|
||||||
|
|
||||||
## Playlists, editing & Continue
|
## Playlists, editing & Continue
|
||||||
|
|
@ -88,7 +88,7 @@ The editor also syncs the device clock, so the practice log gets real wall‑clo
|
||||||
- The firmware **performs** ramps (tempo steps every N bars) and gap-trainer cycles (silent rest bars).
|
- 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)
|
- **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.**
|
— 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 power‑cycles.
|
- The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives power‑cycles.
|
||||||
|
|
||||||
## programs.json
|
## 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
|
- **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`.
|
`TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y`.
|
||||||
- **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_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).
|
- **LED too bright/dim:** `LED_BRIGHTNESS` (0..1, default 0.15).
|
||||||
- **Screen tearing:** SPI panels have no tearing‑effect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast
|
- **Screen tearing:** SPI panels have no tearing‑effect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast
|
||||||
to minimise it — lower only if unstable.
|
to minimise it — lower only if unstable.
|
||||||
|
|
|
||||||
Binary file not shown.
110
pico-cp/app.py
110
pico-cp/app.py
|
|
@ -1,6 +1,6 @@
|
||||||
# VARASYS PolyMeter - PM_K-1 "Kit" firmware (CircuitPython edition)
|
# 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":
|
# 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
|
# 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
|
# 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
|
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
|
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:
|
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
|
||||||
except ImportError:
|
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
|
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)
|
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")
|
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
|
WIDTH, HEIGHT = 320, 480
|
||||||
MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed.
|
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
|
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) -----
|
# ----- 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_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_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
|
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
|
# ----- 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._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler (clock + pushed programs)
|
||||||
self._fw = None # chunked firmware transfer: staging file handle
|
self._fw = None # chunked firmware transfer: staging file handle
|
||||||
self.led = RGB(P_RGB)
|
self.led = RGB(P_RGB)
|
||||||
self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0)
|
self.spk = pwmio.PWMOut(P_SPK, frequency=1600, variable_frequency=True, duty_cycle=0)
|
||||||
self.buz_off = 0
|
self.spk_off = 0
|
||||||
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
|
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
|
||||||
self._aPrev = True; self._bPrev = True
|
self._aPrev = True; self._bPrev = True
|
||||||
self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY)
|
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.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._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.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.sl = 0; self.rebuild_setlists() # built-in playlists (baked) + user playlists (programs.json)
|
||||||
self.dirty = True
|
self.dirty = True
|
||||||
self.pad_pal = displayio.Palette(8) # 0-3 idle levels (mute/normal/accent/ghost), 4-7 the lit playhead
|
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.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.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._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
|
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._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train()
|
||||||
self.build_grid(); self.draw_log()
|
self.build_grid(); self.draw_log()
|
||||||
|
|
@ -706,9 +713,9 @@ class App:
|
||||||
|
|
||||||
# ---------- audio + light ----------
|
# ---------- audio + light ----------
|
||||||
def click(self, level):
|
def click(self, level):
|
||||||
self.buz.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
|
self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
|
||||||
self.buz.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.buz_off = time.monotonic_ns() + 22_000_000
|
self.spk_off = time.monotonic_ns() + 22_000_000
|
||||||
def _led_base(self):
|
def _led_base(self):
|
||||||
return LED_RUN if self.running else LED_IDLE # dim red while playing / dim green when stopped
|
return LED_RUN if self.running else LED_IDLE # dim red while playing / dim green when stopped
|
||||||
def flash(self, level):
|
def flash(self, level):
|
||||||
|
|
@ -719,14 +726,23 @@ class App:
|
||||||
self.led.set(*self.rgb)
|
self.led.set(*self.rgb)
|
||||||
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
|
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
|
||||||
if self.midi is None: return
|
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
|
except Exception: pass
|
||||||
|
|
||||||
# ---------- transport ----------
|
# ---------- transport ----------
|
||||||
def toggle(self):
|
def toggle(self):
|
||||||
self.running = not self.running
|
self.running = not self.running
|
||||||
if self.running: self._reset_clock(); self._start_play()
|
if self.running:
|
||||||
else: self.buz.duty_cycle = 0; self.reset_playheads(); self._log_play()
|
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
|
self.led_rest(); self.draw_meters() # LED shows run state: red running / green stopped
|
||||||
def set_bpm(self, v):
|
def set_bpm(self, v):
|
||||||
v = max(30, min(300, v))
|
v = max(30, min(300, v))
|
||||||
|
|
@ -751,17 +767,25 @@ class App:
|
||||||
# ---------- scheduler ----------
|
# ---------- scheduler ----------
|
||||||
def tick(self):
|
def tick(self):
|
||||||
now = time.monotonic_ns()
|
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:
|
if self.running:
|
||||||
fired = []
|
fired = []
|
||||||
for li, L in enumerate(self.lanes):
|
for li, L in enumerate(self.lanes):
|
||||||
|
if self._advance: break # seam armed - skip remaining lanes for THIS tick
|
||||||
adv = False
|
adv = False
|
||||||
while now >= L['next']:
|
while now >= L['next']:
|
||||||
L['step'] = (L['step'] + 1) % L['steps']
|
L['step'] = (L['step'] + 1) % L['steps']
|
||||||
if li == 0:
|
if li == 0:
|
||||||
self._m_steps += 1 # count master-lane steps -> bars
|
self._m_steps += 1 # count master-lane steps -> bars
|
||||||
nb = self._m_steps // L['steps']
|
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']]
|
lvl = 0 if L['mute'] else L['levels'][L['step']]
|
||||||
if lvl > 0:
|
if lvl > 0:
|
||||||
fired.append(lvl)
|
fired.append(lvl)
|
||||||
|
|
@ -771,7 +795,8 @@ class App:
|
||||||
if adv and li < len(self.lane_pads): self._move_playhead(li, L['step'])
|
if adv and li < len(self.lane_pads): self._move_playhead(li, L['step'])
|
||||||
if fired and not self._muted:
|
if fired and not self._muted:
|
||||||
best = max(fired, key=lambda l: PRIO.get(l, 0))
|
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)
|
self.flash(best)
|
||||||
base = self._led_base() # decay the beat pulse back down to the red running base
|
base = self._led_base() # decay the beat pulse back down to the red running base
|
||||||
if self.rgb != base:
|
if self.rgb != base:
|
||||||
|
|
@ -780,18 +805,49 @@ class App:
|
||||||
b = base[2] + (self.rgb[2]-base[2])*7//10
|
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
|
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)
|
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._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):
|
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
|
if self.bars and bar > 0 and bar % self.bars == 0: # segment boundary
|
||||||
self._seg_start = time.monotonic() # timer resets with the bar counter
|
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 and self._next_pending is not None:
|
||||||
if self.continue_on: self._advance = True # Continue: roll to the next item
|
self._seam_t = self.lanes[0]['next'] # the wall-clock time of THIS boundary step
|
||||||
elif self.ramp and bar > 0 and bar % self.ramp['every'] == 0:
|
self._advance = True # tick() will swap to the prepared track
|
||||||
self.set_bpm(self.bpm + self.ramp['amt']) # mid-segment ramp step
|
# 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
|
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'])
|
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 ----------
|
# ---------- inputs ----------
|
||||||
def poll(self):
|
def poll(self):
|
||||||
|
|
@ -833,14 +889,16 @@ class App:
|
||||||
host = bool(self.last_midi_in) and (nowms - self.last_midi_in) < 1.0
|
host = bool(self.last_midi_in) and (nowms - self.last_midi_in) < 1.0
|
||||||
if host != self.midi_host:
|
if host != self.midi_host:
|
||||||
self.midi_host = 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()
|
self.led_rest(); self.draw_icons()
|
||||||
uc = bool(getattr(supervisor.runtime, "usb_connected", True)) # connected to a computer?
|
uc = bool(getattr(supervisor.runtime, "usb_connected", True)) # connected to a computer?
|
||||||
if uc != self.usb_conn:
|
if uc != self.usb_conn:
|
||||||
self.usb_conn = uc; self.draw_icons()
|
self.usb_conn = uc; self.draw_icons()
|
||||||
|
|
||||||
# ---------- drawing ----------
|
# ---------- 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)
|
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
|
def draw_status(self): # set-list tab (tap=switch) + CONT toggle, above the item title
|
||||||
sl = self.setlists[self.sl]
|
sl = self.setlists[self.sl]
|
||||||
|
|
@ -1068,9 +1126,13 @@ class App:
|
||||||
except OSError: committed = True
|
except OSError: committed = True
|
||||||
while True:
|
while True:
|
||||||
self.tick(); self.poll()
|
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()
|
tnow = time.monotonic()
|
||||||
if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter
|
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
|
if not committed and tnow - boot > 5: # booted & ran fine for 5s -> confirm the update
|
||||||
try: os.remove("/trial")
|
try: os.remove("/trial")
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue