You do not need to build anything to write for MPVST: the installed plug-in
runs any script you point it at, and the library it ships is ordinary Python
you can read. Start from
lib/default_instrument.py, point
MPVST_SCRIPT_PATH at your own file, and use the MPVST Script Host class.
The per-platform pages (Windows, Linux)
cover the edit-and-reload loop; this page is what a script can say about
itself and what the plug-in does with it.
If you want to change MPVST itself rather than write for it, that is development.md.
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. It produces no audio,
so that a slot nobody chose is obvious: every event path is there, but it
does not press a note unless MPVST_DEFAULT_INSTRUMENT_AUDIBLE is set. A
Script Host class loads it when nothing else is named, and a project wired to
a Script Host class ID rather than a named instrument's would otherwise
render a plausible synth on every track.
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 audiocomponents'
audioinstruments and audioeffects packages - host-neutral Python built
on audioif's audio nodes, that any application can import, not just this
plug-in. They are staged beside the engine from a sibling audiocomponents
checkout (MPVST_COMPONENTS_LIB if it is somewhere else; MPVST_AUDIOIF_LIB,
the name from when they lived in audioif, is still honoured for one release).
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_instrument_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.
A generated project names the plug-in by class ID, and there are two kinds.
Each named instrument and effect has its own ID; the two Script Host
IDs are the generic ones that run a bare script. Reach for a Script Host only
when the script is the point. Use it for a named instrument and every track
gets default_instrument.py instead - which, being silent, renders nothing rather than sounding like a plausible synth on every
track. That is the intended alarm.
Read them out of Contents/Resources/catalog.json in the installed bundle.
It is plain JSON, one entry per class, and it carries everything a generator
needs:
import json
catalog = json.load(open(bundle + "/Contents/Resources/catalog.json"))
by_name = {c["name"]: c for c in catalog["classes"]}
limiter = by_name["Limiter"]
limiter["cid"] # 0700D6A78BFA9A03BA678484750C4B21
limiter["macro_labels"] # ['Ceiling', 'Gain', 'Lookahead', ...]
limiter["macro_ranges"] # [[-24.0, 0.0], [0.0, 24.0], ...] -> normalize with these
limiter["patches"] # [{'index': 2, 'name': 'Loud', 'macros': [125, 64, ...]}, ...]macro_ranges is what turns a number you want into the 0.0-1.0 the chunk
takes: 8 dB of Limiter Gain, whose range is 0-24, is 8 / 24. A patch's
macros are MIDI 0-127, so divide by 127 instead.
Do not hardcode a class ID from a document, including this one. The file in the bundle is the only copy that cannot drift, and a test fails the build if it ever disagrees with what the plug-in offers.
moduleinfo.json beside it is Steinberg's file, for hosts. It has the same
IDs but no patches and no ranges, and its reader accepts no field we invent,
which is why there are two files rather than one.
If you generate project files rather than saving them from a DAW, the script and its macro values travel in the plug-in's state chunk, and getting the layout wrong is silent - the host drops the chunk without a word and the instance plays the catalog's argument-free two-liner instead. The layout, Reaper's framing around it, and the class-ID choice are all in generating-projects.md.
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.