Files
sermon-clean/tests/test_denoise_batch.py
Hermes Agent 4590dc0fb9 Add 5 subcommands: normalize, silence-stats, threshold-tune, denoise, batch
Sermon-clean v0.2.0. Five new subcommands that round out the editor:

- normalize (norm): Apply EBU R128 two-pass loudness normalization.
  Default -16 LUFS (podcast/YouTube). Configurable target LUFS, true
  peak, and loudness range. Output reports measured input loudness
  and applied gain offset.

- silence-stats (ss): Quantitative summary of silence distribution —
  count, total/mean/median/longest silence, silence fraction, and
  silence runs per minute. Outputs JSON via --json. Useful for
  comparing recordings and picking the right threshold.

- threshold-tune (tt): Auto-pick the silence threshold for the audio.
  Scans a set of candidate thresholds (default -25..-50), scores each
  against the target silence-runs-per-minute (default 4.0), picks the
  closest match. Shows the full scoring table.

- denoise: Apply ffmpeg's afftdn filter for light FFT-based noise
  reduction. Configurable noise reduction dB (default 12) and noise
  floor dB (default -50). Output at 48kHz to match normalize.

- batch: Run any of the subcommands across many files via glob.
  Output goes to --output-dir with --suffix (default '-fixed') and
  optional --extension override. Failures are collected, not raised
  — one bad file doesn't kill the whole batch.

Implementation:
- sermon_clean/processing.py: normalize_loudness + SilenceStats
  dataclass + silence_stats + threshold_tune.
- sermon_clean/denoise.py: DenoiseResult + denoise.
- sermon_clean/batch.py: run_batch + _expand_globs + _make_output_path.
- sermon_clean/cli.py: 5 new cmd_* functions + 5 subparser registrations.

Tests:
- tests/test_processing.py (7 tests): silence-stats on silent vs loud,
  threshold-tune picks closest, normalize produces output + measures loud.
- tests/test_denoise_batch.py (11 tests): denoise roundtrip, batch
  helpers (glob expansion, output naming), batch run with normalize
  + denoise, unknown subcommand raises, one-bad-file-in-batch continues.

Total: 82/82 tests passing in 48s (was 64/64). Bumped version to 0.2.0.

README updated: step-by-step workflow adds 1d-1g; subcommand table
adds the 5 new commands; new 'Batch processing' section.
2026-07-27 13:31:18 -07:00

134 lines
4.8 KiB
Python

"""Tests for sermon_clean.denoise + sermon_clean.batch."""
from pathlib import Path
import pytest
from sermon_clean.denoise import denoise, DenoiseResult
from sermon_clean.batch import run_batch, _expand_globs, _make_output_path, BATCH_SUBCOMMANDS
def _make_wav(path: Path, *, freq: float = 440.0, duration: float = 5.0) -> Path:
import subprocess
subprocess.run([
"ffmpeg", "-y", "-v", "error",
"-f", "lavfi", "-i", f"sine=frequency={freq}:duration={duration}",
str(path),
], check=True)
return path
# ---------------------------------------------------------------------------
# denoise
# ---------------------------------------------------------------------------
class TestDenoise:
def test_produces_output_file(self, tmp_path):
src = _make_wav(tmp_path / "src.wav")
out = tmp_path / "out.wav"
result = denoise(src, out)
assert isinstance(result, DenoiseResult)
assert out.exists()
assert out.stat().st_size > 1000
def test_custom_reduction_db(self, tmp_path):
src = _make_wav(tmp_path / "src.wav")
out = tmp_path / "out.wav"
result = denoise(src, out, noise_reduction_db=20.0, noise_floor_db=-40.0)
assert result.noise_reduction_db == 20.0
assert result.noise_floor_db == -40.0
# ---------------------------------------------------------------------------
# batch helpers
# ---------------------------------------------------------------------------
class TestBatchHelpers:
def test_expand_globs_dedupes(self, tmp_path):
(tmp_path / "a.wav").write_bytes(b"x")
(tmp_path / "b.wav").write_bytes(b"x")
result = _expand_globs([str(tmp_path / "*.wav"), str(tmp_path / "a.wav")])
# Should be 2 unique files, not 3
assert len(result) == 2
names = sorted(p.name for p in result)
assert names == ["a.wav", "b.wav"]
def test_expand_globs_recursive(self, tmp_path):
sub = tmp_path / "sub"
sub.mkdir()
(sub / "c.wav").write_bytes(b"x")
(tmp_path / "a.wav").write_bytes(b"x")
result = _expand_globs([str(tmp_path / "**" / "*.wav")])
assert len(result) == 2
def test_make_output_path_with_suffix(self, tmp_path):
src = Path("/some/where/sermon.ogg")
out = _make_output_path(src, tmp_path, "-normalized", None)
assert out == tmp_path / "sermon-normalized.ogg"
def test_make_output_path_with_extension_override(self, tmp_path):
src = Path("/some/where/sermon.ogg")
out = _make_output_path(src, tmp_path, "-normalized", ".wav")
assert out == tmp_path / "sermon-normalized.wav"
def test_batch_subcommands_includes_new_ones(self):
for cmd in ["normalize", "denoise", "silence-stats", "threshold-tune"]:
assert cmd in BATCH_SUBCOMMANDS
# batch itself is NOT in BATCH_SUBCOMMANDS — recursive batch would be weird.
assert "batch" not in BATCH_SUBCOMMANDS
# ---------------------------------------------------------------------------
# batch.run_batch end-to-end
# ---------------------------------------------------------------------------
class TestRunBatch:
def test_batch_normalize_two_files(self, tmp_path):
a = _make_wav(tmp_path / "sermon-a.wav", duration=3.0)
b = _make_wav(tmp_path / "sermon-b.wav", duration=3.0)
out_dir = tmp_path / "out"
result = run_batch(
"normalize",
[a, b],
out_dir,
output_suffix="-normalized",
)
assert result.n_ok == 2
assert result.n_failed == 0
assert (out_dir / "sermon-a-normalized.wav").exists()
assert (out_dir / "sermon-b-normalized.wav").exists()
def test_batch_denoise_with_extension_override(self, tmp_path):
a = _make_wav(tmp_path / "sermon.wav", duration=3.0)
out_dir = tmp_path / "out"
result = run_batch(
"denoise",
[a],
out_dir,
output_suffix="-dn",
output_extension=".wav",
)
assert result.n_ok == 1
assert (out_dir / "sermon-dn.wav").exists()
def test_batch_unknown_subcommand_raises(self, tmp_path):
with pytest.raises(ValueError, match="unknown subcommand"):
run_batch("nonexistent", [], tmp_path)
def test_batch_one_failure_does_not_stop_batch(self, tmp_path):
"""One bad file shouldn't kill the batch — failures are collected."""
a = _make_wav(tmp_path / "good.wav", duration=3.0)
bad = tmp_path / "does-not-exist.wav" # doesn't exist
out_dir = tmp_path / "out"
result = run_batch("normalize", [a, bad], out_dir, output_suffix="-n")
assert result.n_ok == 1
assert result.n_failed == 1
assert (out_dir / "good-n.wav").exists()
assert not (out_dir / "does-not-exist-n.wav").exists()