Programmable VST3 instruments and audio effects backed by dedicated
MicroPython engine processes. One bundle ships a whole library: every
audioinstruments module and every audioeffects class appears in the
DAW's browser under its own name and category - TR-808 under
Instrument|Drum, Tape Delay under Fx|Delay - alongside two generic
MicroPython Script Host classes that run any script you point them at.
The list is not compiled in. scan_plugins.py, run by the engine itself,
reads what each library module declares about itself and writes the
moduleinfo.json the plug-in loads at startup - the same file a host reads
to enumerate classes without loading the binary. Adding an instrument is
writing a script and re-scanning; there is no build step and no compiler
involved.
The VST audio callback stays native and real-time safe. Python, garbage collection, filesystem access, and engine lifecycle work all happen in a separate sidecar process - one per active plug-in instance - so a script that loops forever or exhausts memory takes down its own sidecar and gets restarted, rather than taking the DAW with it.
Windows and Linux builds both ship, each with an LVGL editor of its own - a generic panel built from what the instance already declares, painted by the engine into shared memory and blitted by a native view. The host's generic parameter editor still reaches everything it did. The same script and project state render byte-identical PCM on both platforms, with or without an editor attached.
src/ |
the C++ that builds the plug-in: plugin/ (VST3 classes), protocol/ (the shared-memory wire format), runtime/ (shared memory and child processes) |
usermods/ |
the MicroPython C modules the engine binds to: vstaudio/ (the audio API scripts use) and vstui/ (the editor's framebuffer, input and edit rings) |
lib/ |
everything staged into the bundle beside the engine: the bootstrap, the adapters, scan_plugins.py, the default instrument, and the editor's Python half (vst_editor.py, vst_board_config.py, vst_panel/) |
tools/ |
developer tooling - piece.py and render_preview.py for compositions, the harness.py CPython sidecar stand-in, and the library test sweeps |
tests/ |
the ctest suite and smoke_host/, a minimal VST3 host that loads the bundle with no DAW |
scripts/ |
build, packaging and setup automation |
reaper/ + reaper.sh |
everything that drives REAPER. Deletable as a unit; nothing outside it depends on it |
soundtrack/ |
example pieces, each with its own composition.py |
The architecture is written down in
docs/architecture/phase-0.md (the system
boundary and what each process owns) and
docs/architecture/ipc-v1.md (the
shared-memory protocol rules), with
docs/architecture/ui-v1.md covering the
editor. ipc-v1.md and ui-v1.md describe the shipping design;
phase-0.md is historical - the Windows-only, no-editor design accepted
before Linux shipped and before the LVGL editor existed - kept for its
still-valid process/thread/state reasoning, not as a description of
what ships today. The canonical structure sizes and offsets live in
src/protocol/include/mpvst/protocol.h and src/protocol/include/mpvst/ui.h.
- A C++17 toolchain: MSVC on Windows, GCC or Clang on Linux.
- CMake 3.25+ and Ninja (
cmake -S . -B .build-linux -G Ninjais the documented invocation below). - On Linux, X11 development headers (
src/plugin/CMakeLists.txtrunsfind_package(X11 REQUIRED)for the editor's native window) - e.g.libx11-devon Debian/Ubuntu. - Python 3.x for
tools/,scripts/, and thectest-registered Python suites;numpyandpydevices-audioif(from TestPyPI - seetools/README.md) for the instrument/effect tests and preview renders, andflake8for thempvst_lintctest.scripts/bootstrap.shcreates a repo-local.venvwith these. - The Steinberg VST3 SDK, fetched by
scripts/fetch-vst3-sdk.shinto the gitignored.deps/vst3sdk(see License for its terms). - The sibling
cmodsandaudioifcheckouts the MicroPython engine build depends on, fetched byscripts/fetch-sibling-repos.sh. - On WSL, building the Windows engine/plugin needs a reachable Windows
host:
scripts/build-micropython-engine.sh --port windowsandscripts/install-plugin-windows.shboth shell out topowershell.exe, andscripts/bootstrap.shskips the Windows engine port automatically when/mnt/c/Usersorpowershell.exeis not available.
A fresh clone has none of the external dependencies this repo needs - the
VST3 SDK, the sibling cmods/audioif repos the engine build depends
on, or REAPER for the DAW-driven tooling. .deps/ and those sibling
checkouts are all gitignored. One command sets all of it up:
./scripts/bootstrap.shSee scripts/README.md for what it does and how to run each step individually.
The MicroPython sidecar is built separately from the plug-in, and only
needs rebuilding when usermods/vstaudio, usermods/vstui, or the
sibling audioif checkout's C sources change. It lands in the ignored
.deps/engine/, and the plug-in build stages it into the bundle. CMake
never detects a stale engine on its own - it only re-stages the file at
MPVST_MICROPYTHON_ENGINE if that path's mtime changes, so after any of
those three changes you must rerun the build script yourself before
reconfiguring/rebuilding the plug-in:
./scripts/build-micropython-engine.sh --port windows
./scripts/build-micropython-engine.sh --port unixLinux:
cmake -S . -B .build-linux -G Ninja
cmake --build .build-linux
ctest --test-dir .build-linux --output-on-failureWindows, driven from WSL with the vendored CMake. scripts/install-plugin-windows.sh
wraps the build and installs the result into the per-user VST3 directory a
DAW scans:
./scripts/install-plugin-windows.shThe Linux CMake cache remembers the engine path. After switching engines,
reconfigure with
cmake -S . -B .build-linux -U MPVST_MICROPYTHON_ENGINE.
Steinberg hosting tools are off by default so a plug-in-only build does
not pull in editor-host dependencies. Enable them in a dedicated validator
build with -DSMTG_ENABLE_VST3_HOSTING_EXAMPLES=ON. VST3_SDK_ROOT may
point at an existing SDK checkout instead of the fetched one.
A script registers a callback and an output. For a cataloged audioif
component, the provider metadata is mandatory; a consumer such as this
plug-in remains tolerant of missing optional fields.
The bundled lib/default_instrument.py is the working reference - it
tracks voices by VST note ID, maps velocity to amplitude, applies pressure
and pitch bend, and uses an explicit 50 ms release.
Events arrive through vstaudio.on_event() at absolute delayed sample
positions - note on/off with velocity and tuning, poly and channel
pressure, pitch bend, and all 128 MIDI CCs across 16 channels. Named
vstaudio.EVENT_* constants cover every type.
Macro automation arrives through the same callback as
vstaudio.EVENT_PARAMETER: data0 is the zero-based macro index, value0
the normalised value, sample_position the absolute render sample. A script
declares which macros it has the same way a library module does, with a
module-level tuple:
MACRO_LABELS = ("Gain", "Tone", "Attack", "Release")
MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR", 3: "UNIPOLAR"}
PATCHES = {0: ("Default", (64, 64, 64, 64))}A bare script without those declarations is still accepted by this consumer for compatibility and the editor draws no macros or patches. Audioif providers must declare the empty forms explicitly when they expose no controls. Renaming a label does not change parameter IDs or detach automation.
Every instrument also declares PATCHES, whose first entry is the sound
its own defaults describe. That is what an unset macro resolves to - not
the middle of its range, which is not "off" and not anything intended.
Values are MIDI integers 0-127. tools/derive_patches.py generates the
block by measuring the instrument rather than guessing.
The fifty-three instruments and the effects library are audioif's
audioinstruments and audioeffects packages - host-neutral Python that
any application can import, not just this plug-in. They are staged beside
the engine from a sibling audioif checkout (MPVST_AUDIOIF_LIB if it is
somewhere else).
There is no file per instrument. The unit the plug-in deals in is still a
script - the controller parses macro labels out of the embedded source,
and a saved project embeds its bytes - but that script is now built from
its catalog entry when a class is instantiated, rather than kept on disk.
Two lines, synthesized in CatalogEntry::scriptSource. That is what lets
the library be the single source of truth for a plug-in's name, category
and macro labels: there is no generated copy to drift from it.
An audioif provider declares NAME, MACRO_LABELS, MACRO_MODES, and
PATCHES; percussion instruments also declare NOTE_MAP. CATEGORIES,
VERSION, VENDOR, and DISPLAY_NAME are optional. This consumer requires
only NAME when it discovers a component, and uses DISPLAY_NAME when
available for the host-facing title. Its class ID is derived from the file
path plus the stable NAME, so a copy of one of ours is automatically a
distinct plug-in - and renaming the file or NAME is a breaking identity
change.
mpvst- marker comments live in moduleinfo.json and nowhere else. A .py
file - a library module, an effect class, a script you wrote - declares itself
with variables. JSON5 comments are the only extension slot moduleinfo.json
has, which is why they exist there; nothing reads one out of Python.
The consumer reads MACRO_LABELS and PATCHES when present, and reads
MACRO_MODES when a UI wants to distinguish a unipolar, bipolar, or toggle
control. A missing field is treated as absent. The parameters themselves are
unaffected - all sixteen macro slots and the patch parameter are permanent,
because they are what a host automates - but an undeclared optional surface
does not receive a fabricated control.
lib/mpvst_adapter.py is the seam between the two. vstaudio speaks the
normalised floats the VST3 parameter API uses; the instrument API speaks
MIDI 0-127, because that is what a keyboard, a sequencer and a saved
patch speak. The conversion happens there and nowhere else, as a multiply
rather than a quantization, so a host automating a macro with more than 7
bits keeps its resolution.
The soundtrack's piece-private instruments stay whole scripts in their
own piece directory - those files are the patches - and end in a
__main__ guard handing create to the same adapter.
audioeffects is forty-plus effect classes (dynamics, EQ, reverb, delay,
modulation, drive, pitch and stereo) importable from any effect script. Build
them through audioeffects.create(name, source, sample_rate, **options) so
the construction boundary stays portable across CPython, MicroPython and
CircuitPython; direct class constructors remain an implementation convenience.
It compensates for two CircuitPython biquad quirks that audioif
reproduces deliberately: filters in a stereo audiofilters.Filter centre
at twice the requested frequency, so the library halves what it asks for;
and peaking EQ's b2 sign is wrong upstream, so bells are built from
notch and band-pass sections instead. The factory configures the sample rate
for each component before construction; scripts do not need to manage a
process-wide rate.
20 visible parameters - bypass, Reload Script, read-only Engine Ready
and Engine Error, a patch selector, and 16 macros - plus 2,080 hidden
16-channel MIDI mapping parameters. REAPER reports three more of its own.
Macro parameter IDs are permanently 100-115. Current macro values are replayed to the script whenever it loads, reloads, or is restored from project state, so an automated or reopened instance sounds the way it was saved.
Engine Error reports 0 for clear, 1 for a script load failure, 2 for an
uncaught exception while rendering, and 3 for an uncaught exception in a
reload callback.
Project state embeds the active script source, so reopening a project does not depend on the original path. State v2 accepts legacy v1 and caps embedded source at 1 MiB.
An instance started from MPVST_SCRIPT_PATH follows that file: toggling
Reload Script re-reads what is on disk, and saving embeds the current
source. A project restored from state keeps its embedded snapshot and
ignores later edits to the original file. Reload is a rising edge - toggle
off then on - and is only observed while the plug-in is processing; the
value itself is not saved as state. Output uses a 128-sample fade-out, a
640-sample hold at the current 128-frame/512-latency setup, then a
128-sample fade-in.
Host transport position, tempo and time signature reach the script.
Locates, loop wraps and play-state changes arrive as
vstaudio.EVENT_TRANSPORT, and vstaudio.transport() returns
(playing, seconds, bpm, numerator, denominator).
SidecarTransport::telemetry() reports queue depth, render time,
underruns, event drops, restarts, error code and last exit reason, with
peaks tracked from the audio thread. An exit code of -1000 means the
supervisor killed an engine that had hung rather than finding one that
exited on its own.
Environment variables: MPVST_HEAP_BYTES caps the MicroPython heap per
instance, MPVST_SCRIPT_PATH selects a developer script, and
MPVST_ENGINE_PATH overrides which engine binary is launched.
Nothing is published yet: VERSION currently holds 0.0.1 as a
placeholder, and every archive package-linux.sh/package-windows.sh
produce is built and distributed locally, by hand. A real release channel
is expected to arrive with the planned post-program rename/refactor, not
before.
VERSION at the repository root is the single source of truth - CMake and
both packaging scripts read it, so a binary and the archive around it
cannot disagree about which version they are.
./scripts/package-linux.sh
./scripts/package-windows.shEach produces a versioned archive plus a SHA-256 sidecar under the ignored
dist/, after verifying the bundle carries its engine and bootstrap.
See docs/windows-workflow.md and
docs/linux-workflow.md for installation and the
desktop-script security model.
This repository deliberately has no hosted CI. The 14-test ctest suite
(lint included) is the gate, and it is run locally - by a developer before
pushing, or by scripts/bootstrap.sh as its final verification step.
Hosted CI is planned to arrive with the post-program refactor, not before.
ctest covers the plug-in with no DAW involved. Two further harnesses use
REAPER, and both need the packaged plug-in installed first because they
exercise the installed bundle:
./reaper/matrix/run-reaper-matrix.sh --platform windows
./reaper/matrix/run-reaper-matrix.sh --platform linuxThe matrix drives REAPER headlessly through a startup ReaScript, covering
what only a real host can - FX chain add/remove, parameter automation,
project save/reload, macro resync. It overwrites Scripts/__startup.lua
in REAPER's resource path, so remove that file before using REAPER
interactively. A host with no live audio device only processes during a
render, so the matrix forces a short render before reading any status
parameter.
./scripts/check-cross-platform-parity.shBoth smoke hosts render a fixed score through the real MicroPython sidecar and the raw float32 PCM is compared. The current result is an identical SHA-256 - the platforms agree exactly, not within a tolerance.
./reaper.sh renders and plays the example pieces; see
reaper/README.md and
soundtrack/README.md.
The sibling audioif repository is consumed read-only - no build or
formatting command here writes into it. The engine builder likewise leaves
the sibling MicroPython checkout unchanged: it uses the existing cmods
transactional overlay, and removes the temporary vstaudio module link on
exit, including after a failed build.
- No host-visible diagnostic string. Both editors show only the ready and error parameters; the bounded diagnostic text is available through the transport API.
- The editor is one generic panel. Per-script panels, a shared knob widget, meters, and resizable or zoomable editors are all deferred.
- State embeds one source file, not a dependency bundle. Imports must resolve in the sidecar's own MicroPython environment.
MPVST_SCRIPT_PATHis process-wide, so two developer-file instances cannot follow different scripts - they re-read it on restart and on save. Projects that need per-instance scripts embed them in state instead, whichreaper/matrix/build_effect_project.pydemonstrates by synthesizing the chunks directly.- The 2,080 hidden MIDI parameters are standards-compliant and validator-clean but unprofiled for scan and project-load overhead in real DAWs.
- Installer packaging, code signing, and uninstall flows beyond copying and removing the bundle have not been built.
- The Linux REAPER used for testing runs under WSLg with no audio device. Real-time playback on Linux hardware has not been exercised.
- REAPER is the only DAW tested.
- Effect extras: a wet/dry mix parameter and sidechain input buses.
- Float64 host processing and a native floating-point audioif graph.
- macOS bundles, signing, notarisation, and universal binaries.
- Coverage-guided fuzzing.
tests/fuzzexposes libFuzzer entry points; configure with-DMPVST_ENABLE_LIBFUZZER=ONon a clang toolchain and keep interesting inputs intests/fuzz/corpus. The portable driver runs on every toolchain as an ordinary test regardless.
MIT, in LICENSE — the same terms as the rest of PyDevices.
That covers this repository's own source. The Steinberg VST3 SDK is not
vendored here: scripts/fetch-vst3-sdk.sh clones it into .deps/, which is
ignored. It carries its own dual license (GPLv3 or a proprietary Steinberg
agreement), and anyone distributing a built plug-in binary has to satisfy
one of those two for the SDK it links. Building from source for your own use
does not change anything here.