diff --git a/wokwi/README.md b/wokwi/README.md new file mode 100644 index 0000000..9ca301f --- /dev/null +++ b/wokwi/README.md @@ -0,0 +1,40 @@ +# Wokwi simulation — PM‑µ Micro (Raspberry Pi Pico) + +A runnable [Wokwi](https://wokwi.com) simulation of the **PM‑µ Micro** metronome on a +Raspberry Pi Pico (RP2040), in MicroPython. It's a *functional* stand‑in — Wokwi has no +14‑segment display or analog audio path, so we approximate: + +| Real device | Simulated with | +|---|---| +| Clickable thumb‑roller | **KY‑040 rotary encoder** (rotate / press / hold+rotate) | +| Amber 14‑segment display | **SSD1306 OLED** (shows BPM and track names) | +| Analog click + speaker | **Piezo buzzer** (accent beat = higher, longer beep) | + +### Controls +- **Rotate** the encoder → tempo (BPM) +- **Press** (the encoder's button) → start / stop +- **Hold the button + rotate** → switch track (release to load it) + +## Run it (you do this part — I can't operate your Wokwi account) +I can't log into wokwi.com or create the project on the site for you. Use these files: + +1. Open **https://wokwi.com/pi-pico** — it starts a new Pi Pico **MicroPython** project. +2. Click the **`diagram.json`** tab and replace its contents with this folder's `diagram.json`. +3. Replace **`main.py`** with this folder's `main.py`. +4. Add a new file named **`ssd1306.py`** (the **+** next to the file tabs) and paste this folder's `ssd1306.py`. +5. Press **▶ (play)**. Rotate / click the encoder; you'll hear the click and see the OLED update. + +> If you use the Wokwi VS Code extension instead, keep these three files together and add a +> `diagram.json` reference as usual. + +## Pin map (Pico GPIO) +| Function | Pin | +|---|---| +| OLED SDA / SCL (I²C0) | GP0 / GP1 | +| Encoder CLK / DT / SW | GP2 / GP3 / GP4 | +| Buzzer | GP5 | +| OLED + encoder power | 3V3 / GND | + +The real firmware ("PORTS TO FIRMWARE" in the web app) drives a 14‑segment display over +I²C and injects the click into the analog signal path; this sim keeps the same control +model and beat scheduling so the *feel* matches. diff --git a/wokwi/__pycache__/ssd1306.cpython-312.pyc b/wokwi/__pycache__/ssd1306.cpython-312.pyc new file mode 100644 index 0000000..ca23178 Binary files /dev/null and b/wokwi/__pycache__/ssd1306.cpython-312.pyc differ diff --git a/wokwi/diagram.json b/wokwi/diagram.json new file mode 100644 index 0000000..8cb584b --- /dev/null +++ b/wokwi/diagram.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "author": "VARASYS PolyMeter", + "editor": "wokwi", + "parts": [ + { "type": "wokwi-pi-pico", "id": "pico", "top": 0, "left": 0, "attrs": {} }, + { "type": "board-ssd1306", "id": "oled", "top": -118, "left": 200, "attrs": {} }, + { "type": "wokwi-ky-040", "id": "enc", "top": 70, "left": 250, "attrs": {} }, + { "type": "wokwi-buzzer", "id": "bz", "top": 165, "left": 130, "attrs": { "volume": "0.1" } } + ], + "connections": [ + [ "pico:GP0", "oled:SDA", "green", [] ], + [ "pico:GP1", "oled:SCL", "green", [] ], + [ "pico:3V3", "oled:VCC", "red", [] ], + [ "pico:GND", "oled:GND", "black", [] ], + [ "pico:GP2", "enc:CLK", "blue", [] ], + [ "pico:GP3", "enc:DT", "blue", [] ], + [ "pico:GP4", "enc:SW", "yellow",[] ], + [ "pico:3V3", "enc:VCC", "red", [] ], + [ "pico:GND", "enc:GND", "black", [] ], + [ "pico:GP5", "bz:1", "green", [] ], + [ "pico:GND", "bz:2", "black", [] ] + ], + "dependencies": {} +} diff --git a/wokwi/main.py b/wokwi/main.py new file mode 100644 index 0000000..7c72498 --- /dev/null +++ b/wokwi/main.py @@ -0,0 +1,119 @@ +# VARASYS PM-u "Micro" — metronome, simulated on a Raspberry Pi Pico (RP2040). +# +# A functional stand-in for the real inline practice bar, using parts Wokwi has: +# * KY-040 rotary encoder -> the clickable thumb-roller +# rotate = tempo +# press (SW) = start / stop +# hold SW + rotate = switch track +# * SSD1306 OLED -> the amber 14-segment display (shows BPM / track name) +# * Piezo buzzer -> the click (accent beat = higher, longer beep) +# +# Run it at https://wokwi.com/pi-pico (MicroPython). +# Files in this project: diagram.json, main.py, ssd1306.py (see README.md). + +from machine import Pin, I2C, PWM +import ssd1306, time + +# ---- display: SSD1306 128x64 on I2C0 (SDA=GP0, SCL=GP1) ---- +i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400_000) +oled = ssd1306.SSD1306_I2C(128, 64, i2c) + +# ---- KY-040 rotary encoder (CLK=GP2, DT=GP3, SW=GP4) ---- +clk = Pin(2, Pin.IN, Pin.PULL_UP) +dt = Pin(3, Pin.IN, Pin.PULL_UP) +sw = Pin(4, Pin.IN, Pin.PULL_UP) + +# ---- piezo buzzer (GP5) ---- +buz = PWM(Pin(5)); buz.duty_u16(0) + +# ---- built-in "tracks": (name, bpm, accent pattern over the bar) ; 2 = accent, 1 = normal ---- +TRACKS = [ + ("ROCK", 120, (2, 1, 1, 1)), + ("FUNK", 100, (2, 1, 2, 1)), + ("SWING", 140, (2, 1, 1, 2)), + ("WALTZ", 90, (2, 1, 1)), # 3/4 + ("BCKBT", 96, (1, 2, 1, 2)), # backbeat +] + +ti = 0 +bpm = TRACKS[0][1] +pat = TRACKS[0][2] +running = False +mode = "bpm" # "bpm" | "track" +preview = 0 +beat = 0 +next_beat = time.ticks_ms() + +def load(i): + global ti, bpm, pat, beat + ti = i % len(TRACKS) + bpm = TRACKS[ti][1] + pat = TRACKS[ti][2] + beat = 0 + +def show(): + oled.fill(0) + oled.text("PM-u MICRO", 0, 0) + oled.text("RUN" if running else "stop", 96, 0) + oled.hline(0, 12, 128, 1) + if mode == "track": + oled.text("TRACK", 0, 24) + oled.text(TRACKS[preview][0], 0, 42) + oled.text("#%d/%d" % (preview + 1, len(TRACKS)), 70, 42) + else: + oled.text("TEMPO (BPM)", 0, 24) + oled.text("%d" % bpm, 0, 42) + oled.text(TRACKS[ti][0], 64, 42) + oled.show() + +def click(accent): + buz.freq(2000 if accent else 1200) + buz.duty_u16(22000) + time.sleep_ms(18 if accent else 11) + buz.duty_u16(0) + +load(0); show() + +last_clk = clk.value() +sw_down = None +held = False + +while True: + now = time.ticks_ms() + + # --- metronome beat --- + if running and time.ticks_diff(now, next_beat) >= 0: + click(pat[beat % len(pat)] >= 2) + beat = (beat + 1) % len(pat) + next_beat = time.ticks_add(next_beat, int(60000 / bpm)) + + # --- encoder rotation: one detent per CLK falling edge --- + c = clk.value() + if c == 0 and last_clk == 1: + step = 1 if dt.value() else -1 + if held: # hold + rotate -> preview track + mode = "track" + preview = (preview + step) % len(TRACKS) + else: # rotate -> tempo + mode = "bpm" + bpm = max(30, min(300, bpm + step)) + show() + last_clk = c + + # --- button: quick press = start/stop ; hold (~350 ms) = enter track mode --- + if sw.value() == 0 and sw_down is None: + sw_down = now; held = False; preview = ti + if sw_down is not None and not held and time.ticks_diff(now, sw_down) > 350: + held = True; mode = "track"; show() + if sw.value() == 1 and sw_down is not None: + if held: # release after hold+rotate -> commit track + load(preview); mode = "track"; show() + time.sleep_ms(800); mode = "bpm"; show() + else: # quick tap -> start / stop + running = not running + if running: + beat = 0; next_beat = time.ticks_ms() + show() + sw_down = None; held = False + + time.sleep_ms(2) diff --git a/wokwi/ssd1306.py b/wokwi/ssd1306.py new file mode 100644 index 0000000..944bcbf --- /dev/null +++ b/wokwi/ssd1306.py @@ -0,0 +1,101 @@ +# MicroPython SSD1306 OLED driver, I2C interface (from micropython-lib, MIT licence). +# Bundled here so the project runs with no extra installs. +from micropython import const +import framebuf + +SET_CONTRAST = const(0x81) +SET_ENTIRE_ON = const(0xA4) +SET_NORM_INV = const(0xA6) +SET_DISP = const(0xAE) +SET_MEM_ADDR = const(0x20) +SET_COL_ADDR = const(0x21) +SET_PAGE_ADDR = const(0x22) +SET_DISP_START_LINE = const(0x40) +SET_SEG_REMAP = const(0xA0) +SET_MUX_RATIO = const(0xA8) +SET_COM_OUT_DIR = const(0xC0) +SET_DISP_OFFSET = const(0xD3) +SET_COM_PIN_CFG = const(0xDA) +SET_DISP_CLK_DIV = const(0xD5) +SET_PRECHARGE = const(0xD9) +SET_VCOM_DESEL = const(0xDB) +SET_CHARGE_PUMP = const(0x8D) + + +class SSD1306(framebuf.FrameBuffer): + def __init__(self, width, height, external_vcc): + self.width = width + self.height = height + self.external_vcc = external_vcc + self.pages = self.height // 8 + self.buffer = bytearray(self.pages * self.width) + super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB) + self.init_display() + + def init_display(self): + for cmd in ( + SET_DISP | 0x00, + SET_MEM_ADDR, 0x00, + SET_DISP_START_LINE | 0x00, + SET_SEG_REMAP | 0x01, + SET_MUX_RATIO, self.height - 1, + SET_COM_OUT_DIR | 0x08, + SET_DISP_OFFSET, 0x00, + SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12, + SET_DISP_CLK_DIV, 0x80, + SET_PRECHARGE, 0x22 if self.external_vcc else 0xF1, + SET_VCOM_DESEL, 0x30, + SET_CONTRAST, 0xFF, + SET_ENTIRE_ON, + SET_NORM_INV, + SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14, + SET_DISP | 0x01, + ): + self.write_cmd(cmd) + self.fill(0) + self.show() + + def poweroff(self): + self.write_cmd(SET_DISP | 0x00) + + def poweron(self): + self.write_cmd(SET_DISP | 0x01) + + def contrast(self, contrast): + self.write_cmd(SET_CONTRAST) + self.write_cmd(contrast) + + def invert(self, invert): + self.write_cmd(SET_NORM_INV | (invert & 1)) + + def show(self): + x0 = 0 + x1 = self.width - 1 + if self.width == 64: + x0 += 32 + x1 += 32 + self.write_cmd(SET_COL_ADDR) + self.write_cmd(x0) + self.write_cmd(x1) + self.write_cmd(SET_PAGE_ADDR) + self.write_cmd(0) + self.write_cmd(self.pages - 1) + self.write_data(self.buffer) + + +class SSD1306_I2C(SSD1306): + def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False): + self.i2c = i2c + self.addr = addr + self.temp = bytearray(2) + self.write_list = [b"\x40", None] + super().__init__(width, height, external_vcc) + + def write_cmd(self, cmd): + self.temp[0] = 0x80 + self.temp[1] = cmd + self.i2c.writeto(self.addr, self.temp) + + def write_data(self, buf): + self.write_list[1] = buf + self.i2c.writevto(self.addr, self.write_list)