cea6005bf4
Find, cut, paste replacements in sermon audio. - engine.py: SermonClean core (BadSegment, trim, concat, splice verify) - cli.py: find/cut/paste/pipe subcommands - elevenlabs.py: optional ElevenLabs rendering hook - 25 tests passing (timestamp parsing, BadSegment validation, end-to-end trim on real audio) - pyproject.toml: pip-installable; dep only on ffmpeg/ffprobe system-wide - README + LICENSE (MIT) + examples/segs.json Validated against the krystie audio cache (OGG/Opus 48kHz mono).
100 lines
3.1 KiB
Python
100 lines
3.1 KiB
Python
"""Tests for sermon-clean.engine."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from sermon_clean.engine import (
|
|
SermonClean,
|
|
parse_timestamp,
|
|
format_timestamp,
|
|
)
|
|
|
|
|
|
class TestTimestampParsing:
|
|
@pytest.mark.parametrize("s,expected", [
|
|
("30", 30.0),
|
|
("90", 90.0),
|
|
("1:30", 90.0),
|
|
("01:30", 90.0),
|
|
("1:30.5", 90.5),
|
|
("1:00:30", 3630.0),
|
|
("02:01:30.5", 7290.5),
|
|
])
|
|
def test_valid(self, s, expected):
|
|
assert parse_timestamp(s) == expected
|
|
|
|
@pytest.mark.parametrize("s", ["", "abc", "1:60", "60:60", "1.2.3"])
|
|
def test_invalid(self, s):
|
|
with pytest.raises(ValueError):
|
|
parse_timestamp(s)
|
|
|
|
|
|
class TestFormatTimestamp:
|
|
@pytest.mark.parametrize("seconds,expected", [
|
|
(0.0, "00:00.000"),
|
|
(59.999, "00:59.999"),
|
|
(60.0, "01:00.000"),
|
|
(90.5, "01:30.500"),
|
|
(3600.0, "01:00:00.000"),
|
|
(3661.5, "01:01:01.500"),
|
|
(7290.5, "02:01:30.500"),
|
|
])
|
|
def test_round_trip(self, seconds, expected):
|
|
assert format_timestamp(seconds) == expected
|
|
|
|
def test_roundtrip_parse(self):
|
|
for s in [0.0, 5.0, 59.999, 60.0, 3630.0, 7290.5]:
|
|
assert parse_timestamp(format_timestamp(s)) == pytest.approx(s)
|
|
|
|
|
|
class TestBadSegment:
|
|
def test_valid(self):
|
|
from sermon_clean.engine import BadSegment
|
|
s = BadSegment(10.0, 12.0, reason="misspoke")
|
|
assert s.duration == 2.0
|
|
assert s.start == 10.0
|
|
assert s.end == 12.0
|
|
|
|
def test_zero_length_rejected(self):
|
|
from sermon_clean.engine import BadSegment
|
|
with pytest.raises(ValueError):
|
|
BadSegment(10.0, 10.0)
|
|
|
|
def test_negative_rejected(self):
|
|
from sermon_clean.engine import BadSegment
|
|
with pytest.raises(ValueError):
|
|
BadSegment(15.0, 10.0)
|
|
|
|
def test_long_window_rejected(self):
|
|
from sermon_clean.engine import BadSegment
|
|
# 90s window by default > 60s cap
|
|
with pytest.raises(ValueError, match="suspiciously long"):
|
|
BadSegment(0.0, 90.0)
|
|
|
|
|
|
def test_sermon_clean_basic(tmp_path):
|
|
"""Smoke test: load a real audio file from the krystie profile cache."""
|
|
# Find any audio file in the krystie cache
|
|
cache = Path("/root/.hermes/profiles/krystie/cache/audio")
|
|
if not cache.exists():
|
|
pytest.skip("krystie audio cache not present")
|
|
audio_files = sorted(cache.glob("*.ogg")) + sorted(cache.glob("*.mp3"))
|
|
if not audio_files:
|
|
pytest.skip("no audio files in krystie cache")
|
|
audio = audio_files[0]
|
|
sc = SermonClean(audio, workdir=tmp_path)
|
|
assert sc.duration > 0
|
|
assert sc.codec["codec_name"]
|
|
assert sc.codec["sample_rate"]
|
|
seg = sc.add_segment(0.5, 1.0, reason="test")
|
|
assert seg.start == 0.5
|
|
assert len(sc.segments) == 1
|
|
segs = sc.trim_segments()
|
|
assert len(segs) == 2 # before + after the 0.5s window
|
|
assert all(p.exists() for p in segs)
|
|
# Verify the trimmed segments
|
|
from sermon_clean.engine import probe_duration
|
|
total = sum(probe_duration(p) for p in segs)
|
|
assert total == pytest.approx(sc.duration - 0.5, abs=0.1)
|