From 1186e61588890744115d0aba1b947f18cf6fa0a7 Mon Sep 17 00:00:00 2001 From: Me Here Date: Thu, 28 May 2026 08:04:53 -0500 Subject: [PATCH] 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) --- wokwi/README.md | 40 ++++++++ wokwi/__pycache__/ssd1306.cpython-312.pyc | Bin 0 -> 5509 bytes wokwi/diagram.json | 25 +++++ wokwi/main.py | 119 ++++++++++++++++++++++ wokwi/ssd1306.py | 101 ++++++++++++++++++ 5 files changed, 285 insertions(+) create mode 100644 wokwi/README.md create mode 100644 wokwi/__pycache__/ssd1306.cpython-312.pyc create mode 100644 wokwi/diagram.json create mode 100644 wokwi/main.py create mode 100644 wokwi/ssd1306.py 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 0000000000000000000000000000000000000000..ca231785b638e6be98ab53afd61c45dcd430b509 GIT binary patch literal 5509 zcmcgwU2s#!72bRGcO_j(#{b3;WMczrIzYg2Qb+=}C5%&BHnyONXmdyC%2-1F$=(aL zHKKJp%+#8J!8GY0KOxU_3JfzXeQMh2L*M$)mzdaN=f*Fc_Qh}1hD;_e?KyjOuViDw zQ@bvnPrWY^@=s)pVk;7xBOD=jiA-cJO-8vW$05z9 z`6$nm1SeY_K|7YHh4EICw=&*l@;1iXP2SFUhsiq_FPOZ*c&Eub8SgTA7vtR~?`FJc z@*?9sChuXq*W|s7_nEwp@f9Xt!T4P!zl-seCSS?;DwD6`NjH(JZxFdA!KV)CRw>6v z7M+XMFwI)ftV`6G(%NZ0P`{2U0Zy~U$Hx>ky#8}w*MEcXTZG>s_z{{Anh{PT6c8RD z{1xE~1Px&s;Y)>yf&RH_WMO{NAAZU)Eq2L?GkG99H=0^yz1C7hy>;!lDHFuX}xh#=P zg)9$T(UAzrT7WYqMzSPj(q;#6#|%h8uV_?4RHxAjJ@9+m3P`s}kj;Rj8(RpY8E#Kh zI1qZtS+nHUUi?;y9T!8c*E^32(jDfkRv3L#l?GL+9~JGz;g@Pka)WwQBseln0(^K~ z06yT>Y?Db@9ntI~iR9%GRTC3aYJ!f%($ckfT(eEYE+-Vtd1+cr#3+qTYxYZ5hldkX zvnf|6pb{i0IhIr2Ja6O3pKKd}a=zr^r z+PAU8n&8Pf7KQqJ^}WXV#)5o5wUBz;^2gWz@cLh-R!*H;JaukK7+e*^6`^5KXjm2+ zH{r7+Zn_;XI2-1se+KDYqM`yT;BF3ZF>_O8PBq=T*k-z%%rjlwA69mD9MA%HD!G|# zVZFRYFZhD#E-x9udgT!80WaIeIN7e6E+sqEYGV}WS!>Xx8u|#($EnsEGTEi#+F6m^ z>K=nuUo$DcLA`WL5yteC*^Jp?xFJY>QbovhIPj_c#u%kK%NfUyBR6ZT==`mvR)dnu zv_RvaAurWij)yl)ajVvGZz(R4;%=-n8QN)m24#4h*Z+-h6yZMzX!CV6U!YnOhC&f3 z*fTVsiAeQ@`lOCva8Ro<2V=yB1_Jl*42ZckSZfP*o*D;{8edj-r20J1>{p_@8 zK2+)I@00q^@1f@bvl=>e!H?r5gkIMwCh0$Y?n0nCtpiPdvz zu76Fe&$q0J`wA_K;(;}>As<{6{aCl%J2`*y!LIpJYhq(wUKE?LjsO%Q_b)D7eBAk@ zwS7MFS#V91V<+mrom^KGBOc)Rn^z(#I>n)O;~Z8y8HPd{ziRqfg!l}`U` z*V7i_*>yXd3+In7i~F9og7E19;`H2h=A3g)H$?~$NZxW&bh9n-0na>(4{boeB}P)^ z(F0^y%A*9H>5_l5z|A}p?AZaT^ccWHj@eehp>(Ti!o>Jwf{qUlmjb3CIRRRm=)({% zVWQrg_tU9D%blMs3kUxP^L@yNX#hS(yAjR;luy&4PiidVGaR5Oco((bM_?v2!zM`3 ze9VLcK)l+7CFAXE?DFsd=H}#l;2C0Ivuw>ut zlF(AL_1x65;D2#Zc<-@A;qJV8QCY~WGO*p-y@U2bcVhvkbeb)|o*cWDpj)SZ4%#zb z!|=L75M)XnK-uIA*B)~VnZ>$e%fj*R@Q@oYAIS4L7N=o^?M1(@u0x`h+TH}bY+-ki z3sz>$cv!UOixl=i+iWYP`*X_{@1R+}#XFCc@**eGVk`%qICOg z8QooKH7J#Wj7e);uzW3_a5xaQ?I=VxQrFeI1Ej>}imNQ;v6@zq#bZ(%7X zyP;?gtcCU=;Hjs0b|@}e^AwZUcp{yaBnZWre6SdIS~m`+PZR;OUIp*GZ*Guhyu-F< zqq4?Uy>Xbc9on!n;B8GC0%vRc+Ai8!**satIP@_K8Xn8$_W{b^Te7wI;&N_%aj}<` z9bQ&8&0X9gspnW%+;nId#|%H$fMKsId_e)Nmn^(sO0O#wwOJ{-digs_=4Emj-(yxo zPsK!um7E>kLSP7Zz42L_@pNS`5=)qV4|F#17CRkqXqM!$xMq#XGNtf%BAQiAj7}JS znNBJy-3MZ}Mc~^lNSEFfF=Q_x;0r_F6SzsyWueUIJL3m|nR=j2%tytKphbBb03Q`q zweUC)R;%lwa;{bH&Bs3JMRK*Vxe&}B0n%r_9BK;o2ja5u=8F*?je&c&SpdK|`I0!u zzXR>~xZOEQveq3RhbePgV1QzQQOdWuBsCPu>PN*(mTiVLOwqqss=)$Dz)H~#DH>LD zu;8GM>lO}zXv@8bAtqB(=4*$|l<*_eMZl$aRddid&SL@*AW-@SMg`BV=)a#G2 zhWUm<@P5xi&$4j%I}G~^7*_vZU`Nw&{~y!oSFn}JFdN-k0$csHqPdKe!U8YlEmk`Z z9Wu2qS`9-mnqI*T0kiG(gQYN_af^CTWTwIwX};xNU_JmTue2Z;J{aKFaouO&hNNLg zGET=Qrqz-0F^cX^n-Ew!_a0K1l(3|g9!F{jA%cJjF?|!E9pNnmbQJmn1Wb$QNd#OC zJq@6_&)^>po$T)j7AM(EAL=P1JK^67@92U3Jvs(GIQS`l19)oTIPSTfaIbwueE+Cz z%m+WYG9!LjBs>OTN`x0AW|N`(IjJ&~4AG29_>86(h|fud(PnCU4T 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)