serum-render 0.2.0__tar.gz → 0.3.0__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.0}/KNOWN_ISSUES.md +12 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/PKG-INFO +30 -1
- {serum_render-0.2.0 → serum_render-0.3.0}/README.md +29 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/docs/decisions.md +14 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/docs/implementation.md +14 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/pyproject.toml +1 -1
- serum_render-0.3.0/serum_render/__main__.py +11 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/cli.py +73 -11
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/config.py +94 -1
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/discover.py +7 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/engine.py +15 -2
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/formats.py +12 -0
- serum_render-0.3.0/serum_render/output.py +54 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_cli.py +88 -1
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_config.py +82 -1
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_engine.py +6 -1
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_filename.py +28 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_output.py +32 -0
- serum_render-0.2.0/serum_render/output.py +0 -33
- {serum_render-0.2.0 → serum_render-0.3.0}/.github/workflows/publish.yml +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/.github/workflows/tests.yml +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/.gitignore +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/LICENSE +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/docs/determinism-probe-2026-07-16.json +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/scripts/_verify_loky_worker.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/scripts/probe_determinism.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/scripts/verify_dawdreamer.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/scripts/verify_dawdreamer_serum2.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/__init__.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/api.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/isolated.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/jobs.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/serum_render/pool.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/conftest.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_api.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_isolated.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_jobs.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_midi.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_presets.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_sanitize.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/tests/test_serum1_smoke.py +0 -0
- {serum_render-0.2.0 → serum_render-0.3.0}/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.0
|
|
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
|
|
@@ -92,9 +92,38 @@ A directory containing both `.fxp` and `.SerumPreset` files renders as one mixed
|
|
|
92
92
|
| `--skip-existing` | off | Skip presets whose output already exists. |
|
|
93
93
|
| `--no-recurse` | off | Don't descend into subdirectories. |
|
|
94
94
|
| `--dry-run` | off | Print the render plan and exit. |
|
|
95
|
+
| `--json` | off | Emit machine-readable events on stdout (see below). |
|
|
95
96
|
|
|
96
97
|
Run `serum-render --help` for the full list.
|
|
97
98
|
|
|
99
|
+
### Machine-readable output
|
|
100
|
+
|
|
101
|
+
`--json` writes one JSON object per line to stdout and moves every
|
|
102
|
+
human-readable line to stderr, for driving serum-render from another
|
|
103
|
+
program:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
{"event":"start","schema":1,"total":4271,"workers":7}
|
|
107
|
+
{"event":"result","status":"ok","path":".../Bass 1.fxp","peak":0.3325}
|
|
108
|
+
{"event":"result","status":"skipped","path":"..."}
|
|
109
|
+
{"event":"result","status":"error","path":"...","error":"..."}
|
|
110
|
+
{"event":"done","ok":4268,"skipped":0,"failed":3,"elapsed":612.4}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`--dry-run --json` emits the `start` event alone and exits. Exit codes
|
|
114
|
+
are unchanged: `0` clean, `1` some renders failed, `2` usage or
|
|
115
|
+
validation error. Validation errors write to stderr and produce no
|
|
116
|
+
events, so **exit 0 always means at least one `done` event** — a run that
|
|
117
|
+
found no presets still emits `start` with `total: 0` and a zeroed `done`.
|
|
118
|
+
|
|
119
|
+
Two rules for consumers. Reject an unrecognised `schema` rather than
|
|
120
|
+
guessing at the shape. And skip any stdout line that fails to parse:
|
|
121
|
+
loky workers inherit the parent's stdout, so a plugin printing from C
|
|
122
|
+
could in principle interleave with the stream — trust the counts in
|
|
123
|
+
`done` over a local tally of `result` events.
|
|
124
|
+
|
|
125
|
+
**The output shape is unstable until 1.0.**
|
|
126
|
+
|
|
98
127
|
## Library API
|
|
99
128
|
|
|
100
129
|
```python
|
|
@@ -64,9 +64,38 @@ A directory containing both `.fxp` and `.SerumPreset` files renders as one mixed
|
|
|
64
64
|
| `--skip-existing` | off | Skip presets whose output already exists. |
|
|
65
65
|
| `--no-recurse` | off | Don't descend into subdirectories. |
|
|
66
66
|
| `--dry-run` | off | Print the render plan and exit. |
|
|
67
|
+
| `--json` | off | Emit machine-readable events on stdout (see below). |
|
|
67
68
|
|
|
68
69
|
Run `serum-render --help` for the full list.
|
|
69
70
|
|
|
71
|
+
### Machine-readable output
|
|
72
|
+
|
|
73
|
+
`--json` writes one JSON object per line to stdout and moves every
|
|
74
|
+
human-readable line to stderr, for driving serum-render from another
|
|
75
|
+
program:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
{"event":"start","schema":1,"total":4271,"workers":7}
|
|
79
|
+
{"event":"result","status":"ok","path":".../Bass 1.fxp","peak":0.3325}
|
|
80
|
+
{"event":"result","status":"skipped","path":"..."}
|
|
81
|
+
{"event":"result","status":"error","path":"...","error":"..."}
|
|
82
|
+
{"event":"done","ok":4268,"skipped":0,"failed":3,"elapsed":612.4}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`--dry-run --json` emits the `start` event alone and exits. Exit codes
|
|
86
|
+
are unchanged: `0` clean, `1` some renders failed, `2` usage or
|
|
87
|
+
validation error. Validation errors write to stderr and produce no
|
|
88
|
+
events, so **exit 0 always means at least one `done` event** — a run that
|
|
89
|
+
found no presets still emits `start` with `total: 0` and a zeroed `done`.
|
|
90
|
+
|
|
91
|
+
Two rules for consumers. Reject an unrecognised `schema` rather than
|
|
92
|
+
guessing at the shape. And skip any stdout line that fails to parse:
|
|
93
|
+
loky workers inherit the parent's stdout, so a plugin printing from C
|
|
94
|
+
could in principle interleave with the stream — trust the counts in
|
|
95
|
+
`done` over a local tally of `result` events.
|
|
96
|
+
|
|
97
|
+
**The output shape is unstable until 1.0.**
|
|
98
|
+
|
|
70
99
|
## Library API
|
|
71
100
|
|
|
72
101
|
```python
|
|
@@ -75,3 +75,17 @@ 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.
|
|
@@ -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.0"
|
|
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,9 @@ flags beat platform defaults), builds typed Jobs, and drives the pool.
|
|
|
4
4
|
"""
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import json
|
|
7
8
|
import logging
|
|
9
|
+
import time
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from typing import Optional
|
|
10
12
|
|
|
@@ -41,6 +43,17 @@ _EXT_FOR = {PresetFormat.SERUM1: ".fxp", PresetFormat.SERUM2: ".SerumPreset"}
|
|
|
41
43
|
_PLUGIN_NAME_FOR = {PresetFormat.SERUM1: "Serum 1", PresetFormat.SERUM2: "Serum 2"}
|
|
42
44
|
|
|
43
45
|
|
|
46
|
+
# --json stream version. Bumped only on an incompatible shape change;
|
|
47
|
+
# consumers should reject an unknown value rather than guess at it.
|
|
48
|
+
_JSON_SCHEMA = 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _emit(event: dict) -> None:
|
|
52
|
+
"""Write one NDJSON event to stdout. Under --json, stdout carries
|
|
53
|
+
these and nothing else — every human-readable line goes to stderr."""
|
|
54
|
+
typer.echo(json.dumps(event))
|
|
55
|
+
|
|
56
|
+
|
|
44
57
|
def _setup_logging(verbose: bool) -> None:
|
|
45
58
|
"""Only the CLI configures logging — library code uses a named logger."""
|
|
46
59
|
logging.basicConfig(
|
|
@@ -75,7 +88,7 @@ def render(
|
|
|
75
88
|
fmt: str = typer.Option("wav", "--format", help="Output container: wav or npy."),
|
|
76
89
|
filename_template: str = typer.Option(
|
|
77
90
|
"{preset}", "--filename-template",
|
|
78
|
-
help="Filename template. Vars: {preset} {note} {velocity} {folder} {subpath} {subdir}. {subdir} nests output to mirror the preset tree.",
|
|
91
|
+
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
92
|
),
|
|
80
93
|
midi: Optional[Path] = typer.Option(None, "--midi", help="Path to a .mid file (overrides --note)."),
|
|
81
94
|
workers: int = typer.Option(-1, "--workers", help="Parallel workers. -1 = cpu_count - 1."),
|
|
@@ -89,6 +102,12 @@ def render(
|
|
|
89
102
|
no_recurse: bool = typer.Option(False, "--no-recurse", help="Do not recurse into subdirectories."),
|
|
90
103
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print presets that would render and exit."),
|
|
91
104
|
verbose: bool = typer.Option(False, "--verbose", help="Per-preset status logging."),
|
|
105
|
+
json_out: bool = typer.Option(
|
|
106
|
+
False, "--json",
|
|
107
|
+
help="Emit one JSON event per line on stdout (start, result per "
|
|
108
|
+
"preset, done) and move all human-readable output to stderr. "
|
|
109
|
+
"Output shape is unstable until 1.0.",
|
|
110
|
+
),
|
|
92
111
|
) -> None:
|
|
93
112
|
_setup_logging(verbose)
|
|
94
113
|
|
|
@@ -138,6 +157,18 @@ def render(
|
|
|
138
157
|
f"No supported preset files (.fxp, .SerumPreset) found under {presets}",
|
|
139
158
|
err=True,
|
|
140
159
|
)
|
|
160
|
+
# Emit the full pair anyway. Exiting 0 with an empty stream is
|
|
161
|
+
# indistinguishable from a successful run to a consumer, and a
|
|
162
|
+
# mistyped preset directory is the likeliest way to land here.
|
|
163
|
+
if json_out:
|
|
164
|
+
_emit({
|
|
165
|
+
"event": "start", "schema": _JSON_SCHEMA, "total": 0,
|
|
166
|
+
"workers": resolve_worker_count(workers),
|
|
167
|
+
})
|
|
168
|
+
_emit({
|
|
169
|
+
"event": "done", "ok": 0, "skipped": 0, "failed": 0,
|
|
170
|
+
"elapsed": 0.0,
|
|
171
|
+
})
|
|
141
172
|
raise typer.Exit(code=0)
|
|
142
173
|
|
|
143
174
|
# Resolve plugin paths: an explicit flag always wins; otherwise fall
|
|
@@ -155,7 +186,8 @@ def render(
|
|
|
155
186
|
if fallback is not None:
|
|
156
187
|
plugin_paths[preset_fmt] = fallback
|
|
157
188
|
typer.echo(
|
|
158
|
-
f"Using default {_PLUGIN_NAME_FOR[preset_fmt]} plugin: {fallback}"
|
|
189
|
+
f"Using default {_PLUGIN_NAME_FOR[preset_fmt]} plugin: {fallback}",
|
|
190
|
+
err=True,
|
|
159
191
|
)
|
|
160
192
|
|
|
161
193
|
missing = discovered_formats - set(plugin_paths)
|
|
@@ -191,8 +223,8 @@ def render(
|
|
|
191
223
|
|
|
192
224
|
extension = ".npy" if fmt == "npy" else ".wav"
|
|
193
225
|
stems = [
|
|
194
|
-
compose_filename(filename_template, p, presets_root, note, velocity)
|
|
195
|
-
for p,
|
|
226
|
+
compose_filename(filename_template, p, presets_root, note, velocity, pfmt)
|
|
227
|
+
for p, pfmt in preset_files
|
|
196
228
|
]
|
|
197
229
|
output_paths = resolve_output_paths(stems, output, extension)
|
|
198
230
|
jobs = [
|
|
@@ -214,9 +246,17 @@ def render(
|
|
|
214
246
|
]
|
|
215
247
|
|
|
216
248
|
if dry_run:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
249
|
+
if json_out:
|
|
250
|
+
# No separate "plan" event — a start with the total is the
|
|
251
|
+
# same information, and one fewer branch for a consumer.
|
|
252
|
+
_emit({
|
|
253
|
+
"event": "start", "schema": _JSON_SCHEMA, "total": len(jobs),
|
|
254
|
+
"workers": resolve_worker_count(workers),
|
|
255
|
+
})
|
|
256
|
+
else:
|
|
257
|
+
typer.echo(f"Would render {len(jobs)} preset(s):")
|
|
258
|
+
for j in jobs:
|
|
259
|
+
typer.echo(f" {j.preset_path} -> {j.output_path}")
|
|
220
260
|
raise typer.Exit(code=0)
|
|
221
261
|
|
|
222
262
|
output.mkdir(parents=True, exist_ok=True)
|
|
@@ -233,6 +273,7 @@ def render(
|
|
|
233
273
|
)
|
|
234
274
|
|
|
235
275
|
results: list[dict] = []
|
|
276
|
+
t0 = time.monotonic()
|
|
236
277
|
if deterministic:
|
|
237
278
|
from .pool import iter_jobs_isolated
|
|
238
279
|
|
|
@@ -242,10 +283,25 @@ def render(
|
|
|
242
283
|
else:
|
|
243
284
|
result_iter = iter_jobs(jobs, n_workers, serum1_str, serum2_str, sample_rate)
|
|
244
285
|
|
|
286
|
+
# --json is tested before --verbose: they are independent flags, and
|
|
287
|
+
# the other order would silently drop the stream when both are set.
|
|
245
288
|
# In verbose mode, per-preset DEBUG logs replace the progress bar so
|
|
246
|
-
# the two don't fight for the terminal.
|
|
247
|
-
|
|
248
|
-
|
|
289
|
+
# the two don't fight for the terminal. Under --json the bar is
|
|
290
|
+
# suppressed outright: rich writes it to stdout, and when stdout is
|
|
291
|
+
# not a tty it still prints one final renderable that would corrupt
|
|
292
|
+
# the stream.
|
|
293
|
+
if json_out:
|
|
294
|
+
_emit({
|
|
295
|
+
"event": "start", "schema": _JSON_SCHEMA, "total": len(jobs),
|
|
296
|
+
"workers": n_workers,
|
|
297
|
+
})
|
|
298
|
+
for result in result_iter:
|
|
299
|
+
results.append(result)
|
|
300
|
+
_emit({"event": "result", **result})
|
|
301
|
+
elif verbose:
|
|
302
|
+
typer.echo(
|
|
303
|
+
f"Rendering {len(jobs)} preset(s) with {n_workers} workers…", err=True
|
|
304
|
+
)
|
|
249
305
|
results = list(result_iter)
|
|
250
306
|
else:
|
|
251
307
|
with Progress(
|
|
@@ -268,7 +324,13 @@ def render(
|
|
|
268
324
|
skipped = sum(1 for r in results if r["status"] == "skipped")
|
|
269
325
|
errors = [r for r in results if r["status"] == "error"]
|
|
270
326
|
|
|
271
|
-
|
|
327
|
+
if json_out:
|
|
328
|
+
_emit({
|
|
329
|
+
"event": "done", "ok": ok, "skipped": skipped,
|
|
330
|
+
"failed": len(errors), "elapsed": round(time.monotonic() - t0, 3),
|
|
331
|
+
})
|
|
332
|
+
else:
|
|
333
|
+
typer.echo(f"Done: {ok} rendered, {skipped} skipped, {len(errors)} failed.")
|
|
272
334
|
for r in errors:
|
|
273
335
|
typer.echo(f" FAIL {r.get('path')}: {r.get('error')}", err=True)
|
|
274
336
|
raise typer.Exit(code=1 if errors else 0)
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""Frozen render configuration. Stdlib-only at module level."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import json
|
|
4
5
|
import sys
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
|
-
from .formats import PresetFormat
|
|
9
|
+
from .formats import PresetFormat, suffix_for
|
|
9
10
|
|
|
10
11
|
# Peak below this is treated as silent output by the engine.
|
|
11
12
|
# -90 dBFS ~= 16-bit quantization floor. Advisory only — known to be
|
|
@@ -29,6 +30,98 @@ _DEFAULT_PLUGIN_PATHS: dict[str, dict[PresetFormat, str]] = {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
# Where Serum keeps its factory presets when they have not been moved.
|
|
34
|
+
# The macOS entries are verified against a real install (2026-09-01). The
|
|
35
|
+
# Windows entries are NOT verified on a Windows machine — that is safe
|
|
36
|
+
# here, because `default_preset_dir` only ever returns a directory that
|
|
37
|
+
# exists and actually holds presets of that format, so a wrong guess
|
|
38
|
+
# degrades to "not found" instead of to a wrong answer.
|
|
39
|
+
_DEFAULT_PRESET_DIRS: dict[str, dict[PresetFormat, str]] = {
|
|
40
|
+
"darwin": {
|
|
41
|
+
PresetFormat.SERUM1: "/Library/Audio/Presets/Xfer Records/Serum Presets",
|
|
42
|
+
PresetFormat.SERUM2: "/Library/Audio/Presets/Xfer Records/Serum 2 Presets",
|
|
43
|
+
},
|
|
44
|
+
"win32": {
|
|
45
|
+
PresetFormat.SERUM1: "C:/ProgramData/Xfer/Serum Presets",
|
|
46
|
+
PresetFormat.SERUM2: "C:/ProgramData/Xfer/Serum 2 Presets",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Serum records its own preset folder in these prefs files. Reading it
|
|
51
|
+
# beats assuming the default, because the folder is relocatable from
|
|
52
|
+
# inside the plugin. A blank or whitespace-only value means "unset" —
|
|
53
|
+
# that is what Serum 1 writes when the folder has never been moved.
|
|
54
|
+
# Windows paths unverified, same reasoning as above.
|
|
55
|
+
_PREFS_PATHS: dict[str, dict[PresetFormat, str]] = {
|
|
56
|
+
"darwin": {
|
|
57
|
+
PresetFormat.SERUM1: "~/Library/Preferences/SerumPrefs.json",
|
|
58
|
+
PresetFormat.SERUM2: "~/Library/Preferences/Serum2Prefs.json",
|
|
59
|
+
},
|
|
60
|
+
"win32": {
|
|
61
|
+
PresetFormat.SERUM1: "~/AppData/Roaming/Xfer/Serum/SerumPrefs.json",
|
|
62
|
+
PresetFormat.SERUM2: "~/AppData/Roaming/Xfer/Serum2/Serum2Prefs.json",
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
_PREFS_PRESET_KEY = "Serum Presets Path"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _configured_preset_dir(fmt: PresetFormat, platform: str) -> Path | None:
|
|
69
|
+
"""The preset folder Serum itself records, or None if it says nothing.
|
|
70
|
+
|
|
71
|
+
Never raises: an absent, unreadable or malformed prefs file is just
|
|
72
|
+
"no opinion", and the caller falls through to the standard location.
|
|
73
|
+
"""
|
|
74
|
+
table = _PREFS_PATHS.get(platform)
|
|
75
|
+
if table is None:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(Path(table[fmt]).expanduser().read_text(encoding="utf-8"))
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
return None
|
|
81
|
+
value = data.get(_PREFS_PRESET_KEY) if isinstance(data, dict) else None
|
|
82
|
+
if not isinstance(value, str) or not value.strip():
|
|
83
|
+
return None
|
|
84
|
+
return Path(value.strip())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _holds_preset(directory: Path, suffix: str) -> bool:
|
|
88
|
+
"""True as soon as one preset of that suffix turns up. Stops at the
|
|
89
|
+
first hit — the factory tree runs to thousands of files."""
|
|
90
|
+
try:
|
|
91
|
+
for path in directory.rglob("*"):
|
|
92
|
+
if path.suffix.lower() == suffix:
|
|
93
|
+
return True
|
|
94
|
+
except OSError:
|
|
95
|
+
return False
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def default_preset_dir(
|
|
100
|
+
fmt: PresetFormat, platform: str | None = None
|
|
101
|
+
) -> Path | None:
|
|
102
|
+
"""Best guess at where this format's presets live, or None.
|
|
103
|
+
|
|
104
|
+
Prefers the folder Serum records in its own prefs over the standard
|
|
105
|
+
install location. Only returns a directory that exists AND contains
|
|
106
|
+
at least one preset of that format, so a stale prefs entry or a
|
|
107
|
+
wrong default reads as "not found" rather than as an empty batch.
|
|
108
|
+
"""
|
|
109
|
+
platform = platform if platform is not None else sys.platform
|
|
110
|
+
candidates: list[Path] = []
|
|
111
|
+
configured = _configured_preset_dir(fmt, platform)
|
|
112
|
+
if configured is not None:
|
|
113
|
+
candidates.append(configured)
|
|
114
|
+
table = _DEFAULT_PRESET_DIRS.get(platform)
|
|
115
|
+
if table is not None:
|
|
116
|
+
candidates.append(Path(table[fmt]))
|
|
117
|
+
|
|
118
|
+
suffix = suffix_for(fmt).lower()
|
|
119
|
+
for candidate in candidates:
|
|
120
|
+
if candidate.is_dir() and _holds_preset(candidate, suffix):
|
|
121
|
+
return candidate
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
32
125
|
def default_plugin_path(fmt: PresetFormat, platform: str | None = None) -> Path | None:
|
|
33
126
|
"""Return the standard install path for a format if it exists on disk.
|
|
34
127
|
|
|
@@ -75,6 +75,7 @@ def compose_filename(
|
|
|
75
75
|
presets_root: Path | None,
|
|
76
76
|
note: int,
|
|
77
77
|
velocity: int,
|
|
78
|
+
fmt: PresetFormat | None = None,
|
|
78
79
|
) -> str:
|
|
79
80
|
"""
|
|
80
81
|
Compose a filename stem by substituting template variables against a
|
|
@@ -85,6 +86,11 @@ def compose_filename(
|
|
|
85
86
|
None in single-file mode; {subpath} resolves to "" and any adjacent
|
|
86
87
|
separator is collapsed.
|
|
87
88
|
|
|
89
|
+
{format} is the preset's format ("serum1" / "serum2"), for splitting a
|
|
90
|
+
mixed batch into per-synth output folders. It renders empty when `fmt`
|
|
91
|
+
is not supplied, and an empty path component is dropped, so
|
|
92
|
+
"{format}/{preset}" degrades to "{preset}".
|
|
93
|
+
|
|
88
94
|
{subdir} is {subpath}'s nested twin: the same path components, but
|
|
89
95
|
sanitized individually and joined with "/" so the output directory
|
|
90
96
|
mirrors the preset tree instead of flattening it. Components are
|
|
@@ -114,6 +120,7 @@ def compose_filename(
|
|
|
114
120
|
result = result.replace("{folder}", folder)
|
|
115
121
|
result = result.replace("{subpath}", subpath)
|
|
116
122
|
result = result.replace("{subdir}", subdir)
|
|
123
|
+
result = result.replace("{format}", fmt.value if fmt is not None else "")
|
|
117
124
|
|
|
118
125
|
# Collapse separators that an empty {subpath} would leave behind
|
|
119
126
|
# (e.g. "{subpath}_{preset}" -> "_{preset}" -> "preset").
|
|
@@ -205,6 +205,14 @@ def run_job(job: Job) -> dict:
|
|
|
205
205
|
return {"status": "skipped", "path": job.preset_path}
|
|
206
206
|
|
|
207
207
|
audio = _HOST.render(job)
|
|
208
|
+
|
|
209
|
+
import numpy as np
|
|
210
|
+
|
|
211
|
+
# Carried for consumers, not acted on here. The silence warning in
|
|
212
|
+
# EngineHost.render only fires below SILENCE_EPS, which is three
|
|
213
|
+
# orders of magnitude under the wrong-level renders documented in
|
|
214
|
+
# KNOWN_ISSUES.md — those are invisible without the raw number.
|
|
215
|
+
peak = float(np.max(np.abs(audio)))
|
|
208
216
|
if job.output_path is not None:
|
|
209
217
|
write_audio(
|
|
210
218
|
audio,
|
|
@@ -213,8 +221,13 @@ def run_job(job: Job) -> dict:
|
|
|
213
221
|
job.bit_depth,
|
|
214
222
|
job.output_format,
|
|
215
223
|
)
|
|
216
|
-
return {"status": "ok", "path": job.preset_path}
|
|
217
|
-
return {
|
|
224
|
+
return {"status": "ok", "path": job.preset_path, "peak": peak}
|
|
225
|
+
return {
|
|
226
|
+
"status": "ok",
|
|
227
|
+
"path": job.preset_path,
|
|
228
|
+
"peak": peak,
|
|
229
|
+
"audio": audio,
|
|
230
|
+
}
|
|
218
231
|
except Exception as exc:
|
|
219
232
|
logger.warning("Failed to render %s: %s", job.preset_path, exc)
|
|
220
233
|
return {"status": "error", "path": job.preset_path, "error": str(exc)}
|
|
@@ -28,6 +28,18 @@ _FOLDED_SUFFIX_TO_FORMAT: dict[str, PresetFormat] = {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
# Format -> canonical suffix. Derived from the one table above so the two
|
|
32
|
+
# directions cannot drift apart.
|
|
33
|
+
_FORMAT_TO_SUFFIX: dict[PresetFormat, str] = {
|
|
34
|
+
fmt: suffix for suffix, fmt in _SUFFIX_TO_FORMAT.items()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def suffix_for(fmt: PresetFormat) -> str:
|
|
39
|
+
"""Canonical file suffix for a format (`.fxp` / `.SerumPreset`)."""
|
|
40
|
+
return _FORMAT_TO_SUFFIX[fmt]
|
|
41
|
+
|
|
42
|
+
|
|
31
43
|
def format_or_none(path: Path) -> PresetFormat | None:
|
|
32
44
|
"""Resolve a path to its PresetFormat, or None if the suffix is not a
|
|
33
45
|
supported one. The single place suffix matching happens, so directory
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Audio file writing. Part of the worker import graph — module level is
|
|
2
|
+
stdlib-only; numpy/soundfile load inside the function, after dawdreamer
|
|
3
|
+
has already been imported by EngineHost."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_SUBTYPE_MAP = {"16": "PCM_16", "24": "PCM_24", "32f": "FLOAT"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_audio(
|
|
13
|
+
audio, output_path: str, sample_rate: int, bit_depth: str, output_format: str
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Write a (channels, samples) float32 array as wav (given bit depth)
|
|
16
|
+
or npy (raw float32, bit depth ignored).
|
|
17
|
+
|
|
18
|
+
Writes to a sibling temp file and renames it into place, so a killed
|
|
19
|
+
render can never leave a truncated file at `output_path` —
|
|
20
|
+
skip_existing would otherwise skip that garbage forever on the
|
|
21
|
+
re-run. A SIGKILL does leave a stray `.tmp` behind; that is the
|
|
22
|
+
intended trade.
|
|
23
|
+
|
|
24
|
+
The temp suffix goes *after* the real extension (`a.wav.tmp`, not
|
|
25
|
+
`a.tmp.wav`) so strays stay out of `*.wav` globs. Both writers are
|
|
26
|
+
told their format explicitly, because each otherwise infers it from
|
|
27
|
+
the trailing extension: np.save would write `a.npy.tmp.npy`, and
|
|
28
|
+
sf.write raises on an unrecognised one.
|
|
29
|
+
"""
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
if output_format not in ("wav", "npy"):
|
|
33
|
+
raise ValueError(f"Unknown output format: {output_format!r}")
|
|
34
|
+
subtype = None
|
|
35
|
+
if output_format == "wav":
|
|
36
|
+
subtype = _SUBTYPE_MAP.get(bit_depth)
|
|
37
|
+
if subtype is None:
|
|
38
|
+
raise ValueError(f"Unknown bit depth: {bit_depth!r}")
|
|
39
|
+
|
|
40
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
tmp = f"{output_path}.tmp"
|
|
42
|
+
try:
|
|
43
|
+
if output_format == "npy":
|
|
44
|
+
with open(tmp, "wb") as f:
|
|
45
|
+
np.save(f, audio)
|
|
46
|
+
else:
|
|
47
|
+
import soundfile as sf
|
|
48
|
+
|
|
49
|
+
# soundfile expects (samples, channels) — transpose before writing.
|
|
50
|
+
sf.write(tmp, audio.T, sample_rate, subtype=subtype, format="WAV")
|
|
51
|
+
os.replace(tmp, output_path)
|
|
52
|
+
except BaseException:
|
|
53
|
+
Path(tmp).unlink(missing_ok=True)
|
|
54
|
+
raise
|
|
@@ -4,7 +4,9 @@ worker pool — they assert argument parsing, validation, error messaging,
|
|
|
4
4
|
exit codes, default-plugin-path resolution, and the --dry-run path.
|
|
5
5
|
|
|
6
6
|
Output is read via `result.output` (the merged stream) so the suite is
|
|
7
|
-
portable across Click versions.
|
|
7
|
+
portable across Click versions. The --json cases at the bottom are the
|
|
8
|
+
exception: stdout hygiene is only observable in a real subprocess, so
|
|
9
|
+
those shell out rather than using CliRunner.
|
|
8
10
|
|
|
9
11
|
Default-path resolution is stubbed in most tests: this machine may have
|
|
10
12
|
real Serum installs, and the CLI would otherwise silently pick them up
|
|
@@ -12,6 +14,9 @@ and proceed past the validation being tested.
|
|
|
12
14
|
"""
|
|
13
15
|
from __future__ import annotations
|
|
14
16
|
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
15
20
|
from pathlib import Path
|
|
16
21
|
|
|
17
22
|
import mido
|
|
@@ -20,6 +25,8 @@ from typer.testing import CliRunner
|
|
|
20
25
|
|
|
21
26
|
import serum_render.cli as cli
|
|
22
27
|
from serum_render.cli import app
|
|
28
|
+
from serum_render.config import default_plugin_path
|
|
29
|
+
from serum_render.formats import PresetFormat
|
|
23
30
|
|
|
24
31
|
runner = CliRunner()
|
|
25
32
|
|
|
@@ -426,3 +433,83 @@ def test_dry_run_with_relative_presets_dir_resolves_subpath(
|
|
|
426
433
|
)
|
|
427
434
|
assert result.exit_code == 0, result.output
|
|
428
435
|
assert "Leads_lead.wav" in result.output
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---- --json event stream --------------------------------------------------
|
|
439
|
+
#
|
|
440
|
+
# Stdout hygiene is asserted in a real subprocess, not through CliRunner:
|
|
441
|
+
# CliRunner patches sys.stdout in-process, so it cannot see writes that
|
|
442
|
+
# reach the real fd 1 (loky workers, and rich's final Progress renderable).
|
|
443
|
+
# The content assertions below use CliRunner and are stream-agnostic, so
|
|
444
|
+
# they stay portable across Click/Typer versions the way the rest of this
|
|
445
|
+
# module is.
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _json_lines(text: str) -> list[dict]:
|
|
449
|
+
return [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_json_dry_run_emits_one_start_event(fake_env, no_defaults):
|
|
453
|
+
plugin, presets, output = fake_env
|
|
454
|
+
result = runner.invoke(
|
|
455
|
+
app,
|
|
456
|
+
[str(presets), str(output), "--serum1", str(plugin), "--dry-run", "--json"],
|
|
457
|
+
)
|
|
458
|
+
assert result.exit_code == 0, result.output
|
|
459
|
+
events = _json_lines(result.stdout)
|
|
460
|
+
assert len(events) == 1
|
|
461
|
+
assert events[0]["event"] == "start"
|
|
462
|
+
assert events[0]["schema"] == 1
|
|
463
|
+
assert events[0]["total"] == 2 # fake_env plants two .fxp files
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_json_no_presets_still_emits_start_and_done(tmp_path, no_defaults):
|
|
467
|
+
"""Exit 0 with an empty stream is indistinguishable from success, and a
|
|
468
|
+
mistyped preset directory is the likeliest way to reach this branch."""
|
|
469
|
+
plugin = tmp_path / "Serum.vst"
|
|
470
|
+
_touch(plugin)
|
|
471
|
+
empty = tmp_path / "empty"
|
|
472
|
+
empty.mkdir()
|
|
473
|
+
result = runner.invoke(
|
|
474
|
+
app, [str(empty), str(tmp_path / "out"), "--serum1", str(plugin), "--json"]
|
|
475
|
+
)
|
|
476
|
+
assert result.exit_code == 0
|
|
477
|
+
events = _json_lines(result.stdout)
|
|
478
|
+
assert [e["event"] for e in events] == ["start", "done"]
|
|
479
|
+
assert events[0]["total"] == 0
|
|
480
|
+
assert events[1] == {
|
|
481
|
+
"event": "done", "ok": 0, "skipped": 0, "failed": 0, "elapsed": 0.0,
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _run_cli(args: list[str]) -> subprocess.CompletedProcess:
|
|
486
|
+
return subprocess.run(
|
|
487
|
+
[sys.executable, "-m", "serum_render", *args],
|
|
488
|
+
capture_output=True,
|
|
489
|
+
text=True,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def test_json_stdout_is_pure_ndjson_in_a_real_process(fake_env):
|
|
494
|
+
"""The assertion CliRunner cannot make: nothing else reaches fd 1."""
|
|
495
|
+
plugin, presets, output = fake_env
|
|
496
|
+
proc = _run_cli(
|
|
497
|
+
[str(presets), str(output), "--serum1", str(plugin), "--dry-run", "--json"]
|
|
498
|
+
)
|
|
499
|
+
assert proc.returncode == 0, proc.stderr
|
|
500
|
+
events = _json_lines(proc.stdout)
|
|
501
|
+
assert len(events) == 1 and events[0]["total"] == 2
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@pytest.mark.skipif(
|
|
505
|
+
default_plugin_path(PresetFormat.SERUM1) is None,
|
|
506
|
+
reason="No default Serum 1 install to trigger the fallback notice.",
|
|
507
|
+
)
|
|
508
|
+
def test_json_default_plugin_notice_goes_to_stderr(fake_env):
|
|
509
|
+
"""The notice is diagnostic, so it must not land in the event stream."""
|
|
510
|
+
_plugin, presets, output = fake_env
|
|
511
|
+
proc = _run_cli([str(presets), str(output), "--dry-run", "--json"])
|
|
512
|
+
assert proc.returncode == 0, proc.stderr
|
|
513
|
+
assert "Using default" in proc.stderr
|
|
514
|
+
assert "Using default" not in proc.stdout
|
|
515
|
+
assert _json_lines(proc.stdout) # still parses cleanly
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import dataclasses
|
|
2
|
+
import json
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
import pytest
|
|
5
6
|
|
|
7
|
+
import serum_render.config as config
|
|
6
8
|
from serum_render.config import RenderConfig, default_plugin_path
|
|
7
|
-
from serum_render.formats import PresetFormat
|
|
9
|
+
from serum_render.formats import PresetFormat, format_for_path, suffix_for
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
def test_defaults_resolve():
|
|
@@ -163,3 +165,82 @@ def test_default_serum2_paths(monkeypatch):
|
|
|
163
165
|
|
|
164
166
|
def test_default_path_none_on_unknown_platform():
|
|
165
167
|
assert default_plugin_path(PresetFormat.SERUM1, platform="linux") is None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---- preset directory detection -------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _preset_tree(root: Path, suffix: str, name: str = "Bass") -> Path:
|
|
174
|
+
"""A directory that actually holds a preset of that format."""
|
|
175
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
(root / "Sub").mkdir(exist_ok=True)
|
|
177
|
+
(root / "Sub" / f"{name}{suffix}").write_bytes(b"")
|
|
178
|
+
return root
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@pytest.fixture
|
|
182
|
+
def preset_env(tmp_path, monkeypatch):
|
|
183
|
+
"""Point both lookup tables at tmp_path for a fake 'testos' platform."""
|
|
184
|
+
prefs = tmp_path / "SerumPrefs.json"
|
|
185
|
+
fallback = tmp_path / "fallback"
|
|
186
|
+
monkeypatch.setattr(config, "_PREFS_PATHS", {"testos": {PresetFormat.SERUM1: str(prefs)}})
|
|
187
|
+
monkeypatch.setattr(config, "_DEFAULT_PRESET_DIRS", {"testos": {PresetFormat.SERUM1: str(fallback)}})
|
|
188
|
+
return prefs, fallback
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_preset_dir_prefers_the_folder_serum_records(preset_env, tmp_path):
|
|
192
|
+
"""Serum's folder is relocatable from inside the plugin, so its own
|
|
193
|
+
prefs entry beats the standard install location."""
|
|
194
|
+
prefs, fallback = preset_env
|
|
195
|
+
_preset_tree(fallback, ".fxp")
|
|
196
|
+
moved = _preset_tree(tmp_path / "moved", ".fxp")
|
|
197
|
+
prefs.write_text(json.dumps({"Serum Presets Path": f"{moved} "}))
|
|
198
|
+
assert config.default_preset_dir(PresetFormat.SERUM1, "testos") == moved
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_blank_prefs_value_falls_back(preset_env):
|
|
202
|
+
"""Serum 1 writes a single space when the folder was never moved."""
|
|
203
|
+
prefs, fallback = preset_env
|
|
204
|
+
_preset_tree(fallback, ".fxp")
|
|
205
|
+
prefs.write_text(json.dumps({"Serum Presets Path": " "}))
|
|
206
|
+
assert config.default_preset_dir(PresetFormat.SERUM1, "testos") == fallback
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_directory_without_matching_presets_is_not_a_hit(preset_env):
|
|
210
|
+
"""A folder holding only the *other* format is a miss, not an empty batch."""
|
|
211
|
+
_prefs, fallback = preset_env
|
|
212
|
+
_preset_tree(fallback, ".SerumPreset")
|
|
213
|
+
assert config.default_preset_dir(PresetFormat.SERUM1, "testos") is None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_uppercase_suffix_still_counts(preset_env):
|
|
217
|
+
_prefs, fallback = preset_env
|
|
218
|
+
_preset_tree(fallback, ".FXP")
|
|
219
|
+
assert config.default_preset_dir(PresetFormat.SERUM1, "testos") == fallback
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_malformed_prefs_falls_back_instead_of_raising(preset_env):
|
|
223
|
+
prefs, fallback = preset_env
|
|
224
|
+
_preset_tree(fallback, ".fxp")
|
|
225
|
+
prefs.write_text("{not json")
|
|
226
|
+
assert config.default_preset_dir(PresetFormat.SERUM1, "testos") == fallback
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_stale_prefs_path_falls_back(preset_env, tmp_path):
|
|
230
|
+
prefs, fallback = preset_env
|
|
231
|
+
_preset_tree(fallback, ".fxp")
|
|
232
|
+
prefs.write_text(json.dumps({"Serum Presets Path": str(tmp_path / "deleted")}))
|
|
233
|
+
assert config.default_preset_dir(PresetFormat.SERUM1, "testos") == fallback
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_nothing_found_returns_none(preset_env):
|
|
237
|
+
assert config.default_preset_dir(PresetFormat.SERUM1, "testos") is None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_unknown_platform_returns_none(preset_env):
|
|
241
|
+
assert config.default_preset_dir(PresetFormat.SERUM1, "plan9") is None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_suffix_for_round_trips_every_format():
|
|
245
|
+
for fmt in PresetFormat:
|
|
246
|
+
assert format_for_path(Path(f"x{suffix_for(fmt)}")) is fmt
|
|
@@ -265,7 +265,12 @@ def test_run_job_writes_to_disk_and_returns_status_only(monkeypatch, tmp_path):
|
|
|
265
265
|
output_path=str(out),
|
|
266
266
|
)
|
|
267
267
|
result = run_job(job)
|
|
268
|
-
assert result
|
|
268
|
+
assert result["status"] == "ok"
|
|
269
|
+
assert result["path"] == "/p.fxp"
|
|
270
|
+
# The point of the disk path: no audio array crosses the process
|
|
271
|
+
# boundary. `peak` is the only extra, and it is a scalar.
|
|
272
|
+
assert "audio" not in result
|
|
273
|
+
assert result["peak"] == 0.0
|
|
269
274
|
assert out.exists()
|
|
270
275
|
|
|
271
276
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
3
|
from serum_render.discover import compose_filename, resolve_output_paths
|
|
4
|
+
from serum_render.formats import PresetFormat
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
def test_compose_simple_preset(tmp_path: Path):
|
|
@@ -217,3 +218,30 @@ def test_compose_subpath_still_flattens(tmp_path: Path):
|
|
|
217
218
|
assert compose_filename("{subpath}_{preset}", preset, tmp_path, 48, 127) == (
|
|
218
219
|
"Bass_Hard_p"
|
|
219
220
|
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---- {format}: splitting a mixed batch by synth ---------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_compose_format_token(tmp_path: Path):
|
|
227
|
+
preset = tmp_path / "Bass" / "Growl.fxp"
|
|
228
|
+
assert compose_filename(
|
|
229
|
+
"{format}/{preset}", preset, tmp_path, 48, 127, PresetFormat.SERUM1
|
|
230
|
+
) == "serum1/Growl"
|
|
231
|
+
preset2 = tmp_path / "Pads" / "Air.SerumPreset"
|
|
232
|
+
assert compose_filename(
|
|
233
|
+
"{format}/{preset}", preset2, tmp_path, 48, 127, PresetFormat.SERUM2
|
|
234
|
+
) == "serum2/Air"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_compose_format_omitted_collapses(tmp_path: Path):
|
|
238
|
+
"""Callers that don't pass a format still get a usable stem."""
|
|
239
|
+
preset = tmp_path / "Growl.fxp"
|
|
240
|
+
assert compose_filename("{format}/{preset}", preset, tmp_path, 48, 127) == "Growl"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_compose_format_combines_with_subdir(tmp_path: Path):
|
|
244
|
+
preset = tmp_path / "Bass" / "Hard" / "Growl.fxp"
|
|
245
|
+
assert compose_filename(
|
|
246
|
+
"{format}/{subdir}/{preset}", preset, tmp_path, 48, 127, PresetFormat.SERUM2
|
|
247
|
+
) == "serum2/Bass/Hard/Growl"
|
|
@@ -49,3 +49,35 @@ def test_unknown_bit_depth_raises(tmp_path: Path):
|
|
|
49
49
|
def test_unknown_format_raises(tmp_path: Path):
|
|
50
50
|
with pytest.raises(ValueError, match="format"):
|
|
51
51
|
write_audio(_audio(), str(tmp_path / "a.flac"), 44100, "16", "flac")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---- atomic write ---------------------------------------------------------
|
|
55
|
+
#
|
|
56
|
+
# write_audio renames a temp file into place so a killed render can never
|
|
57
|
+
# leave a truncated file at the target — skip_existing would otherwise skip
|
|
58
|
+
# that garbage forever on the re-run.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize("name,fmt", [("a.wav", "wav"), ("a.npy", "npy")])
|
|
62
|
+
def test_no_temp_file_survives_a_successful_write(tmp_path: Path, name, fmt):
|
|
63
|
+
write_audio(_audio(), str(tmp_path / name), 44100, "16", fmt)
|
|
64
|
+
assert sorted(p.name for p in tmp_path.iterdir()) == [name]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_temp_suffix_stays_out_of_extension_globs(tmp_path: Path):
|
|
68
|
+
"""`a.wav.tmp`, never `a.tmp.wav` — a stray from a SIGKILL must not be
|
|
69
|
+
picked up by the `*.wav` globs callers use over the output directory."""
|
|
70
|
+
write_audio(_audio(), str(tmp_path / "a.wav"), 44100, "16", "wav")
|
|
71
|
+
assert [p.name for p in tmp_path.glob("*.wav")] == ["a.wav"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_failed_write_leaves_nothing_behind(tmp_path: Path, monkeypatch):
|
|
75
|
+
"""The existing unknown-format/bit-depth cases raise *before* any write,
|
|
76
|
+
so the cleanup path needs a failure during one."""
|
|
77
|
+
def boom(*args, **kwargs):
|
|
78
|
+
raise RuntimeError("disk full")
|
|
79
|
+
|
|
80
|
+
monkeypatch.setattr(sf, "write", boom)
|
|
81
|
+
with pytest.raises(RuntimeError, match="disk full"):
|
|
82
|
+
write_audio(_audio(), str(tmp_path / "a.wav"), 44100, "16", "wav")
|
|
83
|
+
assert list(tmp_path.iterdir()) == []
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
"""Audio file writing. Part of the worker import graph — module level is
|
|
2
|
-
stdlib-only; numpy/soundfile load inside the function, after dawdreamer
|
|
3
|
-
has already been imported by EngineHost."""
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
_SUBTYPE_MAP = {"16": "PCM_16", "24": "PCM_24", "32f": "FLOAT"}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def write_audio(
|
|
12
|
-
audio, output_path: str, sample_rate: int, bit_depth: str, output_format: str
|
|
13
|
-
) -> None:
|
|
14
|
-
"""Write a (channels, samples) float32 array as wav (given bit depth)
|
|
15
|
-
or npy (raw float32, bit depth ignored)."""
|
|
16
|
-
import numpy as np
|
|
17
|
-
|
|
18
|
-
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
-
|
|
20
|
-
if output_format == "npy":
|
|
21
|
-
np.save(output_path, audio)
|
|
22
|
-
return
|
|
23
|
-
if output_format != "wav":
|
|
24
|
-
raise ValueError(f"Unknown output format: {output_format!r}")
|
|
25
|
-
|
|
26
|
-
subtype = _SUBTYPE_MAP.get(bit_depth)
|
|
27
|
-
if subtype is None:
|
|
28
|
-
raise ValueError(f"Unknown bit depth: {bit_depth!r}")
|
|
29
|
-
|
|
30
|
-
import soundfile as sf
|
|
31
|
-
|
|
32
|
-
# soundfile expects (samples, channels) — transpose before writing.
|
|
33
|
-
sf.write(output_path, audio.T, sample_rate, subtype=subtype)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|