diff options
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | lib/src/producer/cli.py | 54 | ||||
| -rw-r--r-- | lib/src/producer/engines/denoise_dfn.py | 19 | ||||
| -rw-r--r-- | lib/tests/test_cli.py | 82 | ||||
| -rw-r--r-- | lib/tests/test_engines.py | 60 |
5 files changed, 213 insertions, 9 deletions
@@ -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 |
