From eecea625d39de285207596257f91e9b018fe7eec Mon Sep 17 00:00:00 2001 From: Me Here Date: Thu, 28 May 2026 16:10:31 -0500 Subject: [PATCH] =?UTF-8?q?Add=20PM=5FK-1=20"Kit"=20=E2=80=94=20buildable?= =?UTF-8?q?=20Pico=20touchscreen=20unit=20+=20MicroPython=20firmware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new, first-actually-buildable form factor for the 52Pi EP-0172 "Pico Breadboard Kit Plus" (Raspberry Pi Pico; 3.5" ST7796 320x480 cap-touch via GT911, PSP joystick on ADC0/1, WS2812 RGB on GP12, buzzer GP13, buttons GP14/15): - pico/main.py — one self-contained MicroPython file: ST7796 direct-draw driver, GT911 touch (16-bit register addressing), WS2812 RGB (neopixel), PWM buzzer, ADC joystick, buttons. It parses the project's own program-string language (verified against the web engine's semantics) and runs a non-blocking ticks_us scheduler with an on-screen touch UI. CONFIG flags cover panel / colour / touch / joystick calibration. pico/README.md has flashing + calibration steps. - kit.html — lean widget that mirrors the firmware's on-screen UI (portrait 320x480 canvas) plus a joystick / RGB / buzzer / A-B buttons; plays via the shared engine. info-kit.html — the real EP-0172 pinout, a parts list (~$45 incl. Pico) and the firmware to flash (downloads /pico-main.py, links the README + source). - Landing + embed page list the Kit; build.sh/deploy.sh build the two pages and serve pico/main.py as /pico-main.py for download. Co-Authored-By: Claude Opus 4.7 (1M context) --- build.sh | 6 +- deploy.sh | 5 +- embed.html | 1 + index.html | 1 + info-kit.html | 140 ++++++++ kit.html | 308 ++++++++++++++++ pico/README.md | 76 ++++ pico/__pycache__/main.cpython-312.pyc | Bin 0 -> 32265 bytes pico/main.py | 492 ++++++++++++++++++++++++++ 9 files changed, 1025 insertions(+), 4 deletions(-) create mode 100644 info-kit.html create mode 100644 kit.html create mode 100644 pico/README.md create mode 100644 pico/__pycache__/main.cpython-312.pyc create mode 100644 pico/main.py 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 nowRaspberry Pi Pico52Pi 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.

+ + + + + + + + + + + + + + +
ComponentRaspberry Pi Pico pins
Display — 3.5″ ST7796, 320×480 (SPI0)
SCK / MOSIGP2 / GP3
CS / DC / RSTGP5 / GP6 / GP7
Touch — GT911 capacitive (I2C0)
SDA / SCL — addr 0x5DGP8 / GP9
Controls & feedback
PSP joystick X / YADC0 (GP26) / ADC1 (GP27)
Button A (play/stop) / Button B (tap)GP15 / GP14
WS2812 RGB LEDGP12
BuzzerGP13
+
+
+ +
+ Parts +
+

An off‑the‑shelf kit, not a custom board — ballpark one‑off prices (USD).

+ + + + + + + + +
PartQty~$
Raspberry Pi Pico (or Pico W / Pico 2) — the brain15
52Pi EP‑0172 "Pico Breadboard Kit Plus" — 3.5″ ST7796 cap‑touch, GT911, PSP joystick, WS2812 RGB, buzzer, 2 buttons, breadboard, acrylic panel138
USB cable — power + flashing12
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 ↗ +

+
    +
  1. Install MicroPython: hold BOOTSEL, plug the Pico into USB, and drop the MicroPython + .uf2 onto the RPI‑RP2 drive + (Pico / + Pico 2).
  2. +
  3. Copy main.py onto the Pico as main.py (in + Thonny: File ▸ Save as ▸ Raspberry Pi Pico, + or mpremote cp main.py :main.py).
  4. +
  5. Reset — it boots straight into the metronome.
  6. +
  7. 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).
  8. +
