# 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 (two steps — main.py is NOT drag-and-drop):
#   1) Install MicroPython: hold BOOTSEL, plug in, drop the MicroPython .uf2 on the RPI-RP2 drive
#      (https://micropython.org/download/RPI_PICO/ ; RPI_PICO2 for a Pico 2). It reboots itself.
#   2) Copy THIS file to the Pico AS main.py over USB serial — Thonny: File > Save as > Raspberry Pi
#      Pico (name it main.py); or  mpremote cp main.py :main.py . Then reset.
#
# IF SOMETHING LOOKS WRONG, flip a flag in CONFIG below (colours, inversion, touch axes) — see README.md.
#
# Text is drawn with baked anti-aliased fonts (see pico/gen_font.py) for smooth lettering.
# MIT-style. VARASYS — Simplifying Complexity.

from machine import Pin, SPI, I2C, ADC, PWM
from binascii import a2b_base64
import time

try:
    import neopixel
except ImportError:
    neopixel = None

# ============================== CONFIG (tweak if needed) ==============================
SPI_BAUD      = 40_000_000   # 40 MHz safe-fast default; 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 (the web set lists; paste your own from the editor) -----
PROGRAMS = [
    # Styles
    ("Four on the floor",   "t120;kick:4;snare:4=.x.x;hatClosed:4/2"),
    ("Swing ride",          "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"),
    ("Purdie half shuffle", "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
    ("Samba (2/4)",         "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
    ("Nanigo (6/8 bembe)",  "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"),
    ("6/8 groove",          "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"),
    ("7/8 (2+2+3)",         "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"),
    ("5/4 (3+2)",           "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"),
    # Practice
    ("5 over 4 polyrhythm", "t100;kick:4;claves:5~"),
    ("3 over 2 hemiola",    "t96;woodblock:2;cowbell:3~"),
    ("2 & 4 & 3 per bar",   "t100;kick:3;cowbell:2~;claves:4~"),
    ("Triplet hats",        "t100;kick:4;snare:4=.x.x;hatClosed:4/3"),
    ("Accents",             "t92;kick:4=X..X;snare:4=.X.X;hatClosed:4/2"),
    ("Tempo builder 80+",   "t80;woodblock:4;rmp80/4/4"),
    ("Gap trainer 2/2",     "t100;kick:4;hatClosed:4/2;tr2/2"),
    # Song (continuous on the web; here they play as standalone grooves)
    ("Intro - hats+kick",   "t88;kick:4=X.x.;hatClosed:4/2=gggggggg"),
    ("Groove in",           "t88;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"),
    ("Half-time shuffle",   "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
    ("Build 92 to 120",     "t92;kick:4;snare:4=.X.X;hatClosed:4/2"),
    ("Four-floor (909)",    "t124;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"),
    ("Samba break",         "t116;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
    ("Peak - 16ths",        "t132;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"),
    ("Outro",               "t132;kick:4=X..x;hatClosed:4/2=gggggggg"),
]

# ============================== 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(28, 34, 44)
C_TXT    = rgb565(199, 208, 219)
C_MUTE   = rgb565(120, 132, 148)
C_CYAN   = rgb565(10, 179, 247)     # brand cyan / normal beat
C_AMBER  = rgb565(255, 155, 46)     # accent
C_GREEN  = rgb565(47, 224, 122)     # running
C_DIMDOT = rgb565(36, 50, 64)
C_BTN    = rgb565(28, 34, 44)
C_BTNHI  = rgb565(44, 56, 72)
LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)}   # WS2812 accent / normal / ghost

# ============================== ANTI-ALIASED FONTS (baked; see gen_font.py) ==============================
FONT_M_B64 = "[encoded data, 15760 characters]"   # labels / buttons  (DejaVuSans-Bold 22)
FONT_L_B64 = "[encoded data, 21056 characters]"   # big BPM number     (DejaVuSans-Bold 78)

def load_font(b64):
    blob = a2b_base64(b64); count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {}
    for _ in range(count):
        cp = (blob[p] << 8) | blob[p + 1]; w = blob[p + 2]; h = blob[p + 3]
        xoff = blob[p + 4]; xoff = xoff - 256 if xoff > 127 else xoff
        top = blob[p + 5]; adv = blob[p + 6]; p += 7
        glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2
    return (glyphs, blob)

FONT_M = load_font(FONT_M_B64)
FONT_L = load_font(FONT_L_B64)

# ============================== 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)
        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)
        c(0x11); time.sleep_ms(120)
        c(0xF0, b'\xC3'); c(0xF0, b'\x96')
        c(0x36, bytes((MADCTL,)))
        c(0x3A, b'\x55')
        c(0xB4, b'\x01')
        c(0xB6, b'\x80\x02\x3B')
        c(0xE8, b'\x40\x8A\x00\x00\x29\x19\xA5\x33')
        c(0xC1, b'\x06'); c(0xC2, b'\xA7'); c(0xC5, b'\x18'); time.sleep_ms(120)
        c(0xE0, b'\xF0\x09\x0B\x06\x04\x15\x2F\x54\x42\x3C\x17\x14\x18\x1B')
        c(0xE1, b'\xE0\x09\x0B\x06\x04\x03\x2B\x43\x42\x3B\x16\x14\x17\x1B')
        c(0xF0, b'\x3C'); c(0xF0, b'\x69'); time.sleep_ms(120)
        c(0x21 if INVERT_COLORS else 0x20)
        c(0x29); 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)

    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)

    # ---- anti-aliased text (fg blended over bg via a 16-entry alpha LUT) ----
    def _lut(self, fg, bg):
        fgv = (fg[0] << 8) | fg[1]; bgv = (bg[0] << 8) | bg[1]
        fr = (fgv >> 11) & 0x1F; fg6 = (fgv >> 5) & 0x3F; fb = fgv & 0x1F
        br = (bgv >> 11) & 0x1F; bg6 = (bgv >> 5) & 0x3F; bb = bgv & 0x1F
        out = []
        for a in range(16):
            t = a * 17
            r = (br*(255-t) + fr*t) // 255; gg = (bg6*(255-t) + fg6*t) // 255; b = (bb*(255-t) + fb*t) // 255
            v = (r << 11) | (gg << 5) | b; out.append((v >> 8, v & 0xFF))
        return out

    def text_w(self, s, font):
        glyphs = font[0]; w = 0
        for ch in s:
            g = glyphs.get(ord(ch))
            if g: w += g[4]
        return w

    def text_aa(self, s, x, y, fg, bg, font):
        glyphs, blob = font; L = self._lut(fg, bg); pen = x
        for ch in s:
            g = glyphs.get(ord(ch))
            if not g: continue
            w, h, xoff, top, adv, off = g
            if w and h:
                buf = bytearray(w * h * 2); di = 0; k = 0
                for _ in range(h):
                    for _ in range(w):
                        byte = blob[off + (k >> 1)]
                        e = L[(byte >> 4) if (k & 1) == 0 else (byte & 0xF)]
                        buf[di] = e[0]; buf[di+1] = e[1]; di += 2; k += 1
                self._window(pen + xoff, y + top, w, h); self.spi.write(buf); self.cs(1)
            pen += adv
        return pen

    def text_center(self, s, cx, cy, fg, bg, font):
        tw = self.text_w(s, font); glyphs = font[0]; t0 = 999; b1 = 0
        for ch in s:
            g = glyphs.get(ord(ch))
            if g and g[1]:
                if g[3] < t0: t0 = g[3]
                if g[3] + g[1] > b1: b1 = g[3] + g[1]
        if t0 == 999: t0 = 0
        self.text_aa(s, cx - tw // 2, cy - (t0 + b1) // 2, fg, bg, font)

# ============================== 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)
        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<bpm>;<lane>;<lane>;...   (globals like b/rmp/tr are ignored on-device)
#   lane = <sound>:<grouping>[/<sub>[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,
       'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2}   # ornament hits play as their dynamic (no grace-note render here)
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, rmp, tr and other globals
            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('!', '')
    if '@' in tok:
        tok = tok.partition('@')[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')
        sub = int(sd) if sd.isdigit() else 1
    groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4]
    beats = sum(groups)
    starts = set(); acc = 0
    for gp in groups: starts.add(acc); acc += gp
    steps = beats * sub
    if pattern:
        levels = [PAT.get(ch, 0) for ch 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}

# ============================== 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._unpressAt = 0; self._pending = None
        self._touchDown = False; self._touchSeen = 0
        self.running = False
        self.bpm = 120; self.idx = 0; self.lanes = []
        self.rgb = (0, 0, 0)
        self.buttons = []
        self.load(0)
        self.draw_static()
        self.draw_bpm(); self.draw_status(); self.draw_dots()

    # ---------- program ----------
    def load(self, i):
        n = len(PROGRAMS); self.idx = i % n
        self.name, prog = PROGRAMS[self.idx]
        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)
    def flash(self, level):
        self.rgb = LEVEL_RGB.get(level, (0, 150, 255))
        if self.np: self.np[0] = self.rgb; self.np.write()
    def led_off(self):
        self.rgb = (0, 0, 0)
        if self.np: self.np[0] = (0, 0, 0); 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); self.led_off()
        self.draw_status(); self.draw_dots(); self._redraw_play()
    def _redraw_play(self):
        for (x, y, w, h, key) in self.buttons:
            if key == "play": self._draw_button(x, y, w, h, key); return
    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(); self.draw_status(); self.draw_dots(); self._redraw_play()
        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 ----------
    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))
                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 (only while running)
            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
        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
            if abs(y) > JOY_DEADZONE:
                self.set_bpm(self.bpm + (1 if y > 0 else -1) * (5 if abs(y) > 26000 else 1))
                self._joyNext = time.ticks_add(now, 70)
            elif abs(x) > JOY_DEADZONE:
                self.goto(self.idx + (1 if x > 0 else -1)); self._joyNext = time.ticks_add(now, 350); return
            else:
                self._joyNext = time.ticks_add(now, 20)
        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
        # edge-detect the touch like the hardware buttons: act once on finger-DOWN, ignore held
        pt = self.touch.read()
        if pt:
            self._touchSeen = now
            if not self._touchDown:
                self._touchDown = True; self.hit(pt[0], pt[1])
        elif self._touchDown and time.ticks_diff(now, self._touchSeen) > 140:
            self._touchDown = False

    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); self._btn_label(bx, by, bw, bh, key)
                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)
                return

    # ---------- drawing ----------
    def draw_static(self):
        d = self.d; d.fill(C_BG)
        d.text_aa("PM_K-1 KIT", 12, 10, C_CYAN, C_BG, FONT_M)   # VARASYS logo is on the case, not the screen
        d.fill_rect(0, 42, WIDTH, 2, C_PANEL)
        d.text_aa("BPM", 12, 120, C_MUTE, C_BG, FONT_M)
        self.buttons = []
        bw = 96; bh = 56; 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, 300, bw, bh, key)); self._draw_button(x, 300, bw, bh, key)
        for x, key in zip(xs, ("minus", "tap", "plus")):
            self.buttons.append((x, 372, bw, bh, key)); self._draw_button(x, 372, bw, bh, key)

    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)
        self._btn_label(x, y, w, h, key)
    def _btn_label(self, x, y, w, h, key):
        sym = {"prev": "◀◀", "next": "▶▶", "minus": "-", "plus": "+", "tap": "TAP",
               "play": "■" if self.running else "▶"}[key]
        col = C_GREEN if key == "play" else C_TXT
        bg = C_BTNHI if False else C_BTN
        self.d.text_center(sym, x + w//2, y + h//2, col, bg, FONT_M)

    def draw_bpm(self):
        d = self.d; s = str(self.bpm)
        d.fill_rect(60, 84, WIDTH - 60, 96, C_BG)
        tw = d.text_w(s, FONT_L)
        d.text_aa(s, WIDTH - 14 - tw, 90, C_TXT, C_BG, FONT_L)

    def draw_status(self):
        d = self.d; d.fill_rect(0, 236, WIDTH, 58, C_BG)
        d.text_aa("▶ RUN" if self.running else "■ STOP", 12, 240,
                  C_GREEN if self.running else C_MUTE, C_BG, FONT_M)
        idx = "%d/%d" % (self.idx + 1, len(PROGRAMS)); tw = d.text_w(idx, FONT_M)
        d.text_aa(idx, WIDTH - 12 - tw, 240, C_MUTE, C_BG, FONT_M)
        d.text_aa(self.name[:26], 12, 268, C_TXT, C_BG, FONT_M)

    def draw_dots(self):
        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)
        for i in range(bpb):
            lvl = m['levels'][(i*m['sub']) % m['steps']]
            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_aa("touch: not found", 12, HEIGHT - 30, C_AMBER, C_BG, FONT_M)
        while True:
            self.tick(); self.poll(); time.sleep_us(200)

# ============================== GO ==============================
App().run()
