Metadata-Version: 2.5
Name: serum-render
Version: 0.7.1
Summary: Batch-render Serum 1 (.fxp) and Serum 2 (.SerumPreset) presets to audio files via DawDreamer.
Project-URL: Homepage, https://github.com/wiillownet/serum-render
Project-URL: Repository, https://github.com/wiillownet/serum-render
Project-URL: Issues, https://github.com/wiillownet/serum-render/issues
License-Expression: GPL-3.0-or-later
License-File: LICENSE
Keywords: audio,dawdreamer,preset,render,serum,serum2,vst
Classifier: Environment :: Console
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Multimedia :: Sound/Audio
Requires-Python: <3.13,>=3.11
Requires-Dist: dawdreamer
Requires-Dist: loky
Requires-Dist: mido
Requires-Dist: numpy
Requires-Dist: psutil
Requires-Dist: rich
Requires-Dist: serum2-preset-loader<0.7,>=0.6
Requires-Dist: soundfile
Requires-Dist: typer
Provides-Extra: dev
Requires-Dist: pytest; extra == 'dev'
Description-Content-Type: text/markdown

# serum-render

[![PyPI](https://img.shields.io/pypi/v/serum-render)](https://pypi.org/project/serum-render/)
[![Python versions](https://img.shields.io/pypi/pyversions/serum-render)](https://pypi.org/project/serum-render/)
[![tests](https://github.com/wiillownet/serum-render/actions/workflows/tests.yml/badge.svg)](https://github.com/wiillownet/serum-render/actions/workflows/tests.yml)
[![license](https://img.shields.io/pypi/l/serum-render)](LICENSE)

Batch-render Serum presets to audio files. Supports Serum 1 (`.fxp`) and Serum 2 (`.SerumPreset`), on Windows and macOS, using [DawDreamer](https://github.com/DBraun/DawDreamer) as the headless engine.

One render core shared by the sequential, parallel, and CLI paths, typed jobs, stock-install plugin defaults, and a first-class answer to render reproducibility. Scope is Serum 1 + Serum 2 only, permanently.

## How it works

- Each worker process builds **one DawDreamer engine per preset format**, once. Jobs hot-swap presets on the matching engine (`load_preset` for `.fxp`; `convert → load_state` for `.SerumPreset`, decoded by [`serum2-preset-loader`](https://github.com/wiillownet/serum2-preset-loader)). The audio graph is never rebuilt mid-batch.
- A [loky](https://github.com/joblib/loky) pool fans jobs out across CPU cores; mixed-format batches dispatch per preset by file suffix.
- A warmup render at engine build absorbs Serum 2's first-render lazy-load anomaly.

## Requirements

- Windows or macOS, Python 3.11–3.12
- One or both of:
  - **Serum 1** for `.fxp` presets — the VST2 binary. macOS: `/Library/Audio/Plug-Ins/VST/Serum.vst`. Windows: `C:/Program Files/Common Files/VST3/Serum_x64.dll` (the 64-bit VST2 really does live in the VST3 folder). The VST3 build of Serum 1 will not load `.fxp` correctly.
  - **Serum 2** for `.SerumPreset` presets — the VST3. macOS: `/Library/Audio/Plug-Ins/VST3/Serum2.vst3`. Windows: `C:/Program Files/Common Files/VST3/Serum2.vst3`.
- A valid Serum license on the machine (DawDreamer does not bypass authorization).

## Install

```bash
pip install serum-render
```

## CLI

If Serum is installed in the standard location, plugin flags are optional — serum-render finds it:

```bash
serum-render "~/Documents/Serum Presets/Leads/" ./output/
```

Explicit plugin paths override the defaults:

```bash
serum-render presets/ output/ \
    --serum1 "/Library/Audio/Plug-Ins/VST/Serum.vst" \
    --serum2 "/Library/Audio/Plug-Ins/VST3/Serum2.vst3"
```

A directory containing both `.fxp` and `.SerumPreset` files renders as one mixed batch. Common options:

| Flag | Default | Purpose |
| --- | --- | --- |
| `--serum1` | auto | Serum 1 plugin path (VST2 binary). Needed for `.fxp` input. |
| `--serum2` | auto | Serum 2 VST3 path. Needed for `.SerumPreset` input. |
| `--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`. Applies to `wav` and `flac` (`flac` takes `16` or `24`). |
| `--format` | `wav` | `wav`, `flac`, `ogg` (Vorbis, fixed quality), or `npy` (raw float32 stereo array). |
| `--filename-template` | `{preset}` | Vars: `{preset}` `{note}` `{velocity}` `{folder}` `{subpath}`. |
| `--midi` | — | Render a `.mid` file instead of a single note. |
| `--workers` | `-1` | Parallel workers; `-1` = `cpu_count - 1`. |
| `--skip-existing` | off | Skip presets whose output already exists. |
| `--keep-mpe` | off | Keep each Serum 2 preset's own MPE settings. See [MPE](#mpe). |
| `--multisample-wait` | `10` | Seconds between loading a Serum 2 preset that uses MultiSample and its first note; `0` turns it off. See [MultiSample presets](#multisample-presets). |
| `--skip-missing-format` | off | Render what the installed plugins cover instead of refusing the whole batch. |
| `--no-recurse` | off | Don't descend into subdirectories. |
| `--dry-run` | off | Print the render plan and exit. |
| `--json` | off | Emit machine-readable events on stdout (see below). |

Run `serum-render --help` for the full list.

### Machine-readable output

`--json` writes one JSON object per line to stdout and moves every
human-readable line to stderr, for driving serum-render from another
program:

```
{"event":"start","schema":1,"total":4271,"workers":7,"provenance":{"started":"2026-10-26T20:02:37+00:00","serum_render":"0.6.1","serum2_preset_loader":"0.6.1","plugins":{"serum1":"1.368","serum2":"2.1.5"}}}
{"event":"job_start","path":".../Bass 1.fxp"}
{"event":"result","status":"ok","path":".../Bass 1.fxp","peak":0.3325,"preset_version":null}
{"event":"result","status":"ok","path":".../Lead.SerumPreset","peak":0.2693,"preset_version":"2.0.11"}
{"event":"result","status":"skipped","path":"...","reason":"exists"}
{"event":"result","status":"error","path":"...","error":"..."}
{"event":"done","ok":4268,"skipped":0,"failed":3,"elapsed":612.4}
```

`--dry-run --json` emits the `start` event alone and exits. Exit codes
are unchanged: `0` clean, `1` some renders failed, `2` usage or
validation error. Validation errors write to stderr and produce no
events, so **exit 0 always means at least one `done` event** — a run that
found no presets still emits `start` with `total: 0` and a zeroed `done`.

Two rules for consumers. Reject an unrecognised `schema` rather than
guessing at the shape. And skip any stdout line that fails to parse:
loky workers inherit the parent's stdout, so a plugin printing from C
could in principle interleave with the stream — trust the counts in
`done` over a local tally of `result` events.

`job_start` fires when a preset is handed to a free worker (jobs are
submitted one per free worker, so it is the actual start, not a queue
position). Its `path` is the exact string the matching `result` carries;
pair them to show what is in flight and how long each preset took.

If a worker process dies (a plugin crash, an out-of-memory kill), the
batch stops: `done` still arrives, with an additional `aborted` string
saying so, and the exit code is `1`. Re-run with `--skip-existing` to
resume.

A `skipped` result carries a `reason`: `exists` when `--skip-existing`
found the output already written, or `no_plugin` when
`--skip-missing-format` dropped a preset whose plugin is not installed.
Both are counted in `done`'s `skipped`, and both are inside `start`'s
`total` — `total` covers every preset the run reports on, not just the
ones it renders.

`provenance` names the versions that made the run. A plugin version is
read from the bundle's `Info.plist` on macOS and is `null` elsewhere. An
`ok` result's `preset_version` is the Serum version the preset was saved
with (`null` for Serum 1). When the preset file's header names a different
version than its body, the result also carries `preset_header_version`
and `"preset_version_mismatch": true`. If the version can't be read, the
render still goes ahead: `preset_version` is `null` and
`preset_version_error` says why.

### Provenance log

Every run, with or without `--json`, appends one line per written file
to `serum-render-provenance.jsonl` in the output folder: the file's path
relative to that folder, the preset, its version fields and the run's
`provenance`. A run that writes no files leaves no log. To find the files
a given loader made:

```
grep '"serum2_preset_loader": "0.6.1"' out/serum-render-provenance.jsonl
```

A re-render of the same file appends a new line, so the last line for an
`output` describes the file on disk. A file with no line at all predates
the log.

To check a whole folder against the versions installed now (and,
optionally, that every preset in a list got a line), run from a checkout:

```
.venv/bin/python scripts/verify_provenance.py out/ [presets.txt]
```

It exits 1 on a version mismatch, a flagged preset, a listed preset with
no line, or a logged file that is missing.

**The output shape is unstable until 1.0.**

## Library API

```python
from serum_render import RenderConfig, Renderer, ParallelRenderer, render_preset

config = RenderConfig(
    serum1_plugin_path="/Library/Audio/Plug-Ins/VST/Serum.vst",
    serum2_plugin_path="/Library/Audio/Plug-Ins/VST3/Serum2.vst3",
    note=48,
    duration=1.0,
    tail=1.0,
)

# Sequential — one engine per format, reused across renders
with Renderer(config) as r:
    audio = r.render("lead.fxp")          # auto-detected as Serum 1
    audio = r.render("pad.SerumPreset")   # auto-detected as Serum 2

# Parallel mixed batch — dict of path -> (2, N) float32 array
with ParallelRenderer(config, workers=-1) as r:
    results = r.render_batch(["a.fxp", "b.fxp", "c.SerumPreset"])

# One-off
audio = render_preset("lead.fxp", config)
```

Set only the plugin paths for the formats you render; a missing path for a format actually present in the batch raises `ValueError` naming the field, before any worker boots.

## MPE

MPE is off by default, as with a fresh Serum instance, whatever the preset file says. `--keep-mpe` (`RenderConfig(keep_mpe=True)`) restores the file's MPE settings, for MIDI files written for MPE. A run with it records `"keep_mpe": true` on each Serum 2 line of the provenance log.

Single notes and MIDI files on one channel rendered the same either way in the check behind this default. Files that send on member channels can differ, mostly through channel pressure, on presets that respond to it. The option has no effect on Serum 1 presets.

## MultiSample presets

Serum 2 loads MultiSample audio on background threads and nothing waits for it, so a note that arrives before a region's sample is ready plays nothing for that region. An offline render reaches its first note in milliseconds, so it can come out partial or silent. serum-render waits between the load and the first note, only for Serum 2 presets with a MultiSample oscillator. Other presets, and Serum 1, never wait.

The default is 10 seconds (`--multisample-wait`, `RenderConfig(multisample_wait=...)`), chosen from `docs/multisample-wait-2026-11-05.json`: the longest any preset needed was 5 seconds, at the parallel worker counts. Each MultiSample preset costs one worker that long. The full 778-preset library at 7 workers went from 117 to 326 seconds. A slower disk or a first read of samples not yet in the file cache may need more than was measured; lower it only if you check your own presets.

## Reproducibility

By default, batch renders are **not** bit-reproducible: Serum keeps internal DSP state across consecutive renders (LFO phase, envelope residue, lazy-loaded sample data), so a preset rendered mid-batch differs from the same preset rendered alone — measured at 97% of factory presets.

`--deterministic` (or `RenderConfig(deterministic=True)`) fixes this: every preset renders in a fresh single-use process, fanned out across your worker count, making batch output **bit-identical across runs and render orders**. The cost is one plugin load per preset instead of per worker — use it when reproducibility matters (ML datasets, regression baselines), skip it when you just want samples fast.

```bash
serum-render presets/ output/ --deterministic
```

Why a whole process per preset? Serum 2 can be reset by reloading the plugin in place, but Serum 1 keeps state in library-level globals that survive even a full engine rebuild — only process isolation resets both. Probe data and methodology: `docs/decisions.md`, raw numbers in `docs/determinism-probe-2026-07-16.json`. Full caveat list: `KNOWN_ISSUES.md`.

## Development

```bash
python3.12 -m venv .venv
.venv/bin/pip install -e ".[dev]"

# fast unit tests (no plugin required)
.venv/bin/pytest tests/ --ignore=tests/test_serum1_smoke.py --ignore=tests/test_serum2_smoke.py

# integration smokes (real Serum installs; each half gated independently)
.venv/bin/pytest tests/test_serum1_smoke.py tests/test_serum2_smoke.py \
    --serum1-plugin-path "/Library/Audio/Plug-Ins/VST/Serum.vst" \
    --serum2-plugin-path "/Library/Audio/Plug-Ins/VST3/Serum2.vst3" \
    --serum1-preset-dir  "$HOME/Documents/Serum Presets/Leads/" \
    --serum2-preset-dir  "$HOME/Documents/Serum 2 Presets/Pads/"
```

Env vars `SERUM1_PLUGIN_PATH`, `SERUM2_PLUGIN_PATH`, `SERUM1_PRESET_DIR`, `SERUM2_PRESET_DIR` work as flag alternatives.

## License

[GPL-3.0](LICENSE) (inherited from DawDreamer).
