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