Initial commit: sermon-clean v0.1.0
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).
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user