Add Wokwi (Pi Pico) simulation of the Micro metronome

MicroPython sim that runs on https://wokwi.com/pi-pico: KY-040 encoder stands in
for the thumb-roller (rotate=tempo, press=start/stop, hold+rotate=track), an
SSD1306 OLED for the display, and a piezo buzzer for the click. Files:
diagram.json, main.py, ssd1306.py + README with the (manual) setup steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 08:04:53 -05:00
parent 90b41ba21c
commit 1186e61588
5 changed files with 285 additions and 0 deletions

40
wokwi/README.md Normal file
View file

@ -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* standin — Wokwi has no
14segment display or analog audio path, so we approximate:
| Real device | Simulated with |
|---|---|
| Clickable thumbroller | **KY040 rotary encoder** (rotate / press / hold+rotate) |
| Amber 14segment 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 14segment 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.

Binary file not shown.

25
wokwi/diagram.json Normal file
View file

@ -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": {}
}

119
wokwi/main.py Normal file
View file

@ -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)

101
wokwi/ssd1306.py Normal file
View file

@ -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)