From 84dd2d068317998f6fb59400c534ef5be6b51b53 Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 7 Sep 2026 06:47:47 -0400 Subject: slop rewrite --- lib/archive/mastered/README.md | 36 ++ lib/project/examples/voice.toml | 56 +++ lib/project/pyproject.toml | 29 ++ lib/project/src/voiceforge/__init__.py | 3 + lib/project/src/voiceforge/__main__.py | 3 + lib/project/src/voiceforge/ai.py | 157 ++++++++ lib/project/src/voiceforge/ai_worker.py | 161 ++++++++ lib/project/src/voiceforge/audio.py | 128 ++++++ lib/project/src/voiceforge/cli.py | 193 +++++++++ lib/project/src/voiceforge/config.py | 118 ++++++ lib/project/src/voiceforge/pipeline.py | 199 ++++++++++ lib/project/src/voiceforge/progress.py | 40 ++ lib/project/src/voiceforge/setup.py | 203 ++++++++++ lib/project/tests/conftest.py | 43 +++ lib/project/tests/test_ai.py | 366 ++++++++++++++++++ lib/project/tests/test_cli.py | 287 ++++++++++++++ lib/project/tests/test_config.py | 134 +++++++ lib/project/tests/test_install.py | 225 +++++++++++ lib/project/tests/test_pipeline.py | 273 +++++++++++++ lib/pytest.ini | 3 - lib/requirements-core.txt | 4 - lib/ruff.toml | 12 - lib/src/__main__.py | 4 - lib/src/producer/__init__.py | 1 - lib/src/producer/cli.py | 321 --------------- lib/src/producer/config.py | 286 -------------- lib/src/producer/doctor.py | 95 ----- lib/src/producer/dsp.py | 283 -------------- lib/src/producer/engines/__init__.py | 0 lib/src/producer/engines/base.py | 43 --- lib/src/producer/engines/chunking.py | 166 -------- lib/src/producer/engines/denoise_dfn.py | 140 ------- lib/src/producer/engines/denoise_spectral.py | 186 --------- lib/src/producer/engines/denoise_zip.py | 68 ---- lib/src/producer/engines/enhance_mossformer.py | 70 ---- lib/src/producer/engines/enhance_resemble.py | 95 ----- lib/src/producer/engines/resemble_worker.py | 68 ---- lib/src/producer/io.py | 87 ----- lib/src/producer/lazy.py | 257 ------------ lib/src/producer/loudness.py | 30 -- lib/src/producer/meters.py | 112 ------ lib/src/producer/pipeline.py | 269 ------------- lib/src/producer/report.py | 56 --- lib/src/producer/ui.py | 319 --------------- lib/src/producer/updates.py | 273 ------------- lib/tests/conftest.py | 70 ---- lib/tests/test_chunking.py | 250 ------------ lib/tests/test_cli.py | 357 ----------------- lib/tests/test_dsp.py | 130 ------- lib/tests/test_engines.py | 515 ------------------------- lib/tests/test_lazy.py | 286 -------------- lib/tests/test_loudness.py | 30 -- lib/tests/test_meters.py | 65 ---- lib/tests/test_pipeline.py | 114 ------ lib/tests/test_ui.py | 169 -------- lib/tests/test_updates.py | 221 ----------- 56 files changed, 2654 insertions(+), 5455 deletions(-) create mode 100644 lib/archive/mastered/README.md create mode 100644 lib/project/examples/voice.toml create mode 100644 lib/project/pyproject.toml create mode 100644 lib/project/src/voiceforge/__init__.py create mode 100644 lib/project/src/voiceforge/__main__.py create mode 100644 lib/project/src/voiceforge/ai.py create mode 100644 lib/project/src/voiceforge/ai_worker.py create mode 100644 lib/project/src/voiceforge/audio.py create mode 100644 lib/project/src/voiceforge/cli.py create mode 100644 lib/project/src/voiceforge/config.py create mode 100644 lib/project/src/voiceforge/pipeline.py create mode 100644 lib/project/src/voiceforge/progress.py create mode 100644 lib/project/src/voiceforge/setup.py create mode 100644 lib/project/tests/conftest.py create mode 100644 lib/project/tests/test_ai.py create mode 100644 lib/project/tests/test_cli.py create mode 100644 lib/project/tests/test_config.py create mode 100644 lib/project/tests/test_install.py create mode 100644 lib/project/tests/test_pipeline.py delete mode 100644 lib/pytest.ini delete mode 100644 lib/requirements-core.txt delete mode 100644 lib/ruff.toml delete mode 100644 lib/src/__main__.py delete mode 100644 lib/src/producer/__init__.py delete mode 100644 lib/src/producer/cli.py delete mode 100644 lib/src/producer/config.py delete mode 100644 lib/src/producer/doctor.py delete mode 100644 lib/src/producer/dsp.py delete mode 100644 lib/src/producer/engines/__init__.py delete mode 100644 lib/src/producer/engines/base.py delete mode 100644 lib/src/producer/engines/chunking.py delete mode 100644 lib/src/producer/engines/denoise_dfn.py delete mode 100644 lib/src/producer/engines/denoise_spectral.py delete mode 100644 lib/src/producer/engines/denoise_zip.py delete mode 100644 lib/src/producer/engines/enhance_mossformer.py delete mode 100644 lib/src/producer/engines/enhance_resemble.py delete mode 100644 lib/src/producer/engines/resemble_worker.py delete mode 100644 lib/src/producer/io.py delete mode 100644 lib/src/producer/lazy.py delete mode 100644 lib/src/producer/loudness.py delete mode 100644 lib/src/producer/meters.py delete mode 100644 lib/src/producer/pipeline.py delete mode 100644 lib/src/producer/report.py delete mode 100644 lib/src/producer/ui.py delete mode 100644 lib/src/producer/updates.py delete mode 100644 lib/tests/conftest.py delete mode 100644 lib/tests/test_chunking.py delete mode 100644 lib/tests/test_cli.py delete mode 100644 lib/tests/test_dsp.py delete mode 100644 lib/tests/test_engines.py delete mode 100644 lib/tests/test_lazy.py delete mode 100644 lib/tests/test_loudness.py delete mode 100644 lib/tests/test_meters.py delete mode 100644 lib/tests/test_pipeline.py delete mode 100644 lib/tests/test_ui.py delete mode 100644 lib/tests/test_updates.py (limited to 'lib') diff --git a/lib/archive/mastered/README.md b/lib/archive/mastered/README.md new file mode 100644 index 0000000..99284b5 --- /dev/null +++ b/lib/archive/mastered/README.md @@ -0,0 +1,36 @@ +# Test Recording Outputs + +The original `/workspace/testwav.wav` was not changed. These files are listening +samples, not a claim that the presets have been tuned by listening. + +- `testwav.deepfilter-cleaned.wav`: full recording after DeepFilterNet3 at 85% + wet strength, mono 48 kHz float WAV, with exactly 5,684,577 frames. +- `testwav.natural.wav`: the cleaned file through the natural mastering profile, + 48 kHz / 24-bit mono, measured at -19.11 LUFS and -1.49 dBTP. +- `testwav.natural.mp3`: 192 kb/s delivery copy, decoded measurement -19.38 LUFS + and -1.75 dBTP. +- `testwav.fft.wav`: the original processed with the explicit non-AI FFT denoiser, + for comparison, measured at -19.10 LUFS and -1.50 dBTP. +- JSON sidecars record settings and technical measurements for each mastering run. +- `../previews/` contains 30-second natural, narrator and radio samples starting + at 10 seconds in the DeepFilterNet-cleaned recording, normalized to similar + loudness for comparison. + +The container uses musl rather than Arch's glibc. Real CPU DeepFilterNet inference +was tested using a temporary glibc loader; the cleaned output was then mastered +with the normal CLI and `--no-denoise`. Therefore the natural report correctly +lists the cleaned file as its source and `denoiser = none`. This two-step test did +not exercise the standard Arch installer or CUDA. It also ran denoising before +highpass filtering, whereas the normal one-command pipeline prepares/filters the +input before denoising, so a fresh Arch run is not expected to be bit-identical. + +On Arch, reproduce the intended one-command workflow with the launcher: + +```bash +./producer.sh process testwav.wav --device cuda --mp3 -o arch-natural.wav +./producer.sh preview testwav.wav --device cuda --start 10 --duration 30 \ + --profiles natural narrator radio --output-dir arch-previews +``` + +These samples were moved to `lib/archive/` so the checkout root stays clean; +the files themselves are unchanged. diff --git a/lib/project/examples/voice.toml b/lib/project/examples/voice.toml new file mode 100644 index 0000000..6a5251a --- /dev/null +++ b/lib/project/examples/voice.toml @@ -0,0 +1,56 @@ +# Complete natural-profile defaults. Load with --config lib/project/examples/voice.toml. +# Flat TOML: no section headers. Named CLI flags override these values; +# --set KEY=VALUE overrides named flags. Use --profile, not --set profile=... +# All explicit values below override preset defaults, even with --profile radio. +# Remove settings you want a different profile to supply. + +profile = "natural" # natural, narrator, radio, cleanup-only +denoiser = "deepfilter" # DeepFilterNet3 only; fft is explicit non-AI fallback; none +device = "auto" # auto, cpu, cuda; explicit cuda fails if unavailable +denoise_strength = 0.85 # 0..1; AI dry/wet blend; zero skips denoising and AI setup +fft_reduction_db = 8.0 # 0.01..30; FFT reduction is scaled by denoise_strength +channel = "auto" # auto accepts mono only; stereo requires left, right or mix + +highpass = true +highpass_hz = 70.0 # 20..300 +lowpass = false +lowpass_hz = 16000.0 # 4000..22000; must be below output Nyquist when enabled +hum_hz = 0 # 0 (off), 50 or 60; notches fundamental and next two harmonics + +# One static gain adjustment for the whole recording, not automatic gain riding. +leveling = true +level_target_db = -23.0 # -40..-12; heuristic speech level in dBFS, not LUFS +max_gain_db = 18.0 # 0..30; caps both gain and attenuation + +expansion = false +expansion_threshold_db = -50.0 # -90..-20 +expansion_ratio = 1.5 # 1..4 +expansion_range_db = 12.0 # 0..40 + +eq = true +warmth_db = 0.5 # -12..12; 140 Hz +mud_db = -1.5 # -12..12; 300 Hz +presence_db = 1.0 # -12..12; 3500 Hz + +compression = true +compressor_threshold_db = -21.0 # -50..-5 +compressor_ratio = 2.0 # 1..10 +compressor_attack_ms = 15.0 # 0.1..200 +compressor_release_ms = 150.0 # 10..2000 +deess = true +deess_intensity = 0.15 # 0..1 +deess_amount = 0.4 # 0..1 + +# Two-pass normalization plus export verification; target misses warn only. +# Turning normalize off leaves limiter on unless you also disable limiter. +# normalize=true requires limiter=true. Previews force both on, even cleanup-only. +normalize = true +target_lufs = -19.0 # -30..-12; mono delivery target, not ACX certification +loudness_range = 9.0 # 1..20 LU +limiter = true +true_peak_db = -1.5 # -9..-0.5 dBTP; MP3 can overshoot, inspect report warnings + +# Final exports are always mono, 24-bit PCM WAV, with an optional MP3 sidecar. +sample_rate = 48000 # 44100 or 48000; intermediate processing is 48 kHz float +mp3 = false +mp3_bitrate = 192 # 128, 160, 192, 224, 256 or 320 kb/s diff --git a/lib/project/pyproject.toml b/lib/project/pyproject.toml new file mode 100644 index 0000000..082b944 --- /dev/null +++ b/lib/project/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["hatchling==1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "voiceforge" +version = "0.2.0" +description = "Local, configurable speech cleanup and podcast mastering" +license = "0BSD" +requires-python = ">=3.11" +dependencies = ["rich==14.1.0", "numpy>=1.26.4,<3", "soundfile==0.13.1"] + +[project.optional-dependencies] +test = ["pytest>=8,<10"] +# Preferred by producer.sh; falls back to a system FFmpeg where the wheel +# does not exist (for example musl systems). +bundled-ffmpeg = ["imageio-ffmpeg==0.6.0"] + +[project.scripts] +voiceforge = "voiceforge.cli:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/voiceforge"] + +[tool.hatch.build.targets.sdist] +include = ["/src", "/tests", "/examples", "/pyproject.toml"] diff --git a/lib/project/src/voiceforge/__init__.py b/lib/project/src/voiceforge/__init__.py new file mode 100644 index 0000000..1d8663d --- /dev/null +++ b/lib/project/src/voiceforge/__init__.py @@ -0,0 +1,3 @@ +"""VoiceForge: local speech cleanup and mastering.""" + +__version__ = "0.2.0" diff --git a/lib/project/src/voiceforge/__main__.py b/lib/project/src/voiceforge/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/lib/project/src/voiceforge/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/lib/project/src/voiceforge/ai.py b/lib/project/src/voiceforge/ai.py new file mode 100644 index 0000000..493be65 --- /dev/null +++ b/lib/project/src/voiceforge/ai.py @@ -0,0 +1,157 @@ +"""Automatic, isolated DeepFilterNet3 inference (Linux, Python 3.11 worker). + +Public progress callback: (stage: str, completed: float | None, total: float | None). +Known totals are audio frames or download bytes (identified by stage); +unknown totals use elapsed seconds as completed. +First use downloads Python, wheels, and upstream model weights into the +VoiceForge data directory (VOICEFORGE_HOME prefix or the XDG data location). +""" +from __future__ import annotations + +import ctypes +import fcntl +import math +import os +from pathlib import Path +import platform +import shutil +import time + +from .setup import Progress, data_dir, ensure_uv, run_process, tool_env + + +def _cuda_available() -> bool: + # Probe the driver without importing Torch or installing CUDA in the host env. + if os.environ.get("CUDA_VISIBLE_DEVICES") in {"", "-1"}: + return False + try: + driver = ctypes.CDLL("libcuda.so.1") + count = ctypes.c_int() + return driver.cuInit(0) == 0 and driver.cuDeviceGetCount(ctypes.byref(count)) == 0 and count.value > 0 + except (OSError, AttributeError): + return False + + +def _flavor(device: str) -> str: + if device not in {"auto", "cpu", "cuda"}: + raise ValueError("device must be 'auto', 'cpu', or 'cuda'") + if device == "cuda" or (device == "auto" and _cuda_available()): + if platform.machine() not in {"x86_64", "AMD64"}: + raise RuntimeError("The pinned CUDA 12.1 backend requires Linux x86_64.") + return "cu121" + return "cpu" + + +def _worker_env() -> dict[str, str]: + # Same isolation as tool_env(); kept as a named seam for worker launches. + return tool_env() + + +def _interpreter_is_local(python: Path, root: Path) -> bool: + """Whether the environment's interpreter belongs to this prefix. + + An installation duplicated by copying (while the original remained) keeps + interpreter links that resolve into the original directory. Such an + environment must be rebuilt, or the copy would silently depend on files + outside this prefix and break when the original is removed. + """ + try: + return python.resolve().is_relative_to(root) + except OSError: + return False + + +def ensure_ai(device: str = "auto", progress: Progress | None = None, + verify: bool = False) -> Path: + """Install/validate the backend and model; return its Python executable. + + CPU and CUDA environments are separate. Explicit CUDA never falls back; + auto uses Torch's availability check in the selected worker. Downloads trust + PyPI, download.pytorch.org, Astral's Python distribution, and upstream DF. + verify forces a real self-test even when the environment is marked ready + ('setup' uses this); ordinary processing trusts the marker so a ready + environment does not pay for a second model load per run. + """ + flavor = _flavor(device) + try: + libc = os.confstr("CS_GNU_LIBC_VERSION") or "" + except (ValueError, OSError): + libc = "" + if platform.system() != "Linux" or not libc.startswith("glibc ") or tuple( + int(part) for part in libc.split()[1].split(".")[:2] + ) < (2, 28): + raise RuntimeError("The pinned DeepFilterNet3 backend requires Linux with glibc >= 2.28 " + "(for example Ubuntu 22.04+ or Debian 12+). " + "Alpine/musl is not supported by its prebuilt wheels.") + root = data_dir() + root.mkdir(parents=True, exist_ok=True) + environment = root / f"ai-df-0.5.6-torch-2.5.1-{flavor}-py311-v1" + python = environment / "bin/python" + marker = environment / ".voiceforge-ready" + started = time.monotonic() + # Serialize bootstrap/model downloads, including calls from separate CLIs. + with (root / "ai.lock").open("a") as lock: + while True: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if progress: + progress("Waiting for AI setup", time.monotonic() - started, None) + time.sleep(0.25) + ready = (marker.is_file() and python.is_file() + and _interpreter_is_local(python, root)) + if not ready: + # A moved, copied, or half-installed environment cannot be + # repaired in place: interpreter links reference absolute paths. + # Rebuild it; the shared wheel caches make this fast, usually + # offline. + if environment.exists(): + shutil.rmtree(environment) + uv = ensure_uv(progress) + env = _worker_env() + run_process([uv, "venv", "--python", "3.11", "--managed-python", + "--relocatable", "--allow-existing", str(environment)], + "Preparing AI Python 3.11", progress, env=env) + run_process([uv, "pip", "install", "--python", str(python), + "--index-url", f"https://download.pytorch.org/whl/{flavor}", + "torch==2.5.1", "torchaudio==2.5.1"], + f"Installing PyTorch ({flavor})", progress, env=env) + run_process([uv, "pip", "install", "--python", str(python), + "--only-binary", ":all:", + "--index-url", "https://pypi.org/simple", "deepfilternet==0.5.6", + "numpy==1.26.4", "soundfile==0.12.1"], + "Installing DeepFilterNet3", progress, env=env) + # Check a freshly built environment (and any explicit setup) with a + # real model load and inference; this also downloads the weights. + if verify or not ready: + run_process([str(python), "-I", str(Path(__file__).with_name("ai_worker.py")), + "--device", device, "--model-dir", str(root / "models/df3-0.5.6"), + "--check"], "Checking DeepFilterNet3", progress, + env=_worker_env()) + marker.touch() + return python + + +def denoise(source: Path, target: Path, device: str = "auto", strength: float = 1.0, + progress: Progress | None = None) -> None: + """Denoise mono 48 kHz WAV to float WAV, preserving the exact sample count. + + strength is a dry/wet mix in [0, 1]. The worker publishes output atomically; + errors/cancellation leave an existing target untouched. Chunked inference is + bounded-memory, not bit-identical to inference over an entire recording. + """ + if not math.isfinite(strength) or not 0 <= strength <= 1: + raise ValueError("strength must be finite and between 0 and 1") + source, target = Path(source).resolve(), Path(target).resolve() + if source == target: + raise ValueError("source and target must be different files") + if not source.is_file(): + raise FileNotFoundError(source) + if not target.parent.is_dir(): + raise FileNotFoundError(target.parent) + python = ensure_ai(device, progress) + run_process([str(python), "-I", str(Path(__file__).with_name("ai_worker.py")), + "--device", device, "--source", str(source), "--target", str(target), + "--model-dir", str(data_dir() / "models/df3-0.5.6"), + "--strength", str(strength)], "Denoising", progress, env=_worker_env()) diff --git a/lib/project/src/voiceforge/ai_worker.py b/lib/project/src/voiceforge/ai_worker.py new file mode 100644 index 0000000..99ec9f5 --- /dev/null +++ b/lib/project/src/voiceforge/ai_worker.py @@ -0,0 +1,161 @@ +"""Private script executed only by the isolated backend Python.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import signal +import tempfile +import urllib.request +import zipfile + + +def report(stage: str, completed: float | None = None, total: float | None = None) -> None: + print("VOICEFORGE_PROGRESS " + json.dumps([stage, completed, total]), flush=True) + + +def ensure_model(directory: Path) -> Path: + """Install a release-pinned, hash-checked archive atomically under setup lock.""" + model = directory / "DeepFilterNet3" + if (model / "config.ini").is_file() and (model / "checkpoints/model_120.ckpt.best").is_file(): + return model + if directory.exists(): + raise RuntimeError(f"Incomplete model directory: {directory}. Remove it and retry AI setup.") + directory.parent.mkdir(parents=True, exist_ok=True) + url = "https://raw.githubusercontent.com/Rikorose/DeepFilterNet/v0.5.6/models/DeepFilterNet3.zip" + expected = "49c52edc8947ae1f9bf50d81530beaf3a2c3245aeaf34b6f31ff535cd22284d2" + # Release archive size is fixed alongside its hash, even without Content-Length. + expected_bytes = 7986207 + downloaded = 0 + report("Downloading DeepFilterNet3 model (bytes)", 0, expected_bytes) + with tempfile.TemporaryDirectory(prefix=".df3-", dir=directory.parent) as staging: + archive = Path(staging) / "model.zip" + digest = hashlib.sha256() + with urllib.request.urlopen(url, timeout=60) as response, archive.open("wb") as output: + while chunk := response.read(256 * 1024): + downloaded += len(chunk) + if downloaded > expected_bytes: + raise RuntimeError("DeepFilterNet3 model download exceeds the expected size") + digest.update(chunk) + output.write(chunk) + report("Downloading DeepFilterNet3 model (bytes)", downloaded, expected_bytes) + if downloaded != expected_bytes or digest.hexdigest() != expected: + raise RuntimeError("DeepFilterNet3 model checksum mismatch; refusing to load weights") + report("Verifying and extracting DeepFilterNet3 model") + unpacked = Path(staging) / "unpacked" + with zipfile.ZipFile(archive) as bundle: + for member in bundle.infolist(): + destination = (unpacked / member.filename).resolve() + if not destination.is_relative_to(unpacked.resolve()): + raise RuntimeError("Unsafe path in model archive") + bundle.extractall(unpacked) + os.replace(unpacked, directory) + return model + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--device", choices=("auto", "cpu", "cuda"), required=True) + parser.add_argument("--check", action="store_true") + parser.add_argument("--model-dir", type=Path, required=True) + parser.add_argument("--source", type=Path) + parser.add_argument("--target", type=Path) + parser.add_argument("--strength", type=float, default=1.0) + args = parser.parse_args() + + # SIGTERM from the parent takes the same cleanup path as Ctrl-C. + def terminate(signum, frame): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, terminate) + + import numpy as np + import soundfile as sf + import torch + + torch.set_num_threads(min(4, os.cpu_count() or 1)) + available = torch.cuda.is_available() + if args.device == "cuda" and not available: + raise RuntimeError("CUDA was explicitly requested, but PyTorch cannot use it. " + "Check the NVIDIA driver and CUDA_VISIBLE_DEVICES, or select cpu.") + device = "cuda" if args.device != "cpu" and available else "cpu" + # df.utils.get_device() consults DEVICE on every call, including model creation. + os.environ["DEVICE"] = device + from df.enhance import enhance, init_df + from df.model import ModelParams + from libdf import DF + + model_path = ensure_model(args.model_dir) + report(f"Loading DeepFilterNet3 ({device})") + model, state, _ = init_df(str(model_path), log_level="ERROR", log_file=None) + if state.sr() != 48000: + raise RuntimeError("DeepFilterNet3 model must use 48 kHz") + if args.check: + # Exercise both model execution and the compiled libdf/NumPy ABI. + sample = enhance(model, state, torch.zeros(1, 4800), pad=True) + if sample.shape != (1, 4800) or not torch.isfinite(sample).all(): + raise RuntimeError("DeepFilterNet3 self-test failed") + report(f"DeepFilterNet3 ready ({device})", 1, 1) + return + if args.source is None or args.target is None: + parser.error("--source and --target are required unless --check is used") + if not np.isfinite(args.strength) or not 0 <= args.strength <= 1: + parser.error("--strength must be in [0, 1]") + + temporary = None + try: + with sf.SoundFile(args.source) as source: + if source.samplerate != 48000 or source.channels != 1 or source.format not in {"WAV", "WAVEX", "RF64"}: + raise ValueError("AI input must be a mono 48 kHz WAV; convert it before denoising") + frames = len(source) + fd, name = tempfile.mkstemp(prefix=".voiceforge-ai-", suffix=".wav", dir=args.target.parent) + os.close(fd) + temporary = Path(name) + # RF64 avoids RIFF's 4 GiB limit on very long recordings. + output_format = "RF64" if frames * 4 > 0xFFFFFFFF - 4096 else "WAV" + with sf.SoundFile(temporary, "w", samplerate=48000, channels=1, + format=output_format, subtype="FLOAT") as target: + block, context, overlap = 480000, 96000, 2400 + previous = None + params = ModelParams() + report(f"Denoising ({device})", 0, frames) + for start in range(0, frames, block): + end = min(start + block, frames) + left, right = max(0, start - context), min(frames, end + overlap + context) + source.seek(left) + audio = source.read(right - left, dtype="float32") + if not np.isfinite(audio).all(): + raise ValueError("Input contains non-finite audio samples") + if args.strength: + # Fresh STFT state per window; context warms normalization and + # recurrent layers. Pad to a whole hop before delay compensation. + state = DF(sr=params.sr, fft_size=params.fft_size, + hop_size=params.hop_size, nb_bands=params.nb_erb, + min_nb_erb_freqs=params.min_nb_freqs) + padded = np.pad(audio, (0, (-len(audio)) % params.hop_size)) + wet = enhance(model, state, torch.from_numpy(padded).unsqueeze(0), + pad=True).squeeze(0).numpy()[:len(audio)] + if len(wet) != len(audio) or not np.isfinite(wet).all(): + raise RuntimeError("DeepFilterNet3 returned invalid audio") + audio = audio * (1 - args.strength) + wet * args.strength + kept = audio[start - left:min(frames, end + overlap) - left].copy() + if previous is not None and args.strength: + n = len(previous) + fade = np.linspace(0, 1, n, dtype=np.float32) + kept[:n] = previous * (1 - fade) + kept[:n] * fade + target.write(kept[:end - start]) + previous = kept[end - start:].copy() + report(f"Denoising ({device})", end, frames) + if target.tell() != frames: + raise RuntimeError("Output frame count does not match input") + os.replace(temporary, args.target) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/lib/project/src/voiceforge/audio.py b/lib/project/src/voiceforge/audio.py new file mode 100644 index 0000000..7fee0c2 --- /dev/null +++ b/lib/project/src/voiceforge/audio.py @@ -0,0 +1,128 @@ +"""Streaming measurement and FFmpeg execution. No shell interpolation.""" +from collections import deque +import json +import math +import os +from pathlib import Path +import selectors +import signal +import subprocess +import time + +import numpy as np +import soundfile as sf + +from .setup import terminate_group + + +def ffmpeg_run(ffmpeg: str, args: list[str], stage: str, duration: float, + progress) -> str: + command = [ffmpeg, "-hide_banner", "-nostdin", "-y", "-nostats", + "-progress", "pipe:1"] + args + progress(stage, 0, duration or None) + diagnostics = deque(maxlen=128) + pending = b"" + completed = 0.0 + last_advance = time.monotonic() + with subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) as process: + try: + with selectors.DefaultSelector() as selector: + for stream in (process.stdout, process.stderr): + os.set_blocking(stream.fileno(), False) + selector.register(stream, selectors.EVENT_READ) + while selector.get_map() or process.poll() is None: + for key, _ in selector.select(timeout=0.2): + data = os.read(key.fd, 8192) + if not data: + selector.unregister(key.fileobj) + continue + if key.fileobj is process.stderr: + diagnostics.append(data) + else: + pending += data + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + if line.startswith(b"out_time_us="): + try: + value = max(0.0, int(line.split(b"=", 1)[1]) / 1e6) + except ValueError: + continue + if value > completed: + last_advance = time.monotonic() + completed = value + label = stage + if time.monotonic() - last_advance > 60: + label += " (no new audio progress for 60s; still waiting)" + progress(label, min(completed, duration), duration or None) + code = process.wait() + output = b"".join(diagnostics).decode(errors="replace") + if code: + raise RuntimeError(f"{stage} failed (exit {code}):\n{output[-8000:]}") + progress(stage, duration, duration or None) + return output + except BaseException: + terminate_group(process) + raise + + +def measure(path: Path, progress, stage="Analyzing audio") -> dict: + """100ms RMS histogram is bounded in memory, including multi-hour input.""" + with sf.SoundFile(path) as source: + if source.channels not in (1, 2): + raise ValueError("Only mono or stereo voice recordings are supported") + if not len(source): + raise ValueError("The recording is empty") + peaks = np.zeros(source.channels) + sums = np.zeros(source.channels) + squares = np.zeros(source.channels) + clipped = 0 + histogram = np.zeros(121, dtype=np.int64) + frames = 0 + progress(stage, 0, len(source)) + for audio in source.blocks(blocksize=max(1, source.samplerate // 10), + dtype="float64", always_2d=True): + if not np.isfinite(audio).all(): + raise ValueError("Recording contains non-finite samples") + peaks = np.maximum(peaks, np.max(np.abs(audio), axis=0)) + sums += audio.sum(axis=0) + squares += (audio * audio).sum(axis=0) + clipped += int(np.count_nonzero(np.abs(audio) >= 0.9999)) + rms = float(np.sqrt(np.mean(audio * audio))) + db = 20 * math.log10(max(rms, 1e-6)) + histogram[int(np.clip(round(db) + 120, 0, 120))] += 1 + frames += len(audio) + progress(stage, frames, len(source)) + cumulative = histogram.cumsum() + def percentile(p): + return int(np.searchsorted(cumulative, max(1, math.ceil(cumulative[-1] * p)))) - 120 + quiet, speech = percentile(0.1), percentile(0.8) + if speech - quiet < 15: + # Pause-dominated recordings: the 80th percentile of all blocks + # sits in room tone, so estimate the level from the loud tail + # instead of mistaking sparse active speech for silence. + speech = max(speech, percentile(0.98)) + noise_available = speech - quiet >= 15 and quiet > -110 + return {"frames": frames, "duration_seconds": frames / source.samplerate, + "sample_rate": source.samplerate, "channels": source.channels, + "subtype": source.subtype, + "sample_peak_dbfs": float(20 * np.log10(max(float(peaks.max()), 1e-12))), + "channel_rms_dbfs": (20 * np.log10(np.maximum(np.sqrt(squares / frames), 1e-12))).tolist(), + "dc_offset": (sums / frames).tolist(), "near_full_scale_samples": clipped, + "speech_level_estimate_dbfs": speech, + "quiet_blocks_dbfs": quiet, + "noise_floor_estimate_dbfs": quiet if noise_available else None, + "noise_floor_confidence": "low (quiet-block heuristic, not speech recognition)" if noise_available else "insufficient room tone"} + + +def loudness(ffmpeg, source, settings, duration, progress, stage="Measuring loudness"): + output = ffmpeg_run(ffmpeg, ["-i", str(source), "-map", "0:a:0", "-af", + f"loudnorm=I={settings.target_lufs}:TP={settings.true_peak_db}:LRA={settings.loudness_range}:print_format=json", + "-f", "null", "-"], stage, duration, progress) + start, end = output.rfind("{"), output.rfind("}") + if start < 0 or end < start: + raise RuntimeError("FFmpeg did not return loudness measurements") + raw = json.loads(output[start:end + 1]) + return {key: (float(value) if math.isfinite(float(value)) else None) + for key, value in raw.items() if key != "normalization_type"} | { + "normalization_type": raw.get("normalization_type")} diff --git a/lib/project/src/voiceforge/cli.py b/lib/project/src/voiceforge/cli.py new file mode 100644 index 0000000..52cd1bd --- /dev/null +++ b/lib/project/src/voiceforge/cli.py @@ -0,0 +1,193 @@ +"""Command-line entry point; configuration errors fail before model downloads.""" +import argparse +from dataclasses import asdict, fields +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import tomllib + +from rich.console import Console + +from . import __version__ +from .audio import ffmpeg_run, loudness, measure +from .config import PROFILES, Settings, resolve, toml +from .pipeline import check_target, process, targets_for +from .progress import Display +from .setup import data_dir, ensure_ffmpeg + + +def parser(): + app = argparse.ArgumentParser(prog="producer", description="Local speech cleanup and podcast mastering. Originals are never overwritten.") + app.add_argument("--version", action="version", version=__version__) + sub = app.add_subparsers(dest="command", required=True) + for name in ("process", "preview", "config", "analyze"): + cmd = sub.add_parser(name) + cmd.add_argument("--config", type=Path, help="TOML configuration file") + cmd.add_argument("--profile", choices=PROFILES, default=None) + cmd.add_argument("--set", action="append", default=[], metavar="KEY=VALUE", help="Override any setting using TOML value syntax") + for field in fields(Settings): + if field.name == "profile": + continue + default = getattr(Settings(), field.name) + option = "--" + field.name.replace("_", "-") + if isinstance(default, bool): + cmd.add_argument(option, action=argparse.BooleanOptionalAction, default=None) + else: + cmd.add_argument(option, type=type(default), default=None) + cmd.add_argument("--no-denoise", dest="denoiser", action="store_const", const="none") + if name == "config": + cmd.add_argument("--json", action="store_true") + else: + cmd.add_argument("inputs", type=Path, nargs="+" if name == "process" else 1) + if name in ("process", "preview"): + cmd.add_argument("--output-dir", type=Path, default=None, + help="Directory for generated outputs (default: beside each input)") + cmd.add_argument("--overwrite", action="store_true", help="Replace generated outputs, never input files") + if name == "process": + cmd.add_argument("-o", "--output", type=Path, help="Explicit WAV destination (one input only)") + if name == "preview": + cmd.add_argument("--start", type=float, default=0) + cmd.add_argument("--duration", type=float, default=30) + cmd.add_argument("--profiles", nargs="+", choices=PROFILES, default=["natural", "narrator", "radio"]) + sub.add_parser("doctor", help="Check tools and driver without downloading AI") + setup = sub.add_parser("setup", help="Download and self-test the isolated AI backend") + setup.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto") + return app + + +def settings_for(args, profile=None): + overrides = {field.name: getattr(args, field.name) for field in fields(Settings) + if field.name != "profile" and getattr(args, field.name, None) is not None} + for item in args.set: + key, sep, value = item.partition("=") + if not sep: + raise ValueError("--set expects KEY=VALUE") + # Strings can be supplied without TOML quotes for CLI convenience. + try: + parsed = tomllib.loads("value = " + value)["value"] + except tomllib.TOMLDecodeError: + parsed = value + overrides[key.strip()] = parsed + return resolve(args.config, profile or args.profile, overrides) + + +def require_input(source: Path) -> None: + if not source.is_file() or source.suffix.lower() != ".wav": + raise ValueError(f"Input must be an existing WAV file: {source}") + + +def main(): + app = parser() + args = app.parse_args() + console = Console(stderr=True) + try: + if args.command == "doctor": + from .ai import _cuda_available + ffmpeg = ensure_ffmpeg() + version = subprocess.run([ffmpeg, "-version"], capture_output=True, text=True, check=True).stdout.splitlines()[0] + console.print(version, markup=False) + console.print(f"FFmpeg: {ffmpeg}\nData: {data_dir()}\nCUDA driver available: {_cuda_available()}\nAI is installed and self-tested by './producer.sh setup'.", markup=False) + return + if args.command == "setup": + from .ai import ensure_ai + with Display() as display: + ensure_ffmpeg() + python = ensure_ai(args.device, display.update, verify=True) + console.print(f"AI ready: {python}", markup=False) + return + settings = settings_for(args) + if args.command == "config": + print(json.dumps(asdict(settings), indent=2) if args.json else toml(settings), end="\n" if args.json else "") + return + with Display() as display: + if args.command == "analyze": + stats = measure(args.inputs[0], display.update) + stats["loudness"] = loudness(ensure_ffmpeg(), args.inputs[0], settings, stats["duration_seconds"], display.update) + print(json.dumps(stats, indent=2, allow_nan=False)) + return + if args.command == "process": + if args.output and len(args.inputs) != 1: + raise ValueError("--output requires exactly one input") + if args.output and args.output_dir: + raise ValueError("--output and --output-dir cannot be combined") + # Without an explicit destination, outputs are written beside + # each input; --output-dir keeps every result in one directory. + outputs = [args.output or (args.output_dir or p.parent) / f"{p.stem}.{settings.profile}.wav" + for p in args.inputs] + # Preflight the whole plan so a predictable conflict cannot + # fail a batch after earlier inputs have already been published. + sources = {p.resolve() for p in args.inputs} + claimed: set[Path] = set() + for source, output in zip(args.inputs, outputs): + require_input(source) + targets = targets_for(output.absolute(), settings) + resolved = {target.resolve() for target in targets} + if len(resolved) < len(targets) or resolved & claimed: + raise ValueError("Inputs have duplicate output names; process them separately with --output") + claimed |= resolved + if resolved & sources or any( + target.exists() and os.path.samefile(source, target) for target in targets): + raise ValueError("An output would overwrite an input recording") + for target in targets: + check_target(source, target, args.overwrite) + for source, output in zip(args.inputs, outputs): + report = process(source, output, settings, display.update, args.overwrite) + console.print(f"Written: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False) + for warning in report["warnings"]: + console.print(f"Warning: {warning}", markup=False) + return + if not math.isfinite(args.start) or args.start < 0 or not 0 < args.duration <= 300: + raise ValueError("Preview start must be >= 0 and duration must be in (0, 300] seconds") + if len(set(args.profiles)) != len(args.profiles): + raise ValueError("Preview profiles must be unique") + if any(item.partition("=")[0].strip() == "profile" for item in args.set): + raise ValueError("Preview profiles come from --profiles; remove the profile override from --set") + source = args.inputs[0] + require_input(source) + original = source.resolve() + output_dir = args.output_dir or source.parent + # Preflight every preview destination before extracting the excerpt. + claimed: set[Path] = set() + for profile in args.profiles: + output = output_dir / f"{source.stem}.{profile}.preview.wav" + if output.resolve() == original: + raise ValueError("Preview output would overwrite the original") + targets = [output, output.with_suffix(".report.json")] + resolved = {target.resolve() for target in targets} + if len(resolved) < len(targets) or resolved & claimed: + raise ValueError("Preview outputs have duplicate names") + claimed |= resolved + for target in targets: + check_target(original, target, args.overwrite) + output_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".preview-", dir=output_dir) as tmp: + excerpt = Path(tmp) / "excerpt.wav" + ffmpeg_run(ensure_ffmpeg(), ["-ss", str(args.start), "-i", str(original), "-t", str(args.duration), + "-map", "0:a:0", "-c:a", "pcm_f32le", str(excerpt)], "Extracting preview", args.duration, display.update) + for profile in args.profiles: + configured = settings_for(args, profile) + # Equal loudness prevents a louder preset winning an unfair A/B. + configured.normalize = True + configured.limiter = True + output = output_dir / f"{source.stem}.{profile}.preview.wav" + report = process(excerpt, output, configured, display.update, args.overwrite, + provenance={"original": str(original), "preview_start_seconds": args.start, + "requested_duration_seconds": args.duration, + "normalization_forced": True}) + console.print(f"Preview: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False) + for warning in report["warnings"]: + console.print(f"Warning: {warning}", markup=False) + except KeyboardInterrupt: + console.print("Cancelled; temporary files removed. Completed outputs may remain.") + raise SystemExit(130) + except (ValueError, OSError, RuntimeError, subprocess.SubprocessError) as error: + console.print(f"Error: {error}", markup=False) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/lib/project/src/voiceforge/config.py b/lib/project/src/voiceforge/config.py new file mode 100644 index 0000000..9cbd59d --- /dev/null +++ b/lib/project/src/voiceforge/config.py @@ -0,0 +1,118 @@ +"""Flat, strict TOML configuration; CLI overrides are applied last.""" +from dataclasses import asdict, dataclass, fields +import math +from pathlib import Path +import tomllib + + +@dataclass +class Settings: + profile: str = "natural" + denoiser: str = "deepfilter" + device: str = "auto" + denoise_strength: float = 0.85 + fft_reduction_db: float = 8.0 + channel: str = "auto" + highpass: bool = True + highpass_hz: float = 70.0 + lowpass: bool = False + lowpass_hz: float = 16000.0 + hum_hz: int = 0 + leveling: bool = True + level_target_db: float = -23.0 + max_gain_db: float = 18.0 + expansion: bool = False + expansion_threshold_db: float = -50.0 + expansion_ratio: float = 1.5 + expansion_range_db: float = 12.0 + eq: bool = True + warmth_db: float = 0.5 + mud_db: float = -1.5 + presence_db: float = 1.0 + compression: bool = True + compressor_threshold_db: float = -21.0 + compressor_ratio: float = 2.0 + compressor_attack_ms: float = 15.0 + compressor_release_ms: float = 150.0 + deess: bool = True + deess_intensity: float = 0.15 + deess_amount: float = 0.4 + normalize: bool = True + target_lufs: float = -19.0 + loudness_range: float = 9.0 + limiter: bool = True + true_peak_db: float = -1.5 + sample_rate: int = 48000 + mp3: bool = False + mp3_bitrate: int = 192 + + +PROFILES = { + "natural": {}, + "narrator": {"compressor_ratio": 1.6, "compressor_attack_ms": 25.0, + "presence_db": 0.5, "warmth_db": 1.0, "loudness_range": 11.0}, + "radio": {"compressor_ratio": 3.5, "compressor_threshold_db": -24.0, + "compressor_attack_ms": 8.0, "compressor_release_ms": 100.0, + "warmth_db": 2.0, "mud_db": -2.0, "presence_db": 2.0, + "deess_intensity": 0.25, "loudness_range": 6.0}, + "cleanup-only": {"leveling": False, "eq": False, "compression": False, + "deess": False, "normalize": False, "limiter": False}, +} + +RANGES = { + "denoise_strength": (0, 1), "fft_reduction_db": (0.01, 30), + "highpass_hz": (20, 300), "lowpass_hz": (4000, 22000), + "level_target_db": (-40, -12), "max_gain_db": (0, 30), + "expansion_threshold_db": (-90, -20), "expansion_ratio": (1, 4), + "expansion_range_db": (0, 40), "warmth_db": (-12, 12), + "mud_db": (-12, 12), "presence_db": (-12, 12), + "compressor_threshold_db": (-50, -5), "compressor_ratio": (1, 10), + "compressor_attack_ms": (0.1, 200), "compressor_release_ms": (10, 2000), + "deess_intensity": (0, 1), "deess_amount": (0, 1), + "target_lufs": (-30, -12), "loudness_range": (1, 20), + "true_peak_db": (-9, -0.5), +} + + +def resolve(path: Path | None = None, profile: str | None = None, + overrides: dict | None = None) -> Settings: + values = tomllib.loads(path.read_text()) if path else {} + chosen = (overrides or {}).get("profile", profile or values.get("profile", "natural")) + if not isinstance(chosen, str) or chosen not in PROFILES: + raise ValueError(f"Unknown profile {chosen!r}; choose {', '.join(PROFILES)}") + merged = asdict(Settings()) | PROFILES[chosen] | values | {"profile": chosen} + merged.update(overrides or {}) + defaults = asdict(Settings()) + unknown = merged.keys() - defaults.keys() + if unknown: + raise ValueError(f"Unknown setting(s): {', '.join(sorted(unknown))}") + for key, value in merged.items(): + expected = type(defaults[key]) + if expected is float: + if type(value) not in (int, float) or not math.isfinite(value): + raise ValueError(f"{key} must be a finite number") + elif type(value) is not expected: + raise ValueError(f"{key} must be {expected.__name__}") + if key in RANGES and not RANGES[key][0] <= value <= RANGES[key][1]: + raise ValueError(f"{key} must be between {RANGES[key][0]} and {RANGES[key][1]}") + for key, options in { + "denoiser": ("deepfilter", "fft", "none"), "device": ("auto", "cpu", "cuda"), + "channel": ("auto", "left", "right", "mix"), "hum_hz": (0, 50, 60), + "sample_rate": (44100, 48000), "mp3_bitrate": (128, 160, 192, 224, 256, 320), + }.items(): + if merged[key] not in options: + raise ValueError(f"{key} must be one of {options}") + if merged["lowpass"] and merged["lowpass_hz"] >= merged["sample_rate"] / 2: + raise ValueError("lowpass_hz must be below the output Nyquist frequency") + if merged["normalize"] and not merged["limiter"]: + raise ValueError("Normalization includes true-peak limiting; use --no-normalize to bypass it") + return Settings(**merged) + + +def toml(settings: Settings) -> str: + lines = ["# VoiceForge configuration. CLI flags override these values."] + for field in fields(settings): + value = getattr(settings, field.name) + text = str(value).lower() if isinstance(value, bool) else repr(value) + lines.append(f"{field.name} = {text}") + return "\n".join(lines) + "\n" diff --git a/lib/project/src/voiceforge/pipeline.py b/lib/project/src/voiceforge/pipeline.py new file mode 100644 index 0000000..a143dcf --- /dev/null +++ b/lib/project/src/voiceforge/pipeline.py @@ -0,0 +1,199 @@ +"""Offline mono voice mastering with float intermediates and atomic outputs.""" +from dataclasses import asdict +import fcntl +import json +import math +import os +from pathlib import Path +import tempfile + +from . import __version__ +from .audio import ffmpeg_run, loudness, measure +from .config import Settings +from .setup import ensure_ffmpeg + + +def filters(settings: Settings) -> list[str]: + s = settings + result = [] + if s.expansion: + result.append(f"agate=threshold={10 ** (s.expansion_threshold_db / 20)}:ratio={s.expansion_ratio}:range={10 ** (-s.expansion_range_db / 20)}:attack=10:release=250:detection=rms") + if s.eq: + result += [f"equalizer=f=140:t=q:w=0.7:g={s.warmth_db}", + f"equalizer=f=300:t=q:w=0.8:g={s.mud_db}", + f"equalizer=f=3500:t=q:w=0.7:g={s.presence_db}"] + if s.compression: + result.append(f"acompressor=threshold={10 ** (s.compressor_threshold_db / 20)}:ratio={s.compressor_ratio}:attack={s.compressor_attack_ms}:release={s.compressor_release_ms}:knee=4:makeup=1:detection=rms") + if s.deess: + result.append(f"deesser=i={s.deess_intensity}:m={s.deess_amount}:f=0.5") + if s.lowpass: + result.append(f"lowpass=f={s.lowpass_hz}:p=2") + return result or ["anull"] + + +def targets_for(destination: Path, settings: Settings) -> list[Path]: + """Every path a successful run publishes: master WAV, report, optional MP3.""" + targets = [destination, destination.with_suffix(".report.json")] + if settings.mp3: + targets.append(destination.with_suffix(".mp3")) + return targets + + +def check_target(source: Path, target: Path, overwrite: bool) -> None: + """Preflight one published path; the original recording is never touched.""" + if target.resolve() == source or (target.exists() and os.path.samefile(source, target)): + raise ValueError("Refusing to overwrite the original recording") + # lexists also rejects dangling symlinks, which publication would only hit late. + if os.path.lexists(target) and not overwrite: + raise FileExistsError(f"Output exists: {target}; use --overwrite to replace outputs") + if target.exists() and not target.is_file(): + raise ValueError(f"Output is not a regular file: {target}") + + +def publish(source: Path, target: Path, overwrite: bool): + if overwrite: + os.replace(source, target) + else: + # Link is atomic and fails if another process created the destination. + os.link(source, target) + source.unlink() + + +def process(source: Path, destination: Path, settings: Settings, progress, + overwrite=False, provenance: dict | None = None) -> dict: + source, destination = source.resolve(), destination.absolute() + if not source.is_file() or source.suffix.lower() != ".wav": + raise ValueError(f"Input must be an existing WAV file: {source}") + if destination.suffix.lower() != ".wav": + raise ValueError("Master output must have a .wav extension") + report_path = destination.with_suffix(".report.json") + for target in targets_for(destination, settings): + check_target(source, target, overwrite) + ffmpeg = ensure_ffmpeg() + original = measure(source, progress, "Analyzing original") + if original["sample_peak_dbfs"] < -100: + raise ValueError("Recording is silent or too quiet to master safely") + channel = settings.channel + if original["channels"] == 2 and channel == "auto": + raise ValueError("Stereo input: select --channel left, right, or mix explicitly. Auto downmix could cancel your voice.") + if original["channels"] == 1 and channel == "right": + raise ValueError("Cannot select the right channel of a mono recording") + duration = original["duration_seconds"] + warnings = [] + if original["near_full_scale_samples"]: + warnings.append("Input has near-full-scale samples: inspect for clipping; lost peaks cannot be reliably restored.") + if max(abs(x) for x in original["dc_offset"]) > 0.01: + warnings.append("Significant input DC offset detected; enable highpass filtering to remove it.") + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".voiceforge-", dir=destination.parent) as temporary: + work = Path(temporary) + prepared, cleaned, shaped = [work / name for name in ("prepared.wav", "cleaned.wav", "shaped.wav")] + input_filters = [] + if original["channels"] == 2: + input_filters.append({"left": "pan=mono|c0=c0", "right": "pan=mono|c0=c1", + "mix": "pan=mono|c0=0.5*c0+0.5*c1"}[channel]) + if settings.highpass: + input_filters.append(f"highpass=f={settings.highpass_hz}:p=2") + if settings.hum_hz: + input_filters += [f"equalizer=f={settings.hum_hz * n}:t=q:w=25:g=-18" for n in (1, 2, 3)] + ffmpeg_run(ffmpeg, ["-i", str(source), "-map", "0:a:0", "-af", + ",".join(input_filters or ["anull"]), "-ar", "48000", "-ac", "1", + "-c:a", "pcm_f32le", "-rf64", "auto", str(prepared)], "Preparing mono 48 kHz audio", duration, progress) + if settings.denoiser == "deepfilter" and settings.denoise_strength > 0: + from .ai import denoise + denoise(prepared, cleaned, settings.device, settings.denoise_strength, progress) + elif settings.denoiser == "fft" and settings.denoise_strength > 0: + ffmpeg_run(ffmpeg, ["-i", str(prepared), "-af", + f"afftdn=nr={max(0.01, settings.fft_reduction_db * settings.denoise_strength)}:tn=1", + "-c:a", "pcm_f32le", "-rf64", "auto", str(cleaned)], "FFT noise reduction", duration, progress) + else: + cleaned = prepared + clean_stats = measure(cleaned, progress, "Measuring cleaned voice") + if clean_stats["sample_peak_dbfs"] < -100: + raise ValueError("The selected audio is silent or too quiet to master safely; " + "check --channel, the mix, and cleanup settings") + gain = 0.0 + if settings.leveling: + speech_db = clean_stats["speech_level_estimate_dbfs"] + if speech_db <= -110: + warnings.append("Speech level estimate is unreliable; leveling skipped. " + "Inspect levels or rely on normalization.") + else: + desired = settings.level_target_db - speech_db + gain = max(-settings.max_gain_db, min(settings.max_gain_db, desired)) + if abs(desired) > settings.max_gain_db: + warnings.append("Input level correction reached its configured gain limit.") + chain = ([f"volume={gain}dB"] if gain else []) + filters(settings) + ffmpeg_run(ffmpeg, ["-i", str(cleaned), "-af", ",".join(chain), + "-c:a", "pcm_f32le", "-rf64", "auto", str(shaped)], "EQ, dynamics and de-essing", duration, progress) + before = loudness(ffmpeg, shaped, settings, duration, progress, "Loudness analysis (pass 1)") + if settings.normalize and any(before.get(k) is None for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")): + raise ValueError("Cannot normalize: insufficient measurable audio after processing") + final_filters = [] + if settings.normalize: + final_filters.append(f"loudnorm=I={settings.target_lufs}:TP={settings.true_peak_db}:LRA={settings.loudness_range}:" + f"measured_I={before['input_i']}:measured_TP={before['input_tp']}:measured_LRA={before['input_lra']}:" + f"measured_thresh={before['input_thresh']}:offset={before['target_offset']}:linear=true:print_format=json") + elif settings.limiter: + final_filters += ["aresample=192000", f"alimiter=limit={10 ** (settings.true_peak_db / 20)}:level=false:latency=true"] + final_filters.append(f"aresample={settings.sample_rate}:output_sample_bits=24:dither_method=triangular") + master = work / "master.wav" + render_log = ffmpeg_run(ffmpeg, ["-i", str(shaped), "-af", ",".join(final_filters), + "-ar", str(settings.sample_rate), "-c:a", "pcm_s24le", "-rf64", "auto", str(master)], + "Mastering (pass 2)", duration, progress) + # Bounded disk use: drop consumed intermediates instead of waiting for + # the temporary directory teardown at the end of the run. + shaped.unlink(missing_ok=True) + for intermediate in {prepared, cleaned}: + intermediate.unlink(missing_ok=True) + normalization_mode = "disabled" + if settings.normalize: + start, end = render_log.rfind("{"), render_log.rfind("}") + if start < 0 or end < start: + raise RuntimeError("FFmpeg did not report the applied normalization mode") + normalization_mode = json.loads(render_log[start:end + 1])["normalization_type"] + final = loudness(ffmpeg, master, settings, duration, progress, "Verifying exported master") + final_stats = measure(master, progress, "Checking exported samples") + if abs(final_stats["duration_seconds"] - duration) > 0.02: + raise RuntimeError("Output duration differs from input by more than 20 ms") + if (final_stats["channels"] != 1 or final_stats["sample_rate"] != settings.sample_rate + or final_stats["subtype"] != "PCM_24"): + raise RuntimeError("Exported master is not the expected mono PCM_24 WAV") + if settings.normalize and (final["input_i"] is None or abs(final["input_i"] - settings.target_lufs) > 0.5): + warnings.append("Master misses the loudness target by more than 0.5 LU; inspect the report before publishing.") + if settings.limiter and final["input_tp"] is not None and final["input_tp"] > settings.true_peak_db + 0.1: + warnings.append("Master exceeds the true-peak target by more than 0.1 dB; inspect before publishing.") + if final_stats["near_full_scale_samples"]: + warnings.append("Export has near-full-scale samples. Enable limiting or lower gains.") + mp3_stats = None + if settings.mp3: + mp3 = work / "delivery.mp3" + ffmpeg_run(ffmpeg, ["-i", str(master), "-c:a", "libmp3lame", "-b:a", + f"{settings.mp3_bitrate}k", str(mp3)], "Encoding MP3", duration, progress) + mp3_stats = loudness(ffmpeg, mp3, settings, duration, progress, "Verifying decoded MP3") + if mp3_stats["input_tp"] is not None and mp3_stats["input_tp"] > settings.true_peak_db + 0.1: + warnings.append("MP3 encoding increased true peak beyond the configured ceiling; use more headroom.") + report = {"voiceforge_version": __version__, "source": str(source), + "output": str(destination), "settings": asdict(settings), "input": original, + "cleaned": clean_stats, "level_correction_db": gain, + "pre_master_loudness": before, "master_loudness": final, + "master": final_stats, "mp3_loudness": mp3_stats, + "normalization_mode": normalization_mode, + "warnings": warnings} + if provenance: + report["provenance"] = provenance + staged_report = work / "report.json" + staged_report.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n") + # Publish only after all processing and verification succeeded. The + # directory lock serializes publication so concurrent jobs targeting + # the same output directory cannot interleave WAV/report/MP3 sets. + publication_fd = os.open(destination.parent, os.O_RDONLY) + try: + fcntl.flock(publication_fd, fcntl.LOCK_EX) + if settings.mp3: + publish(mp3, destination.with_suffix(".mp3"), overwrite) + publish(staged_report, report_path, overwrite) + publish(master, destination, overwrite) + finally: + os.close(publication_fd) + return report diff --git a/lib/project/src/voiceforge/progress.py b/lib/project/src/voiceforge/progress.py new file mode 100644 index 0000000..a8c5476 --- /dev/null +++ b/lib/project/src/voiceforge/progress.py @@ -0,0 +1,40 @@ +"""A refresh thread keeps elapsed time moving even during native processing.""" +import time + +from rich.console import Console +from rich.progress import (BarColumn, Progress, SpinnerColumn, TaskProgressColumn, + TextColumn, TimeElapsedColumn, TimeRemainingColumn) + + +class Display: + def __init__(self): + self.console = Console(stderr=True) + self.progress = Progress(SpinnerColumn(), TextColumn("{task.description}"), + BarColumn(), TaskProgressColumn(), TimeElapsedColumn(), + TimeRemainingColumn(), console=self.console, + refresh_per_second=5, disable=not self.console.is_terminal) + self.task = None + self.stage = "" + self.started = time.monotonic() + self.last_print = 0.0 + + def __enter__(self): + self.progress.start() + return self + + def __exit__(self, *args): + self.progress.stop() + + def update(self, stage, completed=None, total=None): + changed = stage != self.stage + if changed: + if self.task is not None: + self.progress.remove_task(self.task) + self.task = self.progress.add_task(stage, total=total) + self.stage = stage + self.progress.update(self.task, completed=completed if total else 0, total=total) + now = time.monotonic() + if not self.console.is_terminal and (changed or now - self.last_print >= 5): + fraction = f" {min(100, 100 * (completed or 0) / total):.0f}%" if total else "" + self.console.print(f"[{now - self.started:7.1f}s] {stage}{fraction}", markup=False) + self.last_print = now diff --git a/lib/project/src/voiceforge/setup.py b/lib/project/src/voiceforge/setup.py new file mode 100644 index 0000000..9afeb44 --- /dev/null +++ b/lib/project/src/voiceforge/setup.py @@ -0,0 +1,203 @@ +"""User-local tools. No sudo and no changes to the application's interpreter.""" +from __future__ import annotations + +from collections import deque +from collections.abc import Callable +import json +import os +from pathlib import Path +import selectors +import shutil +import signal +import subprocess +import sys +import time + +Progress = Callable[[str, float | None, float | None], None] + + +def data_dir() -> Path: + """The VoiceForge data/prefix directory. + + VOICEFORGE_HOME wins (the single-directory launcher exports it), then + XDG_DATA_HOME/voiceforge, then ~/.local/share/voiceforge. A relative + VOICEFORGE_HOME resolves against the current directory. + """ + home = os.environ.get("VOICEFORGE_HOME") + if home: + return Path(home).absolute() + root = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) + if not root.is_absolute(): + root = Path.home() / ".local/share" + return root / "voiceforge" + + +def tool_env() -> dict[str, str]: + """Sanitized environment for bootstrap, setup, and worker subprocesses. + + Host Python settings are removed so a virtualenv or sitecustomize on the + machine cannot leak into managed environments. Installation-target, + index, and constraint overrides are dropped and package-manager + configuration files disabled, so packages can only come from the + explicitly passed trusted indexes and land inside the data directory; + every cache (pip, uv, managed Python, XDG, temporary files) stays there + too. + """ + env = os.environ.copy() + for name in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", + "PIP_TARGET", "PIP_PREFIX", "PIP_USER", "PIP_FIND_LINKS", + "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONSTRAINT", + "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", + "UV_INDEX", "UV_FIND_LINKS", "UV_CONSTRAINT"): + env.pop(name, None) + env["PYTHONNOUSERSITE"] = "1" + env["PIP_CONFIG_FILE"] = os.devnull + env["UV_NO_CONFIG"] = "1" + cache = data_dir() / "cache" + env["XDG_CACHE_HOME"] = str(cache) + env["PIP_CACHE_DIR"] = str(cache / "pip") + env["UV_CACHE_DIR"] = str(cache / "uv") + env["UV_PYTHON_INSTALL_DIR"] = str(data_dir() / "uv/python") + tmp = data_dir() / "tmp" + try: + tmp.mkdir(parents=True, exist_ok=True) + env["TMPDIR"] = str(tmp) + except OSError: + pass # Best effort: never break subprocesses over temporary-file containment. + return env + + +def terminate_group(process: subprocess.Popen) -> None: + """SIGTERM the child's whole process group, escalate to SIGKILL, and reap. + + The group is signalled even when the leader has already exited, because + descendants can outlive it while still holding the output pipe open. + A race between exit and signalling is tolerated. + """ + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + pass + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + continue + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + return + process.wait() + + +def run_process(args: list[str], stage: str, progress: Progress | None = None, + *, env: dict[str, str] | None = None) -> None: + """Drain output without blocking; send heartbeat callbacks every 0.25s. + + Worker JSON records carry frame/byte counts. Other processes report elapsed seconds + as completed with total=None. Only the last 64 KiB of diagnostics are retained. + Callback exceptions (including cancellation) terminate the process group. + """ + started = time.monotonic() + current_stage, completed, total = stage, None, None + diagnostics: deque[bytes] = deque(maxlen=16) + pending = b"" + if progress: + progress(stage, None, None) + with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=env, start_new_session=True) as process: + assert process.stdout is not None + os.set_blocking(process.stdout.fileno(), False) + try: + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + while selector.get_map() or process.poll() is None: + for key, _ in selector.select(timeout=0.25): + chunk = os.read(key.fd, 4096) + if not chunk: + selector.unregister(key.fileobj) + continue + diagnostics.append(chunk) + pending += chunk + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + if line.startswith(b"VOICEFORGE_PROGRESS "): + event = json.loads(line[len(b"VOICEFORGE_PROGRESS "):]) + current_stage, completed, total = event + if len(pending) > 65536: + pending = pending[-4096:] + if progress: + progress(current_stage, completed if total is not None else + time.monotonic() - started, total) + code = process.wait() + if code: + detail = b"".join(diagnostics).decode("utf-8", errors="replace") + raise RuntimeError(f"{stage} failed (exit {code}):\n{detail}") + except BaseException: + terminate_group(process) + raise + + +def _uv_supports_relocatable(uv: str) -> bool: + """Whether this uv understands the venv flags AI setup relies on. + + A host uv can be older than the pinned one; capability is checked + directly instead of trusting the version string. + """ + try: + result = subprocess.run([uv, "venv", "--help"], capture_output=True, + text=True, check=True) + except (OSError, subprocess.SubprocessError): + return False + return "--relocatable" in result.stdout + result.stderr + + +def ensure_uv(progress: Progress | None = None) -> str: + """Return a uv that supports the flags AI setup relies on. + + The launcher's contained bootstrap uv is preferred; a host uv is trusted + only if it actually supports --relocatable. Otherwise uv==0.8.17 is + installed from PyPI (trust PyPI's distribution, not a downloaded shell + script). + """ + contained = data_dir() / "bootstrap/bin/uv" + if contained.is_file() and os.access(contained, os.X_OK): + return str(contained) + found = shutil.which("uv") + if found and _uv_supports_relocatable(found): + return found + root = contained.parent.parent + root.parent.mkdir(parents=True, exist_ok=True) + python = sys.executable or shutil.which("python3") + env = tool_env() + run_process([python, "-m", "venv", str(root)], "Creating uv bootstrap", + progress, env=env) + run_process([str(root / "bin/python"), "-m", "pip", "install", + "--index-url", "https://pypi.org/simple", "uv==0.8.17"], + "Installing uv from PyPI", progress, env=env) + if not (contained.is_file() and os.access(contained, os.X_OK)): + raise RuntimeError("Installing uv from PyPI did not produce a usable " + "uv; check for host pip settings (such as " + "PIP_TARGET) that redirect installations outside " + "the data directory.") + return str(contained) + + +def ensure_ffmpeg() -> str: + """Return the bundled FFmpeg, or a system one where the bundle is + absent, not ffprobe. + + The launcher installs imageio-ffmpeg via the bundled-ffmpeg extra; direct + package installs should declare it as a dependency. This helper never + runs pip in the current interpreter. + """ + try: + import imageio_ffmpeg + return imageio_ffmpeg.get_ffmpeg_exe() + except (ImportError, RuntimeError) as error: + found = shutil.which("ffmpeg") + if found: + return found + raise RuntimeError("FFmpeg is unavailable. Rebuild the contained " + "runtime with './producer.sh --rebuild', or " + "install a system FFmpeg.") from error diff --git a/lib/project/tests/conftest.py b/lib/project/tests/conftest.py new file mode 100644 index 0000000..7c75358 --- /dev/null +++ b/lib/project/tests/conftest.py @@ -0,0 +1,43 @@ +from pathlib import Path +import sys + +import numpy as np +import pytest +import soundfile as sf + +# Support running the suite before the project is installed. +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from voiceforge.config import resolve +from voiceforge.setup import ensure_ffmpeg + + +@pytest.fixture +def progress(): + return lambda *_: None + + +@pytest.fixture +def wav(tmp_path): + def write(samples=None, *, rate=48000, name="input.wav"): + if samples is None: + time = np.arange(rate * 3) / rate + samples = 0.15 * np.sin(2 * np.pi * 440 * time) + path = tmp_path / name + sf.write(path, np.asarray(samples), rate, format="WAV", subtype="FLOAT") + return path + + return write + + +@pytest.fixture +def bypass(): + return resolve(profile="cleanup-only", overrides={"denoiser": "none", "highpass": False}) + + +@pytest.fixture +def ffmpeg(): + try: + return ensure_ffmpeg() + except RuntimeError as error: + pytest.skip(str(error)) diff --git a/lib/project/tests/test_ai.py b/lib/project/tests/test_ai.py new file mode 100644 index 0000000..e8c5872 --- /dev/null +++ b/lib/project/tests/test_ai.py @@ -0,0 +1,366 @@ +"""Offline AI contract tests; no Torch installation, model download, or GPU needed.""" +import hashlib +import io +import json +import os +from pathlib import Path +import sys +import time +from types import SimpleNamespace +from unittest.mock import Mock +import zipfile + +import numpy as np +import pytest +import soundfile as sf + +from voiceforge import ai, ai_worker, setup + + +MODEL_BYTES = 7986207 +MODEL_HASH = "49c52edc8947ae1f9bf50d81530beaf3a2c3245aeaf34b6f31ff535cd22284d2" + + +@pytest.mark.parametrize("size,message", [ + (10, "checksum mismatch"), + (MODEL_BYTES, "checksum mismatch"), + (MODEL_BYTES + 1, "exceeds the expected size"), +]) +def test_model_rejects_truncated_corrupt_and_oversized_downloads(tmp_path, monkeypatch, size, message): + monkeypatch.setattr(ai_worker.urllib.request, "urlopen", Mock(return_value=io.BytesIO(b"x" * size))) + progress = Mock() + monkeypatch.setattr(ai_worker, "report", progress) + with pytest.raises(RuntimeError, match=message): + ai_worker.ensure_model(tmp_path / "model") + assert list(tmp_path.iterdir()) == [] + events = [call.args for call in progress.call_args_list] + assert events[0] == ("Downloading DeepFilterNet3 model (bytes)", 0, MODEL_BYTES) + counts = [completed for _, completed, total in events if total == MODEL_BYTES] + assert counts == sorted(counts) + assert all(0 <= count <= MODEL_BYTES for count in counts) + + +@pytest.mark.parametrize("unsafe", [False, True]) +def test_mocked_model_archive_progress_extraction_and_reuse(tmp_path, monkeypatch, unsafe): + # Mock only the release digest for a synthetic archive; corruption tests above + # exercise the real SHA-256 gate. Keep its real byte count and ZIP extraction. + def archive(padding): + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w") as bundle: + bundle.writestr("DeepFilterNet3/config.ini", "[df]\nsr=48000\n") + bundle.writestr("DeepFilterNet3/checkpoints/model_120.ckpt.best", "checkpoint") + bundle.writestr("../escape" if unsafe else "padding", b"x" * padding) + return stream.getvalue() + + payload = archive(MODEL_BYTES - len(archive(0))) + assert len(payload) == MODEL_BYTES + digest = Mock(wraps=hashlib.sha256()) + digest.hexdigest.return_value = MODEL_HASH + monkeypatch.setattr(ai_worker.hashlib, "sha256", lambda: digest) + download = Mock(return_value=io.BytesIO(payload)) + monkeypatch.setattr(ai_worker.urllib.request, "urlopen", download) + progress = Mock() + monkeypatch.setattr(ai_worker, "report", progress) + directory = tmp_path / "model" + if unsafe: + with pytest.raises(RuntimeError, match="Unsafe path"): + ai_worker.ensure_model(directory) + assert not (tmp_path / "escape").exists() + assert list(tmp_path.iterdir()) == [] + else: + model = ai_worker.ensure_model(directory) + assert (model / "config.ini").is_file() + assert (model / "checkpoints/model_120.ckpt.best").is_file() + assert ai_worker.ensure_model(directory) == model + download.assert_called_once() + assert b"".join(call.args[0] for call in digest.update.call_args_list) == payload + events = [call.args for call in progress.call_args_list] + byte_events = [event for event in events if "(bytes)" in event[0]] + assert byte_events[0][1:] == (0, MODEL_BYTES) + assert byte_events[-1][1:] == (MODEL_BYTES, MODEL_BYTES) + assert len(byte_events) > 2 + assert events[-1] == ("Verifying and extracting DeepFilterNet3 model",) + + +def test_incomplete_model_fails_without_network(tmp_path, monkeypatch): + directory = tmp_path / "model" + directory.mkdir() + download = Mock(side_effect=AssertionError("Unexpected network call")) + monkeypatch.setattr(ai_worker.urllib.request, "urlopen", download) + with pytest.raises(RuntimeError, match="Incomplete model directory"): + ai_worker.ensure_model(directory) + download.assert_not_called() + + +@pytest.mark.parametrize("close_output", [False, True]) +def test_subprocess_heartbeat_does_not_block_on_silence_or_closed_output(close_output): + events = [] + code = "import os,time; " + if close_output: + code += "os.close(1); os.close(2); " + code += "time.sleep(0.8)" + setup.run_process([sys.executable, "-c", code], "Waiting", lambda *event: events.append(event)) + assert events[0] == ("Waiting", None, None) + elapsed = [completed for _, completed, total in events[1:] if total is None] + assert len(elapsed) >= 2 + assert elapsed == sorted(elapsed) + + +def test_subprocess_parses_split_byte_progress_records(): + events = [] + record = "VOICEFORGE_PROGRESS " + json.dumps(["Download (bytes)", 123, 456]) + "\n" + code = f"import os,time; os.write(1,{record[:12].encode()!r}); time.sleep(.3); os.write(1,{record[12:].encode()!r})" + setup.run_process([sys.executable, "-c", code], "Download", lambda *event: events.append(event)) + assert events[-1] == ("Download (bytes)", 123, 456) + + +@pytest.mark.parametrize("exception", [KeyboardInterrupt, RuntimeError]) +def test_callback_cancellation_terminates_and_reaps_child(monkeypatch, exception): + popen = setup.subprocess.Popen + children = [] + + def launch(*args, **kwargs): + child = popen(*args, **kwargs) + children.append(child) + return child + + def cancel(stage, completed, total): + if completed is not None: + raise exception("cancelled") + + monkeypatch.setattr(setup.subprocess, "Popen", launch) + started = time.monotonic() + with pytest.raises(exception, match="cancelled"): + setup.run_process([sys.executable, "-c", "import time; time.sleep(30)"], "Waiting", cancel) + assert len(children) == 1 and children[0].poll() is not None + assert time.monotonic() - started < 10 + + +def test_cancellation_kills_descendants_that_outlive_the_leader(tmp_path, monkeypatch): + sentinel = tmp_path / "grandchild.pid" + monkeypatch.setenv("GRANDCHILD_PID_FILE", str(sentinel)) + child_code = ( + "import os, subprocess, sys, time\n" + "subprocess.Popen([sys.executable, '-c', " + "\"import os, time; open(os.environ['GRANDCHILD_PID_FILE'], 'w')" + ".write(str(os.getpid())); time.sleep(60)\"])\n" + "while not os.path.exists(os.environ['GRANDCHILD_PID_FILE']):\n" + " time.sleep(0.02)\n" + ) + + def cancel(stage, completed, total): + if completed is not None and children and children[0].poll() is not None: + raise KeyboardInterrupt("cancelled") + + popen = setup.subprocess.Popen + children = [] + + def launch(*args, **kwargs): + child = popen(*args, **kwargs) + children.append(child) + return child + + monkeypatch.setattr(setup.subprocess, "Popen", launch) + with pytest.raises(KeyboardInterrupt, match="cancelled"): + setup.run_process([sys.executable, "-c", child_code], "Waiting", cancel) + + def alive(pid): + try: + for line in Path(f"/proc/{pid}/status").read_text().splitlines(): + if line.startswith("State:"): + return "Z" not in line + return True + except FileNotFoundError: + return False + + grandchild = int(sentinel.read_text()) + for _ in range(50): + if not alive(grandchild): + break + time.sleep(0.1) + else: + pytest.fail("A descendant survived the cancellation of its process group") + + +def test_subprocess_failure_keeps_bounded_diagnostics(): + code = "import sys; print('x'*100000); print('specific failure'); sys.exit(7)" + with pytest.raises(RuntimeError, match="exit 7") as error: + setup.run_process([sys.executable, "-c", code], "Worker") + assert "specific failure" in str(error.value) + assert len(str(error.value)) < 66000 + + +@pytest.mark.parametrize("strength", [-1, 2, float("nan"), float("inf")]) +def test_invalid_strength_fails_before_setup(tmp_path, monkeypatch, strength): + install = Mock(side_effect=AssertionError("Unexpected setup")) + monkeypatch.setattr(ai, "ensure_ai", install) + with pytest.raises(ValueError, match="strength"): + ai.denoise(tmp_path / "input.wav", tmp_path / "output.wav", strength=strength) + install.assert_not_called() + + +def _ready_environment(root, flavor="cpu"): + environment = root / f"ai-df-0.5.6-torch-2.5.1-{flavor}-py311-v1" + (environment / "bin").mkdir(parents=True) + (environment / "bin/python").write_text("#!/bin/sh\n") + (environment / ".voiceforge-ready").touch() + return environment + + +def test_ready_environment_skips_the_check_worker(tmp_path, monkeypatch): + root = tmp_path / "prefix" + environment = _ready_environment(root) + monkeypatch.setenv("VOICEFORGE_HOME", str(root)) + monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39") + executed = [] + monkeypatch.setattr(ai, "run_process", lambda args, *rest, **kwargs: executed.append(args)) + python = ai.ensure_ai("cpu") + assert executed == [] + assert python == environment / "bin/python" + + +def test_setup_verifies_even_a_ready_environment(tmp_path, monkeypatch): + root = tmp_path / "prefix" + _ready_environment(root) + monkeypatch.setenv("VOICEFORGE_HOME", str(root)) + monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39") + executed = [] + monkeypatch.setattr(ai, "run_process", lambda args, *rest, **kwargs: executed.append(args)) + ai.ensure_ai("cpu", verify=True) + assert len(executed) == 1 + assert executed[0][-1] == "--check" + + +def test_broken_environment_is_rebuilt_and_self_tested(tmp_path, monkeypatch): + root = tmp_path / "prefix" + environment = _ready_environment(root) + (environment / "bin/python").unlink() + (environment / "bin/python").symlink_to("/nonexistent/python3.11") + leftover = environment / "leftover.txt" + leftover.write_text("from the previous location") + monkeypatch.setenv("VOICEFORGE_HOME", str(root)) + monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39") + stages, commands = [], [] + + def fake_run(args, stage, progress=None, **kwargs): + commands.append(args) + stages.append(stage) + if stage == "Checking DeepFilterNet3": + environment.mkdir(parents=True, exist_ok=True) + (environment / ".voiceforge-ready").touch() + + monkeypatch.setattr(ai, "run_process", fake_run) + monkeypatch.setattr(ai, "ensure_uv", lambda progress=None: "uv") + ai.ensure_ai("cpu") + assert not leftover.exists() + assert stages == ["Preparing AI Python 3.11", "Installing PyTorch (cpu)", + "Installing DeepFilterNet3", "Checking DeepFilterNet3"] + assert commands[0][-1] == str(environment) + assert (environment / ".voiceforge-ready").is_file() + + +def test_copied_environment_is_rebuilt_not_trusted(tmp_path, monkeypatch): + # Copying an installation while the original remains leaves the copy's + # interpreter links resolving into the original prefix; that must not + # count as ready, or the copy breaks when the original is removed. + root = tmp_path / "prefix" + environment = _ready_environment(root) + original = _ready_environment(tmp_path / "other") + (environment / "bin/python").unlink() + (environment / "bin/python").symlink_to(original / "bin/python") + monkeypatch.setenv("VOICEFORGE_HOME", str(root)) + monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39") + stages = [] + + def fake_run(args, stage, progress=None, **kwargs): + stages.append(stage) + if stage == "Checking DeepFilterNet3": + environment.mkdir(parents=True, exist_ok=True) + (environment / ".voiceforge-ready").touch() + + monkeypatch.setattr(ai, "run_process", fake_run) + monkeypatch.setattr(ai, "ensure_uv", lambda progress=None: "uv") + ai.ensure_ai("cpu") + assert stages[0] == "Preparing AI Python 3.11" + + +@pytest.fixture +def mocked_worker(tmp_path, monkeypatch): + class Tensor: + def __init__(self, array): + self.array = array + + def unsqueeze(self, axis): + return Tensor(np.expand_dims(self.array, axis)) + + def squeeze(self, axis): + return Tensor(np.squeeze(self.array, axis)) + + def numpy(self): + return self.array + + calls = [] + + def enhance(model, state, tensor, pad): + assert pad is True + assert tensor.array.shape[1] % 480 == 0 + calls.append(tensor.array.shape[1]) + return Tensor(tensor.array * 0.5) + + state = SimpleNamespace(sr=lambda: 48000) + backend = SimpleNamespace(enhance=enhance, init_df=lambda *a, **kw: (None, state, None)) + for name, module in { + "torch": SimpleNamespace(set_num_threads=lambda n: None, from_numpy=Tensor, + cuda=SimpleNamespace(is_available=lambda: False)), + "df": SimpleNamespace(), + "df.enhance": backend, + "df.model": SimpleNamespace(ModelParams=lambda: SimpleNamespace( + sr=48000, fft_size=960, hop_size=480, nb_erb=32, min_nb_freqs=2)), + "libdf": SimpleNamespace(DF=lambda **kw: state), + }.items(): + monkeypatch.setitem(sys.modules, name, module) + monkeypatch.setattr(ai_worker, "ensure_model", lambda path: path) + monkeypatch.setattr(ai_worker.signal, "signal", lambda *args: None) + monkeypatch.setenv("DEVICE", "cpu") + progress = Mock() + monkeypatch.setattr(ai_worker, "report", progress) + + def run(source, target, strength): + monkeypatch.setattr(sys, "argv", ["worker", "--device", "cpu", "--model-dir", str(tmp_path), + "--source", str(source), "--target", str(target), + "--strength", str(strength)]) + ai_worker.main() + + return run, calls, backend, progress + + +@pytest.mark.parametrize("frames", [0, 1, 479, 481, 480001, 960017]) +@pytest.mark.parametrize("strength", [0, 0.85, 1]) +def test_mocked_worker_exact_length_mix_and_bounded_windows(tmp_path, wav, mocked_worker, frames, strength): + run, calls, _, progress = mocked_worker + audio = np.random.default_rng(42).normal(0, 0.1, frames).astype("float32") + source = wav(audio) + target = tmp_path / "clean.wav" + run(source, target, strength) + result, rate = sf.read(target, dtype="float32") + assert len(result) == frames and rate == 48000 + assert sf.info(target).subtype == "FLOAT" + np.testing.assert_allclose(result, audio * (1 - strength * 0.5), rtol=2e-6, atol=1e-8) + if strength == 0: + np.testing.assert_array_equal(result, audio) + assert calls == [] + assert max(calls, default=0) <= 674400 + assert progress.call_args.args == ("Denoising (cpu)", frames, frames) + assert not list(tmp_path.glob(".voiceforge-ai-*")) + + +def test_worker_failure_preserves_existing_target(tmp_path, wav, mocked_worker): + run, _, backend, _ = mocked_worker + backend.enhance = Mock(side_effect=RuntimeError("inference failed")) + source = wav() + target = tmp_path / "existing.wav" + target.write_bytes(b"existing output") + with pytest.raises(RuntimeError, match="inference failed"): + run(source, target, 0.85) + assert target.read_bytes() == b"existing output" + assert not list(tmp_path.glob(".voiceforge-ai-*")) diff --git a/lib/project/tests/test_cli.py b/lib/project/tests/test_cli.py new file mode 100644 index 0000000..a5437b7 --- /dev/null +++ b/lib/project/tests/test_cli.py @@ -0,0 +1,287 @@ +import json +from pathlib import Path +import sys + +import numpy as np +import pytest +import soundfile as sf + +from voiceforge import cli, pipeline + + +@pytest.fixture +def invoke(monkeypatch): + def run(*args): + monkeypatch.setattr(sys, "argv", ["voiceforge", *map(str, args)]) + return cli.main() + + return run + + +def test_preview_real_audio_preserves_original_and_reports_provenance( + wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch +): + time = np.arange(48000 * 7) / 48000 + source = wav(0.15 * np.sin(2 * np.pi * 440 * time)) + original = source.read_bytes() + output_dir = tmp_path / "previews" + modes = [] + real_run = pipeline.ffmpeg_run + + def record_render(*args, **kwargs): + log = real_run(*args, **kwargs) + if args[2] == "Mastering (pass 2)": + modes.append(json.loads(log[log.rfind("{"):log.rfind("}") + 1])["normalization_type"]) + return log + + monkeypatch.setattr(pipeline, "ffmpeg_run", record_render) + invoke("preview", source, "--no-denoise", "--start", "1", "--duration", "5", + "--profiles", "natural", "radio", "--no-normalize", "--no-limiter", + "--output-dir", output_dir) + + captured = capsys.readouterr() + assert captured.out == "" + assert "Preview:" in captured.err + assert source.read_bytes() == original + assert len(modes) == 2 + assert {path.name for path in output_dir.iterdir()} == { + f"input.{profile}.preview{suffix}" + for profile in ("natural", "radio") for suffix in (".wav", ".report.json") + } + for profile, mode in zip(("natural", "radio"), modes): + target = output_dir / f"input.{profile}.preview.wav" + report = json.loads(target.with_suffix(".report.json").read_text()) + info = sf.info(target) + assert info.duration == pytest.approx(5, abs=0.02) + assert info.channels == 1 + assert info.subtype == "PCM_24" + assert report["output"] == str(target) + assert report["provenance"] == { + "original": str(source.resolve()), "preview_start_seconds": 1, + "requested_duration_seconds": 5, "normalization_forced": True, + } + assert not Path(report["source"]).exists() + assert report["settings"]["profile"] == profile + assert report["settings"]["denoiser"] == "none" + assert report["settings"]["normalize"] is True + assert report["settings"]["limiter"] is True + assert report["settings"]["compressor_ratio"] == {"natural": 2.0, "radio": 3.5}[profile] + assert report["normalization_mode"] == mode + assert report["master_loudness"]["input_i"] == pytest.approx(-19, abs=0.5) + assert report["master_loudness"]["input_tp"] <= -1.4 + + +def test_config_stdout_json_and_set_precedence(tmp_path, invoke, capsys): + config = tmp_path / "settings.toml" + config.write_text('profile = "cleanup-only"\ntarget_lufs = -24.0\n') + invoke("config", "--json", "--config", config, "--profile", "narrator", + "--target-lufs", "-21", "--set", "target_lufs=-18", + "--set", "profile=radio", "--no-denoise") + captured = capsys.readouterr() + settings = json.loads(captured.out) + assert captured.err == "" + assert settings["profile"] == "radio" + assert settings["compressor_ratio"] == 3.5 + assert settings["compressor_attack_ms"] == 8 + assert settings["normalize"] is True + assert settings["target_lufs"] == -18 + assert settings["denoiser"] == "none" + + +def test_analyze_stdout_is_json(wav, ffmpeg, invoke, capsys): + source = wav() + original = source.read_bytes() + invoke("analyze", source, "--no-denoise") + stats = json.loads(capsys.readouterr().out) + assert stats["duration_seconds"] == pytest.approx(3) + assert stats["channels"] == 1 + assert stats["frames"] == 144000 + assert np.isfinite(stats["loudness"]["input_i"]) + assert source.read_bytes() == original + assert list(source.parent.iterdir()) == [source] + + +@pytest.mark.parametrize("start", ["nan", "inf", "-inf"]) +def test_preview_rejects_nonfinite_start_before_tool_setup( + tmp_path, invoke, capsys, monkeypatch, start +): + monkeypatch.setattr(cli, "ensure_ffmpeg", lambda: pytest.fail("Invalid start reached FFmpeg")) + output_dir = tmp_path / "previews" + with pytest.raises(SystemExit) as error: + invoke("preview", tmp_path / "missing.wav", f"--start={start}", + "--output-dir", output_dir) + assert error.value.code == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "Preview start" in captured.err + assert not output_dir.exists() + + +def test_unknown_setting_is_a_cli_error(invoke, capsys): + with pytest.raises(SystemExit) as error: + invoke("config", "--json", "--set", "unknown_setting=1") + assert error.value.code == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "Unknown setting(s): unknown_setting" in captured.err + + +def test_missing_input_fails_before_tool_setup(tmp_path, invoke, capsys, monkeypatch): + monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Missing input reached FFmpeg")) + target = tmp_path / "master.wav" + with pytest.raises(SystemExit) as error: + invoke("process", tmp_path / "missing.wav", "--no-denoise", "-o", target) + assert error.value.code == 1 + assert "existing WAV file" in capsys.readouterr().err + assert not target.exists() + + +def test_existing_output_is_not_overwritten(wav, tmp_path, invoke, capsys, monkeypatch): + source = wav() + original = source.read_bytes() + target = tmp_path / "master.wav" + target.write_bytes(b"previous master") + monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Existing output reached FFmpeg")) + with pytest.raises(SystemExit) as error: + invoke("process", source, "--no-denoise", "-o", target) + assert error.value.code == 1 + assert "Output exists" in capsys.readouterr().err + assert target.read_bytes() == b"previous master" + assert source.read_bytes() == original + assert not target.with_suffix(".report.json").exists() + + +def test_overwrite_cannot_replace_input(wav, invoke, capsys, monkeypatch): + source = wav() + original = source.read_bytes() + monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Input collision reached processing")) + with pytest.raises(SystemExit) as error: + invoke("process", source, "-o", source, "--overwrite") + assert error.value.code == 1 + assert "overwrite an input recording" in capsys.readouterr().err + assert source.read_bytes() == original + + +def test_duplicate_batch_names_fail_before_processing(wav, tmp_path, invoke, capsys, monkeypatch): + (tmp_path / "other").mkdir() + sources = [wav(), wav(name="other/input.wav")] + originals = [source.read_bytes() for source in sources] + output_dir = tmp_path / "masters" + monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Duplicate names reached processing")) + with pytest.raises(SystemExit) as error: + invoke("process", *sources, "--output-dir", output_dir, "--overwrite") + assert error.value.code == 1 + assert "duplicate output names" in capsys.readouterr().err + assert not output_dir.exists() + assert [source.read_bytes() for source in sources] == originals + + +def test_default_outputs_are_written_beside_each_input(wav, tmp_path, ffmpeg, invoke, capsys): + (tmp_path / "a").mkdir() + (tmp_path / "b").mkdir() + sources = [wav(name="a/take.wav"), wav(name="b/take.wav")] + invoke("process", *sources, "--no-denoise") + for source in sources: + assert {path.name for path in source.parent.iterdir()} == { + "take.wav", "take.natural.wav", "take.natural.report.json"} + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err.count("Written:") == 2 + + +def test_output_and_output_dir_cannot_be_combined(wav, tmp_path, invoke, capsys): + with pytest.raises(SystemExit) as error: + invoke("process", wav(), "-o", tmp_path / "x.wav", "--output-dir", tmp_path / "outs") + assert error.value.code == 1 + assert "--output-dir cannot be combined" in capsys.readouterr().err + + +def test_missing_later_input_fails_before_any_processing(wav, tmp_path, invoke, capsys, monkeypatch): + source = wav() + monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Preflight must reject the batch")) + with pytest.raises(SystemExit) as error: + invoke("process", source, tmp_path / "missing.wav", "--no-denoise") + assert error.value.code == 1 + assert "existing WAV file" in capsys.readouterr().err + assert not (tmp_path / "input.natural.wav").exists() + + +def test_existing_output_in_batch_fails_before_any_processing(wav, tmp_path, invoke, capsys, monkeypatch): + (tmp_path / "a").mkdir() + (tmp_path / "b").mkdir() + sources = [wav(name="a/take.wav"), wav(name="b/take.wav")] + (tmp_path / "b" / "take.natural.wav").write_bytes(b"previous result") + monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Preflight must reject the batch")) + with pytest.raises(SystemExit) as error: + invoke("process", *sources, "--no-denoise") + assert error.value.code == 1 + assert "Output exists" in capsys.readouterr().err + assert not (tmp_path / "a" / "take.natural.wav").exists() + + +def test_preview_rejects_duplicate_profiles_and_profile_override(wav, invoke, capsys, monkeypatch): + source = wav() + monkeypatch.setattr(cli, "ensure_ffmpeg", lambda: pytest.fail("Invalid preview plan reached FFmpeg")) + with pytest.raises(SystemExit) as error: + invoke("preview", source, "--no-denoise", "--profiles", "natural", "natural") + assert error.value.code == 1 + assert "unique" in capsys.readouterr().err + with pytest.raises(SystemExit) as error: + invoke("preview", source, "--no-denoise", "--profiles", "natural", + "--set", "profile=radio") + assert error.value.code == 1 + assert "remove the profile override" in capsys.readouterr().err + assert list(source.parent.iterdir()) == [source] + + +def test_preview_default_output_is_written_beside_input(wav, tmp_path, ffmpeg, invoke, capsys): + source = wav() + original = source.read_bytes() + invoke("preview", source, "--no-denoise", "--start", "1", "--duration", "2", + "--profiles", "natural") + assert {path.name for path in tmp_path.iterdir()} == { + "input.wav", "input.natural.preview.wav", "input.natural.preview.report.json"} + report = json.loads((tmp_path / "input.natural.preview.report.json").read_text()) + assert report["settings"]["profile"] == "natural" + assert source.read_bytes() == original + + +def test_preview_cancellation_cleans_excerpt(wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch): + source = wav() + original = source.read_bytes() + output_dir = tmp_path / "previews" + excerpts = [] + + def cancel(excerpt, *args, **kwargs): + assert excerpt.is_file() + excerpts.append(excerpt) + raise KeyboardInterrupt + + monkeypatch.setattr(cli, "process", cancel) + with pytest.raises(SystemExit) as error: + invoke("preview", source, "--no-denoise", "--output-dir", output_dir) + assert error.value.code == 130 + assert "Cancelled" in capsys.readouterr().err + assert len(excerpts) == 1 + assert not excerpts[0].exists() + assert list(output_dir.iterdir()) == [] + assert source.read_bytes() == original + + +@pytest.mark.parametrize("suffix", [".wav", ".report.json", ".mp3"]) +def test_nonfile_output_with_overwrite_fails_before_tool_setup( + wav, tmp_path, bypass, progress, monkeypatch, suffix +): + source = wav() + original = source.read_bytes() + target = tmp_path / "master.wav" + nonfile = target.with_suffix(suffix) + nonfile.mkdir() + bypass.mp3 = True + monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Non-file output reached FFmpeg")) + with pytest.raises(ValueError, match="Output is not a regular file"): + pipeline.process(source, target, bypass, progress, overwrite=True) + assert nonfile.is_dir() + assert list(nonfile.iterdir()) == [] + assert set(tmp_path.iterdir()) == {source, nonfile} + assert source.read_bytes() == original diff --git a/lib/project/tests/test_config.py b/lib/project/tests/test_config.py new file mode 100644 index 0000000..8144fc8 --- /dev/null +++ b/lib/project/tests/test_config.py @@ -0,0 +1,134 @@ +from dataclasses import asdict, fields + +import pytest + +from voiceforge.cli import main, parser, settings_for +from voiceforge.config import PROFILES, RANGES, Settings, resolve, toml +from voiceforge.pipeline import filters + + +@pytest.mark.parametrize("profile", PROFILES) +def test_profiles_and_toml_round_trip(profile, tmp_path): + settings = resolve(profile=profile) + assert asdict(settings) == asdict(Settings()) | PROFILES[profile] | {"profile": profile} + path = tmp_path / "settings.toml" + path.write_text(toml(settings)) + assert resolve(path) == settings + + +def test_precedence_defaults_profile_file_flags_then_set(tmp_path): + path = tmp_path / "settings.toml" + path.write_text('profile = "narrator"\ncompressor_ratio = 2.2\nwarmth_db = 3\n') + args = parser().parse_args([ + "config", "--config", str(path), "--profile", "radio", + "--warmth-db", "4", "--set", "warmth_db=5", "--set", "warmth_db=6", + ]) + settings = settings_for(args) + assert settings.profile == "radio" + assert settings.compressor_attack_ms == 8 # Selected profile beats file profile. + assert settings.compressor_ratio == 2.2 # File values beat profile defaults. + assert settings.warmth_db == 6 # Last --set beats named flags. + assert settings.sample_rate == 48000 + + +@pytest.mark.parametrize("field", [f.name for f in fields(Settings) if type(getattr(Settings(), f.name)) is bool]) +def test_boolean_flags_preserve_unspecified_and_accept_both_forms(field): + assert getattr(parser().parse_args(["config"]), field) is None + for enabled in (True, False): + flag = "--" + ("" if enabled else "no-") + field.replace("_", "-") + extra = ["--limiter"] if field == "normalize" and enabled else [] + args = parser().parse_args(["config", "--profile", "cleanup-only", flag, *extra]) + assert getattr(settings_for(args), field) is enabled + + +def test_false_cli_toggle_overrides_true_file_value(tmp_path): + path = tmp_path / "settings.toml" + path.write_text("eq = true\n") + args = parser().parse_args(["config", "--config", str(path), "--no-eq"]) + assert settings_for(args).eq is False + + +def test_no_denoise_and_unquoted_set_strings(): + assert settings_for(parser().parse_args(["config", "--no-denoise"])).denoiser == "none" + args = parser().parse_args(["config", "--no-denoise", "--set", "denoiser=fft"]) + assert settings_for(args).denoiser == "fft" + + +@pytest.mark.parametrize("key", RANGES) +def test_numeric_ranges_are_inclusive_and_reject_outside(key): + low, high = RANGES[key] + for value in (low, high): + assert getattr(resolve(overrides={key: value}), key) == value + for value in (low - 0.01, high + 0.01): + with pytest.raises(ValueError, match=key): + resolve(overrides={key: value}) + + +@pytest.mark.parametrize("overrides,match", [ + ({"typo": True}, "Unknown setting"), + ({"highpass": 1}, "highpass must be bool"), + ({"sample_rate": 48000.0}, "sample_rate must be int"), + ({"target_lufs": True}, "finite number"), + ({"target_lufs": float("nan")}, "finite number"), + ({"target_lufs": float("inf")}, "finite number"), + ({"denoiser": "unknown"}, "denoiser must be one of"), + ({"device": "gpu"}, "device must be one of"), + ({"channel": "stereo"}, "channel must be one of"), + ({"hum_hz": 55}, "hum_hz must be one of"), + ({"sample_rate": 96000}, "sample_rate must be one of"), + ({"mp3_bitrate": 64}, "mp3_bitrate must be one of"), + ({"limiter": False}, "Normalization includes true-peak limiting"), +]) +def test_invalid_configuration(overrides, match): + with pytest.raises(ValueError, match=match): + resolve(overrides=overrides) + + +def test_unknown_profile_and_malformed_toml(tmp_path): + with pytest.raises(ValueError, match="Unknown profile"): + resolve(profile="missing") + path = tmp_path / "bad.toml" + path.write_text("normalize = [") + with pytest.raises(ValueError): + resolve(path) + + +def test_invalid_cli_configuration_fails_before_processing(monkeypatch, capsys): + def unexpected(*args, **kwargs): + pytest.fail("Invalid configuration must not reach processing or tool setup") + + monkeypatch.setattr("voiceforge.cli.process", unexpected) + monkeypatch.setattr("voiceforge.cli.ensure_ffmpeg", unexpected) + monkeypatch.setattr("sys.argv", ["voiceforge", "process", "missing.wav", "--target-lufs", "0"]) + with pytest.raises(SystemExit) as error: + main() + assert error.value.code == 1 + assert "target_lufs must be between" in capsys.readouterr().err + + +def test_set_requires_assignment(): + with pytest.raises(ValueError, match="KEY=VALUE"): + settings_for(parser().parse_args(["config", "--set", "normalize"])) + + +@pytest.mark.parametrize("profile", ["radio", "not-a-profile"]) +def test_set_profile_uses_normal_profile_resolution(profile): + args = parser().parse_args(["config", "--set", f"profile={profile}"]) + if profile not in PROFILES: + with pytest.raises(ValueError, match="Unknown profile"): + settings_for(args) + else: + assert settings_for(args) == resolve(profile=profile) + + +@pytest.mark.parametrize("toggle,prefix,count", [ + ("expansion", "agate=", 1), ("eq", "equalizer=", 3), + ("compression", "acompressor=", 1), ("deess", "deesser=", 1), + ("lowpass", "lowpass=", 1), +]) +def test_shaping_filter_toggles(bypass, toggle, prefix, count): + assert filters(bypass) == ["anull"] + setattr(bypass, toggle, True) + chain = filters(bypass) + assert len(chain) == count + assert all(item.startswith(prefix) for item in chain) diff --git a/lib/project/tests/test_install.py b/lib/project/tests/test_install.py new file mode 100644 index 0000000..f1dfcf2 --- /dev/null +++ b/lib/project/tests/test_install.py @@ -0,0 +1,225 @@ +"""Data-dir resolution, contained tool environment, and launcher script checks.""" +import os +import subprocess +from pathlib import Path +import sys +import types + +import pytest + +from voiceforge import setup + +ROOT = Path(__file__).resolve().parents[3] +LAUNCHER = ROOT / "producer.sh" + + +@pytest.fixture +def shell_env(tmp_path): + env = os.environ.copy() + env.pop("VOICEFORGE_HOME", None) + env.pop("XDG_DATA_HOME", None) + env["HOME"] = str(tmp_path / "home") + return env + + +# --- data_dir resolution --------------------------------------------------- + +def test_data_dir_defaults_and_precedence(tmp_path, monkeypatch): + monkeypatch.delenv("VOICEFORGE_HOME", raising=False) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert setup.data_dir() == tmp_path / ".local/share/voiceforge" + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) + assert setup.data_dir() == tmp_path / "xdg/voiceforge" + monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + assert setup.data_dir() == tmp_path / "prefix" + + +def test_relative_voiceforge_home_resolves_against_cwd(tmp_path, monkeypatch): + monkeypatch.setenv("VOICEFORGE_HOME", "voiceforge-home") + monkeypatch.chdir(tmp_path) + assert setup.data_dir() == tmp_path / "voiceforge-home" + + +def test_relative_xdg_data_home_falls_back_to_home(tmp_path, monkeypatch): + monkeypatch.delenv("VOICEFORGE_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_DATA_HOME", "relative/path") + assert setup.data_dir() == tmp_path / ".local/share/voiceforge" + + +# --- contained tool environment -------------------------------------------- + +def test_tool_env_is_sanitized_and_contained(tmp_path, monkeypatch): + monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setenv("PYTHONPATH", "/host/site-packages") + monkeypatch.setenv("PYTHONHOME", "/host/python") + monkeypatch.setenv("VIRTUAL_ENV", "/host/venv") + monkeypatch.setenv("PIP_TARGET", "/host/target") + monkeypatch.setenv("PIP_PREFIX", "/host/prefix") + monkeypatch.setenv("PIP_USER", "1") + monkeypatch.setenv("PIP_FIND_LINKS", "/host/wheels") + monkeypatch.setenv("PIP_INDEX_URL", "https://host.example/simple") + monkeypatch.setenv("PIP_EXTRA_INDEX_URL", "https://host.example/extra") + monkeypatch.setenv("PIP_CONSTRAINT", "/host/constraints.txt") + monkeypatch.setenv("UV_INDEX_URL", "https://host.example/simple") + monkeypatch.setenv("UV_DEFAULT_INDEX", "https://host.example/simple") + monkeypatch.setenv("UV_EXTRA_INDEX_URL", "https://host.example/extra") + monkeypatch.setenv("UV_INDEX", "https://host.example/extra") + monkeypatch.setenv("UV_FIND_LINKS", "/host/wheels") + monkeypatch.setenv("UV_CONSTRAINT", "/host/constraints.txt") + monkeypatch.delenv("PIP_CACHE_DIR", raising=False) + env = setup.tool_env() + prefix = str(tmp_path / "prefix") + assert "PYTHONPATH" not in env and "PYTHONHOME" not in env and "VIRTUAL_ENV" not in env + for name in ("PIP_TARGET", "PIP_PREFIX", "PIP_USER", "PIP_FIND_LINKS", + "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONSTRAINT", + "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", + "UV_INDEX", "UV_FIND_LINKS", "UV_CONSTRAINT"): + assert name not in env, name + assert env["PYTHONNOUSERSITE"] == "1" + assert env["PIP_CONFIG_FILE"] == os.devnull + assert env["UV_NO_CONFIG"] == "1" + assert env["XDG_CACHE_HOME"] == f"{prefix}/cache" + assert env["PIP_CACHE_DIR"] == f"{prefix}/cache/pip" + assert env["UV_CACHE_DIR"] == f"{prefix}/cache/uv" + assert env["UV_PYTHON_INSTALL_DIR"] == f"{prefix}/uv/python" + assert env["TMPDIR"] == f"{prefix}/tmp" + assert (tmp_path / "prefix/tmp").is_dir() + + +def test_tool_env_tolerates_an_uncreatable_data_dir(monkeypatch): + monkeypatch.setenv("VOICEFORGE_HOME", "/vf-prefix-unwritable") + env = setup.tool_env() + assert env["PIP_CACHE_DIR"] == "/vf-prefix-unwritable/cache/pip" + assert "TMPDIR" not in env + + +def test_tool_env_leaves_process_environment_untouched(tmp_path, monkeypatch): + monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setenv("PYTHONPATH", "/host/site-packages") + setup.tool_env() + assert os.environ["PYTHONPATH"] == "/host/site-packages" + + +def test_ensure_uv_bootstraps_into_data_dir_with_contained_pip_cache(tmp_path, monkeypatch): + monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setattr(setup.shutil, "which", lambda name: None) + calls = [] + uv_binary = tmp_path / "prefix/bootstrap/bin/uv" + + def fake_run(args, stage, progress=None, *, env=None): + calls.append((args, env)) + # Simulate pip actually producing the pinned uv. + if stage == "Installing uv from PyPI": + uv_binary.parent.mkdir(parents=True, exist_ok=True) + uv_binary.write_text("#!/bin/sh\n") + uv_binary.chmod(0o755) + + monkeypatch.setattr(setup, "run_process", fake_run) + uv = setup.ensure_uv() + assert uv == str(uv_binary) + venv_args, venv_env = calls[0] + pip_args, pip_env = calls[1] + assert venv_args[-1] == str(tmp_path / "prefix/bootstrap") + assert pip_env["PIP_CACHE_DIR"] == str(tmp_path / "prefix/cache/pip") + assert pip_env["UV_CACHE_DIR"] == str(tmp_path / "prefix/cache/uv") + assert pip_env["PIP_CONFIG_FILE"] == os.devnull + assert "PYTHONPATH" not in pip_env + assert "uv==0.8.17" in pip_args + + +def test_ensure_uv_reuses_contained_bootstrap(tmp_path, monkeypatch): + monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + uv_binary = tmp_path / "prefix/bootstrap/bin/uv" + uv_binary.parent.mkdir(parents=True) + uv_binary.write_text("#!/bin/sh\n") + uv_binary.chmod(0o755) + monkeypatch.setattr(setup.shutil, "which", lambda name: None) + assert setup.ensure_uv() == str(uv_binary) + + +def test_ensure_uv_prefers_the_contained_bootstrap_over_a_host_uv(tmp_path, monkeypatch): + monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + uv_binary = tmp_path / "prefix/bootstrap/bin/uv" + uv_binary.parent.mkdir(parents=True) + uv_binary.write_text("#!/bin/sh\n") + uv_binary.chmod(0o755) + monkeypatch.setattr(setup.shutil, "which", lambda name: "/host/bin/uv") + assert setup.ensure_uv() == str(uv_binary) + + +def test_ensure_uv_ignores_a_host_uv_without_required_capability(tmp_path, monkeypatch): + monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setattr(setup.shutil, "which", lambda name: "/host/bin/uv") + monkeypatch.setattr(setup, "_uv_supports_relocatable", lambda uv: False) + stages = [] + uv_binary = tmp_path / "prefix/bootstrap/bin/uv" + + def fake_run(args, stage, progress=None, *, env=None): + stages.append(stage) + if stage == "Installing uv from PyPI": + uv_binary.parent.mkdir(parents=True, exist_ok=True) + uv_binary.write_text("#!/bin/sh\n") + uv_binary.chmod(0o755) + + monkeypatch.setattr(setup, "run_process", fake_run) + assert setup.ensure_uv() == str(uv_binary) + assert stages == ["Creating uv bootstrap", "Installing uv from PyPI"] + + +def test_ensure_uv_fails_when_bootstrap_does_not_produce_uv(tmp_path, monkeypatch): + monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setattr(setup.shutil, "which", lambda name: None) + monkeypatch.setattr(setup, "run_process", lambda *args, **kwargs: None) + with pytest.raises(RuntimeError, match="did not produce a usable uv"): + setup.ensure_uv() + + +# --- FFmpeg selection --------------------------------------------------------- + +def test_ensure_ffmpeg_prefers_the_bundled_binary_over_host(tmp_path, monkeypatch): + bundled = tmp_path / "bundled/ffmpeg" + bundled.parent.mkdir(parents=True) + bundled.write_text("#!/bin/sh\n") + monkeypatch.setitem(sys.modules, "imageio_ffmpeg", + types.SimpleNamespace(get_ffmpeg_exe=lambda: str(bundled))) + monkeypatch.setattr(setup.shutil, "which", lambda name: "/usr/bin/ffmpeg") + assert setup.ensure_ffmpeg() == str(bundled) + + +def test_ensure_ffmpeg_falls_back_to_system_without_the_bundle(monkeypatch): + monkeypatch.setitem(sys.modules, "imageio_ffmpeg", None) + monkeypatch.setattr(setup.shutil, "which", lambda name: "/usr/bin/ffmpeg") + assert setup.ensure_ffmpeg() == "/usr/bin/ffmpeg" + + +def test_ensure_ffmpeg_error_points_at_the_launcher(monkeypatch): + monkeypatch.setitem(sys.modules, "imageio_ffmpeg", None) + monkeypatch.setattr(setup.shutil, "which", lambda name: None) + with pytest.raises(RuntimeError, match=r"\./producer\.sh --rebuild") as error: + setup.ensure_ffmpeg() + assert "install.sh" not in str(error.value) + + +# --- producer.sh launcher ---------------------------------------------------- + +@pytest.mark.parametrize("argv", [["--help"], ["--rebuild", "--help"]]) +def test_launcher_forwards_help_without_bootstrapping(argv, shell_env, tmp_path, monkeypatch): + # A missing lib/ must not be created by a pure help request. + staging = tmp_path / "checkout" + staging.mkdir() + (staging / "producer.sh").write_text(LAUNCHER.read_text()) + (staging / "producer.sh").chmod(0o755) + monkeypatch.setenv("PATH", "/usr/bin:/bin") + result = subprocess.run(["./producer.sh", *argv], cwd=staging, env=shell_env, + capture_output=True, text=True, timeout=30) + assert result.returncode == 0 + assert "process" in result.stdout + assert not (staging / "lib").exists() + + +def test_launcher_script_parses(): + result = subprocess.run(["bash", "-n", str(LAUNCHER)], + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr diff --git a/lib/project/tests/test_pipeline.py b/lib/project/tests/test_pipeline.py new file mode 100644 index 0000000..0fa1c52 --- /dev/null +++ b/lib/project/tests/test_pipeline.py @@ -0,0 +1,273 @@ +import json + +import numpy as np +import pytest +import soundfile as sf + +from voiceforge import pipeline +from voiceforge.audio import loudness, measure +from voiceforge.config import resolve + + +@pytest.mark.parametrize("kind", ["missing", "input-extension", "output-extension"]) +def test_invalid_paths_fail_before_tool_setup(wav, tmp_path, bypass, progress, monkeypatch, kind): + source = wav(name="input.txt" if kind == "input-extension" else "input.wav") + if kind == "missing": + source = tmp_path / "missing.wav" + target = tmp_path / ("master.mp3" if kind == "output-extension" else "master.wav") + monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Invalid paths must fail preflight")) + with pytest.raises(ValueError, match="WAV file|.wav extension"): + pipeline.process(source, target, bypass, progress) + assert not target.exists() + + +@pytest.mark.parametrize("alias", ["same", "symlink", "hardlink"]) +@pytest.mark.parametrize("overwrite", [False, True]) +def test_original_is_never_overwritten(wav, tmp_path, bypass, progress, monkeypatch, alias, overwrite): + source = wav() + original = source.read_bytes() + target = source if alias == "same" else tmp_path / "alias.wav" + if alias == "symlink": + target.symlink_to(source) + elif alias == "hardlink": + target.hardlink_to(source) + monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Preflight must reject the input")) + with pytest.raises(ValueError, match="original recording"): + pipeline.process(source, target, bypass, progress, overwrite=overwrite) + assert source.read_bytes() == original + + +@pytest.mark.parametrize("suffix", [".wav", ".report.json", ".mp3"]) +def test_existing_output_or_sidecar_rejected_before_processing(wav, tmp_path, bypass, progress, monkeypatch, suffix): + source = wav() + target = tmp_path / "master.wav" + existing = target.with_suffix(suffix) + existing.write_bytes(b"existing output") + bypass.mp3 = True + monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Preflight must reject existing outputs")) + with pytest.raises(FileExistsError, match="Output exists"): + pipeline.process(source, target, bypass, progress) + assert existing.read_bytes() == b"existing output" + + +@pytest.mark.parametrize("kind,message", [ + ("empty", "empty"), ("silence", "silent or too quiet"), + ("quiet", "silent or too quiet"), ("surround", "Only mono or stereo"), + ("nonfinite", "non-finite"), ("stereo", "select --channel"), + ("mono-right", "right channel of a mono"), +]) +def test_unusable_inputs_leave_no_outputs(wav, tmp_path, bypass, progress, ffmpeg, kind, message): + samples = { + "empty": np.empty(0), "silence": np.zeros(48000), + "quiet": np.full(48000, 1e-6), "surround": np.ones((4800, 3)) * 0.1, + "nonfinite": np.array([0.1, np.nan]), "stereo": np.ones((4800, 2)) * 0.1, + "mono-right": np.ones(4800) * 0.1, + }[kind] + source = wav(samples) + target = tmp_path / "outputs" / "master.wav" + if kind == "mono-right": + bypass.channel = "right" + with pytest.raises(ValueError, match=message): + pipeline.process(source, target, bypass, progress) + assert not target.parent.exists() + + +def test_measure_reports_channels_peaks_clipping_and_progress(wav): + samples = np.tile([1.0, -0.25], (4800, 1)) + events = [] + result = measure(wav(samples), lambda *event: events.append(event)) + assert result["duration_seconds"] == 0.1 + assert result["channels"] == 2 + assert result["sample_peak_dbfs"] == 0 + assert result["channel_rms_dbfs"] == pytest.approx([0, 20 * np.log10(0.25)]) + assert result["dc_offset"] == pytest.approx([1, -0.25]) + assert result["near_full_scale_samples"] == 4800 + assert events[0][1:] == (0, 4800) + assert events[-1][1:] == (4800, 4800) + + +@pytest.mark.parametrize("channel", ["left", "right", "mix"]) +def test_explicit_stereo_selection_preserves_signal_and_duration(wav, tmp_path, bypass, progress, ffmpeg, channel): + time = np.arange(48000 * 3) / 48000 + left = 0.2 * np.sin(2 * np.pi * 440 * time) + right = 0.1 * np.sin(2 * np.pi * 880 * time) + source = wav(np.column_stack([left, right])) + bypass.channel = channel + target = tmp_path / "master.wav" + report = pipeline.process(source, target, bypass, progress) + actual, rate = sf.read(target) + expected = {"left": left, "right": right, "mix": (left + right) / 2}[channel] + assert actual.ndim == 1 + assert rate == 48000 + assert len(actual) == len(expected) + np.testing.assert_allclose(actual, expected, atol=5e-7, rtol=0) + assert report["normalization_mode"] == "disabled" + assert report["level_correction_db"] == 0 + assert sf.info(target).subtype == "PCM_24" + + +@pytest.mark.parametrize("rate", [44100, 48000]) +def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg, rate): + source = wav() + original = source.read_bytes() + target = tmp_path / "nested" / "master.wav" + settings = resolve(overrides={"denoiser": "none", "sample_rate": rate}) + report = pipeline.process(source, target, settings, progress) + stats = sf.info(target) + assert stats.channels == 1 + assert stats.samplerate == rate + assert stats.subtype == "PCM_24" + assert abs(stats.duration - 3) <= 0.02 + verified = loudness(ffmpeg, target, settings, stats.duration, progress) + assert verified["input_i"] == pytest.approx(settings.target_lufs, abs=0.5) + assert verified["input_tp"] <= settings.true_peak_db + 0.1 + assert report["master"]["near_full_scale_samples"] == 0 + assert report["master"]["sample_peak_dbfs"] <= settings.true_peak_db + 0.1 + assert json.loads(target.with_suffix(".report.json").read_text()) == report + assert source.read_bytes() == original + assert not list(target.parent.glob(".voiceforge-*")) + + +def test_limiter_without_normalization(wav, tmp_path, bypass, progress, ffmpeg): + time = np.arange(48000 * 3) / 48000 + source = wav(0.99 * np.sin(2 * np.pi * 440 * time)) + bypass.limiter = True + bypass.true_peak_db = -6 + report = pipeline.process(source, tmp_path / "master.wav", bypass, progress) + assert report["normalization_mode"] == "disabled" + assert report["master_loudness"]["input_tp"] <= -5.9 + assert report["master"]["sample_peak_dbfs"] > -7 + + +def test_speech_estimate_survives_pause_dominated_recordings(wav): + time = np.arange(48000 * 3) / 48000 + audio = np.concatenate([np.zeros(48000 * 27), 0.02 * np.sin(2 * np.pi * 440 * time)]) + stats = measure(wav(audio), lambda *_: None) + assert stats["quiet_blocks_dbfs"] <= -110 + assert stats["speech_level_estimate_dbfs"] == pytest.approx(-37, abs=2) + + +def test_leveling_uses_active_speech_not_pauses(wav, tmp_path, progress, ffmpeg): + time = np.arange(48000 * 3) / 48000 + audio = np.concatenate([np.zeros(48000 * 27), 0.02 * np.sin(2 * np.pi * 440 * time)]) + source = wav(audio) + settings = resolve(profile="cleanup-only", overrides={"denoiser": "none", "leveling": True}) + report = pipeline.process(source, tmp_path / "master.wav", settings, progress) + assert 10 < report["level_correction_db"] < settings.max_gain_db + assert not any("gain limit" in warning for warning in report["warnings"]) + + +def test_leveling_skipped_when_speech_estimate_is_unreliable(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): + real_measure = pipeline.measure + + def measured(path, callback, stage="Analyzing audio"): + stats = real_measure(path, callback, stage) + if stage == "Measuring cleaned voice": + stats["speech_level_estimate_dbfs"] = -120 + return stats + + monkeypatch.setattr(pipeline, "measure", measured) + bypass.leveling = True + report = pipeline.process(wav(), tmp_path / "master.wav", bypass, progress) + assert report["level_correction_db"] == 0 + assert any("leveling skipped" in warning for warning in report["warnings"]) + + +def test_antiphase_mix_is_rejected_after_selection(wav, tmp_path, bypass, progress, ffmpeg): + time = np.arange(48000 * 3) / 48000 + tone = 0.2 * np.sin(2 * np.pi * 440 * time) + source = wav(np.column_stack([tone, -tone])) + bypass.channel = "mix" + target = tmp_path / "master.wav" + with pytest.raises(ValueError, match="selected audio is silent or too quiet"): + pipeline.process(source, target, bypass, progress) + assert not target.exists() + assert not target.with_suffix(".report.json").exists() + assert not list(tmp_path.glob(".voiceforge-*")) + + +def test_silent_selected_channel_is_rejected_after_selection(wav, tmp_path, bypass, progress, ffmpeg): + time = np.arange(48000 * 3) / 48000 + tone = 0.2 * np.sin(2 * np.pi * 440 * time) + source = wav(np.column_stack([tone, np.zeros_like(tone)])) + bypass.channel = "right" + target = tmp_path / "master.wav" + with pytest.raises(ValueError, match="selected audio is silent or too quiet"): + pipeline.process(source, target, bypass, progress) + assert not target.exists() + + +def test_zero_denoise_strength_skips_ai(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): + def unexpected(*args, **kwargs): + pytest.fail("Zero denoise strength must not invoke AI") + + monkeypatch.setattr("voiceforge.ai.denoise", unexpected) + bypass.denoiser = "deepfilter" + bypass.denoise_strength = 0 + report = pipeline.process(wav(), tmp_path / "master.wav", bypass, progress) + assert report["cleaned"]["frames"] == report["input"]["frames"] + + +def test_fft_and_mp3_export_can_replace_generated_outputs(wav, tmp_path, bypass, progress, ffmpeg): + source = wav() + original = source.read_bytes() + bypass.denoiser = "fft" + bypass.mp3 = True + target = tmp_path / "master.wav" + for suffix in (".wav", ".report.json", ".mp3"): + target.with_suffix(suffix).write_bytes(b"old output") + events = [] + report = pipeline.process(source, target, bypass, lambda *event: events.append(event), overwrite=True) + assert any(event[0] == "FFT noise reduction" for event in events) + assert target.with_suffix(".mp3").stat().st_size > 1000 + assert report["mp3_loudness"]["input_i"] is not None + assert json.loads(target.with_suffix(".report.json").read_text()) == report + assert sf.info(target).channels == 1 + assert source.read_bytes() == original + + +def test_render_failure_preserves_existing_outputs_and_removes_temporary_files(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): + source = wav() + target = tmp_path / "master.wav" + bypass.mp3 = True + outputs = [target.with_suffix(suffix) for suffix in (".wav", ".report.json", ".mp3")] + for path in outputs: + path.write_bytes(b"previous publication") + real_run = pipeline.ffmpeg_run + + def fail_encoding(executable, args, stage, duration, callback): + if stage == "Encoding MP3": + raise RuntimeError("injected encoder failure") + return real_run(executable, args, stage, duration, callback) + + monkeypatch.setattr(pipeline, "ffmpeg_run", fail_encoding) + with pytest.raises(RuntimeError, match="injected encoder failure"): + pipeline.process(source, target, bypass, progress, overwrite=True) + assert all(path.read_bytes() == b"previous publication" for path in outputs) + assert not list(tmp_path.glob(".voiceforge-*")) + + +@pytest.mark.parametrize("overwrite", [False, True]) +def test_publish_collision_respects_overwrite(tmp_path, overwrite): + staged = tmp_path / "staged" + target = tmp_path / "target" + staged.write_bytes(b"new") + target.write_bytes(b"concurrent output") + if overwrite: + pipeline.publish(staged, target, overwrite=True) + assert target.read_bytes() == b"new" + assert not staged.exists() + else: + with pytest.raises(FileExistsError): + pipeline.publish(staged, target, overwrite=False) + assert target.read_bytes() == b"concurrent output" + assert staged.read_bytes() == b"new" + + +def test_publish_new_output_moves_staged_file(tmp_path): + staged = tmp_path / "staged" + target = tmp_path / "target" + staged.write_bytes(b"finished") + pipeline.publish(staged, target, overwrite=False) + assert target.read_bytes() == b"finished" + assert not staged.exists() diff --git a/lib/pytest.ini b/lib/pytest.ini deleted file mode 100644 index 6b66778..0000000 --- a/lib/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -markers = - slow: engine integration tests (need torch/model deps) diff --git a/lib/requirements-core.txt b/lib/requirements-core.txt deleted file mode 100644 index bb55492..0000000 --- a/lib/requirements-core.txt +++ /dev/null @@ -1,4 +0,0 @@ -numpy==1.26.4 -scipy>=1.11,<1.15 -soundfile>=0.12,<0.13 -pyloudnorm==0.2.0 diff --git a/lib/ruff.toml b/lib/ruff.toml deleted file mode 100644 index c1a0a5e..0000000 --- a/lib/ruff.toml +++ /dev/null @@ -1,12 +0,0 @@ -line-length = 100 -target-version = "py310" - -[lint] -select = ["E", "F", "W", "I", "UP", "B", "SIM", "NPY", "RUF"] -ignore = ["E501"] - -[lint.isort] -known-first-party = ["producer"] - -[format] -quote-style = "double" diff --git a/lib/src/__main__.py b/lib/src/__main__.py deleted file mode 100644 index cfe0046..0000000 --- a/lib/src/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from producer.cli import main - -if __name__ == "__main__": - main() diff --git a/lib/src/producer/__init__.py b/lib/src/producer/__init__.py deleted file mode 100644 index 3dc1f76..0000000 --- a/lib/src/producer/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.0" diff --git a/lib/src/producer/cli.py b/lib/src/producer/cli.py deleted file mode 100644 index 779de96..0000000 --- a/lib/src/producer/cli.py +++ /dev/null @@ -1,321 +0,0 @@ -from __future__ import annotations - -import argparse -import itertools -import os -import sys -from pathlib import Path - -from . import __version__, pipeline, ui -from . import config as cfgmod -from . import dsp as pdsp -from . import io as pio -from . import report as repmod -from .config import DSP_KEYS, PROFILES, Options - -DATA_DIR = Path(__file__).resolve().parents[2] -EXTS = {".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".wma"} - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser( - prog="producer", - description="One-click narration/podcast mastering: denoise, enhance, voice DSP, loudness.", - ) - p.add_argument("inputs", nargs="*", help="audio file(s) to process") - p.add_argument("doctor", nargs="?", help=argparse.SUPPRESS) - p.add_argument("-o", "--output", help="output file, or directory for batch") - p.add_argument("--profile", choices=tuple(PROFILES), help="audiobook (default) or podcast") - p.add_argument( - "--denoise", - choices=["dfn3", "zipenhancer", "spectral", "off"], - help="denoise engine (default dfn3)", - ) - p.add_argument("--denoise-strength", type=float, help="0-1 blend of denoised signal") - p.add_argument( - "--denoise-pf", - action="store_true", - dest="denoise_pf", - default=None, - help="enable the DeepFilterNet post filter (extra noise reduction, " - "may eat soft speech on clean recordings)", - ) - p.add_argument( - "--enhance", choices=["off", "mossformer2", "resemble"], help="speech enhancement engine" - ) - p.add_argument("--enhance-strength", type=float, help="0-1 blend of enhanced signal") - p.add_argument( - "--no-dsp", action="store_false", dest="dsp", help="skip EQ/compression/de-ess chain" - ) - p.add_argument( - "--no-levelling", action="store_false", dest="levelling", help="skip loudness stage" - ) - for key in DSP_KEYS: - p.add_argument(f"--{key}", type=float, metavar="0-1", help=f"strength for {key} stage") - p.add_argument("--hpf-hz", type=float, help="high-pass corner (default 80)") - p.add_argument("--target", type=float, help="loudness target (RMS dB or LUFS per profile)") - p.add_argument("--ceiling", type=float, dest="ceiling_db", help="true-peak ceiling in dB") - p.add_argument("--sample-rate", type=int, help="output sample rate (44100/48000)") - p.add_argument( - "--bit-depth", type=int, choices=[16, 24, 32], default=None, help="wav/flac bit depth" - ) - p.add_argument( - "--format", choices=["wav", "flac", "mp3"], dest="out_format", help="output format" - ) - p.add_argument("--device", choices=["auto", "cuda", "cpu"], help="compute device") - p.add_argument( - "--engine-chunk", - type=float, - dest="engine_chunk_s", - metavar="SEC", - help="GPU engine chunk length in seconds (default 30, 0 = whole file)", - ) - p.add_argument( - "--engine-overlap", - type=float, - dest="engine_overlap_s", - metavar="SEC", - help="GPU engine chunk overlap in seconds (default 0.5)", - ) - p.add_argument("--batch", action="store_true", help="inputs are directories/globs to expand") - p.add_argument("--report", action="store_true", help="write .report.json") - p.add_argument("--report-path", help="explicit report file path") - p.add_argument("--dry-run", action="store_true", help="show the processing chain and exit") - p.add_argument( - "--no-update-check", action="store_true", help="skip the dependency update check" - ) - p.add_argument("--config", help="config file (default config.toml in the project root)") - p.add_argument("-v", "--verbose", action="count", default=0) - p.add_argument("--version", action="store_true") - return p - - -def _apply_args(opts: Options, args: argparse.Namespace) -> None: - m = { - "profile": "profile", - "denoise": "denoise", - "denoise_strength": "denoise_strength", - "denoise_pf": "denoise_pf", - "enhance": "enhance", - "enhance_strength": "enhance_strength", - "output": "output", - "hpf_hz": "hpf_hz", - "target": "target", - "ceiling_db": "ceiling_db", - "sample_rate": "sample_rate", - "bit_depth": "bit_depth", - "out_format": "out_format", - "device": "device", - "engine_chunk_s": "engine_chunk_s", - "engine_overlap_s": "engine_overlap_s", - "report": "report", - "report_path": "report_path", - "dry_run": "dry_run", - "verbose": "verbose", - } - for arg_name, field_name in m.items(): - v = getattr(args, arg_name, None) - if v is not None: - setattr(opts, field_name, v) - if args.dsp is False: - opts.dsp = False - if args.levelling is False: - opts.levelling = False - for key in DSP_KEYS: - v = getattr(args, key, None) - if v is not None: - opts.strengths[key] = float(v) - if opts.profile not in PROFILES: - raise SystemExit(f"unknown profile: {opts.profile}") - if not 0.0 <= opts.denoise_strength <= 1.0 or not 0.0 <= opts.enhance_strength <= 1.0: - raise SystemExit("engine strengths must be within 0-1") - if opts.engine_chunk_s < 0.0 or opts.engine_overlap_s < 0.0: - raise SystemExit("--engine-chunk/--engine-overlap must be >= 0") - if opts.engine_chunk_s > 0.0 and opts.engine_overlap_s >= opts.engine_chunk_s: - raise SystemExit("--engine-overlap must be smaller than --engine-chunk") - for k in DSP_KEYS: - v = opts.strengths[k] - if v is not None and not 0.0 <= float(v) <= 1.0: - raise SystemExit(f"--{k} must be within 0-1") - - -def _expand_inputs(args: argparse.Namespace) -> list[Path]: - inputs: list[Path] = [] - for raw in args.inputs: - p = Path(raw) - if args.batch and p.is_dir(): - inputs.extend(sorted(q for q in p.iterdir() if q.suffix.lower() in EXTS)) - elif args.batch and not p.exists(): - import glob - - for q in sorted(glob.glob(raw)): - q = Path(q) - if q.suffix.lower() in EXTS: - inputs.append(q) - else: - inputs.append(p) - return inputs - - -def _resolve_output(inp: Path, opts: Options, single: bool) -> Path: - ext = opts.out_format.lower() - if opts.output: - o = Path(opts.output) - if single: - return o if o.suffix else o.with_suffix("." + ext) - o.mkdir(parents=True, exist_ok=True) - 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: - ui.log(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, - pos: tuple[int, int] | None = None, -) -> Path | None: - out_path = _resolve_output(inp, opts, single) - out_path = _resolve_output_conflict(out_path, sys.stdin.isatty()) - if out_path is None: - ui.log(f"[producer] skipped {inp}") - return None - x, sr = pio.decode(inp) - target_sr = opts.out_sample_rate() - pos_s = f"[{pos[0]}/{pos[1]}] " if pos else "" - ui.log(f"[producer] {pos_s}{inp} ({sr} Hz, {len(x) / sr:.1f}s)") - status = ui.Status(prefix=f"[producer] {pos_s}{inp.name} — ") - res = pipeline.run_pipeline(x, sr, opts, reporter=status) - status.finish() - y = res.audio - if target_sr != sr: - y = pdsp.resample(y, sr, target_sr) - pio.encode(y, target_sr, out_path, opts.out_format, opts.bit_depth) - rep = repmod.build(str(inp), str(out_path), opts, res.before, res.after, res.timings, res.notes) - if opts.report or opts.report_path: - rp = ( - Path(opts.report_path) - if opts.report_path - else out_path.with_name(out_path.stem + ".report.json") - ) - repmod.save(rep, rp) - if not quiet: - repmod.print_human(rep) - ui.log(f"[producer] {pos_s}wrote {out_path}") - return out_path - - -def main(argv: list[str] | None = None) -> int: - argv = list(sys.argv[1:] if argv is None else argv) - if argv and argv[0] == "doctor": - from . import doctor - - return doctor.main(argv[1:]) - if argv and argv[0] == "update": - from . import updates - - return updates.run_update_command(argv[1:]) - parser = build_parser() - args = parser.parse_args(argv) - if args.version: - print(f"producer {__version__}") - return 0 - if ( - not args.no_update_check - and os.environ.get("PRODUCER_NO_UPDATE_CHECK") != "1" - and not args.dry_run - ): - try: - from . import updates - - updates.check_and_prompt() - except Exception: - pass - opts = Options() - cfg_path = Path(args.config) if args.config else DATA_DIR.parent / "config.toml" - if not args.config: - cfgmod.write_default_config(cfg_path) - cfgmod.apply_config(opts, cfgmod.load_config(cfg_path)) - _apply_args(opts, args) - inputs = _expand_inputs(args) - if not inputs: - parser.error("no input files given") - if len(inputs) > 1 and opts.output: - o = Path(opts.output) - if o.suffix and not o.is_dir(): - parser.error( - "-o/--output must be a directory when processing multiple files " - "(one _processed. file is written per input)" - ) - if opts.dry_run: - stages = pipeline.build_stages(opts) - print(f"producer dry run (profile={opts.profile}, format={opts.out_format})") - for st in stages: - state = "on " if st.enabled else "off" - print(f" [{state}] {st.name:<10} {st.detail}") - print(f" output: {opts.out_format} @ {opts.out_sample_rate()} Hz, {opts.bit_depth}-bit") - return 0 - (DATA_DIR / "models").mkdir(parents=True, exist_ok=True) - (DATA_DIR / "cache").mkdir(parents=True, exist_ok=True) - failed = 0 - single = len(inputs) == 1 - total = len(inputs) - for idx, inp in enumerate(inputs, start=1): - try: - process_one( - inp, - opts, - single, - quiet=total > 1, - pos=(idx, total) if total > 1 else None, - ) - except Exception as e: - ui.finish_live() - failed += 1 - print(f"[producer] ERROR {inp}: {e}", file=sys.stderr) - if opts.verbose: - import traceback - - traceback.print_exc() - return 1 if failed else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/lib/src/producer/config.py b/lib/src/producer/config.py deleted file mode 100644 index 968df4a..0000000 --- a/lib/src/producer/config.py +++ /dev/null @@ -1,286 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import tomllib - -DSP_KEYS = ( - "hpf", - "mud", - "warmth", - "soothe", - "compress", - "tape", - "deess", - "presence", - "air", - "breath", -) - - -@dataclass -class Profile: - name: str - loudness_mode: str = "rms" - target: float = -20.0 - ceiling_db: float = -3.0 - sample_rate: int = 44100 - # RMS level the DSP chain is gain-staged to before compressors bite; the - # pre-gain lets the absolute EQ/comp thresholds stay meaningful regardless - # of how hot or quiet the (denoised) input arrives. - dsp_ref_db: float = -20.0 - hpf_hz: float = 80.0 - mud: tuple[float, float, float] = (300.0, -2.5, 1.0) - warmth: tuple[float, float] = (150.0, 1.5) - comp1: tuple[float, float, float, float] = (-20.0, 2.0, 15.0, 150.0) - comp2: tuple[float, float, float, float] = (-18.0, 3.0, 5.0, 100.0) - deess: tuple[float, float, float] = (5500.0, 8000.0, 5.0) - presence: tuple[float, float] = (3000.0, 1.5) - air: tuple[float, float] = (10000.0, 1.5) - strengths: dict[str, float] = field( - default_factory=lambda: { - "hpf": 1.0, - "mud": 0.8, - "warmth": 0.8, - "soothe": 0.3, - "compress": 0.8, - "tape": 0.0, - "deess": 0.6, - "presence": 0.8, - "air": 0.6, - "breath": 0.35, - } - ) - - -AUDIOBOOK = Profile("audiobook") -PODCAST = Profile( - "podcast", - loudness_mode="lufs", - target=-16.0, - ceiling_db=-1.5, - sample_rate=48000, - dsp_ref_db=-18.0, - mud=(300.0, -1.5, 1.0), - warmth=(150.0, 1.0), - comp1=(-18.0, 2.0, 15.0, 150.0), - comp2=(-16.0, 3.0, 5.0, 100.0), - deess=(5500.0, 8000.0, 6.0), - presence=(3000.0, 2.5), - air=(10000.0, 2.0), - strengths={ - "hpf": 1.0, - "mud": 0.6, - "warmth": 0.7, - "soothe": 0.5, - "compress": 0.9, - "tape": 0.2, - "deess": 0.7, - "presence": 1.0, - "air": 0.8, - "breath": 0.2, - }, -) -RADIO = Profile( - "radio", - loudness_mode="lufs", - target=-16.0, - ceiling_db=-1.5, - sample_rate=48000, - dsp_ref_db=-17.0, - hpf_hz=70.0, - mud=(280.0, -3.5, 1.3), - warmth=(100.0, 3.0), - comp1=(-16.0, 3.0, 10.0, 120.0), - comp2=(-14.0, 4.0, 3.0, 90.0), - deess=(5000.0, 7500.0, 4.0), - presence=(3500.0, 1.0), - air=(9000.0, 1.0), - strengths={ - "hpf": 1.0, - "mud": 0.9, - "warmth": 1.0, - "soothe": 0.7, - "compress": 1.0, - "tape": 0.55, - "deess": 0.5, - "presence": 0.7, - "air": 0.5, - "breath": 0.4, - }, -) -PROFILES = {"audiobook": AUDIOBOOK, "podcast": PODCAST, "radio": RADIO} - - -@dataclass -class Options: - profile: str = "audiobook" - denoise: str = "dfn3" - # 0.9 flattens the residual gain wobble mask-based denoisers leave on - # speech; the dry blend trades a whisper of noise back for stability. - denoise_strength: float = 0.9 - denoise_pf: bool = False - enhance: str = "off" - enhance_strength: float = 1.0 - dsp: bool = True - levelling: bool = True - target: float | None = None - ceiling_db: float | None = None - sample_rate: int | None = None - bit_depth: int = 32 - out_format: str = "wav" - device: str = "auto" - strengths: dict[str, float | None] = field(default_factory=lambda: {k: None for k in DSP_KEYS}) - hpf_hz: float | None = None - # 0 = feed engines the whole file (one warm model pass, no seam artifacts); - # on OOM the run automatically falls back to large chunks. - engine_chunk_s: float = 0.0 - engine_overlap_s: float = 0.5 - output: str | None = None - report: bool = False - report_path: str | None = None - dry_run: bool = False - verbose: int = 0 - - def prof(self) -> Profile: - return PROFILES[self.profile] - - def eff(self, key: str) -> float: - v = self.strengths.get(key) - if v is None: - return float(self.prof().strengths[key]) - return float(v) - - def target_value(self) -> float: - return self.prof().target if self.target is None else float(self.target) - - def ceiling(self) -> float: - return self.prof().ceiling_db if self.ceiling_db is None else float(self.ceiling_db) - - def out_sample_rate(self) -> int: - return self.prof().sample_rate if self.sample_rate is None else int(self.sample_rate) - - def loudness_mode(self) -> str: - return self.prof().loudness_mode - - def to_dict(self) -> dict[str, Any]: - return { - "profile": self.profile, - "denoise": self.denoise, - "denoise_strength": self.denoise_strength, - "denoise_pf": self.denoise_pf, - "enhance": self.enhance, - "enhance_strength": self.enhance_strength, - "dsp": self.dsp, - "levelling": self.levelling, - "target": self.target_value(), - "ceiling_db": self.ceiling(), - "sample_rate": self.out_sample_rate(), - "bit_depth": self.bit_depth, - "out_format": self.out_format, - "device": self.device, - "strengths": {k: self.eff(k) for k in DSP_KEYS}, - "hpf_hz": self.hpf_hz if self.hpf_hz is not None else self.prof().hpf_hz, - "engine_chunk_s": self.engine_chunk_s, - "engine_overlap_s": self.engine_overlap_s, - } - - -_CFG_FIELDS = { - "profile": "profile", - "denoise_strength": "denoise_strength", - "enhance_strength": "enhance_strength", - "format": "out_format", - "bit_depth": "bit_depth", - "device": "device", - "target": "target", - "ceiling": "ceiling_db", - "sample_rate": "sample_rate", - "engine_chunk": "engine_chunk_s", - "engine_overlap": "engine_overlap_s", -} - - -def _engine_cfg(value: Any) -> tuple[Any, Any]: - if isinstance(value, dict): - return value.get("engine"), value.get("strength") - return value, None - - -def load_config(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - with path.open("rb") as f: - return tomllib.load(f) - - -def apply_config(opts: Options, cfg: dict[str, Any]) -> None: - for key, field_name in _CFG_FIELDS.items(): - if key in cfg: - setattr(opts, field_name, cfg[key]) - if "denoise" in cfg: - eng, strength = _engine_cfg(cfg["denoise"]) - if eng: - opts.denoise = eng - if strength is not None: - opts.denoise_strength = float(strength) - if isinstance(cfg["denoise"], dict) and "pf" in cfg["denoise"]: - opts.denoise_pf = bool(cfg["denoise"]["pf"]) - if "enhance" in cfg: - eng, strength = _engine_cfg(cfg["enhance"]) - if eng: - opts.enhance = eng - if strength is not None: - opts.enhance_strength = float(strength) - prof_cfg = cfg.get(opts.profile) - if isinstance(prof_cfg, dict): - for k in DSP_KEYS: - if k in prof_cfg: - opts.strengths[k] = float(prof_cfg[k]) - if "hpf_hz" in prof_cfg: - opts.hpf_hz = float(prof_cfg["hpf_hz"]) - - -def write_default_config(path: Path) -> None: - if path.exists(): - return - lines = [ - 'profile = "audiobook"', - "", - "# GPU engine processing: engines receive the whole file by default so", - "# models run one warm pass (no seam artifacts). engine_chunk > 0 forces", - "# chunked processing in seconds for very long files / low VRAM.", - "# engine_chunk = 0.0 # 0 = whole file (automatic fallback on OOM)", - "# engine_overlap = 0.5 # crossfade between chunks in seconds", - "", - "[denoise]", - 'engine = "dfn3"', - "# 0.9 flattens residual denoiser gain wobble; 1.0 = full suppression", - "strength = 0.9", - "# pf = false # DFN post filter: extra noise reduction, may eat soft speech", - "", - "[enhance]", - 'engine = "off"', - "", - "[audiobook]", - "mud = 0.8", - "warmth = 0.8", - "compress = 0.8", - "deess = 0.6", - "presence = 0.8", - "air = 0.6", - "breath = 0.35", - "", - "[podcast]", - "mud = 0.6", - "warmth = 0.7", - "compress = 0.9", - "deess = 0.7", - "presence = 1.0", - "air = 0.8", - "breath = 0.2", - "", - ] - path.write_text("\n".join(lines)) diff --git a/lib/src/producer/doctor.py b/lib/src/producer/doctor.py deleted file mode 100644 index 2d8997f..0000000 --- a/lib/src/producer/doctor.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import importlib.util -import shutil -import sys -from pathlib import Path - -from . import __version__ - -DATA_DIR = Path(__file__).resolve().parents[2] - - -def _check(name: str, ok: bool, detail: str = "") -> bool: - mark = "ok " if ok else "MISS" - line = f" [{mark}] {name}" - if detail: - line += f": {detail}" - print(line) - return ok - - -def _import(name: str): - try: - return importlib.import_module(name) - except Exception: - return None - - -def _chain_error(name: str) -> str: - """Import failure text for a module whose deeper import chain doesn't load.""" - try: - importlib.import_module(name) - except Exception as e: - return str(e) or type(e).__name__ - return "" - - -def main(_argv: list[str] | None = None) -> int: - print(f"producer {__version__} doctor") - print(f" data dir: {DATA_DIR}") - critical_ok = True - - v = sys.version_info - critical_ok &= _check("python", v >= (3, 10), f"{v.major}.{v.minor}.{v.micro}") - - for mod in ("numpy", "scipy", "soundfile", "pyloudnorm"): - m = _import(mod) - ok = m is not None - critical_ok &= ok - _check(mod, ok, getattr(m, "__version__", "") if ok else "not installed") - - torch = _import("torch") - if torch is None: - _check("torch", False, "not installed (installs on first denoise/enhance run)") - else: - ver = getattr(torch, "__version__", "?") - cuda = bool(torch.cuda.is_available()) - dev = torch.cuda.get_device_name(0) if cuda else "" - _check("torch", True, f"{ver}, cuda={cuda}" + (f" ({dev})" if dev else "")) - - ffmpeg = shutil.which("ffmpeg") - _check("ffmpeg", ffmpeg is not None, ffmpeg or "missing (needed for mp3 + odd formats)") - - for mod in ("deepfilternet", "zipenhancer", "clearvoice"): - m = _import(mod) or _import(mod.replace("-", "_")) - if m is None: - _check(mod, False, "lazy (installed on first use)") - continue - detail = "installed" - if mod == "zipenhancer": - # zipenhancer loads modelscope lazily on first use; probe the - # config module whose extras-only deps have broken it in the wild - broken = _chain_error("modelscope.utils.config") - if broken and "modelscope" not in broken: - detail += f"; modelscope chain broken: {broken} (will install on first use)" - _check(mod, True, detail) - - dfn3 = DATA_DIR / "models" / "DeepFilterNet3" / "config.ini" - _check("dfn3 weights", dfn3.is_file(), str(dfn3)) - - res_venv = DATA_DIR / "venvs" / "resemble" / "bin" / "python" - _check("resemble venv", res_venv.is_file(), str(res_venv)) - - uv = DATA_DIR / "bin" / "uv" - _check("uv", uv.is_file() or shutil.which("uv") is not None, str(uv)) - - cfg = DATA_DIR.parent / "config.toml" - _check("config.toml", cfg.exists(), str(cfg)) - - print("doctor:", "core OK" if critical_ok else "core problems detected") - return 0 if critical_ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/lib/src/producer/dsp.py b/lib/src/producer/dsp.py deleted file mode 100644 index e65e595..0000000 --- a/lib/src/producer/dsp.py +++ /dev/null @@ -1,283 +0,0 @@ -from __future__ import annotations - -import numpy as np -from scipy import ndimage, signal - -from .meters import SILENCE_DB - -_EPS = np.float32(1e-12) - - -def _coef(time_ms: float, sr: int) -> float: - return float(np.exp(-1000.0 / (max(time_ms, 1e-4) * sr))) - - -def _onepole(x: np.ndarray, a: float) -> np.ndarray: - a = np.float32(a) - return signal.lfilter( - np.array([1.0 - a], dtype=np.float32), np.array([1.0, -a], dtype=np.float32), x - ) - - -def rbj(kind: str, sr: int, freq: float, gain_db: float = 0.0, q: float = 0.7071) -> np.ndarray: - a = 10.0 ** (gain_db / 40.0) - w0 = 2.0 * np.pi * freq / sr - cs = np.cos(w0) - sn = np.sin(w0) - alpha = sn / (2.0 * q) - if kind == "highpass": - b = [(1 + cs) / 2, -(1 + cs), (1 + cs) / 2] - aa = [1 + alpha, -2 * cs, 1 - alpha] - elif kind == "lowpass": - b = [(1 - cs) / 2, 1 - cs, (1 - cs) / 2] - aa = [1 + alpha, -2 * cs, 1 - alpha] - elif kind == "peaking": - b = [1 + alpha * a, -2 * cs, 1 - alpha * a] - aa = [1 + alpha / a, -2 * cs, 1 - alpha / a] - elif kind == "lowshelf": - sq = 2 * np.sqrt(a) * alpha - b = [ - a * ((a + 1) - (a - 1) * cs + sq), - 2 * a * ((a - 1) - (a + 1) * cs), - a * ((a + 1) - (a - 1) * cs - sq), - ] - aa = [ - (a + 1) + (a - 1) * cs + sq, - -2 * ((a - 1) + (a + 1) * cs), - (a + 1) + (a - 1) * cs - sq, - ] - elif kind == "highshelf": - sq = 2 * np.sqrt(a) * alpha - b = [ - a * ((a + 1) + (a - 1) * cs + sq), - -2 * a * ((a - 1) + (a + 1) * cs), - a * ((a + 1) + (a - 1) * cs - sq), - ] - aa = [ - (a + 1) - (a - 1) * cs + sq, - 2 * ((a - 1) - (a + 1) * cs), - (a + 1) - (a - 1) * cs - sq, - ] - else: - raise ValueError(f"unknown filter kind: {kind}") - arr = np.array(b + aa, dtype=np.float64) - return arr / arr[3] - - -def _as_sos(sos: np.ndarray) -> np.ndarray: - sos = sos[None, :] if sos.ndim == 1 else sos - return sos.astype(np.float32, copy=False) - - -def biquad(x: np.ndarray, sos: np.ndarray) -> np.ndarray: - x32 = x.astype(np.float32, copy=False) - y = signal.sosfilt(_as_sos(sos), x32) - return y.astype(np.float32, copy=False) - - -def hpf(x: np.ndarray, sr: int, hz: float) -> np.ndarray: - return biquad(x, rbj("highpass", sr, hz)) - - -def shelf(x: np.ndarray, sr: int, freq: float, gain_db: float, low: bool = True) -> np.ndarray: - if abs(gain_db) < 0.01: - return x - return biquad(x, rbj("lowshelf" if low else "highshelf", sr, freq, gain_db)) - - -def peak_eq(x: np.ndarray, sr: int, freq: float, gain_db: float, q: float = 1.0) -> np.ndarray: - if abs(gain_db) < 0.01: - return x - return biquad(x, rbj("peaking", sr, freq, gain_db, q)) - - -def compressor( - x: np.ndarray, - sr: int, - threshold_db: float, - ratio: float, - attack_ms: float, - release_ms: float, - knee_db: float = 6.0, -) -> np.ndarray: - x32 = x.astype(np.float32, copy=False) - a_att = _coef(attack_ms, sr) - a_rel = _coef(release_ms, sr) - env = np.sqrt(np.clip(_onepole(np.square(x32), a_att), 0.0, None)) - level_db = 20.0 * np.log10(env + _EPS) - over = level_db - np.float32(threshold_db) - k = np.float32(knee_db) - gr = np.where( - over <= -k / 2, - np.float32(0.0), - np.where( - over < k / 2, - (1.0 - 1.0 / ratio) * np.square(over + k / 2) / (2.0 * k), - (1.0 - 1.0 / ratio) * over, - ), - ) - gr = _onepole(np.clip(gr, 0.0, None), a_rel) - gain = 10.0 ** (-gr / 20.0) - return (x32 * gain).astype(np.float32, copy=False) - - -def deesser( - x: np.ndarray, sr: int, lo_hz: float, hi_hz: float, max_reduction_db: float -) -> np.ndarray: - if max_reduction_db < 0.05: - return x - x32 = x.astype(np.float32, copy=False) - low = signal.sosfiltfilt( - signal.butter(4, lo_hz, btype="lowpass", fs=sr, output="sos").astype(np.float32), x32 - ) - high = x32 - low - mid = signal.sosfiltfilt( - signal.butter(4, [lo_hz, hi_hz], btype="bandpass", fs=sr, output="sos").astype(np.float32), - high, - ) - win = max(3, int(0.005 * sr) | 1) - kernel = (np.hanning(win) / np.sum(np.hanning(win))).astype(np.float32) - env = signal.convolve(np.abs(mid), kernel, mode="same") - act = env[env > _EPS] - if act.size == 0: - return x - thr = np.float32(float(np.percentile(act, 95)) * 10.0 ** (-3.0 / 20.0)) - over = np.clip(20.0 * np.log10((env + _EPS) / thr), 0.0, None) - gr = np.clip(over * np.float32(0.6), 0.0, np.float32(max_reduction_db)) - gr = _onepole(_onepole(gr, _coef(1.0, sr)), _coef(30.0, sr)) - gain = 10.0 ** (-gr / 20.0) - high_out = high - mid + mid * gain - return (low + high_out).astype(np.float32, copy=False) - - -def expander( - x: np.ndarray, - sr: int, - max_drop_db: float = 6.0, - ratio: float = 2.0, - attack_ms: float = 10.0, - release_ms: float = 120.0, -) -> np.ndarray: - if max_drop_db < 0.05: - return x - n = x.size - frame = max(1, int(0.020 * sr)) - nf = n // frame - if nf < 2: - return x - frames = x[: nf * frame].reshape(nf, frame).astype(np.float32, copy=False) - frms = np.sqrt(np.mean(np.square(frames), axis=1, dtype=np.float64)) - fdb = 20.0 * np.log10(frms + 1e-12) - active = fdb[fdb > SILENCE_DB + 1.0] - if active.size == 0: - return x - speech_ref = float(np.percentile(active, 90)) - floor_ref = float(np.percentile(active, 5)) - thr = 0.5 * (speech_ref + floor_ref) - drop = np.where(fdb < thr, np.minimum((thr - fdb) * (1.0 - 1.0 / ratio), max_drop_db), 0.0) - centers = (np.arange(nf) + 0.5) * frame - drop_s = np.interp(np.arange(n, dtype=np.float32), centers, drop).astype(np.float32) - drop_s = np.maximum( - _onepole(drop_s, _coef(release_ms, sr)), _onepole(drop_s, _coef(attack_ms, sr)) - ) - gain = 10.0 ** (-np.clip(drop_s, 0.0, np.float32(max_drop_db)) / 20.0) - return (x.astype(np.float32, copy=False) * gain).astype(np.float32, copy=False) - - -def limit( - x: np.ndarray, - sr: int, - ceiling_db: float, - release_ms: float = 60.0, - lookahead_ms: float = 1.0, -) -> np.ndarray: - x32 = x.astype(np.float32, copy=False) - n = x32.size - if n == 0: - return x - lin = 10.0 ** (ceiling_db / 20.0) - win = max(1, int(lookahead_ms * sr / 1000.0) | 1) - env = ndimage.maximum_filter1d(np.abs(x32), size=win, mode="nearest") - over = np.clip(20.0 * np.log10(env + _EPS) - ceiling_db, 0.0, None) - block = max(1, int(0.005 * sr)) - nb = (n + block - 1) // block - padded = np.zeros(nb * block, dtype=np.float32) - padded[:n] = over - block_over = padded.reshape(nb, block).max(axis=1) - decay = float(np.exp(-1000.0 * 0.005 / max(release_ms, 0.1))) - held = np.empty(nb, dtype=np.float64) - prev = 0.0 - for i in range(nb): - prev = max(block_over[i], prev * decay) - held[i] = prev - held_s = np.interp(np.arange(n, dtype=np.float32), (np.arange(nb) + 0.5) * block, held) - held_s = _onepole(held_s.astype(np.float32), _coef(1.0, sr)) - gain = 10.0 ** (-held_s / 20.0) - y = x32 * gain - bad = np.abs(y) > lin - if np.any(bad): - y[bad] = np.float32(lin) * np.sign(y[bad]) - return y.astype(np.float32, copy=False) - - -def resample(x: np.ndarray, sr_in: int, sr_out: int) -> np.ndarray: - if sr_in == sr_out or x.size == 0: - return x - from math import gcd - - g = gcd(int(sr_in), int(sr_out)) - y = signal.resample_poly(x, int(sr_out) // g, int(sr_in) // g, window=("kaiser", 10.0)) - return y.astype(np.float32, copy=False) - - -def tape(x: np.ndarray, sr: int, amount: float) -> np.ndarray: - """Gentle asymmetric soft-clip saturation — analog-style even-harmonic warmth.""" - if amount < 0.02: - return x - s = np.float32(np.clip(amount, 0.0, 1.0)) - drive = np.float32(1.0 + 2.5 * float(s)) - x32 = x.astype(np.float32, copy=False) - curve = np.where(x32 < 0.0, x32 * (1.0 + 0.4 * s), x32) - y = np.tanh(curve * drive) / np.tanh(drive) - rms_in = float(np.sqrt(np.mean(np.square(x32)))) - rms_out = float(np.sqrt(np.mean(np.square(y)))) - y *= np.float32(rms_in / max(rms_out, float(_EPS))) - return (x32 * (1.0 - s) + y * s).astype(np.float32, copy=False) - - -def soothe(x: np.ndarray, sr: int, amount: float) -> np.ndarray: - """Dynamic reducer that digs into resonances only while they stick out. - - Targets boxy 200-450 Hz and harsh 2.5-6 kHz bands; static-only when turned - to zero. Deesser-style: band split -> smoothed envelope -> percentile - threshold -> gain reduce -> subtractive recombination. - """ - if amount < 0.02: - return x - s = np.float32(np.clip(amount, 0.0, 1.0)) - y = x.astype(np.float32, copy=False) - bands = ((200.0, 450.0, 3.5), (2500.0, 6000.0, 5.0)) - win = max(3, int(0.010 * sr) | 1) - kernel = (np.hanning(win) / np.sum(np.hanning(win))).astype(np.float32) - changed = False - for lo, hi, max_db in bands: - if max_db * float(s) < 0.1: - continue - band = signal.sosfiltfilt( - signal.butter(4, [lo, hi], btype="bandpass", fs=sr, output="sos").astype(np.float32), - y, - ) - env = signal.convolve(np.abs(band), kernel, mode="same") - act = env[env > _EPS] - if act.size == 0: - continue - thr = np.float32(float(np.percentile(act, 88)) * 10.0 ** (-6.0 / 20.0)) - over = np.clip(20.0 * np.log10((env + _EPS) / thr), 0.0, None) - gr = np.clip(over * np.float32(0.7), 0.0, np.float32(max_db)) * s - gr = _onepole(_onepole(gr, _coef(5.0, sr)), _coef(60.0, sr)) - gain = 10.0 ** (-gr / 20.0) - y = y - band + band * gain - changed = True - if not changed or np.array_equal(y, x.astype(np.float32, copy=False)): - return x - return y.astype(np.float32, copy=False) diff --git a/lib/src/producer/engines/__init__.py b/lib/src/producer/engines/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lib/src/producer/engines/base.py b/lib/src/producer/engines/base.py deleted file mode 100644 index 9192a17..0000000 --- a/lib/src/producer/engines/base.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -import numpy as np - - -def pick_device(pref: str = "auto") -> str: - if pref == "cpu": - return "cpu" - try: - import torch - except ImportError: - if pref == "cuda": - print("[producer] warning: torch not installed; using cpu", flush=True) - return "cpu" - if torch.cuda.is_available(): - return "cuda" - if pref == "cuda": - print("[producer] warning: CUDA requested but unavailable; using cpu", flush=True) - return "cpu" - - -def device_name(device: str) -> str: - if device == "cuda": - import torch - - return torch.cuda.get_device_name(0) - return "cpu" - - -def blend(x: np.ndarray, y: np.ndarray, strength: float) -> np.ndarray: - if y.size != x.size: - # a resample round trip can drift by a sample or two (resample_poly - # emits ceil(n * up/down) per hop); realign before the elementwise math - y = y[: x.size] if y.size > x.size else np.pad(y, (0, x.size - y.size)) - s = float(np.clip(strength, 0.0, 1.0)) - if s >= 0.999: - return y.astype(np.float32, copy=False) - if s <= 0.001: - return x.astype(np.float32, copy=False) - out = np.asarray(y, dtype=np.float32) - np.asarray(x, dtype=np.float32) - out *= s - out += x - return out diff --git a/lib/src/producer/engines/chunking.py b/lib/src/producer/engines/chunking.py deleted file mode 100644 index 627e3b8..0000000 --- a/lib/src/producer/engines/chunking.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import gc -from collections.abc import Callable, Iterator - -import numpy as np - -from .. import ui - -MIN_CHUNK_S = 2.0 - -_OOM_ERRORS: tuple[type[BaseException], ...] | None = None - - -def _oom_errors() -> tuple[type[BaseException], ...]: - global _OOM_ERRORS - if _OOM_ERRORS is None: - errs: list[type[BaseException]] = [MemoryError] - try: - import torch - - errs.append(torch.cuda.OutOfMemoryError) - except Exception: - pass - _OOM_ERRORS = tuple(errs) - return _OOM_ERRORS - - -def free_vram() -> None: - gc.collect() - try: - import torch - - if torch.cuda.is_available(): - torch.cuda.empty_cache() - except Exception: - pass - - -def plan_chunks(n: int, chunk: int, overlap: int) -> list[tuple[int, int]]: - """Split n samples into spans of at most `chunk` samples sharing `overlap`. - - Consecutive spans advance by chunk - overlap; the last span always covers - the tail (its length is at least overlap + 1). - """ - n = int(n) - if n <= 0: - return [] - chunk = int(max(1, chunk)) - overlap = int(max(0, overlap)) - if overlap >= chunk: - raise ValueError("overlap must be smaller than chunk") - if chunk >= n: - return [(0, n)] - spans: list[tuple[int, int]] = [] - start = 0 - while True: - end = min(start + chunk, n) - spans.append((start, end)) - if end >= n: - return spans - start += chunk - overlap - - -def stitch(spans: list[tuple[int, int]], n: int, pieces) -> np.ndarray: - """Overlap-add engine outputs (aligned with spans) into one n-sample array. - - The first `overlap` samples of each piece crossfade linearly against the - tail already written by the previous piece. - """ - out = np.zeros(n, dtype=np.float32) - prev_end = -1 - for (start, end), piece in zip(spans, pieces, strict=True): - seg = np.asarray(piece, dtype=np.float32).reshape(-1) - want = end - start - if seg.size < want: - seg = np.pad(seg, (0, want - seg.size), mode="edge") - seg = seg[:want] - if start < 0 or end > n: - raise ValueError(f"span ({start}, {end}) outside output of length {n}") - ov = max(0, prev_end - start) - if ov > 0: - ramp = (np.arange(ov, dtype=np.float32) + 0.5) / float(ov) - out[start : start + ov] *= 1.0 - ramp - out[start : start + ov] += seg[:ov] * ramp - out[start + ov : end] = seg[ov:] - else: - out[start:end] = seg - prev_end = end - return out - - -def _tracked( - x: np.ndarray, - spans: list[tuple[int, int]], - ctx: int, - fn, - on_progress: Callable[[int, int], None] | None, -) -> Iterator[np.ndarray]: - total = len(spans) - if on_progress is not None: - on_progress(0, total) - for i, (a, b) in enumerate(spans): - lo = max(0, a - ctx) - hi = min(x.size, b + ctx) - piece = fn(x[lo:hi]) - if on_progress is not None: - on_progress(i + 1, total) - seg = np.asarray(piece, dtype=np.float32).reshape(-1) - want_full = hi - lo - if seg.size < want_full: - seg = np.pad(seg, (0, want_full - seg.size), mode="edge") - seg = seg[:want_full] - yield seg[a - lo : a - lo + (b - a)] - - -def apply_chunked( - x: np.ndarray, - sr: int, - chunk_s: float, - overlap_s: float, - fn, - min_chunk_s: float = MIN_CHUNK_S, - on_progress: Callable[[int, int], None] | None = None, - context_s: float = 0.0, -) -> np.ndarray: - """Run fn on the signal in overlapping chunks and stitch the results. - - fn receives a 1-D chunk and must return a sample-aligned array of the same - length. chunk_s <= 0 disables chunking (single whole-signal call). With - context_s > 0 each chunk is widened by up to that many seconds of - neighbouring audio on both sides before fn runs, and the extra context in - fn's output is trimmed away again — recurrent/stateful models then process - the region that survives stitching with warmed-up state instead of a cold - start. On CUDA or host OOM the whole run is retried with halved chunk - length down to min_chunk_s. on_progress(done, total_chunks) fires as chunks - complete. - """ - n = int(x.size) - chunk_s = float(chunk_s or 0.0) - overlap_s = max(0.0, float(overlap_s or 0.0)) - ctx = max(0, round(float(context_s or 0.0) * sr)) - if chunk_s <= 0.0: - try: - return fn(x) - except _oom_errors(): - # Whole-file passes can OOM on multi-hour files; fall back to - # large chunks and let the halving loop take it from there. - free_vram() - whole = 120.0 - ui.log( - f"[producer] whole-file pass ran out of memory; retrying with {whole:.0f}s chunks" - ) - return apply_chunked(x, sr, whole, overlap_s, fn, min_chunk_s, on_progress, context_s) - try: - spans = plan_chunks(n, round(chunk_s * sr), round(overlap_s * sr)) - return stitch(spans, n, _tracked(x, spans, ctx, fn, on_progress)) - except _oom_errors(): - free_vram() - smaller = chunk_s / 2.0 - if smaller < min_chunk_s: - raise - ui.log( - f"[producer] engine ran out of memory; retrying with {smaller:.0f}s chunks", - ) - return apply_chunked(x, sr, smaller, overlap_s, fn, min_chunk_s, on_progress, context_s) diff --git a/lib/src/producer/engines/denoise_dfn.py b/lib/src/producer/engines/denoise_dfn.py deleted file mode 100644 index 186ffd2..0000000 --- a/lib/src/producer/engines/denoise_dfn.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -import sys -import types -import warnings -import zipfile -from collections.abc import Callable -from pathlib import Path - -import numpy as np - -from .. import dsp, lazy, ui -from .base import blend, device_name, pick_device -from .chunking import apply_chunked, free_vram - -MODELS_DIR = lazy.DATA_DIR / "models" -TAG = "v0.5.6" -# Neighbouring audio fed to each chunk so the recurrent model and its feature -# normalizers run warm at chunk seams; trimmed away before stitching. -CONTEXT_S = 2.0 -MODEL_ZIPS = { - "DeepFilterNet3": "models/DeepFilterNet3.zip", - "DeepFilterNet2": "models/DeepFilterNet2.zip", -} -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: - pkg = sys.modules.get("torchaudio") - if pkg is not None and "torchaudio.backend" not in sys.modules: - stub = types.ModuleType("torchaudio.backend") - stub.__path__ = [] - sys.modules["torchaudio.backend"] = stub - 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(): - return target - url = f"{BASE_URL}/{MODEL_ZIPS[model]}" - MODELS_DIR.mkdir(parents=True, exist_ok=True) - zpath = MODELS_DIR / f"{model}.zip" - ui.download(url, zpath, f"downloading {model} weights") - ui.log(f"[producer] extracting {model} weights...") - with zipfile.ZipFile(zpath) as z: - z.extractall(target) - zpath.unlink(missing_ok=True) - if not (target / "config.ini").is_file(): - inner = list(target.glob(f"**/{model}/config.ini")) - if inner: - src = inner[0].parent - for f in src.iterdir(): - f.rename(target / f.name) - if not (target / "config.ini").is_file(): - raise RuntimeError(f"{model} weights download failed") - return target - - -def denoise( - x: np.ndarray, - sr: int, - strength: float, - device_pref: str = "auto", - chunk_s: float = 30.0, - overlap_s: float = 0.5, - on_progress: Callable[[int, int], None] | None = None, - post_filter: bool = False, -) -> tuple[np.ndarray, str, str]: - """Denoise with DeepFilterNet. - - post_filter opts into DFN's extra noise-reduction post filter; it - over-attenuates and can eat soft speech on clean recordings, so it stays - off unless requested. - """ - # torch first: deepfilternet's declared torch dependency would otherwise - # resolve to the newest (multi-GB CUDA) build before we pin our tested one. - lazy.ensure_torch() - lazy.ensure(["deepfilternet==0.5.6"], purpose="DeepFilterNet") - _shim_torchaudio_backend() - _shim_df_git() - model_name = "DeepFilterNet3" - try: - model_dir = ensure_model(model_name) - except Exception: - model_name = "DeepFilterNet2" - model_dir = ensure_model(model_name) - from df.enhance import enhance as df_enhance - from df.enhance import init_df - - device = pick_device(device_pref) - model, df_state, _ = init_df( - model_base_dir=str(model_dir), - post_filter=post_filter, - log_level="error", - log_file=None, - ) - try: - model = model.to(device) - dev = device - except Exception: - dev = "cpu" - sr_df = int(df_state.sr()) - xin = dsp.resample(x, sr, sr_df) - import torch - - def run(chunk: np.ndarray) -> np.ndarray: - t = torch.from_numpy(np.ascontiguousarray(chunk, dtype=np.float32)).unsqueeze(0) - y = df_enhance(model, df_state, t) - if isinstance(y, torch.Tensor): - y = y.detach().cpu().numpy() - return np.asarray(y, dtype=np.float32).reshape(-1) - - y = apply_chunked( - xin, sr_df, chunk_s, overlap_s, run, on_progress=on_progress, context_s=CONTEXT_S - ) - free_vram() - y = dsp.resample(y, sr_df, sr) - y = blend(x, y, strength) - return y, f"dfn ({model_name})", f"{device_name(dev)} ({dev})" diff --git a/lib/src/producer/engines/denoise_spectral.py b/lib/src/producer/engines/denoise_spectral.py deleted file mode 100644 index e1bb256..0000000 --- a/lib/src/producer/engines/denoise_spectral.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable - -import numpy as np -from scipy import signal - -from .. import ui -from .base import blend -from .chunking import apply_chunked - -N_FFT = 2048 -HOP = 512 -DD_ALPHA = 0.97 -PROFILE_PCT = 35.0 -GATE_PCT = 30.0 -GAIN_SMOOTH_MS = 20.0 -PROFILE_BLOCK_S = 60.0 -RESERVOIR_ROWS = 4096 -MIN_CHUNK_S = 8.0 - - -def _frame_rms(x: np.ndarray, frame: int) -> np.ndarray: - nf = x.size // frame - if nf < 1: - return np.ones(1, dtype=np.float64) - return np.sqrt( - np.mean(np.square(x[: nf * frame].reshape(nf, frame).astype(np.float64)), axis=1) - ) - - -def _noise_profile(x: np.ndarray, sr: int) -> np.ndarray: - """File-global per-bin noise PSD estimate. - - Two cheap passes: a time-domain pass finds the quietest fraction of - frames (speech gaps), then an STFT pass subsamples those frames' PSDs - into a fixed-size per-bin reservoir. Because the profile comes from the - whole file instead of a per-chunk percentile, speech-heavy regions can - no longer leak into the estimate — the failure that made the old engine - under-suppress tape hiss between sentences. - """ - hop_t = _frame_rms(x, HOP) - thr = float(np.percentile(hop_t, GATE_PCT)) - nb_bins = N_FFT // 2 + 1 - reservoir = np.zeros((nb_bins, RESERVOIR_ROWS), dtype=np.float32) - count = 0 - rng = np.random.default_rng(0) - - def add_rows(rows: np.ndarray) -> None: - nonlocal count - k = rows.shape[0] - if k == 0: - return - if count < RESERVOIR_ROWS: - take = min(k, RESERVOIR_ROWS - count) - reservoir[:, count : count + take] = rows[:take].T - count += take - rows = rows[take:] - k = rows.shape[0] - if k > 0: - idx = rng.integers(0, RESERVOIR_ROWS, size=k) - reservoir[:, idx] = rows.T - - block = int(PROFILE_BLOCK_S * sr) - collected = 0 - for start in range(0, x.size, block): - seg = x[start : min(start + block, x.size)] - if seg.size < N_FFT * 2: - break - _f, _t, S = signal.stft( - seg, window="hann", nperseg=N_FFT, noverlap=N_FFT - HOP, boundary="zeros", padded=True - ) - psd = (S.real * S.real + S.imag * S.imag).astype(np.float32) - # STFT frames sit ~head/HOP before the block start due to zero padding - offset = start // HOP - (N_FFT // 2) // HOP - quiet = np.zeros(psd.shape[1], dtype=bool) - vals = hop_t[max(0, offset) : max(0, offset) + psd.shape[1]] - quiet[: vals.size] = vals < thr - add_rows(psd[:, quiet].T) - collected += int(quiet.sum()) - if collected == 0: - # no quiet frames at all (continuous dense speech): blind subsample - for start in range(0, x.size, block): - seg = x[start : min(start + block, x.size)] - if seg.size < N_FFT * 2: - break - _f, _t, S = signal.stft( - seg, - window="hann", - nperseg=N_FFT, - noverlap=N_FFT - HOP, - boundary="zeros", - padded=True, - ) - psd = (S.real * S.real + S.imag * S.imag).astype(np.float32) - step = max(1, psd.shape[1] // (RESERVOIR_ROWS // 8)) - add_rows(psd[:, ::step].T) - if count == 0: - return np.zeros(nb_bins, dtype=np.float32) - return np.percentile(reservoir[:, :count], PROFILE_PCT, axis=1).astype(np.float32) - - -def _denoise_chunk(chunk: np.ndarray, sr: int, strength: float, noise: np.ndarray) -> np.ndarray: - n = chunk.size - if n < N_FFT * 2: - return chunk - head = N_FFT // 2 - tail = head + (-(n - N_FFT)) % HOP - padded = np.pad(chunk, (head, tail), mode="edge") - _f, _t, S = signal.stft( - padded, window="hann", nperseg=N_FFT, noverlap=N_FFT - HOP, boundary="zeros", padded=True - ) - psd = (S.real * S.real + S.imag * S.imag).astype(np.float32) - if not np.any(noise > 0.0): - return chunk[:n].astype(np.float32) - eps = np.float32(1e-20) - over = 1.0 + 3.0 * float(strength) - snr_post = psd / (noise[:, None] * np.float32(over) + eps) - floor_gain = np.float32(10.0 ** (-(10.0 + 28.0 * float(strength)) / 20.0)) - gains = np.empty_like(psd) - prev_g2 = np.ones(psd.shape[0], dtype=np.float32) - prev_post = snr_post[:, 0] - for i in range(psd.shape[1]): - post = snr_post[:, i] - snr_prio = ( - DD_ALPHA * prev_g2 * prev_post - + (1.0 - DD_ALPHA) * np.maximum(post - 1.0, np.float32(0.0)) - ).astype(np.float32) - g = snr_prio / (1.0 + snr_prio) - np.maximum(g, floor_gain, out=g) - gains[:, i] = g - prev_g2 = g * g - prev_post = post - a = np.float32(np.exp(-1000.0 * HOP / sr / GAIN_SMOOTH_MS)) - gains = signal.lfilter([1.0 - a], [1.0, -a], gains, axis=1) - _t2, rec = signal.istft( - S * gains, window="hann", nperseg=N_FFT, noverlap=N_FFT - HOP, boundary=True - ) - out = rec[head : head + n] - if out.size < n: - out = np.pad(out, (0, n - out.size), mode="edge") - return out.astype(np.float32) - - -def denoise( - x: np.ndarray, - sr: int, - strength: float, - device_pref: str = "auto", - chunk_s: float = 30.0, - overlap_s: float = 0.5, - on_progress: Callable[[int, int], None] | None = None, -) -> tuple[np.ndarray, str, str]: - """Denoise with decision-directed spectral subtraction on a global noise - profile. - - Pure DSP: one pass measures the per-bin noise PSD across the whole file - (from its quietest frames), then a Wiener-style gain with decision- - directed smoothing is applied in overlapping blocks. The global profile - keeps suppression uniform everywhere — steady tape hiss and breath noise - between sentences go down by ~20+ dB instead of the old capped-at-24 dB - per-chunk estimate. Deterministic, no model download, - no time-varying gain wobble. - """ - strength = float(np.clip(strength, 0.0, 1.0)) - if strength <= 0.001: - return x.copy(), "spectral", "cpu" - chunk_s = float(chunk_s or 0.0) - if chunk_s <= 0.0: - # a whole-file STFT would need ~10 GB per hour of audio; block instead - ui.log("[producer] spectral denoiser processes in blocks; clamping --engine-chunk") - chunk_s = 30.0 - chunk_s = max(chunk_s, MIN_CHUNK_S) - overlap_s = max(0.0, min(float(overlap_s or 0.0), chunk_s / 2.0)) - noise = _noise_profile(x, sr) - y = apply_chunked( - x, - sr, - chunk_s, - overlap_s, - lambda chunk: _denoise_chunk(chunk, sr, strength, noise), - on_progress=on_progress, - context_s=2.0, - ) - y = blend(x, y, strength) - return y, "spectral (dd-wiener, global profile)", "cpu" diff --git a/lib/src/producer/engines/denoise_zip.py b/lib/src/producer/engines/denoise_zip.py deleted file mode 100644 index 3f37f61..0000000 --- a/lib/src/producer/engines/denoise_zip.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable - -import numpy as np - -from .. import dsp, lazy -from .base import blend -from .chunking import apply_chunked - - -def _peak_normalize(y: np.ndarray) -> np.ndarray: - peak = float(np.max(np.abs(y))) if y.size else 0.0 - if peak > 1e-10: - y *= 10.0 ** (-3.0 / 20.0) / peak - if y.size: - peak = float(np.max(np.abs(y))) - if peak > 0.99: - y *= 0.95 / peak - return y - - -def denoise( - x: np.ndarray, - sr: int, - strength: float, - device_pref: str = "auto", - chunk_s: float = 30.0, - overlap_s: float = 0.5, - on_progress: Callable[[int, int], None] | None = None, -) -> tuple[np.ndarray, str, str]: - # torch first: zipenhancer's declared torch>=2.0 dependency would otherwise - # resolve to the newest (multi-GB CUDA) build before we pin our tested one. - lazy.ensure_torch() - # zipenhancer's weight loader imports modelscope even for the default - # model, but doesn't declare it (it is only an optional extra on PyPI). - lazy.ensure(["zipenhancer==0.3.2", "modelscope"], purpose="ZipEnhancer") - # the loader runs lazily on the first denoise() call, and modelscope's - # config/hub code imports extras-only helpers (addict, simplejson, ...) - # on the way; the import and call probes install whatever trips over. - lazy.ensure_import("zipenhancer", purpose="ZipEnhancer") - # the library API takes the full modelscope repo id; its short-name - # mapping lives only in its CLI, and anything unrecognized is treated - # as a repo id (modelscope E3021) - from zipenhancer import MODEL_ZIPENHANCER as Z_MODEL_REPO - from zipenhancer import denoise as z_denoise - - sr_z = 16000 - strength = float(np.clip(strength, 0.0, 1.0)) - x16 = dsp.resample(x, sr, sr_z) - - def run(chunk: np.ndarray) -> np.ndarray: - result = z_denoise(chunk, sr_z, model=Z_MODEL_REPO, normalize=False, strength=strength) - y = result[0] if isinstance(result, tuple) else result - return np.asarray(y, dtype=np.float32).reshape(-1) - - y = apply_chunked( - x16, - sr_z, - chunk_s, - overlap_s, - lambda chunk: lazy.ensure_call(lambda: run(chunk), purpose="ZipEnhancer"), - on_progress=on_progress, - ) - _peak_normalize(y) - y = dsp.resample(y, sr_z, sr) - y = blend(x, y, strength) - return y, "zipenhancer (16 kHz SOTA, bandwidth restored)", device_pref diff --git a/lib/src/producer/engines/enhance_mossformer.py b/lib/src/producer/engines/enhance_mossformer.py deleted file mode 100644 index 3298c09..0000000 --- a/lib/src/producer/engines/enhance_mossformer.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable - -import numpy as np - -from .. import dsp, io, lazy -from .base import blend, device_name, pick_device -from .chunking import apply_chunked, free_vram - -# ClearVoice's batch decode path crashes on inputs longer than -# one_time_decode_length (20 s); keep chunks below it. -MAX_CHUNK_S = 20.0 - - -def enhance( - x: np.ndarray, - sr: int, - strength: float, - device_pref: str = "auto", - chunk_s: float = 30.0, - overlap_s: float = 0.5, - on_progress: Callable[[int, int], None] | None = None, -) -> tuple[np.ndarray, str, str]: - # torch first: clearvoice's declared torch dependency would otherwise - # resolve to the newest (multi-GB CUDA) build before we pin our tested one. - lazy.ensure_torch() - lazy.ensure(["clearvoice==0.1.2"], purpose="MossFormer2 (ClearVoice)") - # clearvoice is modelscope-based and imports undeclared helpers; the probe - # installs whatever the import chain actually trips over. - lazy.ensure_import("clearvoice", purpose="MossFormer2 (ClearVoice)") - from clearvoice import ClearVoice - - device = pick_device(device_pref) - sr_target = 48000 - xin = dsp.resample(x, sr, sr_target) - cv = ClearVoice(task="speech_enhancement", model_name="MossFormer2_SE_48K") - if chunk_s <= 0: - import tempfile - from pathlib import Path - - with tempfile.TemporaryDirectory(prefix="producer_mf2_") as td: - inp = Path(td) / "in.wav" - outp = Path(td) / "out.wav" - io.encode(xin, sr_target, inp, "wav", 16) - result = cv(input_path=str(inp), output_name=str(outp)) - if isinstance(result, tuple): - y, fs = result[0], int(result[1]) - else: - y, fs = io.decode(outp) - else: - effective = min(float(chunk_s), MAX_CHUNK_S) - - def run(chunk: np.ndarray) -> np.ndarray: - out = cv(chunk[None, :].astype(np.float32)) - if isinstance(out, tuple): - out = out[0] - return np.asarray(out, dtype=np.float32).reshape(-1) - - y = apply_chunked(xin, sr_target, effective, overlap_s, run, on_progress=on_progress) - fs = sr_target - free_vram() - y = np.asarray(y, dtype=np.float32) - if y.ndim > 1: - y = y.mean(axis=-1) if y.shape[-1] <= 2 else y.reshape(-1) - if fs != sr_target: - y = dsp.resample(y, fs, sr_target) - y = dsp.resample(y, sr_target, sr) - y = blend(x, y, strength) - return y, "mossformer2 (MossFormer2_SE_48K)", device_name(device) diff --git a/lib/src/producer/engines/enhance_resemble.py b/lib/src/producer/engines/enhance_resemble.py deleted file mode 100644 index 8042215..0000000 --- a/lib/src/producer/engines/enhance_resemble.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import contextlib -import os -import subprocess -import sys -from collections.abc import Callable -from pathlib import Path - -import numpy as np - -from .. import dsp, io, lazy, ui -from .base import blend - -VENV_DIR = lazy.DATA_DIR / "venvs" / "resemble" -WORKER = Path(__file__).resolve().parent / "resemble_worker.py" -REQ_HASH_FILE = VENV_DIR / ".req-hash" -REQS = ["resemble-enhance==0.0.1", "librosa", "soundfile"] - - -def _ensure_venv() -> Path: - marker = REQ_HASH_FILE.read_text() if REQ_HASH_FILE.exists() else None - want = "|".join(REQS) - py = VENV_DIR / "bin" / "python" - if py.is_file() and marker == want: - return py - uv = lazy.find_uv() - if not py.is_file(): - ui.log("[producer] creating resemble venv (CPython 3.11)...") - ui.run([uv, "venv", str(VENV_DIR), "--python", "3.11"], "creating resemble venv") - ui.log("[producer] installing resemble-enhance into isolated venv (one-time, large)...") - ui.run( - [uv, "pip", "install", "--python", str(py), *REQS], - "installing resemble-enhance dependencies", - ) - REQ_HASH_FILE.write_text(want) - return py - - -def enhance( - x: np.ndarray, - sr: int, - strength: float, - device_pref: str = "auto", - chunk_s: float = 30.0, - overlap_s: float = 0.5, - on_progress: Callable[[int, int], None] | None = None, -) -> tuple[np.ndarray, str, str]: - import tempfile - - py = _ensure_venv() - from .base import device_name as _dname - from .base import pick_device as _pick - - device = _pick(device_pref) - with tempfile.TemporaryDirectory(prefix="producer_res_") as td: - inp = Path(td) / "in.wav" - outp = Path(td) / "out.wav" - io.encode(x, sr, inp, "wav", 16) - env = dict(os.environ) - env["HF_HOME"] = str(lazy.DATA_DIR / "models" / "hf") - env["TORCH_HOME"] = str(lazy.DATA_DIR / "models" / "torch") - cmd = [ - str(py), - str(WORKER), - str(inp), - str(outp), - device, - str(float(np.clip(strength, 0.0, 1.0))), - str(float(chunk_s)), - str(float(overlap_s)), - ] - proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, text=True) - assert proc.stdout is not None - for line in proc.stdout: - parts = line.split() - if len(parts) == 4 and parts[0] == "PROGRESS": - if on_progress is not None: - with contextlib.suppress(ValueError): - on_progress(int(parts[2]), int(parts[3])) - continue - if line.strip(): - sys.stderr.write(line if line.endswith("\n") else line + "\n") - rc = proc.wait() - if rc != 0: - raise subprocess.CalledProcessError(rc, cmd) - y, fs = io.decode(outp) - if fs != sr: - y = dsp.resample(y, fs, sr) - y = blend(x, y, strength) - return y, "resemble-enhance (generative, may alter timbre)", _dname(device) - - -if __name__ == "__main__": - sys.exit(0) diff --git a/lib/src/producer/engines/resemble_worker.py b/lib/src/producer/engines/resemble_worker.py deleted file mode 100644 index 493ceb3..0000000 --- a/lib/src/producer/engines/resemble_worker.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import contextlib -import sys -from pathlib import Path - -import numpy as np -import soundfile as sf - -sys.path.insert(0, str(Path(__file__).resolve().parents[2])) - -from producer.engines.chunking import apply_chunked - - -def load(path: str) -> tuple[np.ndarray, int]: - data, sr = sf.read(path, dtype="float32", always_2d=True) - return data.mean(axis=1).astype(np.float32), int(sr) - - -def _report(stage: str): - def cb(done: int, total: int) -> None: - print(f"PROGRESS {stage} {done} {total}", flush=True) - - return cb - - -def main() -> int: - import torch - from resemble_enhance.enhancer.inference import denoise as r_denoise - from resemble_enhance.enhancer.inference import enhance as r_enhance - - inp, outp, device = sys.argv[1], sys.argv[2], sys.argv[3] - strength = float(sys.argv[4]) - chunk_s = float(sys.argv[5]) if len(sys.argv) > 5 else 30.0 - overlap_s = float(sys.argv[6]) if len(sys.argv) > 6 else 0.5 - x, sr = load(inp) - state = {"sr": sr} - - def denoise_fn(chunk: np.ndarray) -> np.ndarray: - t = torch.from_numpy(np.ascontiguousarray(chunk)) - with contextlib.suppress(Exception): - t, state["sr"] = r_denoise(t, state["sr"], device) - if isinstance(t, torch.Tensor): - t = t.detach().cpu().numpy() - return np.asarray(t, dtype=np.float32).reshape(-1) - - def enhance_fn(chunk: np.ndarray) -> np.ndarray: - t = torch.from_numpy(np.ascontiguousarray(chunk)) - try: - y, _ = r_enhance( - t, state["sr"], device, nfe=64, solver="midpoint", lambd=1.0 - 0.1 * strength - ) - except TypeError: - y, _ = r_enhance(t, state["sr"], device) - if isinstance(y, torch.Tensor): - y = y.detach().cpu().numpy() - return np.asarray(y, dtype=np.float32).reshape(-1) - - x = apply_chunked(x, sr, chunk_s, overlap_s, denoise_fn, on_progress=_report("denoise")) - y = apply_chunked( - x, state["sr"], chunk_s, overlap_s, enhance_fn, on_progress=_report("enhance") - ) - sf.write(outp, y, int(state["sr"]), subtype="FLOAT") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/lib/src/producer/io.py b/lib/src/producer/io.py deleted file mode 100644 index b6d38b6..0000000 --- a/lib/src/producer/io.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import shutil -import subprocess -import tempfile -from pathlib import Path - -import numpy as np -import soundfile as sf - -SUBTYPES = {16: "PCM_16", 24: "PCM_24", 32: "FLOAT"} - - -def ffmpeg_available() -> bool: - return shutil.which("ffmpeg") is not None - - -def decode(path: str | Path) -> tuple[np.ndarray, int]: - path = Path(path) - try: - data, sr = sf.read(str(path), dtype="float32", always_2d=True) - except RuntimeError: - if not ffmpeg_available(): - raise - with tempfile.TemporaryDirectory(prefix="producer_dec_") as tmpdir: - tmp = Path(tmpdir) / "dec.wav" - subprocess.run( - ["ffmpeg", "-v", "error", "-y", "-i", str(path), "-vn", "-ac", "1", str(tmp)], - check=True, - ) - data, sr = sf.read(str(tmp), dtype="float32", always_2d=True) - mono = data.mean(axis=1).astype(np.float32) - return mono, int(sr) - - -def encode( - x: np.ndarray, - sr: int, - path: str | Path, - out_format: str = "wav", - bit_depth: int = 32, -) -> Path: - path = Path(path) - fmt = out_format.lower() - if fmt in ("wav", "flac"): - subtype = SUBTYPES.get(bit_depth, "PCM_16") - sf.write(str(path), x, sr, subtype=subtype, format=fmt.upper()) - return path - if fmt == "mp3": - if not ffmpeg_available(): - raise RuntimeError("mp3 output requires ffmpeg on PATH") - with tempfile.TemporaryDirectory(prefix="producer_enc_") as td: - tmp_wav = Path(td) / "enc.wav" - sf.write(str(tmp_wav), x, sr, subtype="PCM_16") - subprocess.run( - [ - "ffmpeg", - "-v", - "error", - "-y", - "-i", - str(tmp_wav), - "-codec:a", - "libmp3lame", - "-b:a", - "192k", - str(path), - ], - check=True, - ) - return path - raise ValueError(f"unsupported output format: {out_format}") - - -def supported_globs() -> list[str]: - return ( - "*.wav", - "*.flac", - "*.mp3", - "*.m4a", - "*.aac", - "*.ogg", - "*.opus", - "*.aif", - "*.aiff", - "*.wma", - ) diff --git a/lib/src/producer/lazy.py b/lib/src/producer/lazy.py deleted file mode 100644 index 55bbf6c..0000000 --- a/lib/src/producer/lazy.py +++ /dev/null @@ -1,257 +0,0 @@ -from __future__ import annotations - -import importlib -import importlib.util -import json -import platform -import re -import shutil -import sys -import urllib.request -from collections.abc import Callable -from importlib import metadata -from pathlib import Path -from typing import TypeVar -from urllib.parse import unquote - -from . import ui - -_T = TypeVar("_T") - -DATA_DIR = Path(__file__).resolve().parents[2] -TORCH_VERSION = "2.7.1" -TORCH_GPU_INDEX = "https://download.pytorch.org/whl/cu126" -WHEEL_CACHE = DATA_DIR / "cache" / "wheels" -DIST_ALIASES = { - "deepfilternet": "df", - "deepfilterlib": "libdf", -} -# the probe installs by the failing import name, but some import names differ -# from their pip name; only entries actually tripped by a chain get used -IMPORT_ALIASES = { - "PIL": "pillow", - "yaml": "pyyaml", - "dateutil": "python-dateutil", - "dotenv": "python-dotenv", - "cv2": "opencv-python", - "sklearn": "scikit-learn", - "skimage": "scikit-image", -} - - -class EngineUnavailable(RuntimeError): - pass - - -def find_uv() -> str: - uv = shutil.which("uv") - if uv: - return uv - local = DATA_DIR / "bin" / "uv" - if local.is_file(): - return str(local) - raise EngineUnavailable("uv not found; run the producer script to bootstrap") - - -def has_module(name: str) -> bool: - return importlib.util.find_spec(name) is not None - - -def dist_name(spec: str) -> str: - return spec.split("==")[0].split(">=")[0].split("<")[0].split("[")[0] - - -def is_installed(spec: str) -> bool: - dist = dist_name(spec) - try: - metadata.distribution(dist) - return True - except metadata.PackageNotFoundError: - return has_module(DIST_ALIASES.get(dist, dist)) - - -def gpu_present() -> bool: - return shutil.which("nvidia-smi") is not None - - -def _py_tag() -> str: - return f"cp{sys.version_info.major}{sys.version_info.minor}" - - -def _arches() -> tuple[str, ...]: - m = platform.machine().lower() - if m in ("amd64", "x86_64"): - return ("x86_64", "amd64") - if m in ("aarch64", "arm64"): - return ("aarch64", "arm64") - return (m,) - - -def _get_text(url: str) -> str: - req = urllib.request.Request(url, headers={"User-Agent": ui.UA}) - with urllib.request.urlopen(req, timeout=30) as resp: - return resp.read().decode("utf-8", "replace") - - -def _choose_pypi(data: dict) -> tuple[str, str, str | None, int | None] | None: - """Pick the linux wheel for this interpreter from a PyPI JSON release.""" - tag = _py_tag() - arches = _arches() - for u in data.get("urls", []): - fn = u.get("filename", "") - if fn.endswith(".whl") and tag in fn and "linux" in fn and any(a in fn for a in arches): - return fn, u["url"], (u.get("digests") or {}).get("sha256"), u.get("size") - return None - - -def _choose_gpu( - html: str, pkg: str, version: str -) -> tuple[str, str, str | None, int | None] | None: - """Pick a cu126 wheel from a download.pytorch.org index page. - - Hrefs may be relative or absolute and carry a #sha256= fragment, e.g. - https://download-r2.pytorch.org/whl/cu126/torch-2.7.1%2Bcu126-cp311-...whl#sha256=... - """ - prefix = f"{pkg}-{version}%2Bcu126-{_py_tag()}-" - arches = _arches() - for raw in re.findall(r'href="([^"]+\.whl[^"]*)"', html): - href, _, frag = raw.partition("#") - sha = frag[len("sha256=") :] if frag.startswith("sha256=") else None - encoded = href.rsplit("/", 1)[-1] - fname = unquote(encoded) - if not encoded.startswith(prefix) or not any(a in fname for a in arches): - continue - url = href if href.startswith("http") else f"{TORCH_GPU_INDEX}/{pkg}/{href}" - return fname, url, sha, None - return None - - -def _pypi_wheel(pkg: str, version: str) -> tuple[str, str, str | None, int | None] | None: - data = json.loads(_get_text(f"https://pypi.org/pypi/{pkg}/{version}/json")) - return _choose_pypi(data) - - -def _gpu_wheel(pkg: str, version: str) -> tuple[str, str, str | None, int | None] | None: - return _choose_gpu(_get_text(f"{TORCH_GPU_INDEX}/{pkg}/"), pkg, version) - - -def _resolve_torch_wheels(gpu: bool) -> list[tuple[str, str, str | None, int | None]]: - """(filename, url, sha256, size) for torch + torchaudio; [] when unresolvable.""" - pick = _gpu_wheel if gpu else _pypi_wheel - wheels = [] - for pkg in ("torch", "torchaudio"): - found = pick(pkg, TORCH_VERSION) - if found is None: - return [] - wheels.append(found) - return wheels - - -def ensure_torch() -> None: - if has_module("torch"): - return - gpu = gpu_present() - flavor = "CUDA" if gpu else "CPU" - ui.log( - f"[producer] installing torch {TORCH_VERSION} ({flavor})..." - + (" ~2.5 GB download" if gpu else "") - ) - wheels: list[tuple[str, str, str | None, int | None]] = [] - try: - wheels = _resolve_torch_wheels(gpu) - except Exception as e: - ui.log(f"[producer] wheel index lookup failed ({e}); using uv directly") - if wheels: - paths = [ - ui.download( - url, - WHEEL_CACHE / name, - f"downloading {name}", - expected_size=size, - sha256=sha, - ) - for name, url, sha, size in wheels - ] - ui.run( - [find_uv(), "pip", "install", "--python", sys.executable, *(str(p) for p in paths)], - "installing torch", - ) - else: - cmd = [find_uv(), "pip", "install", "--python", sys.executable] - if gpu: - cmd += [ - f"torch=={TORCH_VERSION}+cu126", - f"torchaudio=={TORCH_VERSION}+cu126", - "--index-url", - TORCH_GPU_INDEX, - ] - else: - cmd += [f"torch=={TORCH_VERSION}", f"torchaudio=={TORCH_VERSION}"] - ui.run(cmd, "installing torch") - importlib.invalidate_caches() - - -def ensure(packages: list[str], purpose: str) -> None: - missing = [p for p in packages if not is_installed(p)] - if not missing: - return - if any(m.startswith("torch") for m in missing): - ensure_torch() - missing = [m for m in missing if not m.startswith("torch") and not is_installed(m)] - if not missing: - return - ui.log(f"[producer] installing {purpose} dependencies (one-time)...") - ui.run( - [find_uv(), "pip", "install", "--python", sys.executable, *missing], - f"installing {purpose} dependencies", - ) - importlib.invalidate_caches() - for spec in missing: - if not is_installed(spec): - raise EngineUnavailable(f"failed to install {dist_name(spec)} for {purpose}") - - -def ensure_import(module: str, purpose: str, max_rounds: int = 4) -> None: - """Import `module`, installing any undeclared dependency it trips over. - - Some model loaders import modules their wheels don't declare (ModelScope's - config/hub code needs addict/simplejson/sortedcontainers, for example). - The failing import name usually doubles as the pip spec; the ones where - it doesn't are translated via IMPORT_ALIASES. Runs until the import - succeeds; gives up after max_rounds with a clear error. - """ - missing = module - for _ in range(max_rounds): - try: - importlib.import_module(module) - return - except ModuleNotFoundError as e: - missing = e.name or module - if "." in missing: - # a submodule of an installed package is broken; installing - # "pkg.sub" from pip would be nonsense — surface the real error - raise - ensure([IMPORT_ALIASES.get(missing, missing)], purpose=purpose) - raise EngineUnavailable(f"cannot import {module} for {purpose} (still missing: {missing})") - - -def ensure_call(fn: Callable[[], _T], purpose: str, max_rounds: int = 8) -> _T: - """Run fn(), installing any undeclared dependency its import chain trips over. - - Engine weight loaders import lazily on the first call rather than at - import time, and those imports reach modules whose wheels don't declare - their deps (ModelScope's config/hub code needs addict/simplejson/ - sortedcontainers, for example, which are extras-only on PyPI). Same - recovery loop as ensure_import, around a call instead of an import; only - the first invocation can trip, later ones find the chain importable. - """ - missing = "" - for _ in range(max_rounds): - try: - return fn() - except ModuleNotFoundError as e: - missing = e.name or "" - if not missing or "." in missing: - raise - ensure([IMPORT_ALIASES.get(missing, missing)], purpose=purpose) - raise EngineUnavailable(f"cannot run {purpose} (still missing: {missing})") diff --git a/lib/src/producer/loudness.py b/lib/src/producer/loudness.py deleted file mode 100644 index 8ceae97..0000000 --- a/lib/src/producer/loudness.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -import numpy as np - -from . import dsp -from .meters import meter, true_peak_db - - -def normalize( - x: np.ndarray, - sr: int, - mode: str, - target: float, - ceiling_db: float, - max_iters: int = 4, -) -> np.ndarray: - if not np.any(x): - return x - y = x - for _ in range(max_iters): - cur = meter(y, sr, mode) - gain = target - cur - if abs(gain) < 0.05: - break - y = y * (10.0 ** (gain / 20.0)) - y = dsp.limit(y, sr, ceiling_db) - tp = true_peak_db(y, sr) - if tp > ceiling_db + 0.05: - y = y * (10.0 ** ((ceiling_db - tp) / 20.0)) - return y.astype(np.float32) diff --git a/lib/src/producer/meters.py b/lib/src/producer/meters.py deleted file mode 100644 index dc4c566..0000000 --- a/lib/src/producer/meters.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import numpy as np -from scipy import signal - -SILENCE_DB = -120.0 -_METER_BLOCK = 1 << 20 - - -def _db(v: float) -> float: - if v <= 0.0 or not np.isfinite(v): - return SILENCE_DB - return max(20.0 * np.log10(v), SILENCE_DB) - - -def rms_db(x: np.ndarray) -> float: - total = 0.0 - for i in range(0, x.size, _METER_BLOCK): - blk = x[i : i + _METER_BLOCK] - total += float(np.square(blk.astype(np.float64)).sum()) - mean = total / x.size if x.size else 0.0 - return _db(float(np.sqrt(mean))) - - -def sample_peak_db(x: np.ndarray) -> float: - peak = 0.0 - for i in range(0, x.size, _METER_BLOCK): - blk = x[i : i + _METER_BLOCK] - peak = max(peak, float(np.max(np.abs(blk))) if blk.size else 0.0) - return _db(peak) - - -def true_peak_db(x: np.ndarray, sr: int, oversample: int = 4) -> float: - if x.size < 2: - return sample_peak_db(x) - half = 10 * oversample - h = signal.firwin(2 * half + 1, 1.0 / oversample, window=("kaiser", 8.0)).astype(np.float32) - pad = 4 * half + oversample - block = max(1, int(30.0 * sr)) - peak = 0.0 - start = 0 - while start < x.size: - end = min(start + block, x.size) - a, b = max(0, start - pad), min(x.size, end + pad) - y = signal.resample_poly(x[a:b].astype(np.float32, copy=False), oversample, 1, window=h) - lo = (start - a) * oversample - hi = (end - a) * oversample - peak = max(peak, float(np.max(np.abs(y[lo:hi])))) - start = end - return _db(peak) - - -def block_pct_db(x: np.ndarray, sr: int, block_ms: float = 50.0, pct: float = 5.0) -> float: - n = max(1, int(sr * block_ms / 1000.0)) - nb = x.size // n - if nb < 1: - return sample_peak_db(x) if x.size else SILENCE_DB - view = x[: nb * n].reshape(nb, n) - rows = max(1, _METER_BLOCK // n) - block_rms = np.empty(nb, dtype=np.float64) - for i in range(0, nb, rows): - sl = view[i : i + rows] - block_rms[i : i + rows] = np.sqrt(np.mean(np.square(sl.astype(np.float64)), axis=1)) - active = block_rms[block_rms > 0.0] - if active.size == 0: - return SILENCE_DB - return _db(float(np.percentile(active, pct))) - - -def noise_floor_db(x: np.ndarray, sr: int) -> float: - return block_pct_db(x, sr, block_ms=50.0, pct=5.0) - - -def speech_level_db(x: np.ndarray, sr: int) -> float: - """Robust loudness of the actual speech in x (95th pct of 50 ms frame RMS). - - Unlike plain RMS this barely moves with long pauses, so it is a stable - anchor for gain staging the DSP chain. - """ - return block_pct_db(x, sr, block_ms=50.0, pct=95.0) - - -def lufs(x: np.ndarray, sr: int) -> float: - import pyloudnorm as pyln - - if not np.any(x): - return SILENCE_DB - if x.size < int(sr * 0.2): - return SILENCE_DB - meter = pyln.Meter(sr) - try: - val = meter.integrated_loudness(x) - except ValueError: - return SILENCE_DB - if not np.isfinite(val): - return SILENCE_DB - return float(val) - - -def meter(x: np.ndarray, sr: int, mode: str) -> float: - return lufs(x, sr) if mode == "lufs" else rms_db(x) - - -def all_meters(x: np.ndarray, sr: int) -> dict[str, float]: - return { - "rms_db": rms_db(x), - "sample_peak_db": sample_peak_db(x), - "true_peak_db": true_peak_db(x, sr), - "lufs": lufs(x, sr), - "noise_floor_db": noise_floor_db(x, sr), - "duration_s": round(x.size / sr, 3) if sr else 0.0, - } diff --git a/lib/src/producer/pipeline.py b/lib/src/producer/pipeline.py deleted file mode 100644 index e7aa4ae..0000000 --- a/lib/src/producer/pipeline.py +++ /dev/null @@ -1,269 +0,0 @@ -from __future__ import annotations - -import math -import time -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Protocol - -import numpy as np - -from . import dsp, loudness -from .config import Options -from .meters import all_meters, speech_level_db - - -class Reporter(Protocol): - """Status sink for the pipeline; see producer.ui.Status.""" - - def stage(self, name: str) -> None: ... - - def tick(self, done: int, total: int) -> None: ... - - def stage_done(self, name: str, elapsed: float | None = None) -> None: ... - - -@dataclass -class Stage: - name: str - detail: str - enabled: bool = True - fn: Callable[[np.ndarray, int], np.ndarray] | None = None - - -@dataclass -class RunResult: - audio: np.ndarray - sr: int - stages: list[Stage] - timings: dict[str, float] = field(default_factory=dict) - notes: list[str] = field(default_factory=list) - before: dict[str, float] = field(default_factory=dict) - after: dict[str, float] = field(default_factory=dict) - - -def _chunk_note(opts: Options, notes: list[str], x: np.ndarray, sr: int) -> None: - if opts.engine_chunk_s <= 0 or x.size <= opts.engine_chunk_s * sr: - return - eff = max(0.01, opts.engine_chunk_s - opts.engine_overlap_s) - n_chunks = math.ceil(x.size / sr / eff) - notes.append( - f"engine chunking: {n_chunks} chunks x {opts.engine_chunk_s:g}s" - f" ({opts.engine_overlap_s:g}s overlap)" - ) - - -def _denoise_fn(opts: Options, notes: list[str], reporter: Reporter | None = None): - engine = opts.denoise - prog = reporter.tick if reporter is not None else None - - def fn(x: np.ndarray, sr: int) -> np.ndarray: - if engine == "off": - return x - _chunk_note(opts, notes, x, sr) - if engine == "dfn3": - from .engines import denoise_dfn - - y, eng, dev = denoise_dfn.denoise( - x, - sr, - opts.denoise_strength, - opts.device, - chunk_s=opts.engine_chunk_s, - overlap_s=opts.engine_overlap_s, - on_progress=prog, - post_filter=opts.denoise_pf, - ) - elif engine == "zipenhancer": - from .engines import denoise_zip - - y, eng, dev = denoise_zip.denoise( - x, - sr, - opts.denoise_strength, - opts.device, - chunk_s=opts.engine_chunk_s, - overlap_s=opts.engine_overlap_s, - on_progress=prog, - ) - elif engine == "spectral": - from .engines import denoise_spectral - - y, eng, dev = denoise_spectral.denoise( - x, - sr, - opts.denoise_strength, - opts.device, - chunk_s=opts.engine_chunk_s, - overlap_s=opts.engine_overlap_s, - on_progress=prog, - ) - else: - raise ValueError(f"unknown denoise engine: {engine}") - notes.append(f"denoise: {eng} on {dev}") - return y - - return fn - - -def _enhance_fn(opts: Options, notes: list[str], reporter: Reporter | None = None): - engine = opts.enhance - prog = reporter.tick if reporter is not None else None - - def fn(x: np.ndarray, sr: int) -> np.ndarray: - if engine == "off": - return x - _chunk_note(opts, notes, x, sr) - if engine == "mossformer2": - from .engines import enhance_mossformer - - y, eng, dev = enhance_mossformer.enhance( - x, - sr, - opts.enhance_strength, - opts.device, - chunk_s=opts.engine_chunk_s, - overlap_s=opts.engine_overlap_s, - on_progress=prog, - ) - elif engine == "resemble": - from .engines import enhance_resemble - - y, eng, dev = enhance_resemble.enhance( - x, - sr, - opts.enhance_strength, - opts.device, - chunk_s=opts.engine_chunk_s, - overlap_s=opts.engine_overlap_s, - on_progress=prog, - ) - else: - raise ValueError(f"unknown enhance engine: {engine}") - notes.append(f"enhance: {eng} on {dev}") - return y - - return fn - - -def _dsp_fn(opts: Options, notes: list[str]): - p = opts.prof() - - def fn(x: np.ndarray, sr: int) -> np.ndarray: - # Gain-stage the (denoised) signal to the level the voice chain was - # designed around. Denoisers shift the level distribution, and the - # absolute comp thresholds only make sense at a known input level; - # anchoring on measured speech level keeps the chain predictable and - # stops the compressors over-riding sparse denoised speech. - ref = speech_level_db(x, sr) - pregain_db = float(np.clip(p.dsp_ref_db - ref, -24.0, 24.0)) - notes.append(f"dsp pregain: {pregain_db:+.1f} dB (speech level {ref:.1f} dB)") - x = x * np.float32(10.0 ** (pregain_db / 20.0)) - hpf_hz = opts.hpf_hz if opts.hpf_hz is not None else p.hpf_hz - if opts.eff("hpf") > 0.001: - x = dsp.hpf(x, sr, hpf_hz) - f, g, q = p.mud - x = dsp.peak_eq(x, sr, f, g * opts.eff("mud"), q) - wf, wg = p.warmth - x = dsp.shelf(x, sr, wf, wg * opts.eff("warmth"), low=True) - x = dsp.soothe(x, sr, opts.eff("soothe")) - c1 = p.comp1 - y = dsp.compressor(x, sr, c1[0], c1[1], c1[2], c1[3]) - x = _blend(x, y, opts.eff("compress") * 0.7) - c2 = p.comp2 - y = dsp.compressor(x, sr, c2[0], c2[1], c2[2], c2[3]) - x = _blend(x, y, opts.eff("compress") * 0.5) - x = dsp.tape(x, sr, opts.eff("tape")) - d = p.deess - x = dsp.deesser(x, sr, d[0], d[1], d[2] * opts.eff("deess")) - pf, pg = p.presence - x = dsp.peak_eq(x, sr, pf, pg * opts.eff("presence")) - af, ag = p.air - x = dsp.shelf(x, sr, af, ag * opts.eff("air"), low=False) - # Between sentences a good denoiser leaves near-silence but tape hiss - # and breaths survive; duck them for real instead of the old 6 dB cap. - breath = opts.eff("breath") - max_drop = (12.0 + 12.0 * breath) if breath > 0.02 else 0.0 - x = dsp.expander(x, sr, max_drop_db=max_drop) - return x - - return fn - - -def _blend(x: np.ndarray, y: np.ndarray, s: float) -> np.ndarray: - s = float(np.clip(s, 0.0, 1.0)) - if s >= 0.999: - return y.astype(np.float32, copy=False) - if s <= 0.001: - return x.astype(np.float32, copy=False) - out = np.asarray(y, dtype=np.float32) - np.asarray(x, dtype=np.float32) - out *= s - out += x - return out - - -def _level_fn(opts: Options, notes: list[str]): - def fn(x: np.ndarray, sr: int) -> np.ndarray: - return loudness.normalize(x, sr, opts.loudness_mode(), opts.target_value(), opts.ceiling()) - - return fn - - -def build_stages( - opts: Options, notes: list[str] | None = None, reporter: Reporter | None = None -) -> list[Stage]: - notes = notes if notes is not None else [] - stages: list[Stage] = [] - stages.append( - Stage( - "denoise", - f"engine={opts.denoise} strength={opts.denoise_strength:.2f}" - f" pf={'on' if opts.denoise_pf else 'off'}", - opts.denoise != "off", - _denoise_fn(opts, notes, reporter), - ) - ) - stages.append( - Stage( - "enhance", - f"engine={opts.enhance} strength={opts.enhance_strength:.2f}", - opts.enhance != "off", - _enhance_fn(opts, notes, reporter), - ) - ) - dsp_desc = "hpf→mud→warmth→soothe→comp2x→tape→deess→presence→air→breath" - stages.append(Stage("dsp", dsp_desc, opts.dsp, _dsp_fn(opts, notes) if opts.dsp else None)) - stages.append( - Stage( - "levelling", - f"mode={opts.loudness_mode()} target={opts.target_value()} ceiling={opts.ceiling()}", - opts.levelling, - _level_fn(opts, notes) if opts.levelling else None, - ) - ) - return stages - - -def run_pipeline( - x: np.ndarray, sr: int, opts: Options, reporter: Reporter | None = None -) -> RunResult: - notes: list[str] = [] - stages = build_stages(opts, notes) - before = all_meters(x, sr) - y = x - timings: dict[str, float] = {} - for st in stages: - if not st.enabled or st.fn is None: - continue - if reporter is not None: - reporter.stage(st.name) - t0 = time.perf_counter() - y = st.fn(y, sr) - elapsed = time.perf_counter() - t0 - timings[st.name] = round(elapsed, 3) - if reporter is not None: - reporter.stage_done(st.name, elapsed) - after = all_meters(y, sr) - return RunResult( - audio=y, sr=sr, stages=stages, timings=timings, notes=notes, before=before, after=after - ) diff --git a/lib/src/producer/report.py b/lib/src/producer/report.py deleted file mode 100644 index 27fbb8f..0000000 --- a/lib/src/producer/report.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from . import __version__ -from .config import Options - - -def build( - input_path: str, - out_path: str, - opts: Options, - before: dict[str, float], - after: dict[str, float], - timings: dict[str, float], - notes: list[str], -) -> dict: - return { - "tool": "producer", - "version": __version__, - "input": str(input_path), - "output": str(out_path), - "settings": opts.to_dict(), - "before": before, - "after": after, - "stage_seconds": timings, - "engine_notes": notes, - } - - -def print_human(rep: dict) -> None: - b = rep["before"] - a = rep["after"] - print(" before -> after:") - for key, label in ( - ("rms_db", "RMS"), - ("true_peak_db", "true peak"), - ("lufs", "LUFS"), - ("noise_floor_db", "noise floor"), - ): - print(f" {label:<12} {b[key]:>8.1f} dB -> {a[key]:>8.1f} dB") - if rep["engine_notes"]: - for note in rep["engine_notes"]: - print(f" {note}") - times = rep["stage_seconds"] - if times: - total = sum(times.values()) - detail = ", ".join(f"{k} {v:.1f}s" for k, v in times.items()) - print(f" stages: {detail} (total {total:.1f}s)") - - -def save(rep: dict, path: str | Path) -> Path: - path = Path(path) - path.write_text(json.dumps(rep, indent=2)) - return path diff --git a/lib/src/producer/ui.py b/lib/src/producer/ui.py deleted file mode 100644 index bc08756..0000000 --- a/lib/src/producer/ui.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Terminal progress primitives: live status lines, download bars, subprocess runners. - -All live rendering shares one line on stdout: renderers erase whatever line is -currently live before drawing their own, and `log()` erases it before printing -a permanent line, so engine prints, downloads, and stage ticks never garble -each other. Without a TTY nothing is drawn in place; instead milestones are -logged periodically so piped output still shows regular progress. -""" - -from __future__ import annotations - -import hashlib -import shutil -import subprocess -import sys -import threading -import time -import urllib.request -from pathlib import Path - -_lock = threading.Lock() -_live = "" # contents of the in-place line, "" when none - -UA = "producer/0.1.0" - - -def is_tty() -> bool: - try: - return bool(sys.stdout and sys.stdout.isatty()) - except Exception: - return False - - -def _cols() -> int: - try: - return max(20, shutil.get_terminal_size().columns) - except Exception: - return 80 - - -def _erase() -> None: - global _live - if _live: - sys.stdout.write("\r" + " " * len(_live) + "\r") - _live = "" - - -def _render_live(text: str) -> None: - global _live - if not is_tty(): - return - with _lock: - text = text[: _cols() - 1] - _erase() - sys.stdout.write("\r" + text) - sys.stdout.flush() - _live = text - - -def finish_live() -> None: - """Clear the in-place line, if any.""" - with _lock: - if is_tty(): - _erase() - sys.stdout.flush() - - -def log(msg: str) -> None: - """Print a permanent line, clearing any in-place progress line first.""" - with _lock: - if is_tty(): - _erase() - print(msg, flush=True) - - -def fmt_bytes(n: float | None) -> str: - if n is None: - return "?" - n = float(n) - for unit in ("B", "KB", "MB", "GB", "TB"): - if n < 1024.0 or unit == "TB": - return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B" - n /= 1024.0 - return f"{n:.1f} TB" - - -def fmt_secs(s: float | None) -> str: - if s is None or s < 0 or s != s: # None, negative, NaN - return "?" - s = int(s) - h, rem = divmod(s, 3600) - m, sec = divmod(rem, 60) - return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}" - - -def _bar(frac: float, width: int = 22) -> str: - frac = min(1.0, max(0.0, frac)) - fill = round(frac * width) - return "[" + "#" * fill + "-" * (width - fill) + "]" - - -class Progress: - """Byte progress for one download: live bar on a TTY, milestones otherwise.""" - - def __init__(self, label: str, total: int | None = None) -> None: - self.label = label - self.total = total - self.done = 0 - self._t0 = time.monotonic() - self._last_draw = 0.0 - self._last_ms_t = 0.0 - self._last_ms_pct = -100 - - def update(self, done: int, total: int | None = None) -> None: - if total is not None: - self.total = total - self.done = done - now = time.monotonic() - if is_tty(): - if now - self._last_draw >= 0.1 or (self.total and done >= self.total): - self._draw(now) - else: - self._milestone(now) - - def _draw(self, now: float) -> None: - self._last_draw = now - el = max(1e-6, now - self._t0) - speed = self.done / el - text = f"[producer] {self.label} {_bar(0)}" - if self.total: - frac = self.done / self.total - rem = max(0.0, el * (self.total - self.done) / max(1, self.done)) - text = ( - f"[producer] {self.label} {_bar(frac)} {fmt_bytes(self.done)}/" - f"{fmt_bytes(self.total)} ({frac * 100:.0f}%) {fmt_bytes(speed)}/s" - f" ETA {fmt_secs(rem)}" - ) - else: - text = f"[producer] {self.label} {fmt_bytes(self.done)} {fmt_bytes(speed)}/s" - _render_live(text) - - def _milestone(self, now: float) -> None: - pct = 100.0 * self.done / self.total if self.total else 0.0 - if ( - now - self._last_ms_t >= 30.0 - or (self.total and pct - self._last_ms_pct >= 10.0) - or (self.total and self.done >= self.total) - ): - self._last_ms_t = now - self._last_ms_pct = pct - log(f"[producer] {self.label} {fmt_bytes(self.done)}/{fmt_bytes(self.total)}") - - def close(self, final: str | None = None) -> None: - el = time.monotonic() - self._t0 - msg = final or (f"[producer] {self.label} done ({fmt_bytes(self.done)} in {fmt_secs(el)})") - log(msg) - - -def _sha256_of(path: Path) -> str: - h = hashlib.sha256() - with path.open("rb") as f: - for chunk in iter(lambda: f.read(1 << 20), b""): - h.update(chunk) - return h.hexdigest() - - -def _cache_ok(dest: Path, expected_size: int | None, sha256: str | None) -> bool: - if not dest.is_file(): - return False - if sha256: - if expected_size is not None and dest.stat().st_size != expected_size: - return False - return _sha256_of(dest) == sha256 - if expected_size is not None: - return dest.stat().st_size == expected_size - return True - - -def download( - url: str, - dest: Path, - label: str | None = None, - expected_size: int | None = None, - sha256: str | None = None, - timeout: float = 60.0, -) -> Path: - """Download `url` to `dest` with a progress bar; skips if already cached.""" - dest = Path(dest) - label = label or f"downloading {dest.name}" - if _cache_ok(dest, expected_size, sha256): - log(f"[producer] {label}: already cached ({fmt_bytes(dest.stat().st_size)})") - return dest - dest.parent.mkdir(parents=True, exist_ok=True) - tmp = dest.with_name(dest.name + ".part") - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=timeout) as resp: - total = expected_size - if total is None: - try: - total = int(resp.headers.get("Content-Length") or 0) or None - except (TypeError, ValueError): - total = None - prog = Progress(label, total) - hasher = hashlib.sha256() if sha256 else None - tmp.parent.mkdir(parents=True, exist_ok=True) - with tmp.open("wb") as f: - while True: - chunk = resp.read(1 << 20) - if not chunk: - break - f.write(chunk) - if hasher is not None: - hasher.update(chunk) - prog.update(f.tell()) - prog.close() - if hasher is not None and hasher.hexdigest() != sha256: - tmp.unlink(missing_ok=True) - raise RuntimeError(f"checksum mismatch for {dest.name} ({url})") - tmp.replace(dest) - return dest - - -def run(cmd: list[str], label: str, check: bool = True) -> int: - """Run a subprocess, keeping output visible and adding heartbeats when piped. - - On a TTY the child inherits the terminal (uv draws its own progress bars); - when output is piped, the child's lines are forwarded and a heartbeat with - elapsed time is logged every 30 s so long installs never look frozen. - """ - if is_tty(): - proc = subprocess.run(cmd, check=check) - return proc.returncode - start = time.monotonic() - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - - def beat() -> None: - while p.poll() is None: - time.sleep(30.0) - if p.poll() is None: - log(f"[producer] {label}... {int(time.monotonic() - start)}s elapsed") - - threading.Thread(target=beat, daemon=True).start() - assert p.stdout is not None - for line in p.stdout: - line = line.rstrip() - if line: - log(line) - rc = p.wait() - if check and rc != 0: - raise subprocess.CalledProcessError(rc, cmd) - return rc - - -class Status: - """Per-file pipeline reporter: one live line for the current stage.""" - - def __init__(self, prefix: str = "") -> None: - self.prefix = prefix - self.stage_name: str | None = None - self.total: int | None = None - self.done = 0 - self._t0 = 0.0 - self._last_draw = 0.0 - self._last_ms_t = 0.0 - self._last_ms_pct = -100 - - def stage(self, name: str) -> None: - self.stage_name = name - self.total = None - self.done = 0 - self._t0 = time.monotonic() - self._last_ms_t = self._t0 - self._last_ms_pct = -100 - self._draw(force=True) - - def tick(self, done: int, total: int) -> None: - """Progress within the current stage (engine chunks).""" - self.total = total - self.done = done - now = time.monotonic() - if is_tty(): - if now - self._last_draw >= 0.1 or done >= total: - self._draw(now) - elif total and ( - done >= total - or now - self._last_ms_t >= 30.0 - or 100.0 * done / total - self._last_ms_pct >= 20.0 - ): - self._last_ms_t = now - self._last_ms_pct = 100.0 * done / total - log(f"{self.prefix}{self.stage_name} {done}/{total} chunks ({self._last_ms_pct:.0f}%)") - - def stage_done(self, name: str, elapsed: float | None = None) -> None: - el = time.monotonic() - self._t0 if elapsed is None else elapsed - chunks = f" ({self.done}/{self.total} chunks)" if self.total else "" - log(f"{self.prefix}{name} done in {el:.1f}s{chunks}") - self.stage_name = None - finish_live() - - def _draw(self, force: bool = False) -> None: - if self.stage_name is None: - return - now = time.monotonic() - if not force and now - self._last_draw < 0.1: - return - self._last_draw = now - el = now - self._t0 - text = f"{self.prefix}{self.stage_name}" - if self.total: - pct = 100.0 * self.done / self.total - eta = el * (self.total - self.done) / self.done if self.done else None - text += f" {_bar(self.done / self.total, 18)} {self.done}/{self.total}" - text += f" chunks ({pct:.0f}%) ETA {fmt_secs(eta)}" - else: - text += f"... {el:.0f}s" - _render_live(text) - - def finish(self) -> None: - finish_live() diff --git a/lib/src/producer/updates.py b/lib/src/producer/updates.py deleted file mode 100644 index 690e955..0000000 --- a/lib/src/producer/updates.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Dependency update checks: probe uv for newer allowed versions, prompt, apply. - -Probes are `uv pip install --dry-run --upgrade` resolutions against the live -environment, so "newer" always respects requirements-core.txt pins. Engine -probes pin `torch==` and include the core requirements file so -an engine upgrade can never re-resolve a CUDA torch onto a CPU wheel or drag -core pins. Everything here is best-effort: uv/network failures are skipped -silently and never block a run. -""" - -from __future__ import annotations - -import importlib -import re -import subprocess -import sys -from concurrent.futures import ThreadPoolExecutor -from importlib import metadata -from pathlib import Path -from typing import NamedTuple - -from . import lazy, ui - -CORE_FILE = lazy.DATA_DIR / "requirements-core.txt" -RESEMBLE_PY = lazy.DATA_DIR / "venvs" / "resemble" / "bin" / "python" -ENGINE_DISTS = ("deepfilternet", "zipenhancer", "clearvoice") -PROBE_TIMEOUT = 8.0 - - -class Update(NamedTuple): - """One promptable upgrade; `cmd` is the uv invocation that applies it.""" - - label: str - old: str | None - new: str - note: str - cmd: list[str] - - -def _installed(dist: str) -> str | None: - try: - return metadata.version(dist) - except metadata.PackageNotFoundError: - return None - - -def _installed_in(python: Path, dist: str) -> str | None: - try: - proc = subprocess.run( - [ - str(python), - "-c", - "import sys; from importlib.metadata import version; print(version(sys.argv[1]))", - dist, - ], - capture_output=True, - text=True, - timeout=15.0, - ) - except (OSError, subprocess.SubprocessError): - return None - if proc.returncode != 0: - return None - return proc.stdout.strip() or None - - -def _core_names() -> list[str]: - names = [] - for line in CORE_FILE.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#"): - names.append(re.split(r"[<>=!~;\[\s]", line)[0]) - return names - - -def _parse_would_install(out: str) -> dict[str, str]: - found: dict[str, str] = {} - for line in out.splitlines(): - line = line.strip() - if line.startswith(("+ ", "~ ")): - name, _, ver = line[2:].partition("==") - if name and ver: - found[name.strip()] = ver.strip() - return found - - -def _vkey(version: str) -> tuple: - """Orderable key for simple version comparison ("1.2.10" > "1.2.9").""" - parts = [] - for chunk in re.split(r"[._\-+!]", version): - if chunk: - parts.append((1, int(chunk), "") if chunk.isdigit() else (0, 0, chunk)) - return tuple(parts) - - -def _probe(args: list[str], python: str | None, keep: set[str]) -> dict[str, str]: - """`uv pip install --dry-run --upgrade ` -> {name: version} for `keep`. - - Best-effort: any uv failure (offline, unsatisfiable resolution, timeout) - yields {} so the update check silently skips that group. - """ - try: - cmd = [ - lazy.find_uv(), - "pip", - "install", - "--dry-run", - "--upgrade", - "--python", - python or sys.executable, - *args, - ] - proc = subprocess.run(cmd, capture_output=True, text=True, timeout=PROBE_TIMEOUT) - except Exception: - return {} - if proc.returncode != 0: - return {} - got = _parse_would_install(proc.stdout) - return {name: ver for name, ver in got.items() if name in keep} - - -def _jobs() -> list[tuple[str, list[str], str | None, set[str], str]]: - """(key, probe args, venv python or None, keep-set, display note) per probe.""" - jobs: list[tuple[str, list[str], str | None, set[str], str]] = [ - ("core", ["-U", "-r", str(CORE_FILE)], None, set(_core_names()), "") - ] - torch = _installed("torch") - gpu = lazy.gpu_present() - if torch and _installed("torchaudio"): - args = ["-U", "torch", "torchaudio"] - note = "CUDA, ~2.5 GB download" if gpu else "CPU build" - if gpu: - args += ["--index-url", lazy.TORCH_GPU_INDEX] - jobs.append(("torch+torchaudio", args, None, {"torch", "torchaudio"}, note)) - for name in ENGINE_DISTS: - if torch and _installed(name): - jobs.append( - ( - name, - ["-U", "-r", str(CORE_FILE), f"torch=={torch.split('+')[0]}", name], - None, - {name}, - "", - ) - ) - if RESEMBLE_PY.is_file() and _installed_in(RESEMBLE_PY, "resemble-enhance"): - jobs.append( - ( - "resemble-enhance", - ["-U", "resemble-enhance"], - str(RESEMBLE_PY), - {"resemble-enhance"}, - "isolated venv", - ) - ) - return jobs - - -def collect() -> list[Update]: - """Best-effort list of available updates (empty when up to date or offline).""" - jobs = _jobs() - if not jobs: - return [] - with ThreadPoolExecutor(max_workers=len(jobs)) as ex: - probes = list(ex.map(lambda j: (j[0], j[1], j[4], _probe(j[1], j[2], j[3])), jobs)) - updates: list[Update] = [] - uv = lazy.find_uv() - for key, _args, note, got in probes: - if not got: - continue - if key == "core": - for name in _core_names(): - new, old = got.get(name), _installed(name) - if new and old and _vkey(new) > _vkey(old): - updates.append( - Update( - name, - old, - new, - "", - [ - uv, - "pip", - "install", - "--python", - sys.executable, - "-U", - "-r", - str(CORE_FILE), - ], - ) - ) - elif key == "torch+torchaudio": - new, ta_new, old = got.get("torch"), got.get("torchaudio"), _installed("torch") - if new and ta_new and old and _vkey(new) > _vkey(old): - cmd = [ - uv, - "pip", - "install", - "--python", - sys.executable, - f"torch=={new}", - f"torchaudio=={ta_new}", - ] - if lazy.gpu_present(): - cmd += ["--index-url", lazy.TORCH_GPU_INDEX] - updates.append(Update("torch + torchaudio", old, new, note, cmd)) - else: - new = got.get(key) - old = _installed_in(RESEMBLE_PY, key) if key == "resemble-enhance" else _installed(key) - if new and old and _vkey(new) > _vkey(old): - if key == "resemble-enhance": - cmd = [uv, "pip", "install", "--python", str(RESEMBLE_PY), f"{key}=={new}"] - else: - torch = _installed("torch") - cmd = [ - uv, - "pip", - "install", - "--python", - sys.executable, - "-r", - str(CORE_FILE), - f"torch=={torch.split('+')[0]}", - f"{key}=={new}", - ] - updates.append(Update(key, old, new, note, cmd)) - return updates - - -def _confirm(prompt: str) -> bool: - try: - ans = input(f"[producer] {prompt} [y/N]: ").strip().lower() - except EOFError: - print() - return False - return ans in ("y", "yes") - - -def check_and_prompt(force: bool = False, assume_yes: bool = False) -> bool: - """Check once; prompt on interactive TTYs. Returns True when updates applied.""" - updates = collect() - if not updates: - if force: - ui.log("[producer] everything is up to date") - return False - ui.log("[producer] updates available:") - for u in updates: - line = f" {u.label} {u.old} -> {u.new}" - if u.note: - line += f" ({u.note})" - ui.log(line) - if not assume_yes: - if not sys.stdin.isatty(): - if force: - raise SystemExit("producer update: stdin is not interactive; use --yes") - ui.log("[producer] run ./producer update to install these") - return False - if not _confirm("install updates?"): - ui.log("[producer] skipped updates") - return False - for u in updates: - ui.run(u.cmd, f"updating {u.label}") - importlib.invalidate_caches() - ui.log("[producer] updates installed") - return True - - -def run_update_command(args: list[str]) -> int: - """`producer update [--yes]`: check for updates and install them now.""" - assume_yes = any(a in ("-y", "--yes") for a in args) - check_and_prompt(force=True, assume_yes=assume_yes) - return 0 diff --git a/lib/tests/conftest.py b/lib/tests/conftest.py deleted file mode 100644 index 885d22b..0000000 --- a/lib/tests/conftest.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path - -import numpy as np -import pytest - -SRC = Path(__file__).resolve().parents[1] / "src" -sys.path.insert(0, str(SRC)) -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -SR = 44100 - - -def speechish( - dur: float, - sr: int = SR, - level_dbfs: float = -20.0, - seed: int = 0, -) -> np.ndarray: - rng = np.random.default_rng(seed) - n = int(sr * dur) - t = np.arange(n) / sr - f0 = 110.0 * (1.0 + 0.02 * np.sin(2 * np.pi * 0.9 * t)) - phase = 2 * np.pi * np.cumsum(f0) / sr - x = np.zeros(n) - for k in range(1, 9): - x += (1.0 / k**1.3) * np.sin(k * phase + 0.3 * k) - syll = 0.5 + 0.5 * np.sin(2 * np.pi * 3.0 * t + float(rng.uniform(0, 6))) - pauses = (np.sin(2 * np.pi * 0.5 * t) > -0.6).astype(float) - env = np.clip(syll, 0.02, 1.0) ** 0.6 * np.maximum(pauses, 0.05) - x = x * env - x /= np.max(np.abs(x)) + 1e-12 - return (x * (10 ** (level_dbfs / 20.0))).astype(np.float32) - - -def sine(freq: float, dur: float, sr: int = SR, peak_dbfs: float = -20.0) -> np.ndarray: - t = np.arange(int(sr * dur)) / sr - return (10 ** (peak_dbfs / 20.0) * np.sin(2 * np.pi * freq * t)).astype(np.float32) - - -def band_db(x: np.ndarray, sr: int, lo: float, hi: float) -> float: - from scipy import signal - - sos = signal.butter(4, [lo, hi], btype="bandpass", fs=sr, output="sos") - y = signal.sosfilt(sos, x.astype(np.float64)) - r = np.sqrt(np.mean(np.square(y))) - if r <= 0: - return -120.0 - return float(20 * np.log10(r)) - - -@pytest.fixture -def sr() -> int: - return SR - - -@pytest.fixture -def speech() -> np.ndarray: - return speechish(6.0, level_dbfs=-20.0) - - -@pytest.fixture -def noisy_speech(sr, speech) -> np.ndarray: - rng = np.random.default_rng(7) - noise = rng.standard_normal(speech.size) - noise *= (10 ** (-48.0 / 20.0)) / np.sqrt(np.mean(np.square(noise))) - hum = 0.003 * np.sin(2 * np.pi * 50.0 * np.arange(speech.size) / sr) - return (speech + noise + hum).astype(np.float32) diff --git a/lib/tests/test_chunking.py b/lib/tests/test_chunking.py deleted file mode 100644 index f00c04e..0000000 --- a/lib/tests/test_chunking.py +++ /dev/null @@ -1,250 +0,0 @@ -import numpy as np -import pytest - -from producer.engines.base import blend -from producer.engines.chunking import apply_chunked, plan_chunks, stitch - - -def test_plan_chunks_basic(): - assert plan_chunks(0, 5, 2) == [] - assert plan_chunks(4, 10, 2) == [(0, 4)] - assert plan_chunks(10, 5, 0) == [(0, 5), (5, 10)] - assert plan_chunks(10, 5, 2) == [(0, 5), (3, 8), (6, 10)] - assert plan_chunks(7, 5, 2) == [(0, 5), (3, 7)] - - -def test_plan_chunks_last_span_covers_tail(): - spans = plan_chunks(11, 5, 4) - assert spans[-1][1] == 11 - assert spans[-1][1] - spans[-1][0] >= 5 - - -def test_plan_chunks_rejects_overlap_ge_chunk(): - with pytest.raises(ValueError): - plan_chunks(100, 5, 5) - with pytest.raises(ValueError): - plan_chunks(100, 5, 6) - - -def test_stitch_identity_pieces_reconstruct_signal(): - rng = np.random.default_rng(0) - x = rng.standard_normal(10_000).astype(np.float32) - spans = plan_chunks(x.size, 2500, 300) - y = stitch(spans, x.size, (x[a:b] for a, b in spans)) - assert y.dtype == np.float32 - assert y.shape == x.shape - np.testing.assert_allclose(y, x, atol=1e-5) - - -def test_stitch_single_span_passthrough(): - x = np.arange(100, dtype=np.float32) - y = stitch([(0, 100)], 100, [x]) - np.testing.assert_array_equal(y, x) - - -def test_stitch_crossfade_of_complementary_pieces(): - # piece A is silence, piece B is a full-scale ramp; the crossfade zone - # must be a smooth blend, not a jump. - spans = [(0, 10), (5, 15)] - pieces = [np.zeros(10, dtype=np.float32), np.ones(10, dtype=np.float32)] - y = stitch(spans, 15, pieces) - mid = y[7] # 50% through the overlap - assert 0.4 < mid < 0.6 - assert y[0] == 0.0 - assert y[14] == 1.0 - - -def test_stitch_pads_short_pieces(): - spans = [(0, 10), (5, 15)] - pieces = [np.arange(10, dtype=np.float32), np.arange(8, dtype=np.float32)] - y = stitch(spans, 15, pieces) - assert y.shape == (15,) - assert np.all(np.isfinite(y)) - - -def test_apply_chunked_identity_matches_whole_file(): - rng = np.random.default_rng(1) - x = rng.standard_normal(50_000).astype(np.float32) - sr = 8000 - calls = [] - - def fn(chunk): - calls.append(chunk.size) - return chunk - - y = apply_chunked(x, sr, 7.0, 0.05, fn) - assert calls == [50_000] - np.testing.assert_array_equal(y, x) - - calls.clear() - y = apply_chunked(x, sr, 0.5, 0.05, fn) - assert len(calls) >= 10 - assert all(c <= 4000 for c in calls) - np.testing.assert_allclose(y, x, atol=1e-5) - - -def test_apply_chunked_zero_disables_chunking(): - x = np.zeros(10, dtype=np.float32) - calls = [] - - def fn(chunk): - calls.append(chunk.size) - return chunk - - apply_chunked(x, 8000, 0.0, 0.5, fn) - assert calls == [10] - - -def test_apply_chunked_context_reconstructs_identity(): - rng = np.random.default_rng(3) - x = rng.standard_normal(30_000).astype(np.float32) - y = apply_chunked(x, 8000, 1.5, 0.25, lambda c: c, context_s=0.5) - np.testing.assert_allclose(y, x, atol=1e-5) - - -def test_apply_chunked_context_feeds_padded_chunks(): - x = np.ones(5_000, dtype=np.float32) - sizes = [] - - def fn(chunk): - sizes.append(chunk.size) - return chunk - - apply_chunked(x, 1000, 2.0, 0.0, fn, context_s=0.5) - # plan_chunks(5000, 2000, 0) -> (0,2000), (2000,4000), (4000,5000) - assert sizes == [2500, 3000, 1500] - - -def test_apply_chunked_context_trims_cold_start_artifacts(): - x = np.ones(6_000, dtype=np.float32) - - def fn(chunk): - out = chunk.copy() - out[0] = 0.0 # cold-start artifact at the start of every model call - return out - - broken = apply_chunked(x, 1000, 2.0, 0.0, fn) - assert int(np.sum(broken == 0.0)) > 1 - - y = apply_chunked(x, 1000, 2.0, 0.0, fn, context_s=0.5) - assert y[0] == 0.0 # only the true file start stays degraded - assert np.all(y[1:] == 1.0) - - -def test_apply_chunked_context_survives_oom_retry(): - x = np.ones(8_000, dtype=np.float32) - attempts = [] - - def fn(chunk): - attempts.append(chunk.size) - if chunk.size > 3000: - raise MemoryError("simulated oom") - out = chunk.copy() - out[0] = 0.0 - return out - - y = apply_chunked(x, 1000, 6.0, 0.0, fn, min_chunk_s=1.0, context_s=0.5) - assert max(attempts) > 3000 - assert y[0] == 0.0 - assert np.all(y[1:] == 1.0) - - -def test_apply_chunked_retries_smaller_on_oom(): - x = np.random.default_rng(2).standard_normal(40_000).astype(np.float32) - sr = 8000 - attempts = [] - - def fn(chunk): - attempts.append(chunk.size) - if chunk.size > 3000: - raise MemoryError("simulated oom") - return chunk - - y = apply_chunked(x, sr, 5.0, 0.0, fn, min_chunk_s=0.25) - assert attempts[0] == 40_000 - assert attempts[-1] == 2500 - assert max(a for a in attempts if a <= 3000) == 2500 - np.testing.assert_allclose(y, x, atol=1e-5) - - -def test_apply_chunked_oom_gives_up_at_min_chunk(): - x = np.zeros(40_000, dtype=np.float32) - - def fn(chunk): - raise MemoryError("always") - - with pytest.raises(MemoryError): - apply_chunked(x, 8000, 1.0, 0.0, fn, min_chunk_s=0.5) - - -def test_apply_chunked_progress_callback(): - x = np.random.default_rng(3).standard_normal(20_000).astype(np.float32) - sr = 8000 - seen: list[tuple[int, int]] = [] - y = apply_chunked(x, sr, 0.5, 0.0, lambda c: c, on_progress=lambda d, t: seen.append((d, t))) - total = len(plan_chunks(x.size, round(0.5 * sr), 0)) - assert seen[0] == (0, total) - assert seen[-1] == (total, total) - assert [d for d, _t in seen[1:]] == list(range(1, total + 1)) - np.testing.assert_allclose(y, x, atol=1e-6) - - -def test_apply_chunked_progress_resets_after_oom_retry(): - x = np.random.default_rng(4).standard_normal(40_000).astype(np.float32) - sr = 8000 - seen: list[tuple[int, int]] = [] - - def fn(chunk): - if chunk.size > 3000: - raise MemoryError("simulated oom") - return chunk - - y = apply_chunked( - x, sr, 5.0, 0.0, fn, min_chunk_s=0.25, on_progress=lambda d, t: seen.append((d, t)) - ) - assert seen[0] == (0, 1) # first attempt is one whole-file chunk - done, total = seen[-1] - assert done == total and total > 2 # retried into smaller chunks - np.testing.assert_allclose(y, x, atol=1e-5) - - -def test_apply_chunked_progress_absent_when_disabled(): - x = np.zeros(10_000, dtype=np.float32) - y = apply_chunked(x, 8000, 0.5, 0.0, lambda c: c, on_progress=None) - assert y.shape == x.shape - - -def test_apply_chunked_whole_file_mode_falls_back_on_oom(): - x = np.random.default_rng(5).standard_normal(10_000).astype(np.float32) - sr = 1000 - - def fn(chunk): - if chunk.size > 1500: - raise MemoryError("simulated oom") - return chunk * 2 - - y = apply_chunked(x, sr, 0.0, 0.0, fn, min_chunk_s=0.5) - np.testing.assert_allclose(y, x * 2, atol=1e-6) - - -def test_blend_float32_no_float64_temporaries(): - x = np.full(1000, 0.25, dtype=np.float32) - y = np.full(1000, 0.75, dtype=np.float32) - out = blend(x, y, 0.5) - assert out.dtype == np.float32 - np.testing.assert_allclose(out, 0.5, atol=1e-7) - assert blend(x, y, 1.0) is y - np.testing.assert_allclose(blend(x, y, 0.0), x, atol=1e-7) - - -def test_blend_realigns_resample_drift(): - # a 48k -> 16k -> 48k round trip can come back a sample or two long - # (resample_poly emits ceil(n * up/down) per hop); blend must cope - x = np.ones(5, dtype=np.float32) - long_y = np.full(7, 0.5, dtype=np.float32) - np.testing.assert_allclose(blend(x, long_y, 0.5), np.full(5, 0.75), atol=1e-7) - - short_y = np.full(4, 0.5, dtype=np.float32) - out = blend(x, short_y, 0.5) - assert out.shape == x.shape - np.testing.assert_allclose(out, [0.75, 0.75, 0.75, 0.75, 0.5], atol=1e-7) diff --git a/lib/tests/test_cli.py b/lib/tests/test_cli.py deleted file mode 100644 index 76952ba..0000000 --- a/lib/tests/test_cli.py +++ /dev/null @@ -1,357 +0,0 @@ -import sys -from types import SimpleNamespace - -import numpy as np -import pytest -import soundfile as sf -from conftest import speechish - -from producer import io as pio -from producer.cli import _apply_args, build_parser, process_one -from producer.config import Options - - -def _mk_wav(tmp_path, name="in.wav", stereo=False): - x = speechish(3.0, level_dbfs=-30.0) - if stereo: - data = np.stack([x, x * 0.5], axis=1) - sf.write(str(tmp_path / name), data, 44100, subtype="PCM_16") - else: - sf.write(str(tmp_path / name), x, 44100, subtype="PCM_16") - return tmp_path / name - - -def _opts(**kw): - opts = Options() - opts.denoise = "off" - opts.enhance = "off" - for k, v in kw.items(): - setattr(opts, k, v) - return opts - - -def test_decode_stereo_mixdown(tmp_path): - p = _mk_wav(tmp_path, "st.wav", stereo=True) - x, sr = pio.decode(p) - assert sr == 44100 - assert x.dtype.name == "float32" - assert x.ndim == 1 - - -def test_encode_wav_bitdepths(tmp_path): - x = speechish(2.0, level_dbfs=-20.0) - for depth in (16, 24, 32): - out = tmp_path / f"o{depth}.wav" - pio.encode(x, 44100, out, "wav", depth) - y, sr = pio.decode(out) - assert sr == 44100 - assert ( - abs( - float(np.sqrt(np.mean(y.astype(np.float64) ** 2))) - - float(np.sqrt(np.mean(x.astype(np.float64) ** 2))) - ) - < 1e-3 - ) - - -def test_encode_flac(tmp_path): - x = speechish(2.0, level_dbfs=-20.0) - out = tmp_path / "o.flac" - pio.encode(x, 44100, out, "flac", 24) - y, sr = pio.decode(out) - assert sr == 44100 - assert np.corrcoef(x, y)[0, 1] > 0.999 - - -def test_mp3_roundtrip(tmp_path): - import pytest as _pt - - if not pio.ffmpeg_available(): - _pt.skip("ffmpeg missing") - x = speechish(4.0, level_dbfs=-20.0) - out = tmp_path / "o.mp3" - pio.encode(x, 44100, out, "mp3", 16) - y, sr = pio.decode(out) - assert sr == 44100 - from producer import meters - - assert abs(meters.rms_db(x) - meters.rms_db(y)) < 0.7 - - -def test_decode_via_ffmpeg_fallback(tmp_path): - import pytest as _pt - - if not pio.ffmpeg_available(): - _pt.skip("ffmpeg missing") - x = speechish(4.0, level_dbfs=-20.0) - out = tmp_path / "o.mp3" - pio.encode(x, 44100, out, "mp3", 16) - y, sr = pio.decode(out) - assert sr == 44100 - assert y.size > 0 - - -def test_process_one_end_to_end(tmp_path, capsys): - import json - - from producer import meters - - inp = _mk_wav(tmp_path, "e2e.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_processed.report.json") - assert rep_path.exists() - rep = json.loads(rep_path.read_text()) - y, sr = pio.decode(out) - assert abs(meters.rms_db(y) + 20.0) < 0.6 - assert meters.true_peak_db(y, sr) <= -2.9 - assert rep["after"]["rms_db"] != 0 - - -def test_dry_run_listing(tmp_path, capsys): - from producer.cli import main - - inp = _mk_wav(tmp_path, "dry.wav") - rc = main([str(inp), "--dry-run", "--denoise", "off"]) - assert rc == 0 - out = capsys.readouterr().out - assert "denoise" in out and "dsp" in out and "levelling" in out - - -def test_arg_parsing_precedence(): - parser = build_parser() - args = parser.parse_args( - ["in.wav", "--profile", "podcast", "--warmth", "0.1", "--ceiling", "-2.0"] - ) - opts = Options() - _apply_args(opts, args) - assert opts.profile == "podcast" - assert opts.loudness_mode() == "lufs" - assert opts.strengths["warmth"] == 0.1 - assert opts.ceiling() == -2.0 - assert opts.strengths["air"] is None - - -def test_radio_profile_has_tuned_defaults(): - from producer import pipeline - - opts = Options(profile="radio") - assert opts.eff("tape") > 0 and opts.eff("soothe") > 0 - assert opts.loudness_mode() == "lufs" - stages = pipeline.build_stages(opts) - assert [st.name for st in stages] == ["denoise", "enhance", "dsp", "levelling"] - assert opts.denoise_strength is not None - - -def test_tape_and_soothe_flags_override(): - args = build_parser().parse_args( - ["in.wav", "--profile", "radio", "--tape", "0.4", "--soothe", "0.7"] - ) - opts = Options() - _apply_args(opts, args) - 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_engine_chunk_flags(): - args = build_parser().parse_args(["in.wav", "--engine-chunk", "15", "--engine-overlap", "1.0"]) - opts = Options() - _apply_args(opts, args) - assert opts.engine_chunk_s == 15.0 - assert opts.engine_overlap_s == 1.0 - - -def test_default_options_whole_file_and_soft_denoise(): - opts = Options() - assert opts.engine_chunk_s == 0.0 - assert opts.denoise_strength == 0.9 - - -def test_engine_chunk_validation(): - with pytest.raises(SystemExit): - args = build_parser().parse_args(["in.wav", "--engine-chunk", "5", "--engine-overlap", "5"]) - _apply_args(Options(), args) - with pytest.raises(SystemExit): - args = build_parser().parse_args(["in.wav", "--engine-chunk", "-1"]) - _apply_args(Options(), args) - - -def test_engine_chunk_config_override(tmp_path): - from producer import config as cfgmod - - cfg = tmp_path / "config.toml" - cfg.write_text("engine_chunk = 10.0\nengine_overlap = 1.0\n") - opts = Options() - cfgmod.apply_config(opts, cfgmod.load_config(cfg)) - assert opts.engine_chunk_s == 10.0 - assert opts.engine_overlap_s == 1.0 - - -def test_denoise_pf_flag_and_config_plumbing(tmp_path): - from producer import config as cfgmod - - opts = Options() - _apply_args(opts, build_parser().parse_args(["in.wav"])) - assert opts.denoise_pf is False - - opts = Options() - _apply_args(opts, build_parser().parse_args(["in.wav", "--denoise-pf"])) - assert opts.denoise_pf is True - - cfg = tmp_path / "config.toml" - cfg.write_text('[denoise]\nengine = "dfn3"\nstrength = 0.8\npf = true\n') - opts = Options() - cfgmod.apply_config(opts, cfgmod.load_config(cfg)) - assert opts.denoise_strength == 0.8 - assert opts.denoise_pf is True - - -def test_spectral_denoise_choice(tmp_path): - from producer import config as cfgmod - - opts = Options() - _apply_args(opts, build_parser().parse_args(["in.wav", "--denoise", "spectral"])) - assert opts.denoise == "spectral" - - cfg = tmp_path / "config.toml" - cfg.write_text('[denoise]\nengine = "spectral"\nstrength = 0.8\n') - opts = Options() - cfgmod.apply_config(opts, cfgmod.load_config(cfg)) - assert opts.denoise == "spectral" - assert opts.denoise_strength == 0.8 - - -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 - - -def test_process_one_reports_stage_status(tmp_path, capsys): - inp = _mk_wav(tmp_path, "status.wav") - out_path = process_one(inp, _opts(), single=True) - assert out_path is not None - out = capsys.readouterr().out - assert f"[producer] {inp} (" in out - assert "dsp done in" in out - assert "levelling done in" in out - assert f"[producer] wrote {out_path}" in out - - -def test_multi_file_run_gets_position_prefixes(tmp_path, capsys): - from producer.cli import main - - a = _mk_wav(tmp_path, "a.wav") - b = _mk_wav(tmp_path, "b.wav") - rc = main([str(a), str(b), "--denoise", "off"]) - assert rc == 0 - out = capsys.readouterr().out - assert "[1/2]" in out and "[2/2]" in out - assert (tmp_path / "a_processed.wav").exists() - assert (tmp_path / "b_processed.wav").exists() - - -def test_output_file_rejected_for_multiple_inputs(tmp_path): - from producer.cli import main - - a = _mk_wav(tmp_path, "a.wav") - b = _mk_wav(tmp_path, "b.wav") - with pytest.raises(SystemExit): - main([str(a), str(b), "-o", str(tmp_path / "out.wav"), "--denoise", "off"]) - - -def test_output_dir_accepted_for_multiple_inputs(tmp_path): - from producer.cli import main - - a = _mk_wav(tmp_path, "a.wav") - b = _mk_wav(tmp_path, "b.wav") - outdir = tmp_path / "masters" - rc = main([str(a), str(b), "-o", str(outdir), "--denoise", "off"]) - assert rc == 0 - assert (outdir / "a_processed.wav").exists() - assert (outdir / "b_processed.wav").exists() - - -def test_batch_failure_continues_to_next_file(tmp_path, capsys): - from producer.cli import main - - good = _mk_wav(tmp_path, "good.wav") - missing = tmp_path / "missing.wav" - rc = main([str(missing), str(good), "--denoise", "off"]) - assert rc == 1 - captured = capsys.readouterr() - assert "[2/2]" in captured.out - assert "[producer] ERROR" in captured.err - assert (tmp_path / "good_processed.wav").exists() diff --git a/lib/tests/test_dsp.py b/lib/tests/test_dsp.py deleted file mode 100644 index b9508ab..0000000 --- a/lib/tests/test_dsp.py +++ /dev/null @@ -1,130 +0,0 @@ -import numpy as np -from conftest import band_db, sine, speechish - -from producer import dsp, meters - - -def test_hpf_removes_rumble(sr): - x = sine(40, 3.0, sr, -20.0) + sine(200, 3.0, sr, -20.0) - y = dsp.hpf(x, sr, 80.0) - assert band_db(x, sr, 35, 45) - band_db(y, sr, 35, 45) > 10.0 - assert abs(band_db(x, sr, 190, 210) - band_db(y, sr, 190, 210)) < 0.5 - - -def test_peak_eq_mud_cut(sr): - x = sine(300, 3.0, sr, -20.0) + sine(1000, 3.0, sr, -20.0) - y = dsp.peak_eq(x, sr, 300.0, -3.0, 1.0) - d300 = band_db(x, sr, 280, 320) - band_db(y, sr, 280, 320) - d1k = band_db(x, sr, 950, 1050) - band_db(y, sr, 950, 1050) - assert 2.4 < d300 < 3.6 - assert abs(d1k) < 0.3 - - -def test_shelf(sr): - x = sine(60, 3.0, sr, -20.0) + sine(3000, 3.0, sr, -20.0) - y = dsp.shelf(x, sr, 150.0, 1.5, low=True) - d60 = band_db(y, sr, 50, 70) - band_db(x, sr, 50, 70) - assert 1.1 < d60 < 1.9 - - -def test_compressor_steady_sine(sr): - x = sine(440, 4.0, sr, peak_dbfs=-10.0) - y = dsp.compressor(x, sr, -20.0, 3.0, 15.0, 150.0, 6.0) - in_rms = meters.rms_db(x) - out_rms = meters.rms_db(y) - expected_gr = (1.0 - 1.0 / 3.0) * (in_rms - (-20.0)) - assert abs((in_rms - out_rms) - expected_gr) < 0.4 - - -def test_compressor_quiet_signal_unaffected(sr): - x = sine(440, 4.0, sr, peak_dbfs=-45.0) - y = dsp.compressor(x, sr, -20.0, 3.0, 15.0, 150.0, 6.0) - assert abs(meters.rms_db(x) - meters.rms_db(y)) < 0.1 - - -def test_deesser(sr): - x = sine(1000, 4.0, sr, -20.0) + sine(6500, 4.0, sr, -30.0) - y = dsp.deesser(x, sr, 5500.0, 8000.0, 8.0) - d_sib = band_db(x, sr, 5800, 7200) - band_db(y, sr, 5800, 7200) - d_mid = band_db(x, sr, 900, 1100) - band_db(y, sr, 900, 1100) - assert 1.0 < d_sib < 3.0 - assert abs(d_mid) < 0.4 - - -def test_deesser_bypass(sr): - x = sine(1000, 2.0, sr, -20.0) - y = dsp.deesser(x, sr, 5500.0, 8000.0, 0.0) - assert np.allclose(x, y) - - -def test_expander_attenuates_pauses(sr): - speech = speechish(10.0, sr, level_dbfs=-20.0) - rng = np.random.default_rng(3) - noise = rng.standard_normal(speech.size).astype(np.float64) - noise *= (10 ** (-52.0 / 20.0)) / np.sqrt(np.mean(np.square(noise))) - x = (speech + noise).astype(np.float32) - y = dsp.expander(x, sr, max_drop_db=2.1) - frame = int(0.02 * sr) - nf = x.size // frame - frms_x = np.sqrt(np.mean(np.square(x[: nf * frame].reshape(nf, frame)), axis=1)) - frms_y = np.sqrt(np.mean(np.square(y[: nf * frame].reshape(nf, frame)), axis=1)) - fdb_x = 20 * np.log10(frms_x + 1e-12) - loud = fdb_x > np.percentile(fdb_x, 75) - quiet = fdb_x < np.percentile(fdb_x, 15) - stable_loud = np.convolve(loud.astype(int), np.ones(5, dtype=int), mode="same") == 5 - d_y = 20 * np.log10(frms_y + 1e-12) - assert np.mean(fdb_x[stable_loud] - d_y[stable_loud]) < 0.6 - assert np.mean(fdb_x[quiet] - d_y[quiet]) > 1.5 - - -def test_limit_hits_ceiling(sr): - x = speechish(6.0, sr, level_dbfs=-3.0) - y = dsp.limit(x, sr, -3.0) - assert meters.true_peak_db(y, sr) <= -3.0 + 0.1 - assert meters.rms_db(x) - meters.rms_db(y) < 1.0 - - -def test_limit_below_ceiling_transparent(sr): - x = speechish(6.0, sr, level_dbfs=-20.0) - y = dsp.limit(x, sr, -3.0) - assert np.max(np.abs(x - y)) < 1e-6 - - -def test_resample_roundtrip(sr): - x = speechish(4.0, sr) - y = dsp.resample(dsp.resample(x, sr, 48000), 48000, sr) - assert abs(y.size - x.size) <= 2 - assert abs(meters.rms_db(x) - meters.rms_db(y)) < 0.2 - - -def test_tape_bypass(sr): - x = speechish(3.0, sr) - assert np.array_equal(x, dsp.tape(x, sr, 0.0)) - - -def test_tape_adds_even_harmonics(sr): - x = sine(220, 3.0, sr, -6.0) - y = dsp.tape(x, sr, 1.0) - assert np.all(np.isfinite(y)) - d_h2 = band_db(y, sr, 430, 460) - band_db(x, sr, 430, 460) - d_h3 = band_db(y, sr, 655, 690) - band_db(x, sr, 655, 690) - assert d_h2 > 1.0 - assert d_h3 > 1.0 - assert abs(meters.rms_db(x) - meters.rms_db(y)) < 2.0 - - -def test_soothe_bypass(sr): - x = speechish(3.0, sr) - assert np.array_equal(x, dsp.soothe(x, sr, 0.0)) - - -def test_soothe_reduces_resonant_bands(sr): - x = sine(300, 4.0, sr, -6.0) + sine(3500, 4.0, sr, -6.0) + sine(1000, 4.0, sr, -30.0) - y = dsp.soothe(x, sr, 1.0) - d_low = band_db(x, sr, 260, 350) - band_db(y, sr, 260, 350) - d_harsh = band_db(x, sr, 3200, 3800) - band_db(y, sr, 3200, 3800) - assert 2.0 < d_low <= 3.6 - assert 2.0 < d_harsh <= 5.2 - # frequencies outside the bands stay untouched - d_mid = band_db(x, sr, 900, 1200) - band_db(y, sr, 900, 1200) - assert abs(d_mid) < 0.4 diff --git a/lib/tests/test_engines.py b/lib/tests/test_engines.py deleted file mode 100644 index 9881859..0000000 --- a/lib/tests/test_engines.py +++ /dev/null @@ -1,515 +0,0 @@ -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." -) - - -class _FakeTensor: - def __init__(self, arr): - self.arr = np.asarray(arr) - - def unsqueeze(self, axis): - return _FakeTensor(self.arr[None, ...]) - - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return self.arr - - -def _install_torch_stub(monkeypatch): - fake = types.ModuleType("torch") - fake.Tensor = _FakeTensor - fake.from_numpy = lambda a: _FakeTensor(a) - fake.cuda = types.SimpleNamespace(is_available=lambda: False) - monkeypatch.setitem(sys.modules, "torch", fake) - - -def _install_df_enhance_stub(monkeypatch, transform, calls, records=None): - pkg = types.ModuleType("df") - - class _State: - def sr(self): - return 44100 - - class _Model: - def to(self, dev): - return self - - def init_df(*_args, **kwargs): - if records is not None: - records["init_df"] = kwargs - return _Model(), _State(), "fake" - - def enhance(_model, _state, audio, **kwargs): - calls.append(audio.arr.shape[-1]) - if records is not None: - records["enhance"] = kwargs - return _FakeTensor(transform(audio.arr.copy())) - - pkg.enhance = types.ModuleType("df.enhance") - pkg.enhance.init_df = init_df - pkg.enhance.enhance = enhance - for sub in ("df.io", "df.logger", "df.utils"): - mod = types.ModuleType(sub) - setattr(pkg, sub.split(".")[1], mod) - monkeypatch.setitem(sys.modules, sub, mod) - monkeypatch.setitem(sys.modules, "df", pkg) - monkeypatch.setitem(sys.modules, "df.enhance", pkg.enhance) - - -def test_dfn3_chunked_engine_stitches_full_length(monkeypatch, sr, noisy_speech): - from producer.engines import denoise_dfn - - monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None) - monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None) - _install_torch_stub(monkeypatch) - calls: list[int] = [] - _install_df_enhance_stub(monkeypatch, lambda a: a * 0.5 + 0.001, calls) - - x = noisy_speech[: sr * 4] - y, eng, dev = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=1.0, overlap_s=0.1) - assert "dfn" in eng - assert "cpu" in dev - assert len(calls) > 2 - assert y.shape == x.shape - np.testing.assert_allclose(y, x * 0.5 + 0.001, atol=1e-6) - - -def test_dfn3_whole_file_mode_single_call(monkeypatch, sr, noisy_speech): - from producer.engines import denoise_dfn - - monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None) - monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None) - _install_torch_stub(monkeypatch) - calls: list[int] = [] - _install_df_enhance_stub(monkeypatch, lambda a: a * 0.5, calls) - - x = noisy_speech[: sr * 2] - y, _eng, _dev = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=0.0) - assert calls == [x.size] - np.testing.assert_allclose(y, x * 0.5, atol=1e-7) - - -def test_dfn3_post_filter_opt_in_no_atten_lim(monkeypatch, sr, noisy_speech): - from producer.engines import denoise_dfn - - monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None) - monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None) - _install_torch_stub(monkeypatch) - calls: list[int] = [] - records: dict = {} - _install_df_enhance_stub(monkeypatch, lambda a: a * 0.5, calls, records) - - x = noisy_speech[: sr * 2] - denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=0.0) - assert records["init_df"]["post_filter"] is False - assert records["enhance"] == {} # stock df_enhance call, no atten-lim override - - calls.clear() - records.clear() - denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=0.0, post_filter=True) - assert records["init_df"]["post_filter"] is True - - -def test_dfn3_chunked_feeds_context_padding(monkeypatch, sr, noisy_speech): - from producer.engines import denoise_dfn - - monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None) - monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None) - _install_torch_stub(monkeypatch) - calls: list[int] = [] - _install_df_enhance_stub(monkeypatch, lambda a: a * 0.5, calls) - - x = noisy_speech[: sr * 10] - y, _eng, _dev = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=3.0, overlap_s=0.5) - # chunks are widened with context, then trimmed back to the spans - assert max(calls) > 3.0 * sr - assert min(calls) >= 3.0 * sr - np.testing.assert_allclose(y, x * 0.5, atol=1e-7) - - -_Z_MODEL_REPO = "iic/speech_zipenhancer_ans_multiloss_16k_base" - - -def _install_zipenhancer_stub(monkeypatch, calls): - fake = types.ModuleType("zipenhancer") - fake.MODEL_ZIPENHANCER = _Z_MODEL_REPO - - def denoise(chunk, sample_rate, model=_Z_MODEL_REPO, normalize=True, strength=1.0, **_kw): - calls.append( - {"n": chunk.size, "model": model, "normalize": normalize, "strength": strength} - ) - scale = 0.1 if len(calls) % 2 == 1 else 1.0 - return (chunk * scale, 0.0, chunk.size / sample_rate) - - fake.denoise = denoise - monkeypatch.setitem(sys.modules, "zipenhancer", fake) - - -def test_zipenhancer_chunked_normalizes_once(monkeypatch, sr): - from producer.engines import denoise_zip - - seq: list[str] = [] - monkeypatch.setattr( - "producer.lazy.ensure", - lambda pkgs, **_k: seq.append("ensure:" + ",".join(str(p) for p in pkgs)), - ) - monkeypatch.setattr("producer.lazy.ensure_torch", lambda: seq.append("torch")) - monkeypatch.setattr( - "producer.lazy.ensure_import", lambda mod, **_k: seq.append("import:" + mod) - ) - monkeypatch.setattr("producer.lazy.ensure_call", lambda fn, **_k: (seq.append("call"), fn())[1]) - calls: list[dict] = [] - _install_zipenhancer_stub(monkeypatch, calls) - - n = 4 * 44100 - t = np.arange(n) / sr - x = (0.5 * np.sin(2 * np.pi * 160.0 * t)).astype(np.float32) - y, eng, _dev = denoise_zip.denoise(x, sr, 1.0, "cpu", chunk_s=1.0, overlap_s=0.0) - assert "zipenhancer" in eng - # torch is pinned before package installs; the undeclared modelscope - # import ships with the engine; import and call probes run after both - assert seq == [ - "torch", - "ensure:zipenhancer==0.3.2,modelscope", - "import:zipenhancer", - "call", - "call", - "call", - "call", - ] - assert len(calls) == 4 - # the library API takes the full modelscope repo id; the short name - # would be treated as a repo id and fail with modelscope E3021 - assert all(c["model"] == _Z_MODEL_REPO for c in calls) - assert all(c["normalize"] is False for c in calls) - assert y.shape == x.shape - peak = float(np.max(np.abs(y))) - assert abs(peak - 10 ** (-3.0 / 20.0)) < 0.01 - even_rms = float(np.sqrt(np.mean(y[: n // 4].astype(np.float64) ** 2))) - odd_rms = float(np.sqrt(np.mean(y[n // 4 : n // 2].astype(np.float64) ** 2))) - assert 8.0 < odd_rms / even_rms < 12.0 - - -def test_zipenhancer_resample_roundtrip_length_realigned(monkeypatch): - # 120s @ 48k round-tripped through the 16k engine can come back a sample - # or two long (resample_poly emits ceil(n * up/down) per hop); the blend - # used to crash on the mismatch instead of realigning - from producer.engines import denoise_zip - - monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None) - monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None) - calls: list[dict] = [] - _install_zipenhancer_stub(monkeypatch, calls) - - sr = 48000 - n = 100001 # 48k -> 16k -> 48k drifts +1 for this length - x = (0.3 * np.sin(2 * np.pi * 220.0 * np.arange(n) / sr)).astype(np.float32) - y, _eng, _dev = denoise_zip.denoise(x, sr, 0.5, "cpu") - assert y.shape == x.shape - - -def test_mossformer_clearvoice_probe(monkeypatch, sr, noisy_speech): - from producer.engines import enhance_mossformer - - seq: list[str] = [] - monkeypatch.setattr("producer.lazy.ensure_torch", lambda: seq.append("torch")) - monkeypatch.setattr( - "producer.lazy.ensure", - lambda pkgs, **_k: seq.append("ensure:" + ",".join(str(p) for p in pkgs)), - ) - monkeypatch.setattr( - "producer.lazy.ensure_import", lambda mod, **_k: seq.append("import:" + mod) - ) - - fake = types.ModuleType("clearvoice") - - class _FakeCV: - def __init__(self, task=None, model_name=None): - pass - - def __call__(self, chunk): - return (chunk * 0.5, 48000) - - fake.ClearVoice = _FakeCV - monkeypatch.setitem(sys.modules, "clearvoice", fake) - - x = noisy_speech[: sr * 4] - y, eng, _dev = enhance_mossformer.enhance(x, 48000, 1.0, "cpu", chunk_s=2.0) - assert "mossformer2" in eng - assert seq == ["torch", "ensure:clearvoice==0.1.2", "import:clearvoice"] - assert y.shape == x.shape - np.testing.assert_allclose(y, x * 0.5, atol=1e-6) - - -def _hissy_speech(sr, dur=12.0, noise_db=-42.0, seed=3): - from conftest import speechish - - x = speechish(dur, sr, level_dbfs=-20.0, seed=seed) - rng = np.random.default_rng(seed) - noise = rng.standard_normal(x.size) - noise *= (10 ** (noise_db / 20.0)) / np.sqrt(np.mean(np.square(noise))) - return (x + noise).astype(np.float32) - - -def test_spectral_reduces_steady_noise(sr): - from producer import meters - from producer.engines import denoise_spectral - - x = _hissy_speech(sr) - y, eng, dev = denoise_spectral.denoise(x, sr, 1.0, "cpu") - assert "spectral" in eng and dev == "cpu" - assert y.shape == x.shape - assert np.all(np.isfinite(y)) - assert meters.noise_floor_db(y, sr) < meters.noise_floor_db(x, sr) - 8.0 - assert np.corrcoef(x.astype(np.float64), y.astype(np.float64))[0, 1] > 0.8 - - -def test_spectral_speech_level_flat(sr): - from producer.engines import denoise_spectral - - x = _hissy_speech(sr) - y, _eng, _dev = denoise_spectral.denoise(x, sr, 1.0, "cpu") - frame = int(0.03 * sr) - nf = x.size // frame - - def frms_db(z): - rms = np.sqrt(np.mean(z[: nf * frame].reshape(nf, frame).astype(np.float64) ** 2, axis=1)) - return 20.0 * np.log10(rms + 1e-12) - - xdb, ydb = frms_db(x), frms_db(y) - speech = xdb > np.percentile(xdb, 10) + 12.0 - assert speech.sum() > 20 - # the deterministic engine must not pump the speech level (dfn3's failure mode) - swing = np.abs(ydb[speech] - xdb[speech]) - assert np.percentile(swing, 95) < 2.0 - - -def test_spectral_strength_zero_is_identity(sr): - from producer.engines import denoise_spectral - - x = _hissy_speech(sr) - y, _eng, _dev = denoise_spectral.denoise(x, sr, 0.0, "cpu") - np.testing.assert_array_equal(y, x) - - -def test_spectral_sample_aligned(sr): - from producer.engines import denoise_spectral - - x = _hissy_speech(sr) - y, _eng, _dev = denoise_spectral.denoise(x, sr, 1.0, "cpu") - lo, hi = int(sr * 2.0), int(sr * 9.0) - corrs = {k: float(np.corrcoef(x[lo:hi], y[lo + k : hi + k])[0, 1]) for k in range(-3, 4)} - assert max(corrs, key=corrs.get) == 0 - assert corrs[0] > 0.8 - - -def test_spectral_suppression_capped(sr): - from producer import meters - from producer.engines import denoise_spectral - - noise = (0.008 * np.random.default_rng(5).standard_normal(sr * 6)).astype(np.float32) - y, _eng, _dev = denoise_spectral.denoise(noise, sr, 1.0, "cpu") - drop = meters.noise_floor_db(noise, sr) - meters.noise_floor_db(y, sr) - # bounded: deep enough to matter, never gated to digital silence - assert 20.0 < drop < 40.0 - - -def test_spectral_chunked_matches_whole_file(sr): - from producer.engines import denoise_spectral - - x = _hissy_speech(sr, dur=20.0) - y1, _e, _d = denoise_spectral.denoise(x, sr, 1.0, "cpu", chunk_s=60.0, overlap_s=0.5) - y2, _e, _d = denoise_spectral.denoise(x, sr, 1.0, "cpu", chunk_s=8.0, overlap_s=0.5) - assert y1.shape == y2.shape == x.shape - corr = np.corrcoef(y1.astype(np.float64), y2.astype(np.float64))[0, 1] - assert corr > 0.99 - - -def test_spectral_profile_global_not_per_chunk(sr): - """Tail hiss must get the same suppression with or without speech up front. - - The old per-chunk percentile leaked speech into the noise estimate and - under-suppressed exactly where it matters (between sentences). - """ - from conftest import speechish - - from producer import meters - from producer.engines import denoise_spectral - - speech = speechish(8.0, sr, level_dbfs=-20.0, seed=11) - rng = np.random.default_rng(9) - noise = rng.standard_normal(sr * 16).astype(np.float32) - noise *= (10 ** (-40.0 / 20.0)) / np.sqrt(np.mean(np.square(noise))) - tail_lo, tail_hi = sr * 10, sr * 16 - with_speech = np.concatenate([speech + noise[: speech.size], noise[speech.size :]]).astype( - np.float32 - ) - hiss_only = noise.copy() - y1, _e, _d = denoise_spectral.denoise(with_speech, sr, 1.0, "cpu") - y2, _e, _d = denoise_spectral.denoise(hiss_only, sr, 1.0, "cpu") - drop1 = meters.noise_floor_db(with_speech[tail_lo:tail_hi], sr) - meters.noise_floor_db( - y1[tail_lo:tail_hi], sr - ) - drop2 = meters.noise_floor_db(hiss_only[tail_lo:tail_hi], sr) - meters.noise_floor_db( - y2[tail_lo:tail_hi], sr - ) - assert drop1 > 14.0 - assert drop1 > drop2 - 4.0 - - -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 - - return meters.noise_floor_db(x, sr) - - -@pytest.mark.slow -def test_dfn3_reduces_noise(sr, noisy_speech): - pytest.importorskip("torch") - pytest.importorskip("df") - from producer.engines import denoise_dfn - - x = noisy_speech[: sr * 4] - y, eng, _dev = denoise_dfn.denoise(x, sr, 1.0, "cpu") - assert "dfn" in eng - assert _metrics_floor(y, sr) < _metrics_floor(x, sr) - 5.0 - assert np.corrcoef(x, y.astype(np.float64))[0, 1] > 0.9 - - -@pytest.mark.slow -def test_dfn3_speech_level_flat(sr, noisy_speech): - """Guard against dfn3's reported failure mode: volume wobble in sentences.""" - pytest.importorskip("torch") - pytest.importorskip("df") - from producer.engines import denoise_dfn - - x = noisy_speech[: sr * 8] - y, _eng, _dev = denoise_dfn.denoise(x, sr, 0.9, "cpu") - frame = int(0.03 * sr) - nf = x.size // frame - - def frms_db(z): - rms = np.sqrt(np.mean(z[: nf * frame].reshape(nf, frame).astype(np.float64) ** 2, axis=1)) - return 20.0 * np.log10(rms + 1e-12) - - xdb, ydb = frms_db(x), frms_db(y) - speech = xdb > np.percentile(xdb, 10) + 12.0 - swing = np.abs(ydb[speech] - xdb[speech]) - assert np.percentile(swing, 95) < 2.5 - - -@pytest.mark.slow -def test_dfn3_chunked_matches_whole_file(sr, noisy_speech): - pytest.importorskip("torch") - pytest.importorskip("df") - from producer.engines import denoise_dfn - - x = noisy_speech[: sr * 60] - y_full, _, _ = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=0.0) - y_chunk, _, _ = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=15.0, overlap_s=0.5) - assert y_full.shape == y_chunk.shape == x.shape - corr = np.corrcoef(y_full.astype(np.float64), y_chunk.astype(np.float64))[0, 1] - assert corr > 0.99 - assert float(np.max(np.abs(y_full - y_chunk))) < 0.1 - - -@pytest.mark.slow -def test_zipenhancer_reduces_noise(sr, noisy_speech): - pytest.importorskip("torch") - pytest.importorskip("zipenhancer") - from producer.engines import denoise_zip - - x = noisy_speech[: sr * 4] - y, eng, _ = denoise_zip.denoise(x, sr, 1.0, "cpu") - assert "zipenhancer" in eng - assert _metrics_floor(y, sr) < _metrics_floor(x, sr) - 5.0 - - -@pytest.mark.slow -def test_mossformer2_enhances(sr, noisy_speech): - pytest.importorskip("torch") - pytest.importorskip("clearvoice") - from producer.engines import enhance_mossformer - - x = noisy_speech[: sr * 4] - y, eng, _ = enhance_mossformer.enhance(x, sr, 1.0, "cpu") - assert "mossformer2" in eng - assert np.all(np.isfinite(y)) - - -@pytest.mark.slow -def test_resemble_enhance(sr, noisy_speech): - if not os.environ.get("PRODUCER_TEST_RESEMBLE"): - pytest.skip("set PRODUCER_TEST_RESEMBLE=1 to run the isolated-venv generative engine") - from producer.engines import enhance_resemble - - x = noisy_speech[: sr * 4] - y, eng, _ = enhance_resemble.enhance(x, sr, 1.0, "cpu") - assert "resemble" in eng - assert np.all(np.isfinite(y)) diff --git a/lib/tests/test_lazy.py b/lib/tests/test_lazy.py deleted file mode 100644 index 5ab5b95..0000000 --- a/lib/tests/test_lazy.py +++ /dev/null @@ -1,286 +0,0 @@ -import types - -import pytest - -from producer import lazy - -PYPI_JSON = { - "urls": [ - { - "filename": "torch-2.7.1-cp311-cp311-manylinux_2_28_x86_64.whl", - "url": "https://files.pythonhosted.org/packages/xx/torch-2.7.1-cp311-cp311-manylinux_2_28_x86_64.whl", - "digests": {"sha256": "abc123"}, - "size": 766_668_798, - }, - { - "filename": "torch-2.7.1-cp311-cp311-win_amd64.whl", - "url": "https://example.com/win.whl", - "digests": {}, - "size": 1, - }, - { - "filename": "torch-2.7.1.tar.gz", - "url": "https://example.com/src.tar.gz", - "digests": {}, - "size": 1, - }, - ] -} - -_SHA = "e1a846516570851234567890abcdef1234567890abcdef1234567890abcdef12" -GPU_HTML = ( - "" - 'oldnew' - 'win' - 'relative-dup' - "" -) - - -def test_choose_pypi_picks_linux_wheel(): - got = lazy._choose_pypi(PYPI_JSON) - assert got is not None - name, url, sha, size = got - assert name.endswith("manylinux_2_28_x86_64.whl") - assert "pythonhosted" in url - assert sha == "abc123" - assert size == 766_668_798 - - -def test_choose_gpu_matches_version_and_arch(): - got = lazy._choose_gpu(GPU_HTML, "torch", "2.7.1") - assert got is not None - name, url, sha, size = got - assert name == "torch-2.7.1+cu126-cp311-cp311-manylinux_2_28_x86_64.whl" - assert url == ( - "https://download-r2.pytorch.org/whl/cu126/" - "torch-2.7.1%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl" - ) - assert sha == _SHA - assert size is None - - -def test_choose_gpu_no_match_returns_none(): - assert lazy._choose_gpu("", "torch", "2.7.1") is None - - -def test_resolve_torch_wheels_needs_both_packages(monkeypatch): - def fake(pkg, ver): - return None if pkg == "torchaudio" else ("t.whl", "u", None, 1) - - monkeypatch.setattr(lazy, "_pypi_wheel", fake) - assert lazy._resolve_torch_wheels(gpu=False) == [] - - -def test_resolve_torch_wheels_returns_both(monkeypatch): - wheels = [("torch.whl", "u1", "s1", 1), ("torchaudio.whl", "u2", None, None)] - monkeypatch.setattr(lazy, "_pypi_wheel", lambda pkg, ver: wheels.pop(0)) - got = lazy._resolve_torch_wheels(gpu=False) - assert [w[0] for w in got] == ["torch.whl", "torchaudio.whl"] - - -def test_ensure_torch_downloads_wheels_then_installs(monkeypatch, tmp_path): - wheels = [ - ("torch-2.7.1-cp311.whl", "https://x/torch.whl", "sha", 10), - ("torchaudio-2.7.1-cp311.whl", "https://x/ta.whl", None, 5), - ] - monkeypatch.setattr(lazy, "has_module", lambda name: False) - monkeypatch.setattr(lazy, "_resolve_torch_wheels", lambda gpu: wheels) - monkeypatch.setattr(lazy, "find_uv", lambda: "uv") - monkeypatch.setattr(lazy, "WHEEL_CACHE", tmp_path / "wheels") - downloads: list[str] = [] - runs: list[tuple[list[str], str]] = [] - - def fake_download(url, dest, label=None, expected_size=None, sha256=None, timeout=60.0): - downloads.append(dest.name) - return dest - - monkeypatch.setattr(lazy.ui, "download", fake_download) - monkeypatch.setattr(lazy.ui, "run", lambda cmd, label, check=True: runs.append((cmd, label))) - lazy.ensure_torch() - assert downloads == ["torch-2.7.1-cp311.whl", "torchaudio-2.7.1-cp311.whl"] - assert len(runs) == 1 - cmd = runs[0][0] - assert cmd[0] == "uv" and cmd[1] == "pip" and cmd[2] == "install" - assert str(tmp_path / "wheels" / "torch-2.7.1-cp311.whl") in cmd - assert str(tmp_path / "wheels" / "torchaudio-2.7.1-cp311.whl") in cmd - - -def test_ensure_torch_falls_back_to_uv_index(monkeypatch): - monkeypatch.setattr(lazy, "has_module", lambda name: False) - monkeypatch.setattr(lazy, "_resolve_torch_wheels", lambda gpu: []) - monkeypatch.setattr(lazy, "find_uv", lambda: "uv") - monkeypatch.setattr(lazy, "gpu_present", lambda: True) - runs: list[list[str]] = [] - monkeypatch.setattr(lazy.ui, "run", lambda cmd, label, check=True: runs.append(cmd)) - lazy.ensure_torch() - cmd = runs[0] - assert "torch==2.7.1+cu126" in cmd - assert "--index-url" in cmd - assert lazy.TORCH_GPU_INDEX in cmd - - -def test_ensure_torch_falls_back_to_pypi_cpu(monkeypatch): - monkeypatch.setattr(lazy, "has_module", lambda name: False) - monkeypatch.setattr(lazy, "_resolve_torch_wheels", lambda gpu: []) - monkeypatch.setattr(lazy, "find_uv", lambda: "uv") - monkeypatch.setattr(lazy, "gpu_present", lambda: False) - runs: list[list[str]] = [] - monkeypatch.setattr(lazy.ui, "run", lambda cmd, label, check=True: runs.append(cmd)) - lazy.ensure_torch() - cmd = runs[0] - assert "torch==2.7.1" in cmd - assert "--index-url" not in cmd - - -def test_ensure_torch_noop_when_installed(monkeypatch): - monkeypatch.setattr(lazy, "has_module", lambda name: True) - runs: list = [] - monkeypatch.setattr(lazy.ui, "run", lambda cmd, label, check=True: runs.append(cmd)) - lazy.ensure_torch() - assert runs == [] - - -def test_ensure_import_noop_when_importable(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - lazy.ensure_import("numpy", purpose="test") - assert installs == [] - - -def test_ensure_import_installs_missing_module(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - state = {"round": 0} - - def fake_import(name): - if name == "fakeengine": - if state["round"] == 0: - state["round"] = 1 - raise ModuleNotFoundError("No module named 'addict'", name="addict") - return types.ModuleType("fakeengine") - raise ModuleNotFoundError(f"No module named {name!r}", name=name) - - monkeypatch.setattr(lazy.importlib, "import_module", fake_import) - lazy.ensure_import("fakeengine", purpose="test") - assert installs == [["addict"]] - - -def test_ensure_import_dotted_missing_reraises_without_install(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - - def fake_import(name): - raise ModuleNotFoundError("No module named 'pkg.sub'", name="pkg.sub") - - monkeypatch.setattr(lazy.importlib, "import_module", fake_import) - with pytest.raises(ModuleNotFoundError): - lazy.ensure_import("whatever", purpose="test") - assert installs == [] - - -def test_ensure_import_gives_up_after_rounds(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - - def fake_import(name): - raise ModuleNotFoundError("No module named 'ghost'", name="ghost") - - monkeypatch.setattr(lazy.importlib, "import_module", fake_import) - with pytest.raises(lazy.EngineUnavailable, match="ghost"): - lazy.ensure_import("whatever", purpose="test") - assert len(installs) == 4 - assert all(pkgs == ["ghost"] for pkgs in installs) - - -def test_ensure_import_maps_pil_to_pillow(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - state = {"round": 0} - - def fake_import(name): - if name == "fakeengine": - if state["round"] == 0: - state["round"] = 1 - raise ModuleNotFoundError("No module named 'PIL'", name="PIL") - return types.ModuleType("fakeengine") - raise ModuleNotFoundError(f"No module named {name!r}", name=name) - - monkeypatch.setattr(lazy.importlib, "import_module", fake_import) - lazy.ensure_import("fakeengine", purpose="test") - assert installs == [["pillow"]] - - -def test_ensure_call_passthrough_without_install(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - assert lazy.ensure_call(lambda: 42, purpose="test") == 42 - assert installs == [] - - -def test_ensure_call_installs_missing_module(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - state = {"round": 0} - - def fake_fn(): - if state["round"] == 0: - state["round"] = 1 - raise ModuleNotFoundError("No module named 'addict'", name="addict") - return "ok" - - assert lazy.ensure_call(fake_fn, purpose="test") == "ok" - assert installs == [["addict"]] - - -def test_ensure_call_dotted_missing_reraises_without_install(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - - def fake_fn(): - raise ModuleNotFoundError("No module named 'pkg.sub'", name="pkg.sub") - - with pytest.raises(ModuleNotFoundError): - lazy.ensure_call(fake_fn, purpose="test") - assert installs == [] - - -def test_ensure_call_unnamed_missing_reraises_without_install(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - - def fake_fn(): - raise ModuleNotFoundError("boom") - - with pytest.raises(ModuleNotFoundError): - lazy.ensure_call(fake_fn, purpose="test") - assert installs == [] - - -def test_ensure_call_maps_pil_to_pillow(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - state = {"round": 0} - - def fake_fn(): - if state["round"] == 0: - state["round"] = 1 - raise ModuleNotFoundError("No module named 'PIL'", name="PIL") - return "ok" - - assert lazy.ensure_call(fake_fn, purpose="test") == "ok" - assert installs == [["pillow"]] - - -def test_ensure_call_gives_up_after_rounds(monkeypatch): - installs: list[list[str]] = [] - monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs))) - - def fake_fn(): - raise ModuleNotFoundError("No module named 'ghost'", name="ghost") - - with pytest.raises(lazy.EngineUnavailable, match="ghost"): - lazy.ensure_call(fake_fn, purpose="test") - assert len(installs) == 8 - assert all(pkgs == ["ghost"] for pkgs in installs) diff --git a/lib/tests/test_loudness.py b/lib/tests/test_loudness.py deleted file mode 100644 index 5cd84d6..0000000 --- a/lib/tests/test_loudness.py +++ /dev/null @@ -1,30 +0,0 @@ -import numpy as np -from conftest import speechish - -from producer import loudness, meters - - -def test_rms_normalize_hits_target(sr): - x = speechish(8.0, sr, level_dbfs=-40.0) - y = loudness.normalize(x, sr, "rms", -20.0, -3.0) - assert abs(meters.rms_db(y) + 20.0) < 0.5 - assert meters.true_peak_db(y, sr) <= -2.9 - - -def test_lufs_normalize_hits_target(sr): - x = speechish(8.0, sr, level_dbfs=-35.0) - y = loudness.normalize(x, sr, "lufs", -16.0, -1.5) - assert abs(meters.lufs(y, sr) + 16.0) < 0.6 - assert meters.true_peak_db(y, sr) <= -1.4 - - -def test_silence_passthrough(): - y = loudness.normalize(np.zeros(44100 * 2, dtype=np.float32), 44100, "rms", -20.0, -3.0) - assert np.allclose(y, 0.0) - - -def test_idempotent(sr): - x = speechish(8.0, sr, level_dbfs=-35.0) - y1 = loudness.normalize(x, sr, "rms", -20.0, -3.0) - y2 = loudness.normalize(y1, sr, "rms", -20.0, -3.0) - assert abs(meters.rms_db(y1) - meters.rms_db(y2)) < 0.6 diff --git a/lib/tests/test_meters.py b/lib/tests/test_meters.py deleted file mode 100644 index b013ec9..0000000 --- a/lib/tests/test_meters.py +++ /dev/null @@ -1,65 +0,0 @@ -import numpy as np -from conftest import sine, speechish - -from producer import meters - - -def test_rms_and_peak_of_sine(sr): - x = sine(1000, 3.0, sr, peak_dbfs=-17.0) - assert abs(meters.rms_db(x) + 20.0) < 0.1 - assert abs(meters.sample_peak_db(x) + 17.0) < 0.05 - - -def test_true_peak_bounds(sr): - x = sine(1000, 3.0, sr, peak_dbfs=-17.0) - tp = meters.true_peak_db(x, sr) - sp = meters.sample_peak_db(x) - assert sp - 0.01 <= tp <= sp + 0.6 - - -def test_true_peak_blockwise_matches_whole_file(sr): - from scipy import signal - - rng = np.random.default_rng(3) - n = sr * 75 - x = ( - 0.5 * np.sin(2 * np.pi * 997.0 * np.arange(n) / sr) + 0.02 * rng.standard_normal(n) - ).astype(np.float32) - half = 10 * 4 - h = signal.firwin(2 * half + 1, 0.25, window=("kaiser", 8.0)).astype(np.float32) - ref = meters.sample_peak_db(signal.resample_poly(x, 4, 1, window=h)) - assert abs(meters.true_peak_db(x, sr) - ref) < 0.01 - - -def test_lufs_of_sine(sr): - x = sine(1000, 3.0, sr, peak_dbfs=-17.0) - assert abs(meters.lufs(x, sr) + 20.05) < 0.2 - - -def test_lufs_silence(): - assert meters.lufs(np.zeros(48000, dtype=np.float32), 48000) == meters.SILENCE_DB - - -def test_noise_floor_below_speech(sr): - x = speechish(8.0, sr, level_dbfs=-20.0) - floor = meters.noise_floor_db(x, sr) - assert floor < meters.rms_db(x) - 4.0 - - -def test_speech_level_db_ignores_leading_silence(sr): - x = speechish(6.0, sr, level_dbfs=-20.0) - y = np.concatenate([np.zeros(sr * 4, dtype=np.float32), x]) - assert abs(meters.speech_level_db(x, sr) - meters.speech_level_db(y, sr)) < 1.0 - assert meters.speech_level_db(x, sr) > meters.rms_db(x) - - -def test_all_meters_keys(sr): - d = meters.all_meters(speechish(3.0, sr), sr) - assert set(d) == { - "rms_db", - "sample_peak_db", - "true_peak_db", - "lufs", - "noise_floor_db", - "duration_s", - } diff --git a/lib/tests/test_pipeline.py b/lib/tests/test_pipeline.py deleted file mode 100644 index b3f91f1..0000000 --- a/lib/tests/test_pipeline.py +++ /dev/null @@ -1,114 +0,0 @@ -import numpy as np -from conftest import band_db, sine, speechish - -from producer import meters, pipeline -from producer.config import Options - - -def test_stage_order_and_names(): - opts = Options() - opts.denoise = "off" - opts.enhance = "off" - res = pipeline.run_pipeline(np.zeros(44100, dtype=np.float32), 44100, opts) - names = [s.name for s in res.stages] - assert names == ["denoise", "enhance", "dsp", "levelling"] - by_name = {s.name: s for s in res.stages} - assert by_name["denoise"].enabled is False - assert by_name["enhance"].enabled is False - assert by_name["dsp"].enabled is True - assert by_name["levelling"].enabled is True - - -def test_denoise_stage_detail_shows_pf(): - opts = Options() - stages = pipeline.build_stages(opts) - assert "pf=off" in stages[0].detail - assert "leveling" not in stages[0].detail - opts.denoise_pf = True - stages = pipeline.build_stages(opts) - assert "pf=on" in stages[0].detail - - -def test_full_chain_profile_bounds(sr): - opts = Options() - opts.denoise = "off" - opts.enhance = "off" - x = speechish(8.0, sr, level_dbfs=-35.0) - res = pipeline.run_pipeline(x, sr, opts) - assert abs(meters.rms_db(res.audio) + 20.0) < 0.6 - assert meters.true_peak_db(res.audio, sr) <= -2.9 - assert res.timings.get("levelling", 0) >= 0 - - -def test_passthrough_when_disabled(sr): - opts = Options() - opts.denoise = "off" - opts.enhance = "off" - opts.dsp = False - opts.levelling = False - x = speechish(4.0, sr, level_dbfs=-20.0) - res = pipeline.run_pipeline(x, sr, opts) - assert np.allclose(res.audio, x) - - -def test_knob_zero_disables_eq(sr): - base = Options() - base.denoise = "off" - base.enhance = "off" - base.levelling = False - base.strengths["warmth"] = 0.0 - warm = Options() - warm.denoise = "off" - warm.enhance = "off" - warm.levelling = False - x = sine(60, 4.0, sr, -20.0) + sine(3000, 4.0, sr, -20.0) - y_flat = pipeline.run_pipeline(x, sr, base).audio - y_warm = pipeline.run_pipeline(x, sr, warm).audio - d_flat = band_db(y_flat, sr, 50, 70) - band_db(x, sr, 50, 70) - d_warm = band_db(y_warm, sr, 50, 70) - band_db(x, sr, 50, 70) - assert d_warm - d_flat > 0.8 - - -def test_podcast_profile_bounds(sr): - opts = Options() - opts.profile = "podcast" - opts.denoise = "off" - opts.enhance = "off" - x = speechish(8.0, sr, level_dbfs=-35.0) - res = pipeline.run_pipeline(x, sr, opts) - assert abs(meters.lufs(res.audio, sr) + 16.0) < 0.8 - assert meters.true_peak_db(res.audio, sr) <= -1.4 - - -def test_dsp_pregain_makes_chain_level_invariant(sr): - """The voice chain must treat a quiet and a hot take identically. - - Denoised files often arrive far below the chain's design level; without - the pre-gain the absolute comp thresholds would idle (or slam) depending - on input level alone. - """ - hot = Options() - hot.denoise = "off" - hot.enhance = "off" - hot.levelling = False - quiet = Options() - quiet.denoise = "off" - quiet.enhance = "off" - quiet.levelling = False - x_hot = speechish(6.0, sr, level_dbfs=-14.0) - x_quiet = speechish(6.0, sr, level_dbfs=-38.0) - y_hot = pipeline.run_pipeline(x_hot, sr, hot).audio - y_quiet = pipeline.run_pipeline(x_quiet, sr, quiet).audio - diff = abs(meters.rms_db(y_hot) - meters.rms_db(y_quiet)) - assert diff < 1.0 - - -def test_dsp_pregain_note_recorded(sr): - opts = Options() - opts.denoise = "off" - opts.enhance = "off" - notes: list[str] = [] - stages = pipeline.build_stages(opts, notes) - dsp_stage = next(s for s in stages if s.name == "dsp") - dsp_stage.fn(speechish(4.0, sr, level_dbfs=-30.0), sr) - assert any("pregain" in n for n in notes) diff --git a/lib/tests/test_ui.py b/lib/tests/test_ui.py deleted file mode 100644 index 156b3e8..0000000 --- a/lib/tests/test_ui.py +++ /dev/null @@ -1,169 +0,0 @@ -import hashlib -import subprocess -import sys - -import pytest - -from producer import ui - - -class _FakeResp: - def __init__(self, payload: bytes, length: str | None = None): - self._payload = payload - self.headers = {"Content-Length": length} if length else {} - - def read(self, n=-1): - if not self._payload: - return b"" - out, self._payload = self._payload[:n], self._payload[n:] - return out - - def __enter__(self): - return self - - def __exit__(self, *_a): - return False - - -def test_fmt_bytes(): - assert ui.fmt_bytes(512) == "512 B" - assert ui.fmt_bytes(2048) == "2.0 KB" - assert ui.fmt_bytes(8 << 20) == "8.0 MB" - assert ui.fmt_bytes(None) == "?" - - -def test_fmt_secs(): - assert ui.fmt_secs(0) == "0:00" - assert ui.fmt_secs(59) == "0:59" - assert ui.fmt_secs(61) == "1:01" - assert ui.fmt_secs(3700) == "1:01:40" - assert ui.fmt_secs(None) == "?" - - -def test_bar_fills(): - assert ui._bar(0.0) == "[" + "-" * 22 + "]" - assert ui._bar(1.0) == "[" + "#" * 22 + "]" - half = ui._bar(0.5) - assert half.count("#") == 11 and half.count("-") == 11 - - -def test_download_writes_file(tmp_path, monkeypatch, capsys): - payload = b"x" * (2 << 20) - sha = hashlib.sha256(payload).hexdigest() - monkeypatch.setattr( - "urllib.request.urlopen", lambda req, timeout=None: _FakeResp(payload, str(len(payload))) - ) - dest = tmp_path / "big.bin" - out = ui.download("https://example.com/big.bin", dest, sha256=sha) - assert out == dest - assert dest.read_bytes() == payload - assert "done" in capsys.readouterr().out - - -def test_download_checksum_mismatch(tmp_path, monkeypatch): - monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout=None: _FakeResp(b"abc")) - dest = tmp_path / "f.bin" - with pytest.raises(RuntimeError): - ui.download("https://example.com/f.bin", dest, sha256="0" * 64) - assert not dest.exists() - assert not dest.with_name(dest.name + ".part").exists() - - -def test_download_cached_skips(tmp_path, monkeypatch, capsys): - dest = tmp_path / "cached.bin" - dest.write_bytes(b"hello") - - def boom(*_a, **_k): - raise AssertionError("should not download") - - monkeypatch.setattr("urllib.request.urlopen", boom) - ui.download("https://example.com/cached.bin", dest) - assert dest.read_bytes() == b"hello" - assert "cached" in capsys.readouterr().out - - -def test_download_size_mismatch_redownloads(tmp_path, monkeypatch): - dest = tmp_path / "short.bin" - dest.write_bytes(b"too short") - monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout=None: _FakeResp(b"abcd")) - ui.download("https://example.com/short.bin", dest, expected_size=4) - assert dest.read_bytes() == b"abcd" - - -def test_download_milestones_non_tty(tmp_path, monkeypatch, capsys): - payload = b"y" * (8 << 20) - monkeypatch.setattr( - "urllib.request.urlopen", lambda req, timeout=None: _FakeResp(payload, str(len(payload))) - ) - ui.download("https://example.com/m.bin", tmp_path / "m.bin") - out = capsys.readouterr().out - assert "8.0 MB/8.0 MB" in out - assert "done" in out - - -def test_run_piped_forwards_output(monkeypatch, capsys): - monkeypatch.setattr(ui, "is_tty", lambda: False) - rc = ui.run([sys.executable, "-c", "print('hello-uv-output')"], "test run") - assert rc == 0 - assert "hello-uv-output" in capsys.readouterr().out - - -def test_run_piped_raises_on_failure(monkeypatch): - monkeypatch.setattr(ui, "is_tty", lambda: False) - with pytest.raises(subprocess.CalledProcessError): - ui.run([sys.executable, "-c", "raise SystemExit(3)"], "test run") - - -def test_status_non_tty_milestones_and_done(capsys): - s = ui.Status(prefix="[producer] in.wav — ") - s.stage("denoise") - for i in range(1, 11): - s.tick(i, 10) - s.stage_done("denoise", 2.5) - s.stage("dsp") - s.stage_done("dsp", 0.3) - s.finish() - out = capsys.readouterr().out - assert "denoise 10/10 chunks (100%)" in out - assert "denoise done in 2.5s (10/10 chunks)" in out - assert "dsp done in 0.3s" in out - - -def test_status_tty_live_line(monkeypatch, capsys): - monkeypatch.setattr(ui, "is_tty", lambda: True) - s = ui.Status(prefix="p — ") - s.stage("denoise") - s.tick(10, 10) # completion redraws immediately (bypasses the 0.1 s throttle) - out = capsys.readouterr().out - assert "denoise" in out and "10/10" in out - s.finish() - - -def test_progress_milestones_non_tty(capsys): - p = ui.Progress("downloading x", total=1000) - for i in (100, 300, 1000): - p.update(i) - p.close() - out = capsys.readouterr().out - assert "1000 B/1000 B" in out - assert "done (1000 B" in out - - -def test_progress_tty_bar(monkeypatch, capsys): - monkeypatch.setattr(ui, "is_tty", lambda: True) - p = ui.Progress("downloading torch", total=100) - p.update(50) - p.close() - out = capsys.readouterr().out - assert "50 B/100 B (50%)" in out - - -def test_log_clears_live_line(monkeypatch, capsys): - monkeypatch.setattr(ui, "is_tty", lambda: True) - p = ui.Progress("downloading", total=100) - p.update(50) - ui.log("[producer] some message") - out = capsys.readouterr().out - assert "some message" in out - assert out.index("some message") > out.index("downloading") - p.close() diff --git a/lib/tests/test_updates.py b/lib/tests/test_updates.py deleted file mode 100644 index 7ad1727..0000000 --- a/lib/tests/test_updates.py +++ /dev/null @@ -1,221 +0,0 @@ -import subprocess -import sys -from types import SimpleNamespace - -from producer import lazy, updates - - -def test_parse_would_install(): - out = "\n".join( - [ - "Using Python 3.11.16 environment at: /x", - "Resolved 2 packages in 1ms", - "Would install 2 packages", - " + scipy==1.14.1", - " - numpy==1.26.4", - " + numpy==2.2.6", - " ~ cffi==2.1.1", - "", - ] - ) - assert updates._parse_would_install(out) == { - "scipy": "1.14.1", - "numpy": "2.2.6", - "cffi": "2.1.1", - } - - -def test_vkey_ordering(): - assert updates._vkey("1.26.4") < updates._vkey("2.2.6") - assert updates._vkey("0.5.6") < updates._vkey("0.5.7") - assert updates._vkey("2.7.1") < updates._vkey("2.7.1+cu126") - assert updates._vkey("1.2.10") > updates._vkey("1.2.9") - assert not updates._vkey("0.5.6") > updates._vkey("0.5.6") - - -def test_core_names(tmp_path, monkeypatch): - reqs = tmp_path / "requirements-core.txt" - reqs.write_text("numpy==1.26.4\nscipy>=1.11,<1.15\n\n# comment\nsoundfile>=0.12,<0.13\n") - monkeypatch.setattr(updates, "CORE_FILE", reqs) - assert updates._core_names() == ["numpy", "scipy", "soundfile"] - - -def test_probe_filters_to_keep(monkeypatch): - calls = [] - - def fake_run(cmd, capture_output, text, timeout): - calls.append(cmd) - return SimpleNamespace( - returncode=0, - stdout="Resolved 2 packages\n + scipy==1.14.1\n + numpy==2.2.6\n", - ) - - monkeypatch.setattr(lazy, "find_uv", lambda: "uv") - monkeypatch.setattr(updates.subprocess, "run", fake_run) - got = updates._probe(["-U", "-r", "reqs.txt"], None, {"scipy"}) - assert got == {"scipy": "1.14.1"} - assert calls[0][1:4] == ["pip", "install", "--dry-run"] - - -def test_probe_failure_returns_empty(monkeypatch): - def boom(cmd, capture_output, text, timeout): - raise subprocess.TimeoutExpired(cmd, timeout) - - monkeypatch.setattr(lazy, "find_uv", lambda: "uv") - monkeypatch.setattr(updates.subprocess, "run", boom) - assert updates._probe(["torch"], None, {"torch"}) == {} - - -def test_collect_full_matrix(monkeypatch, tmp_path): - installed = { - "numpy": "1.26.4", - "scipy": "1.13.1", - "soundfile": "0.12.1", - "pyloudnorm": "0.2.0", - "torch": "2.7.1+cu126", - "torchaudio": "2.7.1+cu126", - "deepfilternet": "0.5.6", - "zipenhancer": None, - "clearvoice": None, - } - - def fake_probe(args, python, keep): - if "torch" in args and "torchaudio" in args and "--index-url" in args: - return {"torch": "2.9.0+cu126", "torchaudio": "2.9.0+cu126"} - if "deepfilternet" in args: - return {"deepfilternet": "0.5.7", "torch": "2.14.0"} - if "zipenhancer" in args: - return {} - return {"scipy": "1.14.1"} - - monkeypatch.setattr(updates, "_installed", lambda d: installed.get(d)) - monkeypatch.setattr(updates, "_probe", fake_probe) - monkeypatch.setattr(lazy, "gpu_present", lambda: True) - monkeypatch.setattr(lazy, "find_uv", lambda: "uv") - monkeypatch.setattr(updates, "RESEMBLE_PY", tmp_path / "missing" / "python") - - got = updates.collect() - labels = [u.label for u in got] - assert labels == ["scipy", "torch + torchaudio", "deepfilternet"] - - scipy = got[0] - assert (scipy.old, scipy.new) == ("1.13.1", "1.14.1") - assert "-U" in scipy.cmd and str(updates.CORE_FILE) in scipy.cmd - - torch_u = got[1] - assert (torch_u.old, torch_u.new) == ("2.7.1+cu126", "2.9.0+cu126") - assert "CUDA" in torch_u.note - assert "torch==2.9.0+cu126" in torch_u.cmd - assert "torchaudio==2.9.0+cu126" in torch_u.cmd - assert lazy.TORCH_GPU_INDEX in torch_u.cmd - - dfn = got[2] - assert (dfn.old, dfn.new) == ("0.5.6", "0.5.7") - assert "torch==2.7.1" in dfn.cmd - assert "deepfilternet==0.5.7" in dfn.cmd - assert "torch==2.14.0" not in dfn.cmd - - -def test_collect_skips_uninstalled_engines_and_missing_torch(monkeypatch, tmp_path): - monkeypatch.setattr(updates, "_installed", lambda d: None) - monkeypatch.setattr(lazy, "find_uv", lambda: "uv") - monkeypatch.setattr(updates, "RESEMBLE_PY", tmp_path / "missing" / "python") - - probed = [] - monkeypatch.setattr(updates, "_probe", lambda args, python, keep: probed.append(args) or {}) - assert updates.collect() == [] - assert len(probed) == 1 - assert "deepfilternet" not in probed[0] and "torch" not in probed[0] - - -def test_collect_resemble_isolated_venv(monkeypatch, tmp_path): - py = tmp_path / "resemble" / "bin" / "python" - py.parent.mkdir(parents=True) - py.write_text("") - monkeypatch.setattr(updates, "_installed", lambda d: "2.7.1+cu126" if d == "torch" else None) - monkeypatch.setattr(updates, "_installed_in", lambda p, d: "0.0.1") - monkeypatch.setattr(lazy, "find_uv", lambda: "uv") - monkeypatch.setattr(updates, "RESEMBLE_PY", py) - - def fake_probe(args, python, keep): - if "resemble-enhance" in args: - assert python == str(py) - return {"resemble-enhance": "0.0.2"} - return {} - - monkeypatch.setattr(updates, "_probe", fake_probe) - got = updates.collect() - assert len(got) == 1 - u = got[0] - assert u.label == "resemble-enhance" - assert "resemble-enhance==0.0.2" in u.cmd - assert str(py) in u.cmd - - -def test_confirm(monkeypatch): - answers = iter(["", "no", "y", "yes"]) - monkeypatch.setattr("builtins.input", lambda prompt: next(answers)) - assert not updates._confirm("install updates?") - assert not updates._confirm("install updates?") - assert updates._confirm("install updates?") - assert updates._confirm("install updates?") - - -def test_check_and_prompt_non_tty_notices_only(monkeypatch, capsys): - monkeypatch.setattr( - updates, - "collect", - lambda: [updates.Update("scipy", "1.13.1", "1.14.1", "", ["uv"])], - ) - monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: False)) - assert updates.check_and_prompt() is False - out = capsys.readouterr().out - assert "updates available" in out - assert "./producer update" in out - - -def test_check_and_prompt_assume_yes_applies(monkeypatch, capsys): - u = updates.Update("scipy", "1.13.1", "1.14.1", "", ["uv", "pip", "install", "scipy"]) - monkeypatch.setattr(updates, "collect", lambda: [u]) - runs = [] - monkeypatch.setattr(updates.ui, "run", lambda cmd, label, check=True: runs.append(cmd)) - assert updates.check_and_prompt(assume_yes=True) is True - assert runs == [["uv", "pip", "install", "scipy"]] - assert "updates installed" in capsys.readouterr().out - - -def test_check_and_prompt_declined(monkeypatch, capsys): - u = updates.Update("scipy", "1.13.1", "1.14.1", "", ["uv"]) - monkeypatch.setattr(updates, "collect", lambda: [u]) - monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True)) - monkeypatch.setattr(updates, "_confirm", lambda prompt: False) - runs = [] - monkeypatch.setattr(updates.ui, "run", lambda cmd, label, check=True: runs.append(cmd)) - assert updates.check_and_prompt() is False - assert runs == [] - assert "skipped updates" in capsys.readouterr().out - - -def test_check_and_prompt_up_to_date(monkeypatch, capsys): - monkeypatch.setattr(updates, "collect", lambda: []) - assert updates.check_and_prompt(force=True) is False - assert "up to date" in capsys.readouterr().out - - -def test_update_command_requires_yes_when_not_interactive(monkeypatch): - monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: False)) - monkeypatch.setattr( - updates, - "collect", - lambda: [updates.Update("scipy", "1.13.1", "1.14.1", "", ["uv"])], - ) - try: - updates.run_update_command([]) - except SystemExit as e: - assert "--yes" in str(e) - else: - raise AssertionError("expected SystemExit") - runs = [] - monkeypatch.setattr(updates.ui, "run", lambda cmd, label, check=True: runs.append(cmd)) - assert updates.run_update_command(["--yes"]) == 0 - assert len(runs) == 1 -- cgit v1.2.3