"""Tests for sermon_clean.slices and sermon_clean.speech_regions.""" import re import subprocess import tempfile from pathlib import Path import pytest from sermon_clean.slices import extract_slices, _fmt from sermon_clean.speech_regions import ( find_speech_regions, write_speech_only_via_silenceremove, out_duration_estimate, ) def _make_silent_wav(seconds: float, path: Path) -> None: """Generate a silent WAV file for testing.""" subprocess.run([ "ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "anullsrc=r=8000:cl=mono", "-t", str(seconds), str(path), ], check=True) def _make_speech_wav(seconds: float, path: Path) -> None: """Generate a noisy WAV file (treated as speech by silencedetect).""" subprocess.run([ "ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration={}".format(seconds), str(path), ], check=True) class TestFmt: @pytest.mark.parametrize("s,expected", [ (0.0, "0-00"), (5.0, "0-05"), (59.0, "0-59"), (60.0, "1-00"), (90.0, "1-30"), (3600.0, "1-00-00"), ]) def test_fmt(self, s, expected): assert _fmt(s) == expected class TestExtractSlices: def test_small_audio(self, tmp_path): with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: audio = Path(f.name) try: _make_silent_wav(10.0, audio) paths = extract_slices(audio, tmp_path, slice_seconds=3.0, overlap_seconds=1.0) # 10s audio, 3s slices, 2s stride → 5 slices (0-3, 2-5, 4-7, 6-9, 8-10) assert len(paths) == 5 for p in paths: assert p.exists() assert p.stat().st_size > 0 finally: audio.unlink(missing_ok=True) def test_filename_has_timestamp(self, tmp_path): with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: audio = Path(f.name) try: _make_silent_wav(10.0, audio) paths = extract_slices(audio, tmp_path, slice_seconds=3.0, overlap_seconds=1.0) # First slice should start at 0 assert "0-00" in paths[0].name # Filenames should be sorted by index indices = [] for p in paths: m = re.search(r"slice_(\d+)", p.name) assert m is not None, f"no slice index in {p.name}" indices.append(int(m.group(1))) assert indices == sorted(indices) finally: audio.unlink(missing_ok=True) class TestSpeechRegions: def test_silent_audio_has_one_speech_region(self, tmp_path): """A 100% silent file: silence detection starts at 0, but our speech regions should return empty (no speech).""" audio = tmp_path / "silent.wav" _make_silent_wav(3.0, audio) regions = find_speech_regions(audio, min_silence=0.5, merge_gap=0.3) # 3s of silence → zero speech regions assert regions == [] def test_speech_then_silence(self, tmp_path): """Build a file with 2s speech then 2s silence. Should return one speech region.""" # Create mixed audio: sine then silence speech_part = tmp_path / "speech.wav" silence_part = tmp_path / "silence.wav" _make_speech_wav(2.0, speech_part) _make_silent_wav(2.0, silence_part) mixed = tmp_path / "mixed.wav" # Concat them concat_list = tmp_path / "list.txt" concat_list.write_text(f"file '{speech_part}'\nfile '{silence_part}'\n") subprocess.run([ "ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", str(concat_list), str(mixed), ], check=True) regions = find_speech_regions(mixed, min_silence=0.5, merge_gap=0.3) # Should have at least 1 speech region covering the first 2s assert len(regions) >= 1 assert regions[0][0] < 0.5 assert regions[0][1] > 1.5 def test_write_speech_only_creates_file(self, tmp_path): """Verify silenceremove produces a valid output file.""" audio = tmp_path / "source.wav" _make_silent_wav(2.0, audio) output = tmp_path / "output.mp3" write_speech_only_via_silenceremove(audio, output) assert output.exists() assert output.stat().st_size > 0 class TestDurationEstimate: def test_returns_positive(self, tmp_path): audio = tmp_path / "silent.wav" _make_silent_wav(2.5, audio) d = out_duration_estimate(audio) assert 2.4 < d < 2.6