Batch-render VST presets to audio files using DawDreamer as the headless engine. Windows and macOS; v1 officially supports Serum 1 (.fxp) and Serum 2 (.SerumPreset).
See CLAUDE.md and docs/implementation.md for the live specification, docs/architecture.md for a high-level orientation, KNOWN_ISSUES.md for tracked limitations, and DESIGN.md for the original v1 design rationale.
flowchart TB
A[Preset directory] -->|discover_presets| B[Job list: mix of .fxp + .SerumPreset]
B --> C[loky reusable pool<br/>N workers]
C -.spawn once.-> D
subgraph D [Each worker]
direction TB
D1[init_worker:<br/>import dawdreamer<br/>build engine_fxp + engine_serum2<br/>warmup render]
D1 --> D2{Dispatch on<br/>preset_format}
D2 -->|fxp| D3[engine_fxp.load_preset → render]
D2 -->|serum2| D4[engine_serum2: unwrap + load_state → render]
D3 --> D5[soundfile.write]
D4 --> D5
D5 -.next job.-> D2
end
D --> E[Output WAV files]
- Each worker builds one engine per loaded format, once, in
init_worker. Subsequent jobs hot-swap the preset on the matching engine viaload_preset/load_state— the audio graph is never rebuilt mid-batch. - Only the engines for plugin paths you supplied are created. A
.fxp-only run builds onlyengine_fxpin each worker; a mixed run builds both. Theengine_serum2warmup render absorbs Serum 2's first-render lazy-load anomaly. - Jobs are dispatched per-worker based on each preset's file suffix — there's no static partition; whichever worker is free picks the next job and routes it to the matching engine.
- Windows or macOS
- Python 3.11 – 3.12 (
pyproject.tomlupper bound is<3.13to match DawDreamer 0.8.3's wheel coverage; 3.13 will be allowed once upstream ships) - One or both of:
- A Serum 1 plugin for
.fxppresets:- Windows: 64-bit
.dll. Point atC:/Program Files/Common Files/VST3/Serum_x64.dll— not the 32-bit build underVST2/, which raisesWinError 193on 64-bit Python. - macOS:
.vstbundle, e.g./Library/Audio/Plug-Ins/VST/Serum.vst. - Note:
.fxpis a VST2 preset format. The.vst3and.component(Audio Unit) versions of Serum 1 will not load.fxpfiles even if DawDreamer accepts the path.
- Windows: 64-bit
- The Serum 2 VST3 for
.SerumPresetpresets:- Windows:
Serum2.vst3. - macOS:
/Library/Audio/Plug-Ins/VST3/Serum2.vst3(a.vst3bundle). - Serum 2 stores presets in JUCE
IComponentstate-blob form (cbor2 + zstandard), which vst-render decodes via theserum2-preset-loaderlibrary before handing the bytes tosynth.load_state.
- Windows:
- A Serum 1 plugin for
- A valid plugin license present on the machine (DawDreamer does not bypass authorization).
pip install git+https://github.com/wiillownet/vst-render.gitOr from a local checkout:
pip install -e .vst-render PRESETS OUTPUT --fxp <plugin> [--serum2 <plugin>] [OPTIONS]Pass --fxp for .fxp presets, --serum2 for .SerumPreset presets, or both for a mixed batch. At least one is required, and every preset format encountered in the input must have its corresponding flag — running over a directory containing both formats with only --fxp set is rejected at start-up rather than mid-batch.
Render every .fxp under a directory:
# Windows
vst-render \
"C:/Serum Presets/Leads/" \
./output/ \
--fxp "C:/Program Files/Common Files/VST3/Serum_x64.dll"
# macOS
vst-render \
"~/Documents/Serum Presets/Leads/" \
./output/ \
--fxp "/Library/Audio/Plug-Ins/VST/Serum.vst"Render .SerumPreset files (Serum 2 only):
# macOS
vst-render \
"~/Documents/Serum 2 Presets/Pads/" \
./output/ \
--serum2 "/Library/Audio/Plug-Ins/VST3/Serum2.vst3"Mixed-format directory (one batch hits both engines):
vst-render ~/all-presets/ ./output/ \
--fxp "/Library/Audio/Plug-Ins/VST/Serum.vst" \
--serum2 "/Library/Audio/Plug-Ins/VST3/Serum2.vst3"Common options:
| Flag | Default | Purpose |
|---|---|---|
--fxp |
— | Path to a Serum 1 plugin that loads .fxp. Required if any input is .fxp. |
--serum2 |
— | Path to the Serum 2 VST3. Required if any input is .SerumPreset. |
--note |
48 |
MIDI note (0–127). Mutually exclusive with --midi. |
--velocity |
127 |
MIDI velocity (1–127). |
--duration |
1.0 |
Note-on duration (s). |
--tail |
1.0 |
Release silence after note-off (s). |
--sample-rate |
44100 |
Output sample rate. |
--bit-depth |
16 |
16, 24, or 32f. |
--format |
wav |
wav or npy (raw float32 stereo array). |
--filename-template |
{preset} |
Vars: {preset} {note} {velocity} {folder} {subpath}. |
--midi |
— | Use a .mid file instead of a single note. |
--workers |
-1 |
Parallel workers; -1 = cpu_count - 1. |
--skip-existing |
off | Skip presets whose output file already exists. |
--no-recurse |
off | Don't descend into subdirectories. |
--dry-run |
off | Print the render plan and exit. |
Run vst-render --help for the full list.
RenderConfig accepts both plugin paths; the renderer auto-detects each preset's format from its file suffix and dispatches to the matching synth.
from vst_render import RenderConfig, BatchRenderer, ParallelBatchRenderer, render_preset
config = RenderConfig(
fxp_plugin_path="C:/Program Files/Common Files/VST3/Serum_x64.dll",
serum2_plugin_path="C:/Program Files/Common Files/VST3/Serum2.vst3",
sample_rate=44100,
note=48,
duration=1.0,
tail=1.0,
)
# Sequential, reuses one engine across renders
with BatchRenderer(config) as r:
audio = r.render("C:/Presets/lead.fxp") # auto-detected as fxp
audio = r.render("C:/Presets/pad.SerumPreset") # auto-detected as serum2
# Parallel mixed batch — returns a dict of path -> audio
with ParallelBatchRenderer(config, workers=-1) as r:
results = r.render_batch([
"a.fxp",
"b.fxp",
"c.SerumPreset",
])
# One-off
audio = render_preset("C:/Presets/lead.fxp", config)You only need to set the plugin paths for the formats you actually render — a .fxp-only batch needs only fxp_plugin_path; a .SerumPreset-only batch needs only serum2_plugin_path. If a preset's format is encountered without its matching plugin path, the renderer raises ValueError with a message naming the missing field, before any worker boots.
Windows:
python -m venv .venv
.venv/Scripts/python.exe -m pip install -e ".[dev]"
.venv/Scripts/python.exe -m pytest tests/ \
--ignore=tests/test_parallel_smoke.py --ignore=tests/test_serum2_smoke.py # unit tests
.venv/Scripts/python.exe -m pytest tests/test_parallel_smoke.py tests/test_serum2_smoke.py \
--fxp-plugin-path "C:/Program Files/Common Files/VST3/Serum_x64.dll" \
--serum2-plugin-path "C:/Program Files/Common Files/VST3/Serum2.vst3" \
--preset-dir "C:/Serum Presets/Leads/" \
--serum-preset-dir "C:/Serum 2 Presets/Pads/" # integrationmacOS:
python -m venv .venv
.venv/bin/python -m pip install -e ".[dev]"
.venv/bin/python -m pytest tests/ \
--ignore=tests/test_parallel_smoke.py --ignore=tests/test_serum2_smoke.py
.venv/bin/python -m pytest tests/test_parallel_smoke.py tests/test_serum2_smoke.py \
--fxp-plugin-path "/Library/Audio/Plug-Ins/VST/Serum.vst" \
--serum2-plugin-path "/Library/Audio/Plug-Ins/VST3/Serum2.vst3" \
--preset-dir "$HOME/Documents/Serum Presets/Leads/" \
--serum-preset-dir "$HOME/Documents/Serum 2 Presets/Pads/"Each fixture is gated independently: a user with only one plugin still runs the smoke half they have plumbing for. Env vars VST_FXP_PLUGIN_PATH, VST_SERUM2_PLUGIN_PATH, VST_PRESET_DIR, VST_SERUM_PRESET_DIR are accepted as alternatives to the flags.
scripts/verify_dawdreamer.py sanity-checks three architectural assumptions (preset hot-swap, bad-path recovery, loky crash recovery) against a real plugin. Re-run it after upgrading DawDreamer or adding plugin support.
GPL-3.0 (inherited from DawDreamer).