serum-render 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {serum_render-0.2.0 → serum_render-0.3.1}/KNOWN_ISSUES.md +12 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/PKG-INFO +38 -1
- {serum_render-0.2.0 → serum_render-0.3.1}/README.md +37 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/docs/decisions.md +32 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/docs/implementation.md +14 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/pyproject.toml +1 -1
- serum_render-0.3.1/serum_render/__main__.py +11 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/cli.py +113 -13
- serum_render-0.3.1/serum_render/config.py +227 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/discover.py +7 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/engine.py +20 -3
- {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/formats.py +12 -0
- serum_render-0.3.1/serum_render/output.py +54 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/pool.py +1 -1
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_cli.py +171 -1
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_config.py +139 -1
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_engine.py +9 -2
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_filename.py +28 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_isolated.py +3 -1
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_output.py +32 -0
- serum_render-0.2.0/serum_render/config.py +0 -96
- serum_render-0.2.0/serum_render/output.py +0 -33
- {serum_render-0.2.0 → serum_render-0.3.1}/.github/workflows/publish.yml +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/.github/workflows/tests.yml +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/.gitignore +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/LICENSE +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/docs/determinism-probe-2026-07-16.json +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/scripts/_verify_loky_worker.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/scripts/probe_determinism.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/scripts/verify_dawdreamer.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/scripts/verify_dawdreamer_serum2.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/__init__.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/api.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/isolated.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/jobs.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/conftest.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_api.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_jobs.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_midi.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_presets.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_sanitize.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_serum1_smoke.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_serum2_smoke.py +0 -0
|
@@ -137,3 +137,15 @@ problem.
|
|
|
137
137
|
is bimodal and obvious, not subtle. Affected presets are the sample-based ones
|
|
138
138
|
(pianos, guitars, drum kits, orchestral); pure-synthesis presets and all of
|
|
139
139
|
Serum 1 are unaffected.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## A killed render can leave a stray `.tmp` file
|
|
144
|
+
|
|
145
|
+
`write_audio` renders to `<output>.wav.tmp` / `<output>.npy.tmp` and renames it into place, so a `SIGKILL` mid-write can never leave a truncated file at the real output path — which `--skip-existing` would otherwise skip forever on the re-run. The trade is that a killed batch leaves up to `--workers` stray `.tmp` files. The suffix goes *after* the real extension deliberately, so strays stay out of `*.wav` globs. Delete them with `find <output> -name '*.tmp' -delete`.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Worker stdout is shared with the `--json` event stream
|
|
150
|
+
|
|
151
|
+
loky workers inherit the parent's stdout, so a plugin printing from C code could in principle interleave with `--json` output and split an event line in two. This has never been observed — the only noise Serum's loader is known to produce goes to stderr (see the JUCE entry above) — and nothing in serum-render writes to stdout from a worker. Consumers should skip any line that fails to parse and trust the counts in the `done` event over their own tally of `result` events, which is where a lost line becomes visible.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: serum-render
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Batch-render Serum 1 (.fxp) and Serum 2 (.SerumPreset) presets to audio files via DawDreamer.
|
|
5
5
|
Project-URL: Homepage, https://github.com/wiillownet/serum-render
|
|
6
6
|
Project-URL: Repository, https://github.com/wiillownet/serum-render
|
|
@@ -90,11 +90,48 @@ A directory containing both `.fxp` and `.SerumPreset` files renders as one mixed
|
|
|
90
90
|
| `--midi` | — | Render a `.mid` file instead of a single note. |
|
|
91
91
|
| `--workers` | `-1` | Parallel workers; `-1` = `cpu_count - 1`. |
|
|
92
92
|
| `--skip-existing` | off | Skip presets whose output already exists. |
|
|
93
|
+
| `--skip-missing-format` | off | Render what the installed plugins cover instead of refusing the whole batch. |
|
|
93
94
|
| `--no-recurse` | off | Don't descend into subdirectories. |
|
|
94
95
|
| `--dry-run` | off | Print the render plan and exit. |
|
|
96
|
+
| `--json` | off | Emit machine-readable events on stdout (see below). |
|
|
95
97
|
|
|
96
98
|
Run `serum-render --help` for the full list.
|
|
97
99
|
|
|
100
|
+
### Machine-readable output
|
|
101
|
+
|
|
102
|
+
`--json` writes one JSON object per line to stdout and moves every
|
|
103
|
+
human-readable line to stderr, for driving serum-render from another
|
|
104
|
+
program:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
{"event":"start","schema":1,"total":4271,"workers":7}
|
|
108
|
+
{"event":"result","status":"ok","path":".../Bass 1.fxp","peak":0.3325}
|
|
109
|
+
{"event":"result","status":"skipped","path":"...","reason":"exists"}
|
|
110
|
+
{"event":"result","status":"error","path":"...","error":"..."}
|
|
111
|
+
{"event":"done","ok":4268,"skipped":0,"failed":3,"elapsed":612.4}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`--dry-run --json` emits the `start` event alone and exits. Exit codes
|
|
115
|
+
are unchanged: `0` clean, `1` some renders failed, `2` usage or
|
|
116
|
+
validation error. Validation errors write to stderr and produce no
|
|
117
|
+
events, so **exit 0 always means at least one `done` event** — a run that
|
|
118
|
+
found no presets still emits `start` with `total: 0` and a zeroed `done`.
|
|
119
|
+
|
|
120
|
+
Two rules for consumers. Reject an unrecognised `schema` rather than
|
|
121
|
+
guessing at the shape. And skip any stdout line that fails to parse:
|
|
122
|
+
loky workers inherit the parent's stdout, so a plugin printing from C
|
|
123
|
+
could in principle interleave with the stream — trust the counts in
|
|
124
|
+
`done` over a local tally of `result` events.
|
|
125
|
+
|
|
126
|
+
A `skipped` result carries a `reason`: `exists` when `--skip-existing`
|
|
127
|
+
found the output already written, or `no_plugin` when
|
|
128
|
+
`--skip-missing-format` dropped a preset whose plugin is not installed.
|
|
129
|
+
Both are counted in `done`'s `skipped`, and both are inside `start`'s
|
|
130
|
+
`total` — `total` covers every preset the run reports on, not just the
|
|
131
|
+
ones it renders.
|
|
132
|
+
|
|
133
|
+
**The output shape is unstable until 1.0.**
|
|
134
|
+
|
|
98
135
|
## Library API
|
|
99
136
|
|
|
100
137
|
```python
|
|
@@ -62,11 +62,48 @@ A directory containing both `.fxp` and `.SerumPreset` files renders as one mixed
|
|
|
62
62
|
| `--midi` | — | Render a `.mid` file instead of a single note. |
|
|
63
63
|
| `--workers` | `-1` | Parallel workers; `-1` = `cpu_count - 1`. |
|
|
64
64
|
| `--skip-existing` | off | Skip presets whose output already exists. |
|
|
65
|
+
| `--skip-missing-format` | off | Render what the installed plugins cover instead of refusing the whole batch. |
|
|
65
66
|
| `--no-recurse` | off | Don't descend into subdirectories. |
|
|
66
67
|
| `--dry-run` | off | Print the render plan and exit. |
|
|
68
|
+
| `--json` | off | Emit machine-readable events on stdout (see below). |
|
|
67
69
|
|
|
68
70
|
Run `serum-render --help` for the full list.
|
|
69
71
|
|
|
72
|
+
### Machine-readable output
|
|
73
|
+
|
|
74
|
+
`--json` writes one JSON object per line to stdout and moves every
|
|
75
|
+
human-readable line to stderr, for driving serum-render from another
|
|
76
|
+
program:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
{"event":"start","schema":1,"total":4271,"workers":7}
|
|
80
|
+
{"event":"result","status":"ok","path":".../Bass 1.fxp","peak":0.3325}
|
|
81
|
+
{"event":"result","status":"skipped","path":"...","reason":"exists"}
|
|
82
|
+
{"event":"result","status":"error","path":"...","error":"..."}
|
|
83
|
+
{"event":"done","ok":4268,"skipped":0,"failed":3,"elapsed":612.4}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`--dry-run --json` emits the `start` event alone and exits. Exit codes
|
|
87
|
+
are unchanged: `0` clean, `1` some renders failed, `2` usage or
|
|
88
|
+
validation error. Validation errors write to stderr and produce no
|
|
89
|
+
events, so **exit 0 always means at least one `done` event** — a run that
|
|
90
|
+
found no presets still emits `start` with `total: 0` and a zeroed `done`.
|
|
91
|
+
|
|
92
|
+
Two rules for consumers. Reject an unrecognised `schema` rather than
|
|
93
|
+
guessing at the shape. And skip any stdout line that fails to parse:
|
|
94
|
+
loky workers inherit the parent's stdout, so a plugin printing from C
|
|
95
|
+
could in principle interleave with the stream — trust the counts in
|
|
96
|
+
`done` over a local tally of `result` events.
|
|
97
|
+
|
|
98
|
+
A `skipped` result carries a `reason`: `exists` when `--skip-existing`
|
|
99
|
+
found the output already written, or `no_plugin` when
|
|
100
|
+
`--skip-missing-format` dropped a preset whose plugin is not installed.
|
|
101
|
+
Both are counted in `done`'s `skipped`, and both are inside `start`'s
|
|
102
|
+
`total` — `total` covers every preset the run reports on, not just the
|
|
103
|
+
ones it renders.
|
|
104
|
+
|
|
105
|
+
**The output shape is unstable until 1.0.**
|
|
106
|
+
|
|
70
107
|
## Library API
|
|
71
108
|
|
|
72
109
|
```python
|
|
@@ -75,3 +75,35 @@ Two effects are mixed together here, which is why one knob cannot address them:
|
|
|
75
75
|
Also measured: with no settle, most presets are stable across loads 2..N and only load 1 differs. That is the order-dependence already documented, not a third effect.
|
|
76
76
|
|
|
77
77
|
**Alternatives considered:** a longer warmup at `EngineHost` construction — rejected, it happens before any preset is loaded, so it cannot affect a per-preset sample load. A per-preset settle tuned by sample count or blob size — not attempted: worth doing only if a structural trait is first shown to predict which presets drift, and that has not been established. Waiting on a load-complete signal from the plugin — no such signal is known to be exposed via DawDreamer.
|
|
78
|
+
|
|
79
|
+
## [2026-09-01] A GUI drives serum-render as a subprocess over `--json`, not as a library
|
|
80
|
+
|
|
81
|
+
**Decision:** the forthcoming GUI (separate repo, PySide6) spawns `python -m serum_render ... --json` and parses an NDJSON event stream from stdout. serum-render gains `--json`, `__main__.py`, an atomic `write_audio`, and a `peak` field on the result dict. It does **not** gain a public batch API for the GUI to call.
|
|
82
|
+
|
|
83
|
+
**Reason:** the GUI must not host the render, because DawDreamer hangs off the main thread (`engine.py`) — that is settled. What was not obvious is which *kind* of process boundary. The deciding factor is packaging: the GUI is intended to ship as a bundled app, and a frozen PySide6 app must never host loky's workers, because `spawn` re-executes `sys.executable`, which inside a bundle is the app itself. Spawning a separate interpreter sidesteps that entirely. Secondarily, `--json` is useful for scripting independent of any GUI, and the subprocess reuses the ~110 lines of validation, discovery, filename composition and collision resolution in `cli.py` rather than duplicating them.
|
|
84
|
+
|
|
85
|
+
Two reasons that were considered and are explicitly **not** why, recorded so they are not repeated as justification: *crash isolation* (the parent process is identical either way — dawdreamer only loads inside `EngineHost.__init__`, in a worker — so a segfault kills a worker in both designs, and no worker death has ever been observed here), and *cleaner cancellation* (loky's `shutdown(wait=True, kill_workers=True)` is one line, against the ~40 lines of process-group code the subprocess boundary requires).
|
|
86
|
+
|
|
87
|
+
**Alternatives considered:** extract `cli.py`'s job-building into a public `plan_jobs() -> list[Job]` and drive `pool.iter_jobs` from a GUI background thread — genuinely works (the pool parent never imports dawdreamer, and `iter_jobs_isolated` already threads the parent), and is roughly 200 lines cheaper overall, but forecloses the bundled-app path above. A GUI-owned driver subprocess emitting its own protocol — pays the protocol cost *and* duplicates `cli.py`. Rejected: putting the event stream on stderr, which would delete the stdout-hygiene work but move events onto the one stream with observed C-level writes (JUCE, see `KNOWN_ISSUES.md`). Rejected: an `os.dup2(2, 1)` worker initializer to pre-empt stdout pollution — the hazard is structural but has never been observed, and placing it in `init_worker` breaks every `--deterministic` render, since `isolated.py` calls that same function and then prints its result line to stdout.
|
|
88
|
+
|
|
89
|
+
**Contract the GUI depends on, recorded because it lives nowhere else:** exit `0` clean, `1` some renders failed, `2` usage or validation error. Validation errors produce no events, so exit 0 always implies at least one `done` event — including the no-presets case, which emits `start` with `total: 0`. Shape is unstable until 1.0; the `schema` key exists so a consumer fails loudly instead of mis-parsing.
|
|
90
|
+
|
|
91
|
+
**Reversal criterion:** revisit if the GUI ever needs per-job *start* events (the parent submits all futures at once and cannot know when a job begins, so that needs a worker-side change) or the audio array itself.
|
|
92
|
+
|
|
93
|
+
## [2026-09-05] Render what the installed plugins cover, behind a flag
|
|
94
|
+
|
|
95
|
+
**Decision:** Add `--skip-missing-format`, default off. With it, a batch whose library holds a format with no installed plugin renders the presets it can and reports the rest as ordinary `skipped` results carrying `"reason": "no_plugin"`; without it, `cli.py`'s existing refusal of the whole batch with exit 2 is unchanged. Skips from `--skip-existing` gained the matching `"reason": "exists"`. `start`'s `total` counts dropped presets, matching how skip-existing skips were already counted inside it. If *nothing* is renderable, the flag still exits 2.
|
|
96
|
+
|
|
97
|
+
**Reason:** A library holding both `.fxp` and `.SerumPreset` on a machine with one synth installed was entirely unrenderable — and that is the common install, not an edge case. The GUI's design makes filtering a hard requirement: it states the split ("770 will render · 765 need Serum 2, skipped") in the footer and puts the renderable count on the Render button, so it cannot call a code path that aborts instead. Default-off keeps the failure loud where it should be: a typo'd `--serum2` path must not quietly render half a library, and the flag is opt-in precisely so that mistake still costs an exit 2. `reason` rather than a new status value means `done`'s `skipped` count already includes these and an existing consumer needs no new branch — while the GUI, which must word the two skips differently ("never renderable" vs "already rendered"), can tell them apart.
|
|
98
|
+
|
|
99
|
+
**Alternatives considered:** `--only-format serum1|serum2`, with the caller running one batch per installed synth — two plugin cold starts at ~10–15s each and two event streams to merge, to express what one flag says. Passing an explicit preset list instead of a directory — the CLI's whole input contract is one path, so this is a larger change than the problem. Making the filtering behaviour the default — rejected because it silently converts a misconfiguration into a half-finished batch. A distinct `"status": "unrenderable"` — rejected because every existing consumer would need a new branch to keep its counts reconciling.
|
|
100
|
+
|
|
101
|
+
**Note:** the GUI plan (revision 3) explicitly said "do not add a `--skip-missing-format` flag on speculation." That was correct on the information then available; the speculation resolved when the design made batch filtering a requirement rather than a guess.
|
|
102
|
+
|
|
103
|
+
## [2026-09-05] Expected plugin suffix lives beside the default plugin paths
|
|
104
|
+
|
|
105
|
+
**Decision:** Add `_PLUGIN_SUFFIXES` to `config.py` alongside `_DEFAULT_PLUGIN_PATHS`, with `plugin_suffix_for(fmt, platform)` and `plugin_path_looks_valid(path, fmt, platform)`. Serum 1 (VST2) expects `.vst` on macOS, `.dll` on Windows, `.so` on Linux; Serum 2 (VST3) expects `.vst3` everywhere. Validity is `exists()` plus a case-folded suffix match; an unknown platform enforces existence only.
|
|
106
|
+
|
|
107
|
+
**Reason:** A user browsing for a plugin by hand can pick the folder above the bundle, or hand the Serum 2 row a Serum 1 `.vst` — and the Serum 1 VST3 in particular *silently mis-loads* `.fxp` presets rather than failing, which is the exact failure this check exists to catch before a 1500-preset batch runs. Putting the table next to `_DEFAULT_PLUGIN_PATHS` means the check and the default read the same platform knowledge and cannot drift apart, which is why this lives in serum-render rather than in the GUI that needs it.
|
|
108
|
+
|
|
109
|
+
**Alternatives considered:** Implementing the check GUI-side — rejected because the two tables would then encode the same platform facts in two repos. Using `is_file()` — actively wrong: both plugin formats are *bundles* (directories) on macOS, so `is_file()` rejects every valid macOS plugin.
|
|
@@ -98,6 +98,20 @@ process (`discover.get_midi_duration`); workers never parse MIDI.
|
|
|
98
98
|
4. `EngineHost.render` — one backstop RuntimeError for a format with no
|
|
99
99
|
engine.
|
|
100
100
|
|
|
101
|
+
## Machine-readable output
|
|
102
|
+
|
|
103
|
+
`--json` (`cli.py`) turns stdout into an NDJSON event stream: `start`
|
|
104
|
+
(with `schema`, `total`, `workers`), one `result` per job carrying the
|
|
105
|
+
worker's result dict verbatim, then `done` with the totals and elapsed
|
|
106
|
+
seconds. Every human-readable line moves to stderr, and the rich
|
|
107
|
+
progress bar is suppressed outright — rich writes it to stdout and
|
|
108
|
+
still prints one final renderable when stdout is not a tty.
|
|
109
|
+
|
|
110
|
+
`--json` is tested before `--verbose` in the results loop: they are
|
|
111
|
+
independent flags and the other order drops the stream when both are
|
|
112
|
+
set. The per-result `peak` comes from `engine.run_job` and is carried,
|
|
113
|
+
not acted on; nothing in the render path branches on it.
|
|
114
|
+
|
|
101
115
|
## Default plugin paths
|
|
102
116
|
|
|
103
117
|
`config.default_plugin_path(fmt, platform=None)` returns the standard
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "serum-render"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Batch-render Serum 1 (.fxp) and Serum 2 (.SerumPreset) presets to audio files via DawDreamer."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11,<3.13"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""`python -m serum_render` — same entry point as the `serum-render` script.
|
|
2
|
+
|
|
3
|
+
Exists because the console script is not reliably locatable from a GUI:
|
|
4
|
+
there is no useful PATH when an app is launched from Finder, and Windows
|
|
5
|
+
venvs place it in `Scripts/` with a `.exe` suffix. On Windows that stub
|
|
6
|
+
also spawns a child interpreter which can survive a kill
|
|
7
|
+
(pypa/distlib#175); going through `-m` removes that process level.
|
|
8
|
+
"""
|
|
9
|
+
from .cli import app
|
|
10
|
+
|
|
11
|
+
app()
|
|
@@ -4,7 +4,10 @@ flags beat platform defaults), builds typed Jobs, and drives the pool.
|
|
|
4
4
|
"""
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import itertools
|
|
8
|
+
import json
|
|
7
9
|
import logging
|
|
10
|
+
import time
|
|
8
11
|
from pathlib import Path
|
|
9
12
|
from typing import Optional
|
|
10
13
|
|
|
@@ -41,6 +44,17 @@ _EXT_FOR = {PresetFormat.SERUM1: ".fxp", PresetFormat.SERUM2: ".SerumPreset"}
|
|
|
41
44
|
_PLUGIN_NAME_FOR = {PresetFormat.SERUM1: "Serum 1", PresetFormat.SERUM2: "Serum 2"}
|
|
42
45
|
|
|
43
46
|
|
|
47
|
+
# --json stream version. Bumped only on an incompatible shape change;
|
|
48
|
+
# consumers should reject an unknown value rather than guess at it.
|
|
49
|
+
_JSON_SCHEMA = 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _emit(event: dict) -> None:
|
|
53
|
+
"""Write one NDJSON event to stdout. Under --json, stdout carries
|
|
54
|
+
these and nothing else — every human-readable line goes to stderr."""
|
|
55
|
+
typer.echo(json.dumps(event))
|
|
56
|
+
|
|
57
|
+
|
|
44
58
|
def _setup_logging(verbose: bool) -> None:
|
|
45
59
|
"""Only the CLI configures logging — library code uses a named logger."""
|
|
46
60
|
logging.basicConfig(
|
|
@@ -75,11 +89,18 @@ def render(
|
|
|
75
89
|
fmt: str = typer.Option("wav", "--format", help="Output container: wav or npy."),
|
|
76
90
|
filename_template: str = typer.Option(
|
|
77
91
|
"{preset}", "--filename-template",
|
|
78
|
-
help="Filename template. Vars: {preset} {note} {velocity} {folder} {subpath} {subdir}. {subdir} nests output to mirror the preset tree.",
|
|
92
|
+
help="Filename template. Vars: {preset} {note} {velocity} {folder} {subpath} {subdir} {format}. {subdir} nests output to mirror the preset tree; {format} splits a mixed batch into serum1/ and serum2/ folders.",
|
|
79
93
|
),
|
|
80
94
|
midi: Optional[Path] = typer.Option(None, "--midi", help="Path to a .mid file (overrides --note)."),
|
|
81
95
|
workers: int = typer.Option(-1, "--workers", help="Parallel workers. -1 = cpu_count - 1."),
|
|
82
96
|
skip_existing: bool = typer.Option(False, "--skip-existing", help="Skip if output file already exists."),
|
|
97
|
+
skip_missing_format: bool = typer.Option(
|
|
98
|
+
False, "--skip-missing-format",
|
|
99
|
+
help="Render the presets whose plugin is installed instead of refusing "
|
|
100
|
+
"the whole batch. Presets needing a missing plugin are reported "
|
|
101
|
+
"as skipped with reason 'no_plugin'. Still exits 2 if nothing is "
|
|
102
|
+
"renderable.",
|
|
103
|
+
),
|
|
83
104
|
deterministic: bool = typer.Option(
|
|
84
105
|
False, "--deterministic",
|
|
85
106
|
help="Render every preset in a fresh single-use process so batch "
|
|
@@ -89,6 +110,12 @@ def render(
|
|
|
89
110
|
no_recurse: bool = typer.Option(False, "--no-recurse", help="Do not recurse into subdirectories."),
|
|
90
111
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print presets that would render and exit."),
|
|
91
112
|
verbose: bool = typer.Option(False, "--verbose", help="Per-preset status logging."),
|
|
113
|
+
json_out: bool = typer.Option(
|
|
114
|
+
False, "--json",
|
|
115
|
+
help="Emit one JSON event per line on stdout (start, result per "
|
|
116
|
+
"preset, done) and move all human-readable output to stderr. "
|
|
117
|
+
"Output shape is unstable until 1.0.",
|
|
118
|
+
),
|
|
92
119
|
) -> None:
|
|
93
120
|
_setup_logging(verbose)
|
|
94
121
|
|
|
@@ -138,6 +165,18 @@ def render(
|
|
|
138
165
|
f"No supported preset files (.fxp, .SerumPreset) found under {presets}",
|
|
139
166
|
err=True,
|
|
140
167
|
)
|
|
168
|
+
# Emit the full pair anyway. Exiting 0 with an empty stream is
|
|
169
|
+
# indistinguishable from a successful run to a consumer, and a
|
|
170
|
+
# mistyped preset directory is the likeliest way to land here.
|
|
171
|
+
if json_out:
|
|
172
|
+
_emit({
|
|
173
|
+
"event": "start", "schema": _JSON_SCHEMA, "total": 0,
|
|
174
|
+
"workers": resolve_worker_count(workers),
|
|
175
|
+
})
|
|
176
|
+
_emit({
|
|
177
|
+
"event": "done", "ok": 0, "skipped": 0, "failed": 0,
|
|
178
|
+
"elapsed": 0.0,
|
|
179
|
+
})
|
|
141
180
|
raise typer.Exit(code=0)
|
|
142
181
|
|
|
143
182
|
# Resolve plugin paths: an explicit flag always wins; otherwise fall
|
|
@@ -155,10 +194,15 @@ def render(
|
|
|
155
194
|
if fallback is not None:
|
|
156
195
|
plugin_paths[preset_fmt] = fallback
|
|
157
196
|
typer.echo(
|
|
158
|
-
f"Using default {_PLUGIN_NAME_FOR[preset_fmt]} plugin: {fallback}"
|
|
197
|
+
f"Using default {_PLUGIN_NAME_FOR[preset_fmt]} plugin: {fallback}",
|
|
198
|
+
err=True,
|
|
159
199
|
)
|
|
160
200
|
|
|
161
201
|
missing = discovered_formats - set(plugin_paths)
|
|
202
|
+
# Presets dropped for want of a plugin. Reported as ordinary skipped
|
|
203
|
+
# results so `done`'s counts stay reconcilable, distinguished from
|
|
204
|
+
# skip-existing skips by `reason`.
|
|
205
|
+
skipped_results: list[dict] = []
|
|
162
206
|
if missing:
|
|
163
207
|
msgs = sorted(
|
|
164
208
|
f"found {_EXT_FOR[m]} files but {_FLAG_FOR[m]} was not provided "
|
|
@@ -167,7 +211,27 @@ def render(
|
|
|
167
211
|
)
|
|
168
212
|
for m in msgs:
|
|
169
213
|
typer.echo(m, err=True)
|
|
170
|
-
|
|
214
|
+
# Refusing the whole batch is the default on purpose: a typo'd plugin
|
|
215
|
+
# flag must fail loudly rather than quietly render half a library.
|
|
216
|
+
if not skip_missing_format:
|
|
217
|
+
raise typer.Exit(code=2)
|
|
218
|
+
renderable = [(p, f) for p, f in preset_files if f in plugin_paths]
|
|
219
|
+
if not renderable:
|
|
220
|
+
typer.echo(
|
|
221
|
+
"No discovered preset has an installed plugin; nothing to render.",
|
|
222
|
+
err=True,
|
|
223
|
+
)
|
|
224
|
+
raise typer.Exit(code=2)
|
|
225
|
+
skipped_results = [
|
|
226
|
+
{
|
|
227
|
+
"status": "skipped",
|
|
228
|
+
"path": str(p.resolve()),
|
|
229
|
+
"reason": "no_plugin",
|
|
230
|
+
}
|
|
231
|
+
for p, f in preset_files
|
|
232
|
+
if f not in plugin_paths
|
|
233
|
+
]
|
|
234
|
+
preset_files = renderable
|
|
171
235
|
|
|
172
236
|
# Single-file mode: presets_root=None so {subpath} collapses out.
|
|
173
237
|
# Resolve when a directory so `relative_to` works against the absolute
|
|
@@ -191,8 +255,8 @@ def render(
|
|
|
191
255
|
|
|
192
256
|
extension = ".npy" if fmt == "npy" else ".wav"
|
|
193
257
|
stems = [
|
|
194
|
-
compose_filename(filename_template, p, presets_root, note, velocity)
|
|
195
|
-
for p,
|
|
258
|
+
compose_filename(filename_template, p, presets_root, note, velocity, pfmt)
|
|
259
|
+
for p, pfmt in preset_files
|
|
196
260
|
]
|
|
197
261
|
output_paths = resolve_output_paths(stems, output, extension)
|
|
198
262
|
jobs = [
|
|
@@ -213,10 +277,20 @@ def render(
|
|
|
213
277
|
for (p, preset_fmt), out in zip(preset_files, output_paths)
|
|
214
278
|
]
|
|
215
279
|
|
|
280
|
+
total = len(jobs) + len(skipped_results)
|
|
281
|
+
|
|
216
282
|
if dry_run:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
283
|
+
if json_out:
|
|
284
|
+
# No separate "plan" event — a start with the total is the
|
|
285
|
+
# same information, and one fewer branch for a consumer.
|
|
286
|
+
_emit({
|
|
287
|
+
"event": "start", "schema": _JSON_SCHEMA, "total": total,
|
|
288
|
+
"workers": resolve_worker_count(workers),
|
|
289
|
+
})
|
|
290
|
+
else:
|
|
291
|
+
typer.echo(f"Would render {len(jobs)} preset(s):")
|
|
292
|
+
for j in jobs:
|
|
293
|
+
typer.echo(f" {j.preset_path} -> {j.output_path}")
|
|
220
294
|
raise typer.Exit(code=0)
|
|
221
295
|
|
|
222
296
|
output.mkdir(parents=True, exist_ok=True)
|
|
@@ -233,6 +307,7 @@ def render(
|
|
|
233
307
|
)
|
|
234
308
|
|
|
235
309
|
results: list[dict] = []
|
|
310
|
+
t0 = time.monotonic()
|
|
236
311
|
if deterministic:
|
|
237
312
|
from .pool import iter_jobs_isolated
|
|
238
313
|
|
|
@@ -242,10 +317,29 @@ def render(
|
|
|
242
317
|
else:
|
|
243
318
|
result_iter = iter_jobs(jobs, n_workers, serum1_str, serum2_str, sample_rate)
|
|
244
319
|
|
|
320
|
+
# The dropped presets are already decided, so they lead the stream and a
|
|
321
|
+
# consumer sees one result per preset counted in `total`.
|
|
322
|
+
result_iter = itertools.chain(skipped_results, result_iter)
|
|
323
|
+
|
|
324
|
+
# --json is tested before --verbose: they are independent flags, and
|
|
325
|
+
# the other order would silently drop the stream when both are set.
|
|
245
326
|
# In verbose mode, per-preset DEBUG logs replace the progress bar so
|
|
246
|
-
# the two don't fight for the terminal.
|
|
247
|
-
|
|
248
|
-
|
|
327
|
+
# the two don't fight for the terminal. Under --json the bar is
|
|
328
|
+
# suppressed outright: rich writes it to stdout, and when stdout is
|
|
329
|
+
# not a tty it still prints one final renderable that would corrupt
|
|
330
|
+
# the stream.
|
|
331
|
+
if json_out:
|
|
332
|
+
_emit({
|
|
333
|
+
"event": "start", "schema": _JSON_SCHEMA, "total": total,
|
|
334
|
+
"workers": n_workers,
|
|
335
|
+
})
|
|
336
|
+
for result in result_iter:
|
|
337
|
+
results.append(result)
|
|
338
|
+
_emit({"event": "result", **result})
|
|
339
|
+
elif verbose:
|
|
340
|
+
typer.echo(
|
|
341
|
+
f"Rendering {len(jobs)} preset(s) with {n_workers} workers…", err=True
|
|
342
|
+
)
|
|
249
343
|
results = list(result_iter)
|
|
250
344
|
else:
|
|
251
345
|
with Progress(
|
|
@@ -258,7 +352,7 @@ def render(
|
|
|
258
352
|
TimeRemainingColumn(),
|
|
259
353
|
) as progress:
|
|
260
354
|
task_id = progress.add_task(
|
|
261
|
-
f"Rendering ({n_workers} workers)", total=
|
|
355
|
+
f"Rendering ({n_workers} workers)", total=total
|
|
262
356
|
)
|
|
263
357
|
for result in result_iter:
|
|
264
358
|
results.append(result)
|
|
@@ -268,7 +362,13 @@ def render(
|
|
|
268
362
|
skipped = sum(1 for r in results if r["status"] == "skipped")
|
|
269
363
|
errors = [r for r in results if r["status"] == "error"]
|
|
270
364
|
|
|
271
|
-
|
|
365
|
+
if json_out:
|
|
366
|
+
_emit({
|
|
367
|
+
"event": "done", "ok": ok, "skipped": skipped,
|
|
368
|
+
"failed": len(errors), "elapsed": round(time.monotonic() - t0, 3),
|
|
369
|
+
})
|
|
370
|
+
else:
|
|
371
|
+
typer.echo(f"Done: {ok} rendered, {skipped} skipped, {len(errors)} failed.")
|
|
272
372
|
for r in errors:
|
|
273
373
|
typer.echo(f" FAIL {r.get('path')}: {r.get('error')}", err=True)
|
|
274
374
|
raise typer.Exit(code=1 if errors else 0)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Frozen render configuration. Stdlib-only at module level."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .formats import PresetFormat, suffix_for
|
|
10
|
+
|
|
11
|
+
# Peak below this is treated as silent output by the engine.
|
|
12
|
+
# -90 dBFS ~= 16-bit quantization floor. Advisory only — known to be
|
|
13
|
+
# conservative for 24/32f bit depths.
|
|
14
|
+
SILENCE_EPS = 3.16e-5
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Standard Serum install locations, used as fallbacks when no explicit
|
|
18
|
+
# plugin path is given. INVARIANT: the SERUM1 entry must be a VST2 binary —
|
|
19
|
+
# the Serum 1 VST3 silently mis-loads .fxp presets. On Windows the 64-bit
|
|
20
|
+
# VST2 really does live in the VST3 folder (Xfer installer quirk).
|
|
21
|
+
_DEFAULT_PLUGIN_PATHS: dict[str, dict[PresetFormat, str]] = {
|
|
22
|
+
"darwin": {
|
|
23
|
+
PresetFormat.SERUM1: "/Library/Audio/Plug-Ins/VST/Serum.vst",
|
|
24
|
+
PresetFormat.SERUM2: "/Library/Audio/Plug-Ins/VST3/Serum2.vst3",
|
|
25
|
+
},
|
|
26
|
+
"win32": {
|
|
27
|
+
PresetFormat.SERUM1: "C:/Program Files/Common Files/VST3/Serum_x64.dll",
|
|
28
|
+
PresetFormat.SERUM2: "C:/Program Files/Common Files/VST3/Serum2.vst3",
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Expected binary suffix per platform per format, derived from the same
|
|
34
|
+
# knowledge as _DEFAULT_PLUGIN_PATHS above so a check and a default can never
|
|
35
|
+
# disagree. Serum 1 is VST2 (a different extension on every platform); Serum 2
|
|
36
|
+
# is VST3 everywhere. INVARIANT: never `.vst3` for SERUM1 — the Serum 1 VST3
|
|
37
|
+
# silently mis-loads .fxp presets, which is the failure this table exists to
|
|
38
|
+
# catch when a user browses by hand.
|
|
39
|
+
_PLUGIN_SUFFIXES: dict[str, dict[PresetFormat, str]] = {
|
|
40
|
+
"darwin": {PresetFormat.SERUM1: ".vst", PresetFormat.SERUM2: ".vst3"},
|
|
41
|
+
"win32": {PresetFormat.SERUM1: ".dll", PresetFormat.SERUM2: ".vst3"},
|
|
42
|
+
"linux": {PresetFormat.SERUM1: ".so", PresetFormat.SERUM2: ".vst3"},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def plugin_suffix_for(fmt: PresetFormat, platform: str | None = None) -> str | None:
|
|
47
|
+
"""Expected binary suffix for a format on a platform, or None if the
|
|
48
|
+
platform is unknown (in which case callers should not enforce a check)."""
|
|
49
|
+
platform = platform if platform is not None else sys.platform
|
|
50
|
+
table = _PLUGIN_SUFFIXES.get(platform)
|
|
51
|
+
return None if table is None else table[fmt]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def plugin_path_looks_valid(
|
|
55
|
+
path: str | Path, fmt: PresetFormat, platform: str | None = None
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""True when `path` exists and carries the suffix this format expects.
|
|
58
|
+
|
|
59
|
+
Existence is `exists()`, not `is_file()`: on macOS both plugin formats are
|
|
60
|
+
bundles — directories — so an is_file() check rejects every valid macOS
|
|
61
|
+
plugin. An unknown platform has no expectation to violate, so only
|
|
62
|
+
existence is checked there.
|
|
63
|
+
"""
|
|
64
|
+
path = Path(path)
|
|
65
|
+
if not path.exists():
|
|
66
|
+
return False
|
|
67
|
+
suffix = plugin_suffix_for(fmt, platform)
|
|
68
|
+
return suffix is None or path.suffix.lower() == suffix
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Where Serum keeps its factory presets when they have not been moved.
|
|
72
|
+
# The macOS entries are verified against a real install (2026-09-01). The
|
|
73
|
+
# Windows entries are NOT verified on a Windows machine — that is safe
|
|
74
|
+
# here, because `default_preset_dir` only ever returns a directory that
|
|
75
|
+
# exists and actually holds presets of that format, so a wrong guess
|
|
76
|
+
# degrades to "not found" instead of to a wrong answer.
|
|
77
|
+
_DEFAULT_PRESET_DIRS: dict[str, dict[PresetFormat, str]] = {
|
|
78
|
+
"darwin": {
|
|
79
|
+
PresetFormat.SERUM1: "/Library/Audio/Presets/Xfer Records/Serum Presets",
|
|
80
|
+
PresetFormat.SERUM2: "/Library/Audio/Presets/Xfer Records/Serum 2 Presets",
|
|
81
|
+
},
|
|
82
|
+
"win32": {
|
|
83
|
+
PresetFormat.SERUM1: "C:/ProgramData/Xfer/Serum Presets",
|
|
84
|
+
PresetFormat.SERUM2: "C:/ProgramData/Xfer/Serum 2 Presets",
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Serum records its own preset folder in these prefs files. Reading it
|
|
89
|
+
# beats assuming the default, because the folder is relocatable from
|
|
90
|
+
# inside the plugin. A blank or whitespace-only value means "unset" —
|
|
91
|
+
# that is what Serum 1 writes when the folder has never been moved.
|
|
92
|
+
# Windows paths unverified, same reasoning as above.
|
|
93
|
+
_PREFS_PATHS: dict[str, dict[PresetFormat, str]] = {
|
|
94
|
+
"darwin": {
|
|
95
|
+
PresetFormat.SERUM1: "~/Library/Preferences/SerumPrefs.json",
|
|
96
|
+
PresetFormat.SERUM2: "~/Library/Preferences/Serum2Prefs.json",
|
|
97
|
+
},
|
|
98
|
+
"win32": {
|
|
99
|
+
PresetFormat.SERUM1: "~/AppData/Roaming/Xfer/Serum/SerumPrefs.json",
|
|
100
|
+
PresetFormat.SERUM2: "~/AppData/Roaming/Xfer/Serum2/Serum2Prefs.json",
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
_PREFS_PRESET_KEY = "Serum Presets Path"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _configured_preset_dir(fmt: PresetFormat, platform: str) -> Path | None:
|
|
107
|
+
"""The preset folder Serum itself records, or None if it says nothing.
|
|
108
|
+
|
|
109
|
+
Never raises: an absent, unreadable or malformed prefs file is just
|
|
110
|
+
"no opinion", and the caller falls through to the standard location.
|
|
111
|
+
"""
|
|
112
|
+
table = _PREFS_PATHS.get(platform)
|
|
113
|
+
if table is None:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
data = json.loads(Path(table[fmt]).expanduser().read_text(encoding="utf-8"))
|
|
117
|
+
except (OSError, ValueError):
|
|
118
|
+
return None
|
|
119
|
+
value = data.get(_PREFS_PRESET_KEY) if isinstance(data, dict) else None
|
|
120
|
+
if not isinstance(value, str) or not value.strip():
|
|
121
|
+
return None
|
|
122
|
+
return Path(value.strip())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _holds_preset(directory: Path, suffix: str) -> bool:
|
|
126
|
+
"""True as soon as one preset of that suffix turns up. Stops at the
|
|
127
|
+
first hit — the factory tree runs to thousands of files."""
|
|
128
|
+
try:
|
|
129
|
+
for path in directory.rglob("*"):
|
|
130
|
+
if path.suffix.lower() == suffix:
|
|
131
|
+
return True
|
|
132
|
+
except OSError:
|
|
133
|
+
return False
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def default_preset_dir(
|
|
138
|
+
fmt: PresetFormat, platform: str | None = None
|
|
139
|
+
) -> Path | None:
|
|
140
|
+
"""Best guess at where this format's presets live, or None.
|
|
141
|
+
|
|
142
|
+
Prefers the folder Serum records in its own prefs over the standard
|
|
143
|
+
install location. Only returns a directory that exists AND contains
|
|
144
|
+
at least one preset of that format, so a stale prefs entry or a
|
|
145
|
+
wrong default reads as "not found" rather than as an empty batch.
|
|
146
|
+
"""
|
|
147
|
+
platform = platform if platform is not None else sys.platform
|
|
148
|
+
candidates: list[Path] = []
|
|
149
|
+
configured = _configured_preset_dir(fmt, platform)
|
|
150
|
+
if configured is not None:
|
|
151
|
+
candidates.append(configured)
|
|
152
|
+
table = _DEFAULT_PRESET_DIRS.get(platform)
|
|
153
|
+
if table is not None:
|
|
154
|
+
candidates.append(Path(table[fmt]))
|
|
155
|
+
|
|
156
|
+
suffix = suffix_for(fmt).lower()
|
|
157
|
+
for candidate in candidates:
|
|
158
|
+
if candidate.is_dir() and _holds_preset(candidate, suffix):
|
|
159
|
+
return candidate
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def default_plugin_path(fmt: PresetFormat, platform: str | None = None) -> Path | None:
|
|
164
|
+
"""Return the standard install path for a format if it exists on disk.
|
|
165
|
+
|
|
166
|
+
A missing default is "unset" (returns None), never an error — the
|
|
167
|
+
caller falls through to its normal missing-plugin message.
|
|
168
|
+
"""
|
|
169
|
+
platform = platform if platform is not None else sys.platform
|
|
170
|
+
table = _DEFAULT_PLUGIN_PATHS.get(platform)
|
|
171
|
+
if table is None:
|
|
172
|
+
return None
|
|
173
|
+
candidate = Path(table[fmt])
|
|
174
|
+
return candidate if candidate.exists() else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class RenderConfig:
|
|
179
|
+
# At least one of these must be set. `serum1_plugin_path` accepts either
|
|
180
|
+
# the VST2 binary or the VST3 build of Serum 1 for library users who
|
|
181
|
+
# know what they're doing — but only the VST2 build loads .fxp
|
|
182
|
+
# correctly, so the CLI default never picks the VST3.
|
|
183
|
+
# `serum2_plugin_path` is Serum 2's VST3, paired with `load_state`.
|
|
184
|
+
serum1_plugin_path: str | Path | None = None
|
|
185
|
+
serum2_plugin_path: str | Path | None = None
|
|
186
|
+
sample_rate: int = 44100
|
|
187
|
+
note: int = 48
|
|
188
|
+
velocity: int = 127
|
|
189
|
+
duration: float = 1.0
|
|
190
|
+
tail: float = 1.0
|
|
191
|
+
midi_path: str | Path | None = None
|
|
192
|
+
# Render every preset in a fresh single-use process, making batch
|
|
193
|
+
# output bit-reproducible. Costs a plugin load per preset instead of
|
|
194
|
+
# per worker. In-process resets don't work for Serum 1 (state
|
|
195
|
+
# survives even a full engine reload); see docs/decisions.md.
|
|
196
|
+
deterministic: bool = False
|
|
197
|
+
|
|
198
|
+
def __post_init__(self) -> None:
|
|
199
|
+
# Cheap shape/range checks only — no disk I/O. Path existence is
|
|
200
|
+
# verified on first use (renderer entry), keeping construction free
|
|
201
|
+
# of filesystem side effects.
|
|
202
|
+
if self.serum1_plugin_path is None and self.serum2_plugin_path is None:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
"RenderConfig requires at least one of serum1_plugin_path or "
|
|
205
|
+
"serum2_plugin_path to be set."
|
|
206
|
+
)
|
|
207
|
+
for field in ("serum1_plugin_path", "serum2_plugin_path", "midi_path"):
|
|
208
|
+
value = getattr(self, field)
|
|
209
|
+
if value is not None:
|
|
210
|
+
object.__setattr__(self, field, Path(value))
|
|
211
|
+
|
|
212
|
+
if self.sample_rate <= 0:
|
|
213
|
+
raise ValueError(f"sample_rate must be > 0, got {self.sample_rate}")
|
|
214
|
+
if not (0 <= self.note <= 127):
|
|
215
|
+
raise ValueError(f"note must be 0-127, got {self.note}")
|
|
216
|
+
if not (1 <= self.velocity <= 127):
|
|
217
|
+
raise ValueError(f"velocity must be 1-127, got {self.velocity}")
|
|
218
|
+
if self.duration <= 0:
|
|
219
|
+
raise ValueError(f"duration must be > 0, got {self.duration}")
|
|
220
|
+
if self.tail < 0:
|
|
221
|
+
raise ValueError(f"tail must be >= 0, got {self.tail}")
|
|
222
|
+
|
|
223
|
+
def plugin_path_for(self, fmt: PresetFormat) -> Path | None:
|
|
224
|
+
return {
|
|
225
|
+
PresetFormat.SERUM1: self.serum1_plugin_path,
|
|
226
|
+
PresetFormat.SERUM2: self.serum2_plugin_path,
|
|
227
|
+
}[fmt]
|