"""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}"