aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-06 15:45:33 -0400
committerhistoria <historiavg@proton.me>2026-09-06 15:45:33 -0400
commit55bc4004a5dfced8b4c53a88f735b4c2b64f6d48 (patch)
treec2c4e2fe7c30cc9ffc9e0140aff894c64bd0b07e
parentc2b0f7e4fb4738afcae1705db8f983dd90a669a4 (diff)
downloadproducer-55bc4004a5dfced8b4c53a88f735b4c2b64f6d48.tar.gz
feat: output conflict handling
-rw-r--r--README.md7
-rw-r--r--lib/src/producer/cli.py54
-rw-r--r--lib/src/producer/engines/denoise_dfn.py19
-rw-r--r--lib/tests/test_cli.py82
-rw-r--r--lib/tests/test_engines.py60
5 files changed, 213 insertions, 9 deletions
diff --git a/README.md b/README.md
index 057caa8..2a4f544 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ loudness-normalized master.
```bash
./producer episode.wav
-# -> episode_master.wav (44.1 kHz mono, RMS -20 dB, true peak <= -3 dB)
+# -> episode_processed.wav (44.1 kHz mono, RMS -20 dB, true peak <= -3 dB)
```
Everything a user doesn't need to touch lives in `lib/`: the first run
@@ -62,6 +62,11 @@ Batch: `./producer --batch takes/ -o masters/` expands a directory (or glob)
and processes each file. Reports: `--report` writes `<output>.report.json`
with before/after RMS, true peak, LUFS, noise floor, and per-stage timings.
+Without `-o`, outputs are written next to the input as
+`<input>_processed.<format>`. If the output path already exists, producer
+prompts to overwrite, rename (auto-numbered `..._1`, `..._2`, ...), or cancel;
+non-interactive runs (no terminal on stdin) auto-rename and say so.
+
Persistent settings go in `config.toml` in the repository root (auto-created on
first run; CLI flags always win). Example:
diff --git a/lib/src/producer/cli.py b/lib/src/producer/cli.py
index 745b797..476adf3 100644
--- a/lib/src/producer/cli.py
+++ b/lib/src/producer/cli.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
+import itertools
import sys
from pathlib import Path
@@ -128,13 +129,54 @@ def _resolve_output(inp: Path, opts: Options, single: bool) -> Path:
if single:
return o if o.suffix else o.with_suffix("." + ext)
o.mkdir(parents=True, exist_ok=True)
- return o / f"{inp.stem}_master.{ext}"
- return inp.with_name(f"{inp.stem}_master.{ext}")
-
-
-def process_one(inp: Path, opts: Options, single: bool, quiet: bool = False) -> Path:
- x, sr = pio.decode(inp)
+ return o / f"{inp.stem}_processed.{ext}"
+ return inp.with_name(f"{inp.stem}_processed.{ext}")
+
+
+def _next_free(path: Path) -> Path:
+ for i in itertools.count(1):
+ cand = path.with_name(f"{path.stem}_{i}{path.suffix}")
+ if not cand.exists():
+ return cand
+
+
+def _resolve_output_conflict(out_path: Path, interactive: bool) -> Path | None:
+ """Returns the path to write, or None when the user cancels."""
+ if not out_path.exists():
+ return out_path
+ alt = _next_free(out_path)
+ if not interactive:
+ print(f"[producer] {out_path} exists, writing {alt.name} instead")
+ return alt
+ while True:
+ try:
+ ans = (
+ input(
+ f"[producer] {out_path} exists "
+ f"([o]verwrite / [r]ename to {alt.name} / [c]ancel): "
+ )
+ .strip()
+ .lower()
+ )
+ except EOFError:
+ print()
+ return None
+ if ans in ("o", "overwrite"):
+ return out_path
+ if ans in ("r", "rename"):
+ return alt
+ if ans in ("c", "cancel"):
+ return None
+ print("[producer] please answer o, r, or c")
+
+
+def process_one(inp: Path, opts: Options, single: bool, quiet: bool = False) -> Path | None:
out_path = _resolve_output(inp, opts, single)
+ out_path = _resolve_output_conflict(out_path, sys.stdin.isatty())
+ if out_path is None:
+ print(f"[producer] skipped {inp}")
+ return None
+ x, sr = pio.decode(inp)
target_sr = opts.out_sample_rate()
if not quiet:
print(f"[producer] {inp} ({sr} Hz, {len(x) / sr:.1f}s)")
diff --git a/lib/src/producer/engines/denoise_dfn.py b/lib/src/producer/engines/denoise_dfn.py
index 66a8cf6..6b7d1d5 100644
--- a/lib/src/producer/engines/denoise_dfn.py
+++ b/lib/src/producer/engines/denoise_dfn.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import sys
import types
import urllib.request
+import warnings
import zipfile
from pathlib import Path
@@ -21,6 +22,12 @@ BASE_URL = f"https://raw.githubusercontent.com/Rikorose/DeepFilterNet/{TAG}"
def _shim_torchaudio_backend() -> None:
+ warnings.filterwarnings(
+ "ignore",
+ message=r".*AudioMetaData.*has been moved.*",
+ category=UserWarning,
+ module=r"df[./]io",
+ )
try:
import torchaudio.backend # noqa: F401
except Exception:
@@ -32,6 +39,17 @@ def _shim_torchaudio_backend() -> None:
pkg.backend = stub
+def _shim_df_git() -> None:
+ import df.io
+ import df.logger
+ import df.utils
+
+ for mod in (df.utils, df.logger, df.io):
+ for name in ("get_git_root", "get_commit_hash", "get_branch_name"):
+ if hasattr(mod, name):
+ setattr(mod, name, lambda: None)
+
+
def ensure_model(model: str) -> Path:
target = MODELS_DIR / model
if (target / "config.ini").is_file():
@@ -61,6 +79,7 @@ def denoise(
lazy.ensure(["deepfilternet==0.5.6"], purpose="DeepFilterNet")
lazy.ensure_torch()
_shim_torchaudio_backend()
+ _shim_df_git()
model_name = "DeepFilterNet3"
try:
model_dir = ensure_model(model_name)
diff --git a/lib/tests/test_cli.py b/lib/tests/test_cli.py
index e43489c..1a157f7 100644
--- a/lib/tests/test_cli.py
+++ b/lib/tests/test_cli.py
@@ -1,3 +1,6 @@
+import sys
+from types import SimpleNamespace
+
import numpy as np
import soundfile as sf
from conftest import speechish
@@ -93,11 +96,11 @@ def test_process_one_end_to_end(tmp_path, capsys):
from producer import meters
inp = _mk_wav(tmp_path, "e2e.wav")
- out = tmp_path / "e2e_master.wav"
+ out = tmp_path / "e2e_processed.wav"
rc = process_one(inp, _opts(report=True), single=True)
assert rc == out
assert out.exists()
- rep_path = out.with_name("e2e_master.report.json")
+ rep_path = out.with_name("e2e_processed.report.json")
assert rep_path.exists()
rep = json.loads(rep_path.read_text())
y, sr = pio.decode(out)
@@ -150,3 +153,78 @@ def test_tape_and_soothe_flags_override():
assert opts.profile == "radio"
assert opts.strengths["tape"] == 0.4
assert opts.strengths["soothe"] == 0.7
+
+
+def test_default_output_suffix_processed(tmp_path):
+ from producer.cli import _resolve_output
+
+ inp = tmp_path / "song.wav"
+ assert _resolve_output(inp, _opts(), single=True) == tmp_path / "song_processed.wav"
+ odir = tmp_path / "out"
+ opts = _opts(output=str(odir))
+ assert _resolve_output(inp, opts, single=False) == odir / "song_processed.wav"
+
+
+def test_conflict_auto_renames_when_not_a_tty(tmp_path):
+ inp = _mk_wav(tmp_path, "in.wav")
+ first = process_one(inp, _opts(), single=True)
+ assert first == tmp_path / "in_processed.wav"
+ assert process_one(inp, _opts(), single=True) == tmp_path / "in_processed_1.wav"
+ assert process_one(inp, _opts(), single=True) == tmp_path / "in_processed_2.wav"
+ assert (tmp_path / "in_processed.wav").exists()
+
+
+def test_conflict_prompt_overwrite(tmp_path, monkeypatch):
+ inp = _mk_wav(tmp_path, "in.wav")
+ out = tmp_path / "in_processed.wav"
+ process_one(inp, _opts(), single=True)
+ monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
+ monkeypatch.setattr("builtins.input", lambda _prompt: "o")
+ assert process_one(inp, _opts(), single=True) == out
+ assert out.exists()
+
+
+def test_conflict_prompt_rename(tmp_path, monkeypatch):
+ inp = _mk_wav(tmp_path, "in.wav")
+ out = tmp_path / "in_processed.wav"
+ process_one(inp, _opts(), single=True)
+ monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
+ monkeypatch.setattr("builtins.input", lambda _prompt: "r")
+ second = process_one(inp, _opts(), single=True)
+ assert second == tmp_path / "in_processed_1.wav"
+ assert second.exists()
+ assert out.exists()
+
+
+def test_conflict_prompt_invalid_then_overwrite(tmp_path, monkeypatch):
+ inp = _mk_wav(tmp_path, "in.wav")
+ out = tmp_path / "in_processed.wav"
+ process_one(inp, _opts(), single=True)
+ monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
+ answers = iter(["maybe", "O"])
+ monkeypatch.setattr("builtins.input", lambda _prompt: next(answers))
+ assert process_one(inp, _opts(), single=True) == out
+
+
+def test_conflict_prompt_cancel(tmp_path, monkeypatch, capsys):
+ inp = _mk_wav(tmp_path, "in.wav")
+ out = tmp_path / "in_processed.wav"
+ process_one(inp, _opts(), single=True)
+ before = out.read_bytes()
+ monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
+ monkeypatch.setattr("builtins.input", lambda _prompt: "c")
+ assert process_one(inp, _opts(), single=True) is None
+ assert out.read_bytes() == before
+ assert "skipped" in capsys.readouterr().out
+
+
+def test_conflict_prompt_eof_cancels(tmp_path, monkeypatch):
+ inp = _mk_wav(tmp_path, "in.wav")
+ process_one(inp, _opts(), single=True)
+ monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
+
+ def _eof(_prompt):
+ raise EOFError
+
+ monkeypatch.setattr("builtins.input", _eof)
+ assert process_one(inp, _opts(), single=True) is None
diff --git a/lib/tests/test_engines.py b/lib/tests/test_engines.py
index 0efccc6..781b46e 100644
--- a/lib/tests/test_engines.py
+++ b/lib/tests/test_engines.py
@@ -1,8 +1,68 @@
import os
+import sys
+import types
+import warnings
import numpy as np
import pytest
+AUDIO_META_MSG = (
+ "`torchaudio.backend.common.AudioMetaData` has been moved to "
+ "`torchaudio.AudioMetaData`. Please update the import path."
+)
+
+
+def _stub_df_modules(calls: list[str]) -> tuple[types.ModuleType, list[types.ModuleType]]:
+ pkg = types.ModuleType("df")
+ mods = []
+ for full in ("df.utils", "df.logger", "df.io"):
+ mod = types.ModuleType(full)
+
+ def probe(name: str):
+ def fn(*_args):
+ calls.append(name)
+ return "deadbeef"
+
+ return fn
+
+ for fn_name in ("get_git_root", "get_commit_hash", "get_branch_name"):
+ setattr(mod, fn_name, probe(f"{full}.{fn_name}"))
+ setattr(pkg, full.split(".")[1], mod)
+ mods.append(mod)
+ return pkg, mods
+
+
+def test_dfn_shim_neutralizes_git_probes(monkeypatch):
+ from producer.engines import denoise_dfn
+
+ calls: list[str] = []
+ pkg, mods = _stub_df_modules(calls)
+ for name, mod in zip(("df", "df.utils", "df.logger", "df.io"), (pkg, *mods), strict=True):
+ monkeypatch.setitem(sys.modules, name, mod)
+ assert pkg.logger.get_commit_hash() == "deadbeef"
+ calls.clear()
+ denoise_dfn._shim_df_git()
+ for mod in mods:
+ for fn_name in ("get_git_root", "get_commit_hash", "get_branch_name"):
+ assert mod.__dict__[fn_name]() is None
+ assert calls == []
+
+
+def test_dfn_shim_silences_torchaudio_warning():
+ from producer.engines import denoise_dfn
+
+ code = "import warnings\nwarnings.warn(MESSAGE, UserWarning)"
+ denoise_dfn._shim_torchaudio_backend()
+ with warnings.catch_warnings(record=True) as caught:
+ exec(compile(code, "df/io.py", "exec"), {"__name__": "df.io", "MESSAGE": AUDIO_META_MSG})
+ assert caught == []
+ with warnings.catch_warnings(record=True) as caught:
+ exec(
+ compile(code, "other/mod.py", "exec"),
+ {"__name__": "other.mod", "MESSAGE": AUDIO_META_MSG},
+ )
+ assert len(caught) == 1
+
def _metrics_floor(x, sr):
from producer import meters