diff options
| author | historia <historiavg@proton.me> | 2026-09-07 06:47:47 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-07 06:47:47 -0400 |
| commit | 84dd2d068317998f6fb59400c534ef5be6b51b53 (patch) | |
| tree | 025293e9d9229e02960374771ae522d9de2628ce /lib/tests | |
| parent | 39b0f2bbed74f6487a41b82501ae3c6799e4b5c4 (diff) | |
| download | producer-84dd2d068317998f6fb59400c534ef5be6b51b53.tar.gz | |
Diffstat (limited to 'lib/tests')
| -rw-r--r-- | lib/tests/conftest.py | 70 | ||||
| -rw-r--r-- | lib/tests/test_chunking.py | 250 | ||||
| -rw-r--r-- | lib/tests/test_cli.py | 357 | ||||
| -rw-r--r-- | lib/tests/test_dsp.py | 130 | ||||
| -rw-r--r-- | lib/tests/test_engines.py | 515 | ||||
| -rw-r--r-- | lib/tests/test_lazy.py | 286 | ||||
| -rw-r--r-- | lib/tests/test_loudness.py | 30 | ||||
| -rw-r--r-- | lib/tests/test_meters.py | 65 | ||||
| -rw-r--r-- | lib/tests/test_pipeline.py | 114 | ||||
| -rw-r--r-- | lib/tests/test_ui.py | 169 | ||||
| -rw-r--r-- | lib/tests/test_updates.py | 221 |
11 files changed, 0 insertions, 2207 deletions
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 = ( - "<html><body>" - '<a href="https://download-r2.pytorch.org/whl/cu126/torch-2.6.0%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl#sha256=00' - f'00">old</a><a href="https://download-r2.pytorch.org/whl/cu126/torch-2.7.1%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl#sha256={_SHA}">new</a>' - '<a href="https://download-r2.pytorch.org/whl/cu126/torch-2.7.1%2Bcu126-cp311-cp311-win_amd64.whl#sha256=1111">win</a>' - '<a href="torch-2.7.1%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl">relative-dup</a>' - "</body></html>" -) - - -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("<html></html>", "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 |
