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:
parent
90b41ba21c
commit
1186e61588
5 changed files with 285 additions and 0 deletions
40
wokwi/README.md
Normal file
40
wokwi/README.md
Normal 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* 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.
|
||||
BIN
wokwi/__pycache__/ssd1306.cpython-312.pyc
Normal file
BIN
wokwi/__pycache__/ssd1306.cpython-312.pyc
Normal file
Binary file not shown.
25
wokwi/diagram.json
Normal file
25
wokwi/diagram.json
Normal 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
119
wokwi/main.py
Normal 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
101
wokwi/ssd1306.py
Normal 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)
|
||||
Loading…
Reference in a new issue