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.
Files changed (43) hide show
  1. {serum_render-0.2.0 → serum_render-0.3.1}/KNOWN_ISSUES.md +12 -0
  2. {serum_render-0.2.0 → serum_render-0.3.1}/PKG-INFO +38 -1
  3. {serum_render-0.2.0 → serum_render-0.3.1}/README.md +37 -0
  4. {serum_render-0.2.0 → serum_render-0.3.1}/docs/decisions.md +32 -0
  5. {serum_render-0.2.0 → serum_render-0.3.1}/docs/implementation.md +14 -0
  6. {serum_render-0.2.0 → serum_render-0.3.1}/pyproject.toml +1 -1
  7. serum_render-0.3.1/serum_render/__main__.py +11 -0
  8. {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/cli.py +113 -13
  9. serum_render-0.3.1/serum_render/config.py +227 -0
  10. {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/discover.py +7 -0
  11. {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/engine.py +20 -3
  12. {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/formats.py +12 -0
  13. serum_render-0.3.1/serum_render/output.py +54 -0
  14. {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/pool.py +1 -1
  15. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_cli.py +171 -1
  16. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_config.py +139 -1
  17. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_engine.py +9 -2
  18. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_filename.py +28 -0
  19. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_isolated.py +3 -1
  20. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_output.py +32 -0
  21. serum_render-0.2.0/serum_render/config.py +0 -96
  22. serum_render-0.2.0/serum_render/output.py +0 -33
  23. {serum_render-0.2.0 → serum_render-0.3.1}/.github/workflows/publish.yml +0 -0
  24. {serum_render-0.2.0 → serum_render-0.3.1}/.github/workflows/tests.yml +0 -0
  25. {serum_render-0.2.0 → serum_render-0.3.1}/.gitignore +0 -0
  26. {serum_render-0.2.0 → serum_render-0.3.1}/LICENSE +0 -0
  27. {serum_render-0.2.0 → serum_render-0.3.1}/docs/determinism-probe-2026-07-16.json +0 -0
  28. {serum_render-0.2.0 → serum_render-0.3.1}/scripts/_verify_loky_worker.py +0 -0
  29. {serum_render-0.2.0 → serum_render-0.3.1}/scripts/probe_determinism.py +0 -0
  30. {serum_render-0.2.0 → serum_render-0.3.1}/scripts/verify_dawdreamer.py +0 -0
  31. {serum_render-0.2.0 → serum_render-0.3.1}/scripts/verify_dawdreamer_serum2.py +0 -0
  32. {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/__init__.py +0 -0
  33. {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/api.py +0 -0
  34. {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/isolated.py +0 -0
  35. {serum_render-0.2.0 → serum_render-0.3.1}/serum_render/jobs.py +0 -0
  36. {serum_render-0.2.0 → serum_render-0.3.1}/tests/conftest.py +0 -0
  37. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_api.py +0 -0
  38. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_jobs.py +0 -0
  39. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_midi.py +0 -0
  40. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_presets.py +0 -0
  41. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_sanitize.py +0 -0
  42. {serum_render-0.2.0 → serum_render-0.3.1}/tests/test_serum1_smoke.py +0 -0
  43. {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.2.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.2.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
- raise typer.Exit(code=2)
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, _ in preset_files
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
- typer.echo(f"Would render {len(jobs)} preset(s):")
218
- for j in jobs:
219
- typer.echo(f" {j.preset_path} -> {j.output_path}")
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
- if verbose:
248
- typer.echo(f"Rendering {len(jobs)} preset(s) with {n_workers} workers…")
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=len(jobs)
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
- typer.echo(f"Done: {ok} rendered, {skipped} skipped, {len(errors)} failed.")
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]