mi-sim — Mutable Instruments firmware simulator for macOS
Runs the unmodified Mutable Instruments Eurorack firmware
(pichenettes/eurorack, pulled in as a git submodule) on the Mac, with the
STM32 drivers swapped for a virtual front panel. Not just the DSP: the real
ui.cc (buttons, long presses, hidden parameters, LED animations,
calibration, normalization probing) and settings.cc (flash storage with
wear levelling) run block by block exactly as the DAC interrupt drives them on
hardware.
Supported modules: Plaits (STM32F37x), Rings and Elements (STM32F4). The layout is per-module so others (Stages, Marbles, Clouds, ...) can follow the same recipe.
third_party/eurorack/ upstream firmware, untouched (submodule)
sim/hal/sim_hal.h fake HAL (F37x + F4): only what firmware *headers* use inline
sim/shadow/stmlib/system/ storage.h replacement -> in-memory flash (page + sector layouts)
sim/shadow/rings/ui.h one-line host-portability fix (checked by tools/check_shadows.py)
sim/common/ VirtualFlash, CoreAudio output, global lock, zeroed allocation
sim/<module>/drivers/ replacement drivers (ADCs, LEDs, debug UART)
sim/<module>/<module>_hardware.* virtual panel + analog front-end model
sim/<module>/<module>_sim.* boots the firmware objects, replicates Init()/FillBuffer()/SysTick
sim/<module>/<module>_c_api.* C API (thread-safe) for bindings
python/mi_sim/{plaits,rings,elements}.py ctypes wrappers, ms-based deterministic time
python/mi_sim/gui.py + panels.py Dear PyGui front panels with live audio + scope
tests/ pytest suites exercising the firmware's behaviour
Build
git clone --recursive <this repo> # or: git submodule update --init --recursive
brew install cmake ninja # Xcode command line tools for clang
make build # -> build/libmi_sim.dylib
make venv # python deps: numpy, pytest, dearpygui
make test # 66 tests, < 1 s
make gui MODULE=rings # interactive panel with sound (plaits | rings | elements)
Only plaits/test/-style host compilation is needed: Apple clang, -DTEST,
no ARM toolchain.
Scripted use
from mi_sim.plaits import PlaitsSim, Pot, CV, Switch
sim = PlaitsSim() # factory-fresh unit (blank flash)
sim.run(1000) # 1 s of power-on settling (pot filters)
sim.patch(CV.V_OCT, 1.0) # insert a cable carrying 1 V
sim.tap(Switch.ROW_1) # next engine
sim.hold_both(2100); sim.release_all() # enter calibration mode
audio = sim.run(500) # (frames, 2) int16, OUT / AUX
print(sim.led_pattern()) # e.g. "g......." (blinking green LED 1)
print(sim.modulations["note"]) # 12.0
image = sim.flash # bytes; PlaitsSim(flash=image) reboots with it
from mi_sim.rings import RingsSim, CV, Switch
r = RingsSim(); r.run(1000)
r.patch_trigger(); r.strum() # a cable in STRUM disables the internal exciter
r.tap(Switch.MODEL) # next resonator model
r.performance["internal_note"] # False once V/OCT is patched
audio = r.run(500, audio_in=my_int16) # feed the IN jack
from mi_sim.elements import ElementsSim, Pot, CV
e = ElementsSim(); e.run(1500)
e.press(); e.run(400) # PLAY button gates the voice
e.set_pot(Pot.RESONATOR_GEOMETRY, 0.7)
Time is deterministic: run(ms) executes an exact number of firmware
blocks (Plaits: 4 per ms of 12 samples at 48 kHz; Rings: 2 per ms of 24 at
48 kHz; Elements: 2 per ms of 16 at 32 kHz). Rings and Elements run their
1 kHz SysTick UI poll and the main-loop event queue every second block,
as on hardware. "Hold for 3 s" is always the same iterations, so tests
never depend on wall-clock timing.
What you can observe
| accessor | source in firmware |
|---|---|
leds, leds_avg, led_pattern() | Leds::Write (average over the last run gives PWM/blink brightness) |
params | Patch (knobs after smoothing, hidden params) |
modulations (Plaits), performance (Rings, Elements) | calibrated CV, cable-detection flags, note/strum/gate |
state, calibration, settings | what is persisted in flash |
active_engine, polyphony, model, resonator_model, easter_egg | Voice / Part |
factory_test(cmd, arg) | the UART factory-test protocol in Ui::HandleFactoryTestingRequest |
flash, flash_erase_count | virtual flash image |
Virtual hardware model
- Pots: 0..1, 12-bit left-aligned like the ADC.
- CV jacks: volts + a patched flag. Every input runs through the same
inverting stage (
adc = -0.2 * V + offset); V/OCT, TRIGGER and LEVEL carry the DC offsets implied by the factory calibration defaults. Override withset_front_end()to test calibration against a "bad" unit. - Normalization: unpatched jacks read the firmware's pseudo-random probe bit (Plaits PA9: FM/TIMBRE/MORPH/TRIG/LEVEL; Rings PC8: STRUM, V/OCT and, with one block of codec latency, the audio IN), so the cable detectors behave as on hardware.
- Rings / Elements front-end: V/OCT
adc = 0.7912 - 0.1424 V(the 1 V / 3 V readings quoted inCalibrateC3), other CVsadc = 0.505 - 0.0758 Vso that a fully open attenuverter gives 1 V/oct on the FM inputs. Elements' STRENGTH jack is normalled to full. - Switches: active-low with debouncing done by the firmware.
- Flash: 64 KB image, page erase = 0xFF fill, program = AND (bits can only be cleared), so wear-levelling and "write before erase" bugs show.
- Debug UART: USART3 as a pair of byte queues.
GUI
make gui MODULE=elements (or python -m mi_sim --module rings --flash my_unit.bin).
Left: knobs and attenuverters, the buttons (hold with the mouse), LEDs.
Right: CV sliders with a cable checkbox each, pulse buttons for gate/trigger
jacks, and for modules with audio inputs a cable checkbox, a noise level and
an impulse button. Bottom: scope of the two outputs. Audio goes to the
default output via CoreAudio; without audio the firmware still runs in real
time.
Flashing the real module
See docs/flashing.md: cross-compiling upstream, encoding
the audio-bootloader WAV with tools/qpsk_encoder.py (a Python 3 port of the
upstream encoder, verified against the bootloader's demodulator), and SWD
with OpenOCD.
Adding another module
- Look at
<module>/test/makefileupstream for the host-buildable DSP set. - Compile GPIO-only upstream drivers unchanged (switches, gate/trigger
inputs, GPIO LEDs); write replacement
drivers/*.cconly for ADCs, PWM LEDs and the debug UART. Extendsim/hal/sim_hal.hwith whatever HAL symbols the headers use inline. - Replicate the module's
Init,FillBufferandSysTick_Handlerin a<module>_sim.cc; interleave SysTick andDoEvents()at 1 kHz. - Derive the front-end model from the calibration defaults and comments.
- Expose a C API (
struct : sim::ZeroedNew) and a thin Python class; add a panel inpanels.py; write behaviour tests. - If an upstream header does not compile on the host, shadow it under
sim/shadow/and register the substitution intools/check_shadows.py.
Licence
Simulator code: MIT (see LICENSE). The firmware in third_party/eurorack
keeps its own licences (MIT for STM32 modules, GPL for AVR modules,
cc-by-sa for hardware).
