serum-render 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,35 @@
1
+ """
2
+ serum-render — batch Serum 1 (.fxp) and Serum 2 (.SerumPreset) preset
3
+ rendering via DawDreamer.
4
+
5
+ Public API:
6
+ from serum_render import (
7
+ RenderConfig,
8
+ Renderer,
9
+ ParallelRenderer,
10
+ render_preset,
11
+ )
12
+
13
+ RenderConfig is eager (pure-Python). The renderer entry points are
14
+ exposed lazily via PEP 562 __getattr__ so that importing worker-side
15
+ modules inside a loky worker process does NOT transitively import
16
+ dawdreamer / numpy at module level — dawdreamer must be the first
17
+ non-stdlib import in a render process, enforced inside EngineHost.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from .config import RenderConfig
22
+
23
+ __all__ = [
24
+ "RenderConfig",
25
+ "Renderer",
26
+ "ParallelRenderer",
27
+ "render_preset",
28
+ ]
29
+
30
+
31
+ def __getattr__(name: str):
32
+ if name in ("Renderer", "ParallelRenderer", "render_preset"):
33
+ from . import api
34
+ return getattr(api, name)
35
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
serum_render/api.py ADDED
@@ -0,0 +1,203 @@
1
+ """
2
+ Public library API: Renderer, ParallelRenderer, render_preset.
3
+
4
+ Thin wrappers over the single render core (engine.EngineHost) — no
5
+ render logic lives here. Module level stays stdlib-only so importing
6
+ the package stays cheap and worker-safe.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Iterable, Iterator
12
+
13
+ from .config import RenderConfig
14
+ from .discover import get_midi_duration
15
+ from .engine import EngineHost
16
+ from .formats import PresetFormat, format_for_path
17
+ from .jobs import Job
18
+
19
+ if TYPE_CHECKING:
20
+ import numpy as np
21
+
22
+
23
+ def _validate_entry(config: RenderConfig) -> float | None:
24
+ """First-use validation: any plugin/MIDI path the caller set must
25
+ exist. Returns the precomputed MIDI duration (workers never parse
26
+ MIDI) or None."""
27
+ for path in (config.serum1_plugin_path, config.serum2_plugin_path):
28
+ if path is not None and not Path(path).exists():
29
+ raise FileNotFoundError(f"Plugin not found: {path}")
30
+ if config.midi_path is not None:
31
+ if not Path(config.midi_path).exists():
32
+ raise FileNotFoundError(f"MIDI file not found: {config.midi_path}")
33
+ return get_midi_duration(Path(config.midi_path))
34
+ return None
35
+
36
+
37
+ def _check_format_coverage(
38
+ config: RenderConfig, formats: Iterable[PresetFormat]
39
+ ) -> None:
40
+ """Every format actually being rendered must have its plugin path on
41
+ the config. Fails before any engine boots, naming the missing field."""
42
+ missing = sorted(
43
+ f"{'.fxp' if fmt is PresetFormat.SERUM1 else '.SerumPreset'} preset(s) "
44
+ f"supplied but RenderConfig.{fmt.value}_plugin_path is unset"
45
+ for fmt in set(formats)
46
+ if config.plugin_path_for(fmt) is None
47
+ )
48
+ if missing:
49
+ raise ValueError("; ".join(missing))
50
+
51
+
52
+ def _build_job(
53
+ config: RenderConfig, preset_path: str | Path, midi_duration: float | None
54
+ ) -> Job:
55
+ path = Path(preset_path)
56
+ fmt = format_for_path(path)
57
+ _check_format_coverage(config, [fmt])
58
+ return Job(
59
+ preset_path=str(path.resolve()),
60
+ format=fmt,
61
+ note=config.note,
62
+ velocity=config.velocity,
63
+ duration=config.duration,
64
+ tail=config.tail,
65
+ midi_path=(
66
+ str(Path(config.midi_path).resolve())
67
+ if config.midi_path is not None
68
+ else None
69
+ ),
70
+ midi_duration=midi_duration,
71
+ )
72
+
73
+
74
+ class Renderer:
75
+ """
76
+ Single-process, sequential renderer. Loads the configured plugin(s)
77
+ once in `__enter__` and hot-swaps presets for every `render()` call.
78
+ Preset format is auto-detected from the file suffix. Errors raise.
79
+ """
80
+
81
+ def __init__(self, config: RenderConfig):
82
+ self.config = config
83
+ self._host: EngineHost | None = None
84
+ self._midi_duration: float | None = None
85
+ self._entered = False
86
+
87
+ def __enter__(self) -> "Renderer":
88
+ self._midi_duration = _validate_entry(self.config)
89
+ if not self.config.deterministic:
90
+ # Deterministic mode never builds an in-process engine —
91
+ # every render runs in its own single-use process.
92
+ self._host = EngineHost(
93
+ self.config.serum1_plugin_path,
94
+ self.config.serum2_plugin_path,
95
+ self.config.sample_rate,
96
+ )
97
+ self._entered = True
98
+ return self
99
+
100
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
101
+ # DawDreamer has no explicit teardown — drop the ref for GC.
102
+ self._host = None
103
+ self._entered = False
104
+
105
+ def render(self, preset_path: str | Path) -> "np.ndarray":
106
+ if not getattr(self, "_entered", False):
107
+ raise RuntimeError("Renderer must be used as a context manager")
108
+ job = _build_job(self.config, preset_path, self._midi_duration)
109
+ if self.config.deterministic:
110
+ from .pool import render_isolated
111
+
112
+ cfg = self.config
113
+ result = render_isolated(
114
+ job,
115
+ str(cfg.serum1_plugin_path) if cfg.serum1_plugin_path else None,
116
+ str(cfg.serum2_plugin_path) if cfg.serum2_plugin_path else None,
117
+ cfg.sample_rate,
118
+ keep_audio=True,
119
+ )
120
+ if result["status"] != "ok":
121
+ raise RuntimeError(
122
+ f"Deterministic render failed for {preset_path}: "
123
+ f"{result.get('error')}"
124
+ )
125
+ return result["audio"]
126
+ return self._host.render(job)
127
+
128
+
129
+ class ParallelRenderer:
130
+ """
131
+ Multi-process renderer for bulk use. Mixed-format batches are fine —
132
+ format is auto-detected per path. Audio ships back from workers to
133
+ the main process (~700 KB per 2s stereo render); for very large
134
+ libraries iterate and spill to disk instead of holding the dict.
135
+ """
136
+
137
+ def __init__(self, config: RenderConfig, workers: int = -1):
138
+ self.config = config
139
+ self.workers = workers
140
+ self._midi_duration: float | None = None
141
+ self._entered = False
142
+
143
+ def __enter__(self) -> "ParallelRenderer":
144
+ self._midi_duration = _validate_entry(self.config)
145
+ self._entered = True
146
+ return self
147
+
148
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
149
+ # Executor is owned by loky's reusable cache; leave it warm so
150
+ # the next ParallelRenderer in this process reuses the workers.
151
+ self._entered = False
152
+
153
+ def _build_jobs(self, preset_paths: list[str | Path]) -> list[Job]:
154
+ if not self._entered:
155
+ raise RuntimeError(
156
+ "ParallelRenderer must be used as a context manager"
157
+ )
158
+ # Coverage-check the whole batch first so the error names every
159
+ # missing plugin path in one pass, before any worker boots.
160
+ _check_format_coverage(
161
+ self.config, [format_for_path(Path(p)) for p in preset_paths]
162
+ )
163
+ return [
164
+ _build_job(self.config, p, self._midi_duration) for p in preset_paths
165
+ ]
166
+
167
+ def iter_batch(
168
+ self, preset_paths: list[str | Path]
169
+ ) -> Iterator[tuple[str, "np.ndarray"]]:
170
+ """Yield `(preset_path, audio)` as each job completes (unordered).
171
+ Failed jobs are logged by the worker and skipped here."""
172
+ from .pool import iter_jobs, iter_jobs_isolated
173
+
174
+ jobs = self._build_jobs(preset_paths)
175
+ cfg = self.config
176
+ serum1 = str(cfg.serum1_plugin_path) if cfg.serum1_plugin_path else None
177
+ serum2 = str(cfg.serum2_plugin_path) if cfg.serum2_plugin_path else None
178
+ if cfg.deterministic:
179
+ results = iter_jobs_isolated(
180
+ jobs, self.workers, serum1, serum2, cfg.sample_rate,
181
+ keep_audio=True,
182
+ )
183
+ else:
184
+ results = iter_jobs(jobs, self.workers, serum1, serum2, cfg.sample_rate)
185
+ for result in results:
186
+ if result["status"] == "ok":
187
+ yield result["path"], result["audio"]
188
+
189
+ def render_batch(
190
+ self, preset_paths: list[str | Path]
191
+ ) -> dict[str, "np.ndarray"]:
192
+ """Render all presets and return a dict mapping path -> audio."""
193
+ return dict(self.iter_batch(preset_paths))
194
+
195
+
196
+ def render_preset(preset_path: str | Path, config: RenderConfig) -> "np.ndarray":
197
+ """
198
+ One-off render. Spins up a fresh EngineHost, renders, returns audio.
199
+ Not suitable for batch use — each call pays the ~1-2s plugin
200
+ cold-start plus a 0.1s warmup render per loaded synth.
201
+ """
202
+ with Renderer(config) as renderer:
203
+ return renderer.render(preset_path)
serum_render/cli.py ADDED
@@ -0,0 +1,274 @@
1
+ """
2
+ Typer CLI entry point. Discovers presets, resolves plugin paths (explicit
3
+ flags beat platform defaults), builds typed Jobs, and drives the pool.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.progress import (
13
+ BarColumn,
14
+ MofNCompleteColumn,
15
+ Progress,
16
+ TextColumn,
17
+ TimeElapsedColumn,
18
+ TimeRemainingColumn,
19
+ )
20
+
21
+ from .config import default_plugin_path
22
+ from .discover import (
23
+ compose_filename,
24
+ discover_presets,
25
+ get_midi_duration,
26
+ resolve_output_paths,
27
+ )
28
+ from .formats import PresetFormat
29
+ from .jobs import Job
30
+ from .pool import iter_jobs, resolve_worker_count
31
+
32
+ logger = logging.getLogger("serum_render")
33
+
34
+ app = typer.Typer(
35
+ add_completion=False,
36
+ help="Batch-render Serum presets (.fxp, .SerumPreset) to audio.",
37
+ )
38
+
39
+ _FLAG_FOR = {PresetFormat.SERUM1: "--serum1", PresetFormat.SERUM2: "--serum2"}
40
+ _EXT_FOR = {PresetFormat.SERUM1: ".fxp", PresetFormat.SERUM2: ".SerumPreset"}
41
+ _PLUGIN_NAME_FOR = {PresetFormat.SERUM1: "Serum 1", PresetFormat.SERUM2: "Serum 2"}
42
+
43
+
44
+ def _setup_logging(verbose: bool) -> None:
45
+ """Only the CLI configures logging — library code uses a named logger."""
46
+ logging.basicConfig(
47
+ level=logging.DEBUG if verbose else logging.WARNING,
48
+ format="%(levelname)s %(name)s: %(message)s",
49
+ )
50
+
51
+
52
+ @app.command()
53
+ def render(
54
+ presets: Path = typer.Argument(..., help="Path to a single preset (.fxp or .SerumPreset) or a directory of them."),
55
+ output: Path = typer.Argument(..., help="Output directory (created if missing)."),
56
+ serum1: Optional[Path] = typer.Option(
57
+ None, "--serum1",
58
+ help="Path to a Serum 1 plugin that loads .fxp presets — the VST2 "
59
+ "binary (.dll on Windows, .vst bundle on macOS). Defaults to "
60
+ "the standard install location if .fxp files are being "
61
+ "rendered and it exists.",
62
+ ),
63
+ serum2: Optional[Path] = typer.Option(
64
+ None, "--serum2",
65
+ help="Path to the Serum 2 VST3 plugin. Defaults to the standard "
66
+ "install location if .SerumPreset files are being rendered "
67
+ "and it exists.",
68
+ ),
69
+ note: Optional[int] = typer.Option(None, min=0, max=127, help="MIDI note (0-127). Default 48 (C3)."),
70
+ velocity: int = typer.Option(127, min=1, max=127, help="MIDI velocity (1-127)."),
71
+ duration: float = typer.Option(1.0, help="Note-on duration in seconds (> 0)."),
72
+ tail: float = typer.Option(1.0, min=0.0, help="Release silence in seconds (>= 0)."),
73
+ sample_rate: int = typer.Option(44100, "--sample-rate", min=1, help="Output sample rate in Hz."),
74
+ bit_depth: str = typer.Option("16", "--bit-depth", help="Output bit depth: 16, 24, or 32f."),
75
+ fmt: str = typer.Option("wav", "--format", help="Output container: wav or npy."),
76
+ filename_template: str = typer.Option(
77
+ "{preset}", "--filename-template",
78
+ help="Filename template. Vars: {preset} {note} {velocity} {folder} {subpath}.",
79
+ ),
80
+ midi: Optional[Path] = typer.Option(None, "--midi", help="Path to a .mid file (overrides --note)."),
81
+ workers: int = typer.Option(-1, "--workers", help="Parallel workers. -1 = cpu_count - 1."),
82
+ skip_existing: bool = typer.Option(False, "--skip-existing", help="Skip if output file already exists."),
83
+ deterministic: bool = typer.Option(
84
+ False, "--deterministic",
85
+ help="Render every preset in a fresh single-use process so batch "
86
+ "output is bit-reproducible. Slower: one plugin load per "
87
+ "preset instead of per worker.",
88
+ ),
89
+ no_recurse: bool = typer.Option(False, "--no-recurse", help="Do not recurse into subdirectories."),
90
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print presets that would render and exit."),
91
+ verbose: bool = typer.Option(False, "--verbose", help="Per-preset status logging."),
92
+ ) -> None:
93
+ _setup_logging(verbose)
94
+
95
+ # --note and --midi are mutually exclusive. Typer can't detect a
96
+ # user-set default, so we use None sentinel + manual check.
97
+ if midi is not None and note is not None:
98
+ raise typer.BadParameter(
99
+ "--note and --midi are mutually exclusive. Use --midi to render a "
100
+ "MIDI sequence, or --note to render a single note."
101
+ )
102
+ if note is None:
103
+ note = 48
104
+
105
+ # Typer's `min=` is inclusive, so "> 0" on duration needs a manual check.
106
+ if duration <= 0:
107
+ raise typer.BadParameter(f"--duration must be > 0 (got {duration}).")
108
+
109
+ if bit_depth not in ("16", "24", "32f"):
110
+ raise typer.BadParameter(f"--bit-depth must be 16, 24, or 32f (got {bit_depth!r}).")
111
+ if fmt not in ("wav", "npy"):
112
+ raise typer.BadParameter(f"--format must be wav or npy (got {fmt!r}).")
113
+
114
+ # Path.exists() returns True for VST3 bundle directories on macOS and
115
+ # for plain .vst3 / .dll / .vst files on Windows + macOS — both shapes
116
+ # are valid plugin paths, so no is_file() check.
117
+ if serum1 is not None and not serum1.exists():
118
+ typer.echo(f"Plugin not found: {serum1}", err=True)
119
+ raise typer.Exit(code=2)
120
+ if serum2 is not None and not serum2.exists():
121
+ typer.echo(f"Plugin not found: {serum2}", err=True)
122
+ raise typer.Exit(code=2)
123
+ if not presets.exists():
124
+ typer.echo(f"Presets path not found: {presets}", err=True)
125
+ raise typer.Exit(code=2)
126
+ if output.exists() and not output.is_dir():
127
+ typer.echo(f"Output path exists and is not a directory: {output}", err=True)
128
+ raise typer.Exit(code=2)
129
+
130
+ try:
131
+ preset_files = discover_presets(presets, recurse=not no_recurse)
132
+ except ValueError as exc:
133
+ # Single-file mode with an unsupported extension.
134
+ typer.echo(f"Error: {exc}", err=True)
135
+ raise typer.Exit(code=2) from None
136
+ if not preset_files:
137
+ typer.echo(
138
+ f"No supported preset files (.fxp, .SerumPreset) found under {presets}",
139
+ err=True,
140
+ )
141
+ raise typer.Exit(code=0)
142
+
143
+ # Resolve plugin paths: an explicit flag always wins; otherwise fall
144
+ # back to the standard install location — but only for formats that
145
+ # actually appear in the discovered set, and only if the default
146
+ # exists on disk (a missing default is "unset", not an error).
147
+ discovered_formats = {fmt_tag for _, fmt_tag in preset_files}
148
+ plugin_paths: dict[PresetFormat, Path] = {}
149
+ explicit = {PresetFormat.SERUM1: serum1, PresetFormat.SERUM2: serum2}
150
+ for preset_fmt in discovered_formats:
151
+ if explicit[preset_fmt] is not None:
152
+ plugin_paths[preset_fmt] = explicit[preset_fmt]
153
+ else:
154
+ fallback = default_plugin_path(preset_fmt)
155
+ if fallback is not None:
156
+ plugin_paths[preset_fmt] = fallback
157
+ typer.echo(
158
+ f"Using default {_PLUGIN_NAME_FOR[preset_fmt]} plugin: {fallback}"
159
+ )
160
+
161
+ missing = discovered_formats - set(plugin_paths)
162
+ if missing:
163
+ msgs = sorted(
164
+ f"found {_EXT_FOR[m]} files but {_FLAG_FOR[m]} was not provided "
165
+ f"and no default {_PLUGIN_NAME_FOR[m]} install was found"
166
+ for m in missing
167
+ )
168
+ for m in msgs:
169
+ typer.echo(m, err=True)
170
+ raise typer.Exit(code=2)
171
+
172
+ # Single-file mode: presets_root=None so {subpath} collapses out.
173
+ # Resolve when a directory so `relative_to` works against the absolute
174
+ # preset paths that discover_presets returns — a relative presets arg
175
+ # would otherwise silently collapse {subpath} to an empty string.
176
+ presets_root: Path | None = presets.resolve() if presets.is_dir() else None
177
+
178
+ # Compute MIDI duration once in the main process — all workers share it.
179
+ midi_duration: float | None = None
180
+ midi_str: str | None = None
181
+ if midi is not None:
182
+ if not midi.exists():
183
+ typer.echo(f"MIDI file not found: {midi}", err=True)
184
+ raise typer.Exit(code=2)
185
+ try:
186
+ midi_duration = get_midi_duration(midi)
187
+ except (TypeError, ValueError) as exc:
188
+ typer.echo(f"Error reading MIDI file '{midi}': {exc}", err=True)
189
+ raise typer.Exit(code=2) from None
190
+ midi_str = str(midi.resolve())
191
+
192
+ extension = ".npy" if fmt == "npy" else ".wav"
193
+ stems = [
194
+ compose_filename(filename_template, p, presets_root, note, velocity)
195
+ for p, _ in preset_files
196
+ ]
197
+ output_paths = resolve_output_paths(stems, output, extension)
198
+ jobs = [
199
+ Job(
200
+ preset_path=str(p.resolve()),
201
+ format=preset_fmt,
202
+ note=note,
203
+ velocity=velocity,
204
+ duration=duration,
205
+ tail=tail,
206
+ midi_path=midi_str,
207
+ midi_duration=midi_duration,
208
+ output_path=out,
209
+ bit_depth=bit_depth,
210
+ output_format=fmt,
211
+ skip_existing=skip_existing,
212
+ )
213
+ for (p, preset_fmt), out in zip(preset_files, output_paths)
214
+ ]
215
+
216
+ if dry_run:
217
+ typer.echo(f"Would render {len(jobs)} preset(s):")
218
+ for j in jobs:
219
+ typer.echo(f" {j.preset_path} -> {j.output_path}")
220
+ raise typer.Exit(code=0)
221
+
222
+ output.mkdir(parents=True, exist_ok=True)
223
+ n_workers = resolve_worker_count(workers)
224
+ serum1_str = (
225
+ str(plugin_paths[PresetFormat.SERUM1].resolve())
226
+ if PresetFormat.SERUM1 in plugin_paths
227
+ else None
228
+ )
229
+ serum2_str = (
230
+ str(plugin_paths[PresetFormat.SERUM2].resolve())
231
+ if PresetFormat.SERUM2 in plugin_paths
232
+ else None
233
+ )
234
+
235
+ results: list[dict] = []
236
+ if deterministic:
237
+ from .pool import iter_jobs_isolated
238
+
239
+ result_iter = iter_jobs_isolated(
240
+ jobs, n_workers, serum1_str, serum2_str, sample_rate
241
+ )
242
+ else:
243
+ result_iter = iter_jobs(jobs, n_workers, serum1_str, serum2_str, sample_rate)
244
+
245
+ # In verbose mode, per-preset DEBUG logs replace the progress bar so
246
+ # the two don't fight for the terminal.
247
+ if verbose:
248
+ typer.echo(f"Rendering {len(jobs)} preset(s) with {n_workers} workers…")
249
+ results = list(result_iter)
250
+ else:
251
+ with Progress(
252
+ TextColumn("[progress.description]{task.description}"),
253
+ BarColumn(),
254
+ MofNCompleteColumn(),
255
+ TextColumn("•"),
256
+ TimeElapsedColumn(),
257
+ TextColumn("•"),
258
+ TimeRemainingColumn(),
259
+ ) as progress:
260
+ task_id = progress.add_task(
261
+ f"Rendering ({n_workers} workers)", total=len(jobs)
262
+ )
263
+ for result in result_iter:
264
+ results.append(result)
265
+ progress.advance(task_id)
266
+
267
+ ok = sum(1 for r in results if r["status"] == "ok")
268
+ skipped = sum(1 for r in results if r["status"] == "skipped")
269
+ errors = [r for r in results if r["status"] == "error"]
270
+
271
+ typer.echo(f"Done: {ok} rendered, {skipped} skipped, {len(errors)} failed.")
272
+ for r in errors:
273
+ typer.echo(f" FAIL {r.get('path')}: {r.get('error')}", err=True)
274
+ raise typer.Exit(code=1 if errors else 0)
serum_render/config.py ADDED
@@ -0,0 +1,96 @@
1
+ """Frozen render configuration. Stdlib-only at module level."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .formats import PresetFormat
9
+
10
+ # Peak below this is treated as silent output by the engine.
11
+ # -90 dBFS ~= 16-bit quantization floor. Advisory only — known to be
12
+ # conservative for 24/32f bit depths.
13
+ SILENCE_EPS = 3.16e-5
14
+
15
+
16
+ # Standard Serum install locations, used as fallbacks when no explicit
17
+ # plugin path is given. INVARIANT: the SERUM1 entry must be a VST2 binary —
18
+ # the Serum 1 VST3 silently mis-loads .fxp presets. On Windows the 64-bit
19
+ # VST2 really does live in the VST3 folder (Xfer installer quirk).
20
+ _DEFAULT_PLUGIN_PATHS: dict[str, dict[PresetFormat, str]] = {
21
+ "darwin": {
22
+ PresetFormat.SERUM1: "/Library/Audio/Plug-Ins/VST/Serum.vst",
23
+ PresetFormat.SERUM2: "/Library/Audio/Plug-Ins/VST3/Serum2.vst3",
24
+ },
25
+ "win32": {
26
+ PresetFormat.SERUM1: "C:/Program Files/Common Files/VST3/Serum_x64.dll",
27
+ PresetFormat.SERUM2: "C:/Program Files/Common Files/VST3/Serum2.vst3",
28
+ },
29
+ }
30
+
31
+
32
+ def default_plugin_path(fmt: PresetFormat, platform: str | None = None) -> Path | None:
33
+ """Return the standard install path for a format if it exists on disk.
34
+
35
+ A missing default is "unset" (returns None), never an error — the
36
+ caller falls through to its normal missing-plugin message.
37
+ """
38
+ platform = platform if platform is not None else sys.platform
39
+ table = _DEFAULT_PLUGIN_PATHS.get(platform)
40
+ if table is None:
41
+ return None
42
+ candidate = Path(table[fmt])
43
+ return candidate if candidate.exists() else None
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class RenderConfig:
48
+ # At least one of these must be set. `serum1_plugin_path` accepts either
49
+ # the VST2 binary or the VST3 build of Serum 1 for library users who
50
+ # know what they're doing — but only the VST2 build loads .fxp
51
+ # correctly, so the CLI default never picks the VST3.
52
+ # `serum2_plugin_path` is Serum 2's VST3, paired with `load_state`.
53
+ serum1_plugin_path: str | Path | None = None
54
+ serum2_plugin_path: str | Path | None = None
55
+ sample_rate: int = 44100
56
+ note: int = 48
57
+ velocity: int = 127
58
+ duration: float = 1.0
59
+ tail: float = 1.0
60
+ midi_path: str | Path | None = None
61
+ # Render every preset in a fresh single-use process, making batch
62
+ # output bit-reproducible. Costs a plugin load per preset instead of
63
+ # per worker. In-process resets don't work for Serum 1 (state
64
+ # survives even a full engine reload); see docs/decisions.md.
65
+ deterministic: bool = False
66
+
67
+ def __post_init__(self) -> None:
68
+ # Cheap shape/range checks only — no disk I/O. Path existence is
69
+ # verified on first use (renderer entry), keeping construction free
70
+ # of filesystem side effects.
71
+ if self.serum1_plugin_path is None and self.serum2_plugin_path is None:
72
+ raise ValueError(
73
+ "RenderConfig requires at least one of serum1_plugin_path or "
74
+ "serum2_plugin_path to be set."
75
+ )
76
+ for field in ("serum1_plugin_path", "serum2_plugin_path", "midi_path"):
77
+ value = getattr(self, field)
78
+ if value is not None:
79
+ object.__setattr__(self, field, Path(value))
80
+
81
+ if self.sample_rate <= 0:
82
+ raise ValueError(f"sample_rate must be > 0, got {self.sample_rate}")
83
+ if not (0 <= self.note <= 127):
84
+ raise ValueError(f"note must be 0-127, got {self.note}")
85
+ if not (1 <= self.velocity <= 127):
86
+ raise ValueError(f"velocity must be 1-127, got {self.velocity}")
87
+ if self.duration <= 0:
88
+ raise ValueError(f"duration must be > 0, got {self.duration}")
89
+ if self.tail < 0:
90
+ raise ValueError(f"tail must be >= 0, got {self.tail}")
91
+
92
+ def plugin_path_for(self, fmt: PresetFormat) -> Path | None:
93
+ return {
94
+ PresetFormat.SERUM1: self.serum1_plugin_path,
95
+ PresetFormat.SERUM2: self.serum2_plugin_path,
96
+ }[fmt]