Files
sermon-clean/tests/test_slices.py
T
sami7777 fa0628f0e2 Add slices + speech_regions modules
- slices.py: extract overlapping audio chunks for manual review (filename
  embeds start/end timestamps for easy reference)
- speech_regions.py: find/extract only the speech regions (silence stripping),
  useful as a faster-whisper pre-processing step
- waveform.py: fix silencedetect verbosity (info, not error) + regex parser
  to handle '[silencedetect @ 0x...] silence_start: 0' prefix
- cli.py: new 'slices' subcommand
- tests: 12 new tests (slices, speech_regions, regex); 61 total all passing
- README: updated with subcommand table + step-by-step workflow
2026-07-27 04:00:45 -07:00

132 lines
4.6 KiB
Python

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