The on-screen header showed VARASYS + the model; since the wordmark is already silkscreened on the case/PCB, keep only the PM_K-1 KIT label on the display. Applied to both pico/main.py (draw_static) and the kit.html web simulator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
491 lines
21 KiB
Python
491 lines
21 KiB
Python
# 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<bpm>;[vol];[cd];[b];<lane>;<lane>;...
|
|
# 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}
|
|
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("PM_K-1 KIT", 12, 12, C_CYAN, C_BG, 2) # VARASYS logo is on the case, not the screen
|
|
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()
|