aboutsummaryrefslogtreecommitdiff
path: root/lib/project/tests/test_cli.py
blob: a5437b72cb5c0b2e7246ca17299fdbff2b9af3cc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
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