fa0628f0e2
- 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
132 lines
4.6 KiB
Python
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 |