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.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for sermon_clean.processing — normalize, silence_stats, threshold_tune."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from sermon_clean.processing import (
|
||||
normalize_loudness,
|
||||
silence_stats,
|
||||
threshold_tune,
|
||||
SilenceStats,
|
||||
)
|
||||
|
||||
|
||||
def _make_wav(path: Path, *, freq: float = 440.0, duration: float = 5.0) -> Path:
|
||||
"""Create a synthetic WAV file for testing. Returns the path."""
|
||||
import subprocess
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-v", "error",
|
||||
"-f", "lavfi", "-i", f"sine=frequency={freq}:duration={duration}",
|
||||
str(path),
|
||||
], check=True)
|
||||
return path
|
||||
|
||||
|
||||
def _make_silent_wav(path: Path, duration: float = 5.0) -> Path:
|
||||
"""Create a silent WAV file."""
|
||||
import subprocess
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-v", "error",
|
||||
"-f", "lavfi", "-i", f"anullsrc=r=8000:cl=mono",
|
||||
"-t", str(duration),
|
||||
str(path),
|
||||
], check=True)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# silence_stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSilenceStats:
|
||||
def test_silent_audio_all_silence(self, tmp_path):
|
||||
audio = _make_silent_wav(tmp_path / "silent.wav", duration=5.0)
|
||||
stats = silence_stats(audio, threshold_db=-30.0, min_duration_seconds=0.3)
|
||||
assert isinstance(stats, SilenceStats)
|
||||
assert stats.duration_seconds == pytest.approx(5.0, abs=0.5)
|
||||
# A silent file should have 1 big silence run
|
||||
assert stats.n_silence_runs == 1
|
||||
assert stats.silence_fraction > 0.9
|
||||
assert stats.longest_silence_seconds > 4.0
|
||||
assert stats.silence_per_minute > 10.0 # very dense silence
|
||||
|
||||
def test_loud_tone_no_silences(self, tmp_path):
|
||||
audio = _make_wav(tmp_path / "tone.wav", freq=440.0, duration=5.0)
|
||||
stats = silence_stats(audio, threshold_db=-30.0, min_duration_seconds=0.3)
|
||||
# 440Hz tone has no silences above the threshold
|
||||
assert stats.n_silence_runs == 0
|
||||
assert stats.total_silence_seconds == 0.0
|
||||
assert stats.silence_fraction == 0.0
|
||||
assert stats.silence_per_minute == 0.0
|
||||
|
||||
def test_to_dict_roundtrip(self, tmp_path):
|
||||
audio = _make_silent_wav(tmp_path / "silent.wav")
|
||||
stats = silence_stats(audio)
|
||||
d = stats.to_dict()
|
||||
assert d["audio_path"] == str(audio)
|
||||
assert isinstance(d["longest_silence_range"], list)
|
||||
assert d["threshold_db"] == -35.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# threshold_tune
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThresholdTune:
|
||||
def test_picks_threshold_closest_to_target(self, tmp_path):
|
||||
# 5s silence + 1s tone alternating — moderate silence density
|
||||
import subprocess
|
||||
audio = tmp_path / "mixed.wav"
|
||||
# 5s silence, 1s tone, 5s silence, 1s tone (12s total, 10s silence, ~83% silence)
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-v", "error",
|
||||
"-f", "lavfi", "-i", "anullsrc=r=8000:cl=mono",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440",
|
||||
"-filter_complex", "[0:a]atrim=0:5[s1];[1:a]atrim=0:1[t1];[0:a]atrim=0:5[s2];[1:a]atrim=0:1[t2];[s1][t1][s2][t2]concat=n=4:v=0:a=1[out]",
|
||||
"-map", "[out]", "-t", "12",
|
||||
str(audio),
|
||||
], check=True)
|
||||
|
||||
result = threshold_tune(audio, candidates=[-25.0, -35.0, -50.0], target_silence_per_minute=4.0)
|
||||
assert "picked_threshold_db" in result
|
||||
assert "candidates" in result
|
||||
assert len(result["candidates"]) == 3
|
||||
# The picked threshold should have the smallest distance
|
||||
picked = result["picked_threshold_db"]
|
||||
picked_row = next(r for r in result["candidates"] if r["threshold_db"] == picked)
|
||||
assert picked_row["distance_from_target"] == min(r["distance_from_target"] for r in result["candidates"])
|
||||
|
||||
def test_handles_no_silences_at_all(self, tmp_path):
|
||||
# A pure tone has zero silences regardless of threshold
|
||||
audio = _make_wav(tmp_path / "tone.wav", freq=440.0, duration=3.0)
|
||||
result = threshold_tune(audio, candidates=[-25.0, -40.0], target_silence_per_minute=4.0)
|
||||
# All candidates give 0 silence/min, all distance == 4.0. Picked is just the first.
|
||||
assert result["picked_threshold_db"] in [-25.0, -40.0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalize:
|
||||
def test_produces_output_file(self, tmp_path):
|
||||
src = _make_wav(tmp_path / "src.wav", freq=440.0, duration=5.0)
|
||||
out = tmp_path / "out.wav"
|
||||
result = normalize_loudness(src, out, target_lufs=-16.0)
|
||||
assert out.exists()
|
||||
assert out.stat().st_size > 1000 # not a silent stub
|
||||
assert result["target_lufs"] == -16.0
|
||||
assert result["measured_input_i"] is not None
|
||||
assert result["output_path"] == str(out)
|
||||
|
||||
def test_normalized_audio_is_quieter_than_loud_input(self, tmp_path):
|
||||
# Generate a LOUD sine (0.9 amplitude) — should be normalized DOWN
|
||||
import subprocess
|
||||
src = tmp_path / "loud.wav"
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-v", "error",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=5",
|
||||
"-af", "volume=0.9",
|
||||
str(src),
|
||||
], check=True)
|
||||
out = tmp_path / "out.wav"
|
||||
result = normalize_loudness(src, out, target_lufs=-16.0)
|
||||
# The applied offset should be NEGATIVE (reducing volume) for a hot input
|
||||
offset = float(result["applied_offset"])
|
||||
assert offset < 0.0, f"expected negative gain offset for loud input, got {offset}"
|
||||
Reference in New Issue
Block a user