diff --git a/build.sh b/build.sh
index 1658c22..b53dba2 100755
--- a/build.sh
+++ b/build.sh
@@ -29,10 +29,12 @@ def build(name):
out.write_text(src)
return out.stat().st_size
-for name in ("index.html","editor.html","player.html","teacher.html","stage.html","micro.html","showcase.html",
+for name in ("index.html","editor.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html",
"embed.html",
- "info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html"):
+ "info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html"):
print("built %s (%dKB)" % (name, build(name) // 1024))
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
print("copied embed.js")
+pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_text()) # PM_K-1 firmware, downloadable
+print("copied pico-main.py")
PY
diff --git a/deploy.sh b/deploy.sh
index 918bb7d..bed9532 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -40,13 +40,14 @@ fi
# stamp the version into the built copy only (source stays clean)
echo "deployed v$BUILD -> $DEST_DIR"
-for f in index.html editor.html player.html teacher.html stage.html micro.html showcase.html \
+for f in index.html editor.html player.html teacher.html stage.html micro.html showcase.html kit.html \
embed.html \
- info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html; do
+ info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
done
cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)"
+cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download
rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/)
# info-*.html are first-class pages again: each form factor has a lean widget page
diff --git a/embed.html b/embed.html
index af48772..04f8c28 100644
--- a/embed.html
+++ b/embed.html
@@ -104,6 +104,7 @@ const ORIGIN = "https://metronome.varasys.io";
const DEMO_PATCH = "v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2";
const FF = [
{ k:"editor", name:"PM_E‑1 Editor", file:"editor.html", h:560 },
+ { k:"kit", name:"PM_K‑1 Kit", file:"kit.html", h:560 },
{ k:"teacher", name:"PM_T‑1 Teacher", file:"teacher.html", h:440 },
{ k:"stage", name:"PM_S‑1 Stage", file:"stage.html", h:430 },
{ k:"micro", name:"PM_P‑1 Practice", file:"micro.html", h:240 },
diff --git a/index.html b/index.html
index c934cc0..20fe592 100644
--- a/index.html
+++ b/index.html
@@ -138,6 +138,7 @@ const SAMPLES = {}; let state = { bpm:120, volume:0.85 }, meters = [], muteWindo
const VERSIONS = [
{ key:"editor", file:"/editor.html", name:"PM_E‑1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, per‑step accents/ghosts/mutes, swing & polyrhythm, set lists, per‑lane dB gain." },
+ { key:"kit", file:"/kit.html", name:"PM_K‑1 Kit", chip:"hw", h:560, sum:"Build it today — a Raspberry Pi Pico on the 52Pi touchscreen kit; tap the 3.5″ screen, joystick tempo, RGB beat light, buzzer. MicroPython firmware included." },
{ key:"teacher", file:"/teacher.html", name:"PM_T‑1 Teacher", chip:"hw", h:440, sum:"Studio / lesson desk console — colour TFT of every lane, arcade buttons, instrument pass‑through." },
{ key:"stage", file:"/stage.html", name:"PM_S‑1 Stage", chip:"hw", h:430, sum:"Live foot pedal — two footswitches, expression‑pedal tempo, a big floor‑readable RGB beat light." },
{ key:"micro", file:"/micro.html", name:"PM_P‑1 Practice", chip:"hw", h:240, sum:"Inline practice bar — clickable thumb‑roller, amber 14‑segment, instrument in/out pass‑through." },
diff --git a/info-kit.html b/info-kit.html
new file mode 100644
index 0000000..5ca1d0a
--- /dev/null
+++ b/info-kit.html
@@ -0,0 +1,140 @@
+
+
+
+
+
+VARASYS PM_K‑1 Kit — wiring, parts & firmware (Raspberry Pi Pico build)
+
+
+
+
+
+
+
+/*@BUILD:include:src/header.html@*/
+
+
+
+ PM_K‑1 Kit
+ Build it yourself: a Raspberry Pi Pico on the 52Pi breadboard kit becomes a touchscreen polymeter metronome — same engine, same program strings, with MicroPython firmware you flash in two minutes.
+
+
+ /*@BUILD:include:src/infoembed.html@*/
+
+
+ What it is
+ Buildable now Raspberry Pi Pico 52Pi EP‑0172 kit ~$45 incl. Pico
+ This is the first member of the family you can actually build today from off‑the‑shelf parts: a
+ Raspberry Pi Pico seated on the 52Pi EP‑0172 "Pico Breadboard Kit Plus" , which carries a
+ 3.5″ ST7796 320×480 capacitive‑touch screen (GT911 ), a PSP joystick , a WS2812 RGB
+ LED, a buzzer and two buttons — all pre‑wired, so you don't solder anything; you just seat the Pico
+ and copy one file onto it.
+ It runs the same polymeter engine and the same program strings as the web editor: design a
+ groove on the site, copy its program string into the firmware's PROGRAMS list, and it plays on
+ the device. Tap the screen, nudge tempo with the joystick; the RGB flashes each beat (amber accent / cyan
+ normal / violet ghost) and the buzzer clicks. Powered over the Pico's USB.
+
+
+
+ Wiring — the EP‑0172 fixed pinout (Raspberry Pi Pico)
+
+
Everything is wired on the board; this is just what the firmware drives. No breadboarding required.
+
+ Component Raspberry Pi Pico pins
+
+ Display — 3.5″ ST7796, 320×480 (SPI0)
+ SCK / MOSI GP2 / GP3
+ CS / DC / RST GP5 / GP6 / GP7
+ Touch — GT911 capacitive (I2C0)
+ SDA / SCL — addr 0x5D GP8 / GP9
+ Controls & feedback
+ PSP joystick X / Y ADC0 (GP26) / ADC1 (GP27)
+ Button A (play/stop) / Button B (tap) GP15 / GP14
+ WS2812 RGB LED GP12
+ Buzzer GP13
+
+
+
+
+
+
+ Parts
+
+
An off‑the‑shelf kit, not a custom board — ballpark one‑off prices (USD).
+
+ Part Qty ~$
+
+ Raspberry Pi Pico (or Pico W / Pico 2) — the brain 1 5
+ 52Pi EP‑0172 "Pico Breadboard Kit Plus" — 3.5″ ST7796 cap‑touch, GT911, PSP joystick, WS2812 RGB, buzzer, 2 buttons, breadboard, acrylic panel 1 38
+ USB cable — power + flashing 1 2
+ Total (one‑off) ≈ $45
+
+
+
Reference: 52Pi EP‑0172 wiki
+ · vendor code . Lots may ship the screen as ST7796 (320×480) — this build targets that.
+
+
+
+
+ Firmware — flash it in two minutes
+
+
+ Download main.py ↓
+ Source + README ↗
+
+
+ Install MicroPython : hold BOOTSEL , plug the Pico into USB, and drop the MicroPython
+ .uf2 onto the RPI‑RP2 drive
+ (Pico /
+ Pico 2 ).
+ Copy main.py onto the Pico as main.py (in
+ Thonny : File ▸ Save as ▸ Raspberry Pi Pico ,
+ or mpremote cp main.py :main.py).
+ Reset — it boots straight into the metronome.
+ Add your own grooves by pasting program strings from the editor into the PROGRAMS list at the
+ top of main.py. If colours, touch, or the joystick look off, flip a flag in the
+ CONFIG block (see the README's calibration notes).
+
+
It's one self‑contained file — the ST7796 driver, GT911 touch, WS2812 RGB, buzzer and the
+ polymeter engine, no external libraries.
+
+
+
+Embed this widget elsewhere with one <div> + a script —
+ see the embed docs .
+
+
+/*@BUILD:include:src/footer.html@*/
+
+
+
+
diff --git a/kit.html b/kit.html
new file mode 100644
index 0000000..8f715b3
--- /dev/null
+++ b/kit.html
@@ -0,0 +1,308 @@
+
+
+
+
+
+VARASYS PM_K‑1 — Kit (Raspberry Pi Pico touchscreen build)
+
+
+
+
+
+
+
+
+/*@BUILD:include:src/header.html@*/
+
+PM_K‑1 Kit
+The build‑it‑yourself touchscreen unit — a Raspberry Pi Pico on the 52Pi breadboard kit (3.5″ cap‑touch, joystick, RGB, buzzer). Tap the screen, nudge tempo with the stick; runs the same program strings, with MicroPython firmware you flash yourself.
+
+
+
+
PM_K‑1 Kit
+
Pico · USB‑C
+
+
+
+
+
+
+
+Tap the on‑screen buttons (the real unit is capacitive touch). The joystick sets tempo (up/down)
+ and switches grooves (left/right); A = play/stop, B = tap. The RGB LED flashes each beat and the buzzer clicks.
+
+/*@BUILD:include:src/progbox.html@*/
+
+Wiring, parts & firmware to flash →
+
+
+
+/*@BUILD:include:src/footer.html@*/
+
+
diff --git a/pico/README.md b/pico/README.md
new file mode 100644
index 0000000..fe95f2f
--- /dev/null
+++ b/pico/README.md
@@ -0,0 +1,76 @@
+# PM_K‑1 "Kit" — VARASYS PolyMeter firmware for the Raspberry Pi Pico
+
+MicroPython firmware that turns a **Raspberry Pi Pico** on the **52Pi EP‑0172 "Pico
+Breadboard Kit Plus"** into a touchscreen polymeter metronome. It runs the *same program
+strings* as — design a groove in the web editor, copy its
+program string, paste it into `PROGRAMS` in `main.py`, and it plays here.
+
+Everything is in one file: `main.py` (ST7796 display driver, GT911 touch, WS2812 RGB,
+buzzer, joystick, the polymeter engine — no external libraries).
+
+## The board (EP‑0172) — fixed pinout
+
+| Component | Pico pins |
+|---|---|
+| 3.5″ ST7796 320×480 display | SPI0 — SCK `GP2`, MOSI `GP3`, CS `GP5`, DC `GP6`, RST `GP7` |
+| GT911 capacitive touch | I2C0 — SDA `GP8`, SCL `GP9` (addr 0x5D) |
+| WS2812 RGB LED | `GP12` |
+| Buzzer | `GP13` |
+| Button A / Button B | `GP15` / `GP14` |
+| PSP joystick | X = `ADC0`/`GP26`, Y = `ADC1`/`GP27` |
+
+The components are wired on the board — you don't breadboard anything; just seat the Pico.
+
+## Flash it
+
+1. **MicroPython:** hold **BOOTSEL**, plug the Pico into USB, and copy the MicroPython UF2
+ onto the `RPI-RP2` drive that appears:
+ - Pico / Pico W →
+ - Pico 2 / Pico 2 W →
+2. **The firmware:** open `main.py` in [Thonny](https://thonny.org) (or `rshell`/`mpremote`)
+ and save it to the Pico **as `main.py`**.
+ - Thonny: open the file → **File ▸ Save as… ▸ Raspberry Pi Pico** → name it `main.py`.
+ - mpremote: `mpremote cp main.py :main.py`
+3. Reset (replug). It boots straight into the metronome.
+
+## Controls
+
+- **Touch:** on‑screen `<<` / `>||` / `>>` (prev · play/stop · next) and `−` / `TAP` / `+`.
+- **Joystick:** up/down = tempo (push far for ±5), left/right = previous/next groove.
+- **Button A (GP15):** play / stop. **Button B (GP14):** tap tempo.
+- **RGB LED** flashes each beat (amber = accent, cyan = normal, violet = ghost); the
+ **buzzer** clicks with matching pitch.
+
+## Add your own grooves
+
+Edit the `PROGRAMS` list near the top of `main.py` — each entry is `("Name", "program string")`.
+Get program strings from the web editor's program box (e.g. `v1;t120;kick:4;snare:4=.X.X;hat:4/2`).
+Supported: tempo `t`, lanes `sound:grouping[/sub][=pattern][~][!]`, pattern chars
+`X` accent · `x` normal · `g` ghost · `.` `-` `_` rest, grouped meters like `3+3+2`, polymeter `~`.
+(Per‑lane dB gain `@n` is parsed but ignored — the buzzer is mono.)
+
+## If something looks off — calibration
+
+All the knobs are flags in the `CONFIG` block at the top of `main.py`:
+
+- **Colours look negative / washed out:** toggle `INVERT_COLORS`.
+- **Red and blue swapped:** set `SWAP_RB = True`.
+- **Taps land in the wrong place:** set `TOUCH_DEBUG = True`, reset, watch the raw
+ coordinates over the USB serial (Thonny shell) as you tap the corners, then set
+ `TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y` to match.
+- **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`; widen `JOY_DEADZONE` if it drifts.
+- **Screen stays black:** the backlight is hardwired on, so this usually means the SPI init
+ didn't take — drop `SPI_BAUD` to `24_000_000` and retry.
+- **Garbled / wrong size image:** your panel lot may be a 240×320 ILI9341 instead of the
+ 320×480 ST7796. This firmware targets the ST7796 you have (you said 320×480); if a unit
+ ever ships ILI9341, set `WIDTH,HEIGHT = 240,320` and use an ILI9341 init sequence.
+
+## Notes
+
+- Audio is a single passive buzzer, so coincident lane hits play one click at the highest
+ priority (accent > normal > ghost); the RGB + screen still show the combined activity.
+- The scheduler is non‑blocking and timed off `time.ticks_us()`, so tempo stays steady while
+ the screen and inputs update.
+
+Hardware reference: [52Pi EP‑0172 wiki](https://wiki.52pi.com/index.php?title=EP-0172) ·
+[vendor code](https://github.com/geeekpi/pico_breakboard_kit). VARASYS — Simplifying Complexity.
diff --git a/pico/__pycache__/main.cpython-312.pyc b/pico/__pycache__/main.cpython-312.pyc
new file mode 100644
index 0000000..e72355c
Binary files /dev/null and b/pico/__pycache__/main.cpython-312.pyc differ
diff --git a/pico/main.py b/pico/main.py
new file mode 100644
index 0000000..1acc602
--- /dev/null
+++ b/pico/main.py
@@ -0,0 +1,492 @@
+# VARASYS PolyMeter — PM_K-1 "Kit" firmware
+# Raspberry Pi Pico (or Pico W / Pico 2) on the 52Pi EP-0172 "Pico Breadboard Kit Plus":
+# 3.5" ST7796 320x480 capacitive-touch screen (GT911), PSP joystick, WS2812 RGB, buzzer, 2 buttons.
+#
+# It runs the SAME program-string language as https://metronome.varasys.io — design a groove in
+# the web editor, copy its program string, paste it into PROGRAMS below, and it plays here.
+#
+# FLASH: 1) Hold BOOTSEL, plug in the Pico, drop the MicroPython UF2 on the RPI-RP2 drive
+# (https://micropython.org/download/RPI_PICO/ ; use RPI_PICO2 for a Pico 2).
+# 2) Copy THIS file to the Pico as main.py (Thonny: File > Save as > Raspberry Pi Pico).
+# 3) Reset. It boots straight into the metronome.
+#
+# IF SOMETHING LOOKS WRONG, flip a flag in CONFIG below (colours, inversion, touch axes) — see README.md.
+#
+# MIT-style: do whatever you like with it. VARASYS — Simplifying Complexity.
+
+from machine import Pin, SPI, I2C, ADC, PWM
+import time, framebuf
+
+try:
+ import neopixel
+except ImportError:
+ neopixel = None
+
+# ============================== CONFIG (tweak if needed) ==============================
+SPI_BAUD = 40_000_000 # 40 MHz is a safe-fast default; the vendor demo uses 62.5 MHz
+WIDTH, HEIGHT = 320, 480 # ST7796 portrait
+MADCTL = 0x48 # memory access ctrl (MX | BGR) -> portrait, 320 wide x 480 tall
+INVERT_COLORS = True # most ST7796 modules need display inversion ON; set False if colours look negative
+SWAP_RB = False # set True if red and blue are swapped
+# Touch (GT911) calibration — flip these if taps land on the wrong spot:
+TOUCH_SWAP_XY = False
+TOUCH_INVERT_X = False
+TOUCH_INVERT_Y = False
+TOUCH_DEBUG = False # True -> print raw touch coords over USB serial to calibrate
+
+# Joystick calibration:
+JOY_INVERT_X = False
+JOY_INVERT_Y = False
+JOY_DEADZONE = 9000 # of 0..65535 around centre
+
+# ----- pins (fixed by the EP-0172 board) -----
+PIN_SCK, PIN_MOSI, PIN_CS, PIN_DC, PIN_RST = 2, 3, 5, 6, 7
+PIN_SDA, PIN_SCL = 8, 9
+PIN_RGB = 12
+PIN_BUZZER = 13
+PIN_BTN_A = 15 # play / stop
+PIN_BTN_B = 14 # tap tempo
+PIN_JOY_X = 26 # ADC0
+PIN_JOY_Y = 27 # ADC1
+
+# ----- the grooves on the device (paste program strings from the web editor) -----
+PROGRAMS = [
+ ("Four on the floor", "v1;t120;kick:4;snare:4=.X.X;hat:4/2"),
+ ("Son clave 3-2", "v1;t100;clap:4=X..X..X.;kick:4"),
+ ("7/8 + 4 polymeter", "v1;t132;kick:7/2;hat:4/2~;snare:4=..X."),
+ ("Shuffle", "v1;t96;kick:4;snare:4=.X.X;hat:4/3"),
+ ("Straight click", "v1;t120;beep:4"),
+]
+
+# ============================== COLOURS ==============================
+def rgb565(r, g, b):
+ if SWAP_RB: r, b = b, r
+ v = ((b & 0xF8) << 8) | ((g & 0xFC) << 3) | (r >> 3) # packed BGR for the BGR panel
+ return bytes((v >> 8, v & 0xFF)) # MSB-first for ST7796
+
+C_BG = rgb565(6, 9, 14)
+C_PANEL = rgb565(18, 22, 30)
+C_TXT = rgb565(199, 208, 219)
+C_MUTE = rgb565(110, 122, 138)
+C_CYAN = rgb565(10, 179, 247) # VARASYS brand cyan / normal beat
+C_AMBER = rgb565(255, 155, 46) # accent
+C_VIOLET = rgb565(150, 100, 255) # ghost
+C_GREEN = rgb565(47, 224, 122) # running
+C_DIMDOT = rgb565(36, 50, 64)
+C_BTN = rgb565(28, 34, 44)
+C_BTNHI = rgb565(40, 52, 66)
+LEVEL_COL = {2: C_AMBER, 1: C_CYAN, 3: C_VIOLET, 0: C_DIMDOT}
+LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)} # WS2812 (logical r,g,b)
+
+# ============================== ST7796 DISPLAY ==============================
+class ST7796:
+ def __init__(self):
+ self.spi = SPI(0, baudrate=SPI_BAUD, polarity=0, phase=0,
+ sck=Pin(PIN_SCK), mosi=Pin(PIN_MOSI))
+ self.cs = Pin(PIN_CS, Pin.OUT, value=1)
+ self.dc = Pin(PIN_DC, Pin.OUT, value=0)
+ self.rst = Pin(PIN_RST, Pin.OUT, value=1)
+ self._chunk = bytearray(1024) # scratch for fills (512 px)
+ self.reset(); self.init()
+
+ def _cmd(self, c, data=None):
+ self.cs(0); self.dc(0); self.spi.write(bytes((c,)))
+ if data is not None:
+ self.dc(1); self.spi.write(bytes(data))
+ self.cs(1)
+
+ def reset(self):
+ self.rst(1); time.sleep_ms(20); self.rst(0); time.sleep_ms(40); self.rst(1); time.sleep_ms(150)
+
+ def init(self):
+ c = self._cmd
+ c(0x01); time.sleep_ms(120) # software reset
+ c(0x11); time.sleep_ms(120) # sleep out
+ c(0xF0, b'\xC3'); c(0xF0, b'\x96') # command set control (unlock)
+ c(0x36, bytes((MADCTL,)))
+ c(0x3A, b'\x55') # 16 bits/pixel (RGB565)
+ c(0xB4, b'\x01') # 1-dot inversion
+ c(0xB6, b'\x80\x02\x3B') # display function control
+ c(0xE8, b'\x40\x8A\x00\x00\x29\x19\xA5\x33')
+ c(0xC1, b'\x06') # power control 2
+ c(0xC2, b'\xA7') # power control 3
+ c(0xC5, b'\x18'); time.sleep_ms(120) # VCOM
+ c(0xE0, b'\xF0\x09\x0B\x06\x04\x15\x2F\x54\x42\x3C\x17\x14\x18\x1B') # +gamma
+ c(0xE1, b'\xE0\x09\x0B\x06\x04\x03\x2B\x43\x42\x3B\x16\x14\x17\x1B') # -gamma
+ c(0xF0, b'\x3C'); c(0xF0, b'\x69'); time.sleep_ms(120) # lock command set
+ c(0x21 if INVERT_COLORS else 0x20) # inversion on/off
+ c(0x29) # display on
+ time.sleep_ms(50)
+
+ def _window(self, x, y, w, h):
+ x1, y1 = x + w - 1, y + h - 1
+ self._cmd(0x2A, bytes((x >> 8, x & 0xFF, x1 >> 8, x1 & 0xFF)))
+ self._cmd(0x2B, bytes((y >> 8, y & 0xFF, y1 >> 8, y1 & 0xFF)))
+ self.cs(0); self.dc(0); self.spi.write(bytes((0x2C,))); self.dc(1) # leaves us mid-RAMWR
+
+ def fill_rect(self, x, y, w, h, color):
+ if w <= 0 or h <= 0: return
+ self._window(x, y, w, h)
+ ch = self._chunk; px = len(ch) // 2
+ for i in range(px): ch[i*2] = color[0]; ch[i*2+1] = color[1]
+ n = w * h
+ while n > 0:
+ k = px if n >= px else n
+ self.spi.write(ch if k == px else ch[:k*2]); n -= k
+ self.cs(1)
+
+ def fill(self, color): self.fill_rect(0, 0, WIDTH, HEIGHT, color)
+
+ # text via the built-in 8x8 mono font, expanded to colour and integer-scaled
+ def text(self, s, x, y, fg, bg, scale=2):
+ if not s: return
+ w8 = len(s) * 8
+ stride = w8 // 8
+ mbuf = bytearray(stride * 8)
+ mfb = framebuf.FrameBuffer(mbuf, w8, 8, framebuf.MONO_HLSB)
+ mfb.fill(0); mfb.text(s, 0, 0, 1)
+ dw = w8 * scale
+ row = bytearray(dw * 2)
+ self._window(x, y, dw, 8 * scale)
+ for r in range(8):
+ base = r * stride
+ di = 0
+ for col in range(w8):
+ bit = (mbuf[base + (col >> 3)] >> (7 - (col & 7))) & 1
+ cpx = fg if bit else bg
+ for _ in range(scale):
+ row[di] = cpx[0]; row[di+1] = cpx[1]; di += 2
+ for _ in range(scale): self.spi.write(row)
+ self.cs(1)
+
+ def text_w(self, s, scale=2): return len(s) * 8 * scale
+
+# seven-segment digit renderer (for the big BPM) — no font, just rectangles
+_SEG = { # a,b,c,d,e,f,g
+ '0': 0b1111110, '1': 0b0110000, '2': 0b1101101, '3': 0b1111001,
+ '4': 0b0110011, '5': 0b1011011, '6': 0b1011111, '7': 0b1110000,
+ '8': 0b1111111, '9': 0b1111011, ' ': 0b0000000, '-': 0b0000001,
+}
+def draw_digit(d, ch, x, y, W, H, T, on, off):
+ seg = _SEG.get(ch, 0); v = (H - 3 * T) // 2
+ rects = [
+ (x + T, y, W - 2*T, T, 6), # a top
+ (x + W - T, y + T, T, v, 5), # b top-right
+ (x + W - T, y + 2*T + v, T, v, 4), # c bottom-right
+ (x + T, y + H - T, W - 2*T, T, 3), # d bottom
+ (x, y + 2*T + v, T, v, 2), # e bottom-left
+ (x, y + T, T, v, 1), # f top-left
+ (x + T, y + T + v, W - 2*T, T, 0), # g middle
+ ]
+ for rx, ry, rw, rh, bitpos in rects:
+ d.fill_rect(rx, ry, rw, rh, on if (seg >> bitpos) & 1 else off)
+
+# ============================== GT911 TOUCH ==============================
+class GT911:
+ def __init__(self, i2c):
+ self.i2c = i2c; self.addr = None
+ found = i2c.scan()
+ for a in (0x5D, 0x14):
+ if a in found: self.addr = a; break
+ if self.addr is None and found: self.addr = found[0]
+ def read(self):
+ if self.addr is None: return None
+ try: # GT911 uses 16-bit register addresses
+ st = self.i2c.readfrom_mem(self.addr, 0x814E, 1, addrsize=16)[0]
+ except OSError:
+ return None
+ if not (st & 0x80):
+ return None
+ n = st & 0x0F
+ pt = None
+ if n >= 1:
+ b = self.i2c.readfrom_mem(self.addr, 0x8150, 4, addrsize=16)
+ tx = b[0] | (b[1] << 8); ty = b[2] | (b[3] << 8)
+ pt = self._map(tx, ty)
+ try: self.i2c.writeto_mem(self.addr, 0x814E, b'\x00', addrsize=16) # clear ready flag
+ except OSError: pass
+ return pt
+ def _map(self, tx, ty):
+ if TOUCH_DEBUG: print("touch raw", tx, ty)
+ if TOUCH_SWAP_XY: tx, ty = ty, tx
+ if TOUCH_INVERT_X: tx = WIDTH - 1 - tx
+ if TOUCH_INVERT_Y: ty = HEIGHT - 1 - ty
+ if 0 <= tx < WIDTH and 0 <= ty < HEIGHT: return (tx, ty)
+ return None
+
+# ============================== POLYMETER ENGINE ==============================
+# program string: v1;t;[vol];[cd];[b];;;...
+# lane = :[/[s]][=pattern][@db][~][!]
+# pattern chars: X=accent(2) x=normal(1) g=ghost(3) . - _ =mute(0)
+PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
+PRIO = {2: 3, 1: 2, 3: 1} # click priority when lanes coincide: accent > normal > ghost
+
+def parse_program(s):
+ bpm = 120; lanes = []
+ for tok in s.strip().split(';'):
+ tok = tok.strip()
+ if not tok: continue
+ if tok[0] == 't' and tok[1:].isdigit():
+ bpm = int(tok[1:]); continue
+ if ':' not in tok: # skip v1, vol, cd, b and other globals we don't need on-device
+ continue
+ lane = _parse_lane(tok)
+ if lane: lanes.append(lane)
+ if not lanes: lanes = [_parse_lane("beep:4")]
+ return max(30, min(300, bpm)), lanes
+
+def _parse_lane(tok):
+ poly = '~' in tok
+ mute = '!' in tok
+ tok = tok.replace('~', '').replace('!', '')
+ db = 0
+ if '@' in tok:
+ tok, _, rest = tok.partition('@')
+ try: db = int(rest)
+ except: db = 0
+ sound, _, rest = tok.partition(':')
+ pattern = None
+ if '=' in rest:
+ rest, _, pattern = rest.partition('=')
+ sub = 1
+ if '/' in rest:
+ rest, _, sd = rest.partition('/')
+ sd = sd.rstrip('s') # ignore swing flag on-device
+ sub = int(sd) if sd.isdigit() else 1
+ # grouping: "4" or "3+3+2"
+ groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4]
+ beats = sum(groups)
+ starts = set(); acc = 0
+ for g in groups: starts.add(acc); acc += g
+ steps = beats * sub
+ if pattern:
+ levels = [PAT.get(c, 0) for c in pattern]
+ if len(levels) < steps: levels += [0] * (steps - len(levels))
+ steps = len(levels)
+ else:
+ levels = []
+ for i in range(steps):
+ if i % sub == 0:
+ levels.append(2 if (i // sub) in starts else 1)
+ else:
+ levels.append(0)
+ return {'sound': sound, 'sub': sub, 'steps': steps, 'levels': levels,
+ 'poly': poly, 'mute': mute, 'db': db}
+
+# ============================== APP ==============================
+class App:
+ def __init__(self):
+ self.d = ST7796()
+ self.i2c = I2C(0, sda=Pin(PIN_SDA), scl=Pin(PIN_SCL), freq=100_000)
+ self.touch = GT911(self.i2c)
+ self.np = neopixel.NeoPixel(Pin(PIN_RGB), 1) if neopixel else None
+ self.buz = PWM(Pin(PIN_BUZZER)); self.buz.duty_u16(0)
+ self.buz_off = 0
+ self.btnA = Pin(PIN_BTN_A, Pin.IN, Pin.PULL_UP)
+ self.btnB = Pin(PIN_BTN_B, Pin.IN, Pin.PULL_UP)
+ self._aPrev = 1; self._bPrev = 1
+ self.jx = ADC(PIN_JOY_X); self.jy = ADC(PIN_JOY_Y)
+ self._joyNext = 0
+ self._touchLock = 0; self._unpressAt = 0; self._pending = None
+ self.running = False
+ self.bpm = 120
+ self.idx = 0
+ self.lanes = []
+ self.rgb = (0, 0, 0)
+ self.buttons = [] # touch hit zones: (x,y,w,h,key)
+ self.load(0)
+ self.draw_static()
+ self.draw_bpm(force=True)
+ self.draw_status()
+ self.draw_dots(force=True)
+
+ # ---------- program ----------
+ def load(self, i):
+ n = len(PROGRAMS); self.idx = i % n
+ name, prog = PROGRAMS[self.idx]
+ self.name = name
+ self.bpm, self.lanes = parse_program(prog)
+ self.master = self.lanes[0]
+ self.beat = -1
+ self._reset_clock()
+
+ def _reset_clock(self):
+ now = time.ticks_us()
+ for L in self.lanes:
+ L['next'] = now
+ L['step'] = -1
+ L['stepdur'] = int(60_000_000 / self.bpm / L['sub'])
+
+ # ---------- audio + light ----------
+ def click(self, level):
+ f = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
+ duty = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000)
+ self.buz.freq(f); self.buz.duty_u16(duty)
+ self.buz_off = time.ticks_add(time.ticks_us(), 22000) # 22 ms
+ def flash(self, level):
+ self.rgb = LEVEL_RGB.get(level, (0, 150, 255))
+ if self.np: self.np[0] = self.rgb; self.np.write()
+
+ # ---------- transport ----------
+ def toggle(self):
+ self.running = not self.running
+ if self.running: self._reset_clock(); self.beat = -1
+ else:
+ self.buz.duty_u16(0)
+ if self.np: self.np[0] = (0, 0, 0); self.np.write()
+ self.draw_status(); self.draw_dots(force=True)
+ def set_bpm(self, v):
+ v = max(30, min(300, v))
+ if v != self.bpm:
+ self.bpm = v
+ for L in self.lanes: L['stepdur'] = int(60_000_000 / self.bpm / L['sub'])
+ self.draw_bpm()
+ def goto(self, i):
+ was = self.running; self.load(i)
+ self.draw_bpm(force=True); self.draw_status(); self.draw_dots(force=True)
+ if was: self.running = True; self._reset_clock(); self.beat = -1
+ def tap(self):
+ now = time.ticks_ms()
+ if not hasattr(self, '_taps'): self._taps = []
+ self._taps = [t for t in self._taps if time.ticks_diff(now, t) < 2400]
+ self._taps.append(now)
+ if len(self._taps) >= 2:
+ span = time.ticks_diff(self._taps[-1], self._taps[0]) / (len(self._taps) - 1)
+ if span > 0: self.set_bpm(round(60000 / span))
+
+ # ---------- scheduler (call often) ----------
+ def tick(self):
+ now = time.ticks_us()
+ if self.buz_off and time.ticks_diff(now, self.buz_off) >= 0:
+ self.buz.duty_u16(0); self.buz_off = 0
+ if self.running:
+ fired = []; beat_hit = False
+ for L in self.lanes:
+ while time.ticks_diff(now, L['next']) >= 0:
+ L['step'] = (L['step'] + 1) % L['steps']
+ lvl = 0 if L['mute'] else L['levels'][L['step']]
+ if lvl > 0: fired.append(lvl)
+ if L is self.master and L['step'] % L['sub'] == 0:
+ beat_hit = True
+ L['next'] = time.ticks_add(L['next'], L['stepdur'])
+ if fired:
+ best = max(fired, key=lambda l: PRIO.get(l, 0)) # accent > normal > ghost
+ self.click(best); self.flash(best)
+ if beat_hit:
+ self.beat = (self.master['step'] // self.master['sub'])
+ self.draw_dots()
+ # fade the RGB between beats
+ if self.rgb != (0, 0, 0):
+ r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10
+ self.rgb = (r, g, b) if (r + g + b) > 12 else (0, 0, 0)
+ if self.np: self.np[0] = self.rgb; self.np.write()
+
+ # ---------- inputs ----------
+ def poll(self):
+ a = self.btnA.value()
+ if a == 0 and self._aPrev == 1: self.toggle()
+ self._aPrev = a
+ b = self.btnB.value()
+ if b == 0 and self._bPrev == 1: self.tap()
+ self._bPrev = b
+ # joystick: up/down = tempo, left/right = prev/next item (with repeat)
+ now = time.ticks_ms()
+ if time.ticks_diff(now, self._joyNext) >= 0:
+ x = self.jx.read_u16() - 32768; y = self.jy.read_u16() - 32768
+ if JOY_INVERT_X: x = -x
+ if JOY_INVERT_Y: y = -y
+ acted = False
+ if abs(y) > JOY_DEADZONE:
+ self.set_bpm(self.bpm + (1 if y > 0 else -1) * (5 if abs(y) > 26000 else 1)); acted = True
+ elif abs(x) > JOY_DEADZONE:
+ self.goto(self.idx + (1 if x > 0 else -1)); acted = True
+ self._joyNext = time.ticks_add(now, 350); return
+ self._joyNext = time.ticks_add(now, 70 if acted else 20)
+ # touch — non-blocking: redraw a pressed button after its hold, debounce repeats
+ if self._unpressAt and time.ticks_diff(now, self._unpressAt) >= 0:
+ x, y, w, h, key = self._pending; self._draw_button(x, y, w, h, key)
+ self._unpressAt = 0
+ if time.ticks_diff(now, self._touchLock) >= 0:
+ pt = self.touch.read()
+ if pt: self.hit(pt[0], pt[1])
+
+ def hit(self, x, y):
+ for bx, by, bw, bh, key in self.buttons:
+ if bx <= x <= bx+bw and by <= y <= by+bh:
+ self.d.fill_rect(bx, by, bw, bh, C_BTNHI) # pressed flash
+ if key == 'play': self.toggle()
+ elif key == 'prev': self.goto(self.idx - 1)
+ elif key == 'next': self.goto(self.idx + 1)
+ elif key == 'minus': self.set_bpm(self.bpm - 1)
+ elif key == 'plus': self.set_bpm(self.bpm + 1)
+ elif key == 'tap': self.tap()
+ self._pending = (bx, by, bw, bh, key)
+ self._unpressAt = time.ticks_add(time.ticks_ms(), 120)
+ self._touchLock = time.ticks_add(time.ticks_ms(), 280) # ignore held finger
+ return
+
+ # ---------- drawing ----------
+ def draw_static(self):
+ d = self.d; d.fill(C_BG)
+ d.text("VARASYS", 12, 12, C_CYAN, C_BG, 2)
+ d.text("PM_K-1 KIT", WIDTH - d.text_w("PM_K-1 KIT", 1) - 12, 16, C_MUTE, C_BG, 1)
+ d.fill_rect(0, 34, WIDTH, 2, C_PANEL)
+ d.text("BPM", 12, 196, C_MUTE, C_BG, 2)
+ # build + paint the touch buttons
+ self.buttons = []
+ row1 = 300; bw = 96; bh = 54; gap = (WIDTH - 3*bw) // 4
+ xs = [gap, gap*2 + bw, gap*3 + bw*2]
+ for x, key in zip(xs, ('prev', 'play', 'next')):
+ self.buttons.append((x, row1, bw, bh, key)); self._draw_button(x, row1, bw, bh, key)
+ row2 = row1 + bh + 16
+ for x, key in zip(xs, ('minus', 'tap', 'plus')):
+ self.buttons.append((x, row2, bw, bh, key)); self._draw_button(x, row2, bw, bh, key)
+ d.text("joystick: tempo / item button A: play B: tap", 12, HEIGHT - 20, C_MUTE, C_BG, 1)
+
+ def _draw_button(self, x, y, w, h, key):
+ d = self.d; d.fill_rect(x, y, w, h, C_BTN)
+ d.fill_rect(x, y, w, 2, C_PANEL); d.fill_rect(x, y+h-2, w, 2, C_PANEL)
+ label = {'prev':'<<','play':'>||','next':'>>','minus':'-','plus':'+','tap':'TAP'}[key]
+ col = C_GREEN if key == 'play' else C_TXT
+ sc = 3 if key in ('minus','plus') else 2
+ tw = d.text_w(label, sc)
+ d.text(label, x + (w - tw)//2, y + (h - 8*sc)//2, col, C_BTN, sc)
+
+ def draw_bpm(self, force=False):
+ d = self.d
+ s = "%3d" % self.bpm
+ W = 64; H = 96; T = 12; gap = 12; x0 = WIDTH - 12 - (3*W + 2*gap); y0 = 92
+ for i, ch in enumerate(s):
+ draw_digit(d, ch, x0 + i*(W+gap), y0, W, H, T, C_TXT, C_BG)
+
+ def draw_status(self):
+ d = self.d
+ d.fill_rect(0, 240, WIDTH, 40, C_BG)
+ st = ">RUN" if self.running else "=STOP"
+ d.text(st, 12, 244, C_GREEN if self.running else C_MUTE, C_BG, 2)
+ nm = self.name[:18]
+ d.text(nm, WIDTH - d.text_w(nm, 2) - 12, 244, C_TXT, C_BG, 2)
+ d.text("%d/%d" % (self.idx+1, len(PROGRAMS)), 12, 266, C_MUTE, C_BG, 1)
+
+ def draw_dots(self, force=False):
+ d = self.d; m = self.master
+ bpb = max(1, m['steps'] // m['sub'])
+ yy = 200; sz = 18; sp = 26
+ x0 = max(12, WIDTH - 12 - bpb * sp)
+ d.fill_rect(0, yy, WIDTH, sz, C_BG) # clear the dot row
+ for i in range(bpb):
+ lvl = m['levels'][(i*m['sub']) % m['steps']] # accent (2) shows amber when lit
+ on = self.running and i == self.beat
+ col = (C_AMBER if lvl == 2 else C_CYAN) if on else C_DIMDOT
+ d.fill_rect(x0 + i*sp, yy, sz, sz, col)
+
+ def run(self):
+ if self.touch.addr is None:
+ self.d.text("touch: not found", 12, HEIGHT - 40, C_AMBER, C_BG, 1)
+ while True:
+ self.tick()
+ self.poll()
+ time.sleep_us(200)
+
+# ============================== GO ==============================
+App().run()