+

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 +
+ +
+ +
+
Joystick
+
+
RGB +
Buzzer +
+
+ + +
+
+
+ +
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@*/ + + + + + +/*@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 0000000000000000000000000000000000000000..e72355c0c97da098d98291013d7b5ad52a9d8ccf GIT binary patch literal 32265 zcmb`w2~=EHnkE=8_7wN1|mnIS7(CzWUFbP)oja1>ds?y57>^f}!vOPd)IW;3f0{{5Zot`!X`)!);D6l~GRvrB4~>V}F} z@m#m6him3~)XnN1O|zy)+pO);HS0LaQ+Ml|_3YQsY%qx_#%3e0X*ToPH_$?}rP<2s znr*zkIf*wkC*v;#e|G$(;x7$<>G;dwjm??7=?xufWHo0u=QQUwui(wBg}mmKyrp>+ zZ*9)!ZOsLIQgb1n++4(`G#7WMc>52z<`O>jyQ=2ZYL#m>pT?*2Ngrv%w`g9&(lYo= zK1)tp%hIy>96nc0TgTE?@Ok`7InBY+R`L0Kft{G@}S=t7EBfm*b+rrW|^IQ0>a@tnc2G@GmM%QM(^rp7ClrQ7U z`3ip9O-*x|T6IXpZ~v}}-|-Q?H-+YMekZ?+-_7sg_wxJr{rmy`6}}R)RK-`{v^H1p zHTYWFSiM@*J1E!N&L2XV!zfAr9l5D*-hn4dqn>v1ucF>A{wUyXR{I!gAD3(I!MOK) zBo0BWHSY2H@mt$Ll&(&8=#Oy7LuIrRsbPuyhzT8V`1^E2fG|^d**P5iDUv zsh0Ev3;+)Gl9A#j0#*XF-j|XIBoW9ZumZrLbEt>x2mAVkg1+7Y&pB5?dv{--FjRc0 zbdRUBZ0nx$oo(lLSL|{3ItAD6ihY~UY(BH+oYS+rVoMon+8R)+t=oCYRZzaEY-km$ zwRP(rBwax1Gn-K!|3_P)u6AtMS+Jp?qToVb_h669;}V9}uuA1+Vxb*d%B1$+QhG%j z4t2!PaIU|-z1uZZL{)Zef63s=(N|J~M{ss_obzCWc#EMV>780#E{v`%x_j^yR4V#@ z^!%&~;D*WrCS2{v&2vMT171CZl3tG2uqQ1&4dJVMRlIfxU!3B@8jsw% zSA!Zz$FGiRw5vY?q|l&p=<)0`URI_Pn^9oh&d zM7WL!*Baq2xwR-%fbf;|LT6jwmL6wk@8%1G5xvmSx^4S5p$OUg@#m&BGot#nA<1{q zU+63KaSLY4C_lE&cX2G=?+lbooC}stw1kbDLiUjJZf4lHbEI<7lHx1$T^wKIFZA;h z>t^l6bC!}>ZOJpVr4ndK32Zf`BDR!CU=@L15SS(Kw*-0#3=w#j0DHBcQ0zAV0$YS1 z(BozTe@uWUu!X>50wn~>2)sfdj{v>b(nbR72viWL0&r*rw%+OEvacx6_HbxeKf-!S zrGzOB`w^&BmgUea{T|{=2c-m*poWA41Zd1{_>%**LE#yF)*C99s+r?e%~%`27aHEe zYre}hYk4i6bi58w&+7pVyaCY28v#wcl{cZ38NX)yS_ah)TSVW`xMRnz?QPMu!TO-i zX&pWLGo)fw%6UBJk;Vbag(q6SEO-bBupq6nm^?xX%ku81KZ=(3s#=sblo-u43m#f+ zEOgOYjSIZyj#k3rXi4rTrf*s)EPPE%+_IMF3y8IpG0}1p(|(sRDQ`k}j|E;QeItxn zek-r4D_<(1{3=)-`Grcl6jSmCX;+JDPZu$^I{SIS>2XDjz<5rf(=!;+T{!1-yCNEQ z+xdvLr_bHVR+@v0=q@?C`(2$Xyq3c*(91=Ppb9Njl}$AQt%Hc+9M*YqB+sj7*V&m#RQNMHQy#yI@Qn^(Fkr+mXDF{wrik7x>{k`WS zI>F_3c_P}*-cHXH7ty+1-R*8#YXt(mX~fvlLRl>>L&@SI-7F=5tx~za12CdmOw06L zytOmroW8iYV%5!o5BA<^ySMJ6o-Z|A`o2-^72`joWnRB@OLN;WX$W4NPb)>50hJP_ z4JS8*Nc1e#T$iNHyKh;9JO)+G?~1vY(?lDM{rmUnubZu)82E3dyA(B3vq8gC~}Cf(@|IYW&feP_O4&w?d+ z*0N&3nsW8f*dbr#l~?`R4~-MX50fU6ZuJKn|MWZa+2wL;-#4@QUxqemQ1tigKLC7_ z=~sw#SAvhu$9$|8EG3Qd!M~<0ty1y@ZOKc9Q+-WaGWueZ2&6+4YbhN-ByoEwMsyC- z_8NX7T2E&WFp;|(3{XptTVT_pY;>U+g@&xr`C~|-6)EmR0OAB1$BaJv70dO;8*h%k z87Q02SQFHKY@9NN>~}2p8b5jS?wd1Z^V<&I*Zz(1m&V!pQ-5ulZ#w&^sWsfxI@{L% zu&Lwu$s+p0ChIgP8k;O6#D`%{O&r%Z|1>dE#%g}^35u_&1(k@>T295<((?Ma6oW^` z10I?7#q9m%^^LqKuD+SK#HCnyTU<&KpB$Hx!rS9gQu(yFlyp8rN%@-P6#wQVO)eif zq{+AxEu_okWwiVXCFOVP(IZoPLCxp&a*$G^csu&#S1He5({jF268Nnkx-~9iT`k!O zJOOw{jsKj%KVI5Ssk^1rrlosQlw0}>L>ZzRQSVtIl;Wm2e&t>4tDGN|Fa1m^sbBg_ zDSCV9ucTrl?sKDQwB(B6disr(<178{d3*5-QhbJaWA;R$ zzb3GDK6gDUbX|A-lCN_vrC{Dz$P!qY_*nA1v4Ew`rj*YcE8?@MStade$%mUJHU%~F zxf`Q(j?Np8#YdNqt^^$u?&5wLUcFh^R?s zGw~&4_WLc4p(f6&Az_B+hL8iXP^&x*>xXqOz~^dZsWPDv>;ZnYBno?sD>V+$LjTX;R%WYV%IK5n2#TGtLv*h4?j%|Tltj*k((P5Fi>19LgT z4MwGdTntuBUJj*(F5bX=AYGo zCUAoydF53FMF`3c$B;{}ydG*)Z>~U|5qp8l zK|5Z(*NF01rNqY2PFbFcHI8)3yRuRXgsz9UMPRPW^pvRs+g5)@|uUTmi5YA*^6Ez^)f~$1wMua!Ftw4 zS#A;*M84Y~T%q_`fQY8s)eEuA+1ue_JUCmsY&|q58Y!*bdwN+%#*fS+LrC+*s`>w~&RlAewsOL zf1*<*Lv{6@>xo`vvWXIhBqPMGQ`+o|?Iac1i)DmZMT?@i@NyqTu_tlfQ|hR=uMtRx z716zRq^9w3M1Q#U$f3iH5(V}lE0zLBwDh@U5=;-^C{*qYfDx6y;=}zD`-6qS`g^*2 zm!|t4uBa91Q!GxF-{E_BGrR-m#=cdQL>Ofhn}cG}-Y|F#h|vx-Di|5NjO9vYhfT`P z?TW=37#9WFRro5={jW;6qa^Hr9cNBN50*((=0v*%6jF`DTNrb0(JyP^UP==CFg1z+@vPuJ#I-}fHp9tR^0cZeN3^OJw7HVCsp^u|aN>It?4l(X7ra*Kn{uiv z`Dpn);8;}`a9wuKLZdkwhPW4S8IQJ#Bz{g zRZ#D{gox?*iMkUlhmSQ>2_H}~t;coQBVxy^RF||8F&HsDO48kej1(j4_Kt|UwIiZ) zw>i6A5%s{%h~DiHI(ZjVZq%Wsr@a;0u>mN>`UV7i9dy%5YB@FE8PNcdMl`LR9>fTd zwzx^CjzOVvl;Kh#fLwVe(_p`k1o!Kh?-AAKKu2p9Z7Dwcd#8QQv1YIa_B3eLvqtrc z$=UwQK;cC0Tyn{%b}=dQQBqMjsVGnpOvS!8KH6Fp-dZ)6R6VL$Fj}rgWp7`@jWgqC zptrXNDgzhiGaL)HWZ!CPcQGl;ulCnZ=;xC1sfvQB%Yb{gO$^Q(9fX)KUdw)}GMaV( zt)`{>E{*RVtpOV|TD6#!=Rb9A@Tp2`tKxi`g{&3e=c|LIlZV53o5ER}L#5LiAX&rR zec`Oinfh?%L7(RHy!`Rjz$u?*F=v&(|AV}zD$b6|l?$s1@nY@1YQK8?P^cJog*Ab; zU{SDss${Zrx@d;`bnT}_UudZC69e`4rCF7^!oTg>8xO48eO#d8_Rh(jxA#x(4;4Qw z-X6AYe`3dopQJvA(+P)5f#M9Ommm_WE|15AUdC6z`~u8Pf*F`cT9i@kmN1F9a0`G* zeVA*JV(MvTTLxhC61hR~@rP87mdA+dPFk@3qs&m@RBp%>cI=rg+QV?RgR4UaKml0% zq)Y9^N^fb2m|I$U`uP5Cid$P+F7`XS#T?;VC@RW$%@li`0OJjays$07R-EwXc$`wP zKE!ocOZfy&;m=K)pYN)kstvlLXQ^6U+EXj1JI6iMr|NQ_uG8v{a!;)$q-Lsg2f1hZ zG+o-4>s7kFVsq5U(pvHgQ+5Wn!&wiL|mz4@Bq%0qmZRSBq$! z5G@cRkvB6CzMfDqv4y-w$p<5+ZP!Vn8`4n+WyG?2uz0%XbLINfo|&~uCH1VN)Qe6` zXYIxJsAeTKFK9<2=EjV{u&iaV2eIGymbrFahhf;_wSZ`>RjJ6YVAwir8%`Qd23b;d z;W<8dtsrbk-eeH16h@W|M3$l~vQc4ct~j)$8HU)!Fge5DG-aagk0xwMOOK>-bZ8>l zmWJ9x5lx57!+0QvJ;L$AI2BNEGWS}9I~?I)py-3SyRRKc*zM{N3Q&qn5N#fJL@iv# z-yr@5@OLhv2M)f_=XMjFmT|5W5qG5-=9PgKzO$p#Bm5nT{Tcq;n;Gg|G$&nM2Rmn( z@8Ww~Mydl`pfbo!)=rv&&T#$~f9YeBd9;2k!{-dt2kpVqskF)0g3geA)>ys}&9RT= z_?-U2aTiK(iE{jv6UE~l0dD;Kb0t_kzjM4ZQR~!d;<_*B@5QY>NjBQ_BelSd#p{56 zb%(jIan-1Lw92RURr&P`M$=Wxm<3U_zbc>(mWS3%?VGNAuzufU)lB(A;~`*S)M4Ac z4c|@<_=GGxTZNzJ44vxls$k~S0O|&{@L|v$YTQ*?+7{iHtfGvLZ?Xm;I;sd~hyenX zszvGotZSmR`cbs(Fs!Rs;b82BHQ24{ODZ>a#fVr;3!JK#V=PMS2%_~zlDulc0{gwl zP{q_5#2Uvc*VMsdf(#piivTr&HLd9^gPFt0^8!p_ZO&d8m|2;>M|MQl-q+vDM>wZ! zh8IyV;qIX%_Az8D+5d(#HyKqRPNij%Ei6#u*WNTOq@>+2jvKF;M|BJKm6$uzR?uYd z3$^bK1upt4Ztj{|{X~P*$Cl))yT*3STJk0{LbcO{cMk`(KQ;w2kRZ;R{2|c^ZF+`G zXOa3Mh58621ru>}2|ib%sX$3I8y{eLR!e4IkvkpcB;6LtB8W4W2TUMM9VURu<4ynK ze)Z5J(K{jxYYD|OPFX8XFmIGxGN6;w@}_8>nSNvP^cY1f5n`$8dSw9#Y7L+APuKFizO&I1;yoRDVJ(V`3`GcXWctdyvr8q(g+43 zqyluFq>O(qblqV_7)Zm`4qdGJT9wDDaJiyhS!ROUxT55a9Ebak{&!D*bO8`Ze)*VKeDe0+t&m%;Z~qg9aO!7n$yZ(2ypTCgVh(n&E5 zr33hAQ8lrUl1{tmQOfFY%IW}ryK}NL1VzAH${uVcsOL(@)BSdT=|noz4>@6b@n{vq z>dL7SraUOYW^#nr?+u)yf#g7oSLPSy>_wwhq}MnzcIJ_-Fl;Lfl-zbqI)bM|ug%-` zJx;cdntuCbjw&UK_H6d&X<4*Ov;Y4uGm-SbnCI6nP?0AZm1*Ty?qa<7)%DdT)n68@ zt0(Wv(UyVt4oo|nbQ&nrzK8V zYl2lVO+k}Ij1SXeqYM#u7EnfZlZcjKC9!`sUan1jSxtIE!KAcw9y>+I?p2c>1ZC86 znV6PX89j})UEQFfl1%?KWb*X&x1B2h`4$C5+H?*>#L{@8srqnBO>I@vA^1uNoxPrj zO-ySbcc?RGBT3?8)Y5TAm_W_A#IwQ=s5#MIEqsBv@D#vJSOW}%h^+q;9;AgzD?dyn zcmTLdSu&xyJs&I!3iH`zzqVut$^wGF)nD{$OU^AF*a%awYBI@>oSa+wpncLDJT;l@ zFQYZ7zH;Dt)0G1Y$!S;t1%-jC`TQ++G9Tn`nNFQPHEj&#&*pFWJUNYF?mFzspP)nG z9)W*NfGCLYi~tQ!AnOw=Dh~NEJrej7e~d387GI~^_tcuAOMOaG9Qp`% zM&!kaoPWh_7XF$FgaDS=&Sjm_w;A=|xnZsY?r*?u42ht4Oppk4A~s#o@ql!6|z3EY+180QRG zA4?PjJvVsOiF8%abw}SEnh+OQ47J;;v;sO5&F#Q`^Vp=1!y7%a9*Q?X{)|!-=~vtf z4s)H*i7H=ldl)W{a2~c5zT$Qd=|wL^VGZ(^Hlh=U5!wu>FfQQdLN|01hE6wQd%>t+ zi&$DNI0d(>rQ6x-is+peF1Wy+XnLHN0XlmfIwsXZsd1qvqN6hIh{n?g2P76Jo`JE| zY>`E5VpA7{z7D8m1RHvPj1umT0f58JwvlR|Fxn4RWzHJ>M^`VKExl149%ZTB_2yU*VM#YL+BwGUe+TIN@7UQEsKwSg5b z@^xI>I9eSrqNuNJZ2y8aeK9%LUp|+dKdOybl6@IB^2YQ0{6l*&Gy%GjFH%)j`^`Op z%X2HX&gPc-RxhFp;d;5x?XSMJZ_ZK}*fwifH>+L8Hp8+NP8I;d0?(cT!u1<$=_S=L zI2rc)f|U+VMb@ z4kS^1SzpNN>#06?EVvnZb?7z2t4r9cyjoc;9cmeP<1ic&Sgq<8)G|qHT&@KxOCr}N z-Yk_QJ4_<(O5U-Sn75VNhshtK!6*{&S8$Pt?z9@!RdbuVL)E5!6S$1&m=o}EQcu{~ z=`nrDBOF&>DbS*gVe@6q4K9qgVh3B49qg6+8@4KYS!#9IhB;1Rf_F9>gRP5X)VSbH zlEw_)FA2&6;D_O)E4jcT)~I$l*=rC^c@21{l;}HAdnq1b=vYXoolW7j(rUzepuJ%` z>d4wzuU+cbn|z)qbcWZ?Ydbh^a-uTC3y`Lpjn1B^#Bg%&K~Mp^hY&Lh-ejo?Q+aOdJ zcF`KiC*3pUKx8q?8h6M`XY-2^RVpIZw|4c={LWKDGBwz99 z?vWZLVzyn~Ft!2My=>0B>XCU(*t{m-4pz;Xp^AZiWu$7sU> z%Q0ph)r{8rv_uy8YmU)Vk2A8Ft6BM6#@cu4Mrub(NBM=M)R99}nSbXy3oG)Op{#gr z#pbXvdsI8>^ku=n9bH5VWai%J9q&c!nT|0F%TI;I{OZ}Uvp)BG-+~m8v*N?tiQJoc zSK53_ADd70MxN z*%N4$%6|J~hsu=YtG}lHC#Z~*_W$Ekt17k3P0Q& zs?h#owf4|PO&dzcAxa`u&%8)_nNo>)(Cc~fpf@lNdL#3oH!%--GyLaq<_g~PDxABr z0NQvfU=nWwOy-jSQ*c5m8Q%4FJ_S#yc(UUujh&%NA53#(;GE5c3(GhInfLt)6gSp> zAYuJjg_A;4U=5|3EGUwZgt21sWI0R9ApCwW~MYioCCOIy|&o4=Ab_V!o&Rtz*jVPEbz%*6a(Cs)hyjR1kd-zpgw3Y^ICkJaDcp z#qtyMWG(VHXRWN3@;YdR$lnFLr>bS`vOM|AV;wh0Im&PBe4|8@k|V{WoyYu)UXxOl zU*32}<{nv@vr3*6Th<1duHwS-HlUk;R7QBJGw?l*>4Dw66BN3w8!j7y>muV44l=oI zDteZPO0z1BI6eW*u+ZjmY-WZyfm8|tiC3`)IBF`TlN`sGI*tkS5gpTqM2x+zz6+g~ zUEL95ovZI8i?MSbrw&y`)V&u3LN*aIo%pC~YHqGQ1);8gC}QOMJ%cU%rQ0J0JhqT| z#l*@q*0oed)JN*jWz(@^Elnq3==SthD!ElGp$f-AoF@g>CH%J1uR!QXEcNP%vn^*L z>aNSm<5@fo!kXFDH&_R=D6E-m^vC*esKwmUk72sp?n)0zkRaOG+X0@azqc0=!32&0 zGAn;sFtZrWtI?P}p1xl2Qr&${K4M`;9dIe0&NjRmS$IfrRg(MN5tC^6>GM4A$Nnvh ze8>v)F00^)IdUHdz(Eyz)-^B+SM=<}%FVW0Yd`3|r@43NC)SxA^Jzy(J-?Wd3kRH? z6FaZ9;L-f+ov&P9_b9bEoLYRV`i|jGkA({7Q!8e8y*-zE!U zV27@EUfYZW!=37TrjP0ZCAZctWal8kwvf95Kgq$1{>GavGbN#tdz%)r@{nYW+8SWw z$CGg(gBmn1q*0!6A&V+oCC|e`7JX#Q1`joF%%x?1;lApN8^b|Yt;Er)gqI0QVb|$7Ne4htJD(6Ni@n1Vnk2V zEd4Wr!_4@2oYy*a;?Swe;|;<|TJu`6^fSE*+YRDI(DyhYS3n6*OgdZw*a3`|wr*@G zsBf6*=^FZBYIuR1OCWeaL*u5mV|=>Eo;M(8$ViKyw$fHSx!X_z3*VZ2-T2<|Q7+&N z7EZcgU)s5tvC_|fcy8icIAiUY8Qd`sz7Gq-w3xp!*f^J87B;fw;w!nnegT|x*LYXJ zKHh^8mW6b(WSQ9F$O#*B9vW9XuWO-?&6dM0L}HgiBD<6H5n)40IQ5V;H(}!0vf;VC zXc1Eb%{0Xn+D$Yyk(cL?1xrEA>u3Q{Tv~=m(^CsqwlIUYp$?Nn?nYLg3jtpMZsN z7)AMgFJ4EW?I-Lbz}AaNTrXr(c2VMffT&p+T3OO2M7VYwtph1vZdZnHFFV6-qGiLP zm8k0O!5AQXr=mdOBk+ z`M{`_5Ip?J*x3g-@n-u=K3q4k?osZBaPEd+&3ta@V%CZqZ;iioi@)77+4HA;q5ion z9OC@G^(705tlY7tszeINSl=CdV0n_UWeHWp1)(A_B1l}CSJO65ocA(SWYi!9to;h= zGd2$X45nkXr)!V3faeltv!14FH-T@Xl;qS)n`N0(udcn@={`5arin!lQi)xtLMLSN zZtNT17sv?O=F+wUc|zHpe09gWI|AvqvnR8qQ<3AZ1}i?^F}364eN+2>d|-6P^H`Pk zE!(?|X#7PMek&0^6chtKixiFd6|#{|EyV?CH$w(~IL=ZM{%4CpC`GJMCm# zHj5ZZE3J*a@9y=dKgwAj&RIX7vvIm8$p5(WvFHl_$htCYT?r3-YXJ<6R|no52%N<^ zS;K?@r%=ox?I(u2hEHsFZTFI6**Aa9hjkNmp>%)U!@TX!?`1-GY+?O-L}JM`R2=d$ z0nvTVoJDRQ`1D#-iwoy>;5)oZxnH;2LEejryeo*BEsq3kMoGCg0V&rpQodSCc)$`j z6g(;mff=DO&1;YotNj{({k5ZW)&k&aSdO*i$nY)`8EUQ^UdYHH4dMf9 zlaITn3B5X#{)_CNWrr({+^@Xv`sLwpMdRGoCRo#U{V;FV+C)brj*lJx{)s0Bl{I>p zP2RZZlZHos&;A3zi;xC0^irv~pg7R7&76RI^BnGy!23~PWn|(hrV&)WK4wQI)=GX{ z8)_k+sT<@1UpJ?;UKIX5>SiGBT(+Iz%fhxVamWHqV$4>Rx3n~|#A-)015WqyiPd)W zdHO{5nqp^YN=Yw)?F+80d#rm@gH3j1;z+=G^B9p?MUDzoJSr{^7njc$Zwq-odF$?5 z_iI9L&F`rH+>+$?__WupfyPIxw})46pI^OmrgggH|8R_!Kac$7J(`5sjoqU!XQ>r+ zCGM$__pjT)R5Lex#4qq50OmHi`YT4jM^3>P# zm}u|DuE7{HUBVGdE0NbQhqIoq8F9ikc!pmRe-TlPklykekB0dh6ZRr!2VQr+21{@i zdPqRBlw%xFt7&2d7U|tYzGYA{ZK+|}GS)?OEgt6ucjvdUy+rR1g^nG-GVT2Ke`I8g zaidY`rNp5Z;xm3Qhy`8SEikhP68^TYQtYDHSWIJX4H`*v#_XTHf z9LuV~NQMlI)HyG_fuioeWE&+$yje`n^m+WR%_px8RRuh^h87A}-_D)P{Z&Stzw(af zU z%g_QYvmwy-VQBW-AY9*kdtwowe0&gUV-{|Ljrk&?D=kc%gaivB6rpvxw?#LHO43ozzbs86x^SO+0%XS{6*s>BDCYB}mIx@mclqY^GtR^6T zd6i&1vVB77Z5f+TprlJNK8Z1vBdDDqm-(m|hK=w*G4RRc*TAQUr;c&bG4|Prlftpj zC=U+?bmBD;y8n2zB2CZcJ{RL&izBVj0Qkcws|jW>MuzG!8bMPQk_FHZN#qTh8I6332>5rF{uD zp;mIfpaX==)q?p(yGd?^ED#M$?MIjI;1WWnn57uC>;6Z8<@!ER>bqI<;faY8!Rk=@ zCwX`C=JNK>6h6qSj-G;!5&c4TSOPXOQSrUKq>-%+Z1Y3a&jg33DueAX=i?Tkxa}M7 zgBL<+@buKKxy)_wj?Ub@P*@b$KA8j8gq3iGK6H0|xL~iZ($_wIWFfyGkokkbi9(!) zF;1PIE}TC2sUy6(2EGkhx&He12Lig=)=6uyVZNXg<_++>M<KSWt2scRU3zhBBuH(cFWg{p9*!<9t!s=gCYUJ>>S(a8+>3XAg%Vf@pEH`pf0`W zE5w;f=VWy-b@EUE=Y2w@chf?xce8@^(@q>&Y?~?jv}3w(Vf7j~{5IjkoedQ|D5(&S z!jBJ5J4Q)ri%R2-x6e$TQ6%!9=i`B?fsco$hJXC_=m5!Rg)ialQ-QW#B%{4dVZ)AJ zh{Px#Xe9wl0gfC-S&*@W>Qn{6JSVXyVjyHL9?Lx%q!u7af*7;DXe6{D;zG?U8xqxu*_2@BIIN_N<^L|ax5}VU~ZC8PIi(|`bk7arM__n z5pN6Yl&ULNsUgL_K~0?}Pbw|o{Gx@JC)ucv6dh7MFl7iv)HKSJ0>zzng~m!RJA)hBF5>s#ZR#v?oB33uONk;zeYl|Nqw3~ZEfQBHI+c3yTw z`357bc)s1Z5pKr_WmgrYbk{B=F2$tXLGHRj4(jcZa+F`3suiz(!Bs1<%&{ct;_#EG zk!6xB2&d{G)9ZBlTtI0SG}PG1^f*t6m}8Y1z^0+YGFN3lPkLON%g$Hgm` zzzDxj^`9Us@wG4HtJ*DcAryNDrQKfwKz}9P`gQfh*oisoN>L$6H^1;V&eQ4E2bpx& zpJmE&#}@TX_(m0QIp7_0w8B3qcFjeQ-~(d^{N=a-(l%+Evuq9(&RNQdMjOTrk5UT4 zDFt&WMS-Sp%KFhtQJwv0>nq`{ugq<&nrRAeeKlD5>*N)ILYyV2mm>@5Iit0UIB`(= z-ldz?2gxPWo^{;nZ~XAg#2Iiig@5LnvQMW%{cyC>%IYwmLo z@{cUQ<+Nv_XY7Q?hTwi3O|bFNrv2ef`=>9$*R=jVcfWjo(}{&u`Dp7z?B`3KOrEoB z4OKp{Y>QfpZ{&~Xvoma;C)<4$H+GKiq}z8UYH@_gIl1j&nqyQTeMZy(5KF{~$TRJQ z9`HpBNDZzrfAeA#5y1@us7Q~yWtN=~!xNL%*}YQSl^i-Db|nvJNjV{A!V^2(9xbzh zl_8`DDKS1IJd+CINT)RwA3X)x(H%96`LLtC1gd5U9pTvUk5E=*iilAmED{%xnn3d* zl6i&j^}d64MBn${wH@>q)6Sb{M<+8*GI>%4h4Ca{eV!Z7F0`ZnA8Kd$crwtAco!Bi zjU+F|1?5{}*4d1B=?a4wGOOq$x@6x+rIDE21PqhZ`ZCMBtU-vW`#;{Yd>tpoEPrnz zI?N-)437{v^TWX(K5dso`PvDL8PVZf75vbU3I7j9NILOSo#apNND;``BYOY+F;XLj z>Xxd;y2D3A0;CWGg-Ea#h$@Kmh{>vqC^2$Dv;m$4%nL}t{_0ko!EMET39YyJ}QfvSdf%I!Gn7Z08oDM+S_|4_dZiuO<7@Ue)P%!wZ9tYSAF&K$@%^w z|EY=9ze>&z;Lz-RNkvcy6;1X3s-%MOqJCU2BI-bD;1sT`Idv~wn zZXcaI`lI8@x#Xx7hjOJRj-u(s?E~p3ab2x) zs23#?$X9&Ky|yH;h{t_-y+_$vvh;|?j@a2C87Zkru#-SeWN(wqRDMwU(#3(4Dqh`*rzG)7OeHy@Ljy@l z=8JJdOp3(iLUp}6>TL*K8^|w#Gkqd?4k|--Nut--9^+Pk`iWGla#`g|UD->j-01b9 zj6~v6w%8HM_x}D1MrdNl`kRd~S~hNkiRTm7mMQWppGLEhaCT$X6cm~1a8Ev6d8)GE zYy(s8G27LU`Q-7Iqnk<#jvi@bcz22Do+tufV!$C+ojktu8Ksd0f9cy4>m@*of}P%i zHTMRxgg+)gSQ`m`iZg6Gf!I(fT$tUYk=tG1arIp2E7($i+i-gbgNjbc1(mxChyWm= z3T2!i18ClnEMoT$5RYhq-46)?it4Lds?S!|u`}uHh9*im-qcttUJeL4aI&(l_86lN z5zP?Jg6U~I;{<*{?MC&EVb(4#Co2^QV+;Qa04~_U^$hCE(VNbQ)yLtlzJant3P+(4 z*+a>sdyVm?{xK5W^hrd9|KQj`oK&j3ad7V$@*4!xxar4G9 zDb;(kDNs-NHP}5}Ik$R$c;)_?)bPscnNxqAIbYudTiU$wtaRT@rBArIEl_ytwRz(v zseyi9tH1c>rCXk0{k(C@(aLQ%f@&6-GS1H{(u1YRnLTX*0vFso@M6vi)L4CpUd1qmoYdwyinz+n`Dz?W5*s@ z^Fi0(BT<99$67S!w%Rw zHs~)KdkdGc{m*Y+{KT`#RWnt8t)H!L_$BU0e|`Q<=WHAQ(C8A$O%&Z?38+*uxp^0} z_9C7fwjF&Uazn;=+UN@LP&V<)YQW(~9>X`P5px$ZOxp=-iyo#??@{ATy_fmoLUPdY zCdQ}g6=K(`QxH456V}Ymi&~(F;b7U(Cn9M;yD}#SHG@8pSsi6?i!)B*0z>fZE=(c; zglu^$>^>6tP;!jQmqY6^83f9*)rm+-y?5`BX1~`vq~5<@yi_3r6BW@kR-S|t(Q@n~ z;vPafA_5|`1Ybgikyt82TttGw>k$Ts-YCkLMd7;q#KA=3NVa~&MBHq#;wA}tOaT6W5P9KdgJv7lJhmQ6Qy^n7_!9Ln%(SA87J6EPvV?G1 zCoMc&OGHZz*HReRlB1eP$3c7pD80-JD$dQI^U#&Da}Q=5QkMlWZ3yHWn1Exf8`7*U=Y^dpDBPm8$;d2Qid1$9J_K}eHd~L( z6>b6v5tFO8ABPz5Z`-hY*MvW(nm+=-6`xE@DW?QhX_8ugKwyr*JON^VLCUt`>g}!U z&@sC%&P@|Qq*n^ecE+5w!~;FzTD;Kn3siUiJ_^SyL^@nsbK~&%;jlFi`YUS^NL!VU zgYtt5v<5WzCq{&+q3QbRi=Q^kl+HB#Y}===hqoLF7R{bKb-!#eW0n8nwKs@}9Hx7S zD@TPzD=y0VF3K$ig8A$#Dnt-@TUc!Nh~y_IjVY%~e`fLs>{;%Y#hf1F_`-hozd>c`ei!ggj4xDJIrmK{4{v$qjT><*><#bx-m%lcECuBX zxMIQOpqAH~nY^=e$pKOA3Z8HMt+qSs{U{p+mz+pH93Kav8JWx3qBJj(+c1Wm{ghQZsx^o4(PafLOm|BLI6>6Y0 z%#br7X2j*%MtC8~Xk6-%c9>VE)Q4_UM&Jq;7UOOx8`nS}IzT`ms73F)qnJ2m^envx zktcc|a7PJ!wJyWp36+8=@!lpLjV>D%>@d`rqa2%T-s-g|dlcA1-gU#)#BWMoH46D7 zJud#UDsOqElMyG2`0qZ59xYjz(WU!%0k(+*!U-a+V%skVxonGp0I~JYd_I`F2N5jB zN-+W@T%||mAVS_B#8Ooruc|#I{IB$6tZu0}a=hk5qr(S(=yd!q-Ta&r&i z?}G3t^?HpO9HrPpiV=6^CZ&r+#n`V%%3edMA)73f@i@Qw2ube0Me!I~2G0S@1Q@7A z$UlX?df&i!GpJD0h&i7DU>P>swdZlGuF@9 zHi(-}WW-1sE4szc8#mBJ5$Hal;7D0&n(y%V2D+U;ux&DXVklT29GGejui6n# z-8ouKCI#HQ^uWFqwib3h-{j`es?T@q^0x)*e{k-PcKrC@PcNgG)pqsh*ipKF8mw32 zgo&<8!{ur2ht~2haJ59mODN=5c=H$S(?ol8WDC}(7M0?{iw{JI^wX(#EkAwjGyZ<%ytNKK?@=q@ zm7{WG5p{490e-pGRd95XZ@S|SIx-SocGp0|WrkbDElZ#@rW_xj7*p3M??wu3rr!|t zCQbBsngH!C;gow)7j1~q{I31{pO2Hl&Nr7M0~VbJA0P1Wftxu+>w-TtR$ zwQkQ-vtGC3X_5{(R*mim_ta=WVtTgjz|-xyy7fL2Ew zCK+@m5jX29o@MKG`C|9fsbgQn(Bo`7*V*f0_W-be+)=z1fS4A2Ez(`?mYH2X^3U(y%H8&shse_#t2> zV2T4z^?Iv%#P~E-!>#_ptg@yOYWJ(>EGtH|pIhv{BIw78<}52m4D?h?m)Xr(3PyCx zp7dhLHe8Np$sb`)RI-Ymme(^bE6MWY9-?{C{#Xxko?#&+%YQI%IRrs!A6_D5WnfdN zE^MtHG5tn=lH>M%p(6O?oLXg0^XCL}rgIidsd14n3|galMElfiCE&Mh%z$g^Iymy^7QR zE8QQM-ZA~Y<+o3Il2pbt2y0wA=Hy=;ruk0)irt6l*Vb%OeYVL^vs3fg4i50Y15%!~ A`2YX_ literal 0 HcmV?d00001 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()