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.
- serum_render/__init__.py +35 -0
- serum_render/api.py +203 -0
- serum_render/cli.py +274 -0
- serum_render/config.py +96 -0
- serum_render/discover.py +155 -0
- serum_render/engine.py +220 -0
- serum_render/formats.py +38 -0
- serum_render/isolated.py +93 -0
- serum_render/jobs.py +36 -0
- serum_render/output.py +33 -0
- serum_render/pool.py +149 -0
- serum_render-0.1.0.dist-info/METADATA +159 -0
- serum_render-0.1.0.dist-info/RECORD +16 -0
- serum_render-0.1.0.dist-info/WHEEL +4 -0
- serum_render-0.1.0.dist-info/entry_points.txt +2 -0
- serum_render-0.1.0.dist-info/licenses/LICENSE +674 -0
serum_render/__init__.py
ADDED
|
@@ -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]
|