Files
sermon-clean/sermon_clean/engine.py
T
sami7777 cea6005bf4 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).
2026-07-27 02:40:18 -07:00

401 lines
14 KiB
Python

"""Core engine for sermon-clean.
The pipeline is intentionally straightforward: each step is a pure function that
takes the output of the previous step. The CLI in `cli.py` orchestrates them.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Iterable
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class BadSegment:
"""A bad window in the original audio, expressed in seconds."""
start: float
end: float
reason: str = ""
replacement_text: str = ""
def __post_init__(self) -> None:
if self.end <= self.start:
raise ValueError(f"BadSegment end must be > start (got {self.start}..{self.end})")
if (self.end - self.start) > 60:
raise ValueError(
f"BadSegment suspiciously long ({self.end - self.start:.1f}s). "
"Surgical replacement is the goal — keep windows short. "
"If you really need a long window, pass --allow-long-windows."
)
@property
def duration(self) -> float:
return self.end - self.start
@dataclass
class Replacement:
"""A replacement audio clip for a BadSegment."""
segment: BadSegment
audio_path: Path
def __post_init__(self) -> None:
if not self.audio_path.exists():
raise FileNotFoundError(f"Replacement audio not found: {self.audio_path}")
if self.audio_path.stat().st_size < 1000:
raise ValueError(
f"Replacement audio file is too small ({self.audio_path.stat().st_size} bytes). "
"ElevenLabs sometimes returns 200 with empty bytes on quota/auth failure. "
"Re-render and verify size > 1KB before splicing."
)
@dataclass
class SpliceResult:
"""The output of a successful splice."""
output_path: Path
duration_seconds: float
n_segments: int
n_replacements: int
original_duration: float = 0.0
expected_duration: float = 0.0
def duration_check(self) -> str:
"""Return 'ok' if the splice matches the expected duration ±1s."""
delta = abs(self.duration_seconds - self.expected_duration)
if delta > 1.0:
return f"DRIFT ({delta:.2f}s from expected {self.expected_duration:.2f}s)"
return "ok"
# ---------------------------------------------------------------------------
# Timestamp parsing
# ---------------------------------------------------------------------------
_TS_RE = re.compile(r"^(?:(\d+):)?(\d{1,2}):(\d{1,2}(?:\.\d+)?)$|^\d+(?:\.\d+)?$")
def parse_timestamp(s: str) -> float:
"""Parse 'SS', 'MM:SS', or 'HH:MM:SS' into seconds. Raises ValueError on bad input."""
s = s.strip()
if not s:
raise ValueError("empty timestamp")
# Pure seconds
if re.match(r"^\d+(?:\.\d+)?$", s):
return float(s)
# HH:MM:SS or MM:SS
m = re.match(r"^(?:(\d+):)?(\d{1,2}):(\d{1,2}(?:\.\d+)?)$", s)
if not m:
raise ValueError(f"could not parse timestamp: {s!r}")
h, mm, ss = m.groups()
h = int(h) if h else 0
mm = int(mm)
ss = float(ss)
if mm >= 60 or ss >= 60:
raise ValueError(f"invalid timestamp: {s!r}")
return h * 3600 + mm * 60 + ss
def format_timestamp(seconds: float) -> str:
"""Render seconds as HH:MM:SS.mmm (or MM:SS.mmm if < 1h)."""
if seconds < 0:
return f"-{format_timestamp(-seconds)}"
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = seconds % 60
if h:
return f"{h:02d}:{m:02d}:{s:06.3f}"
return f"{m:02d}:{s:06.3f}"
# ---------------------------------------------------------------------------
# ffprobe wrapper
# ---------------------------------------------------------------------------
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, check=check)
def probe_duration(path: Path) -> float:
"""Return the duration of an audio file in seconds (via ffprobe)."""
out = _run([
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(path),
])
return float(out.stdout.strip())
def probe_codec(path: Path) -> dict:
"""Return codec_name, sample_rate, channels for the first audio stream."""
out = _run([
"ffprobe", "-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=codec_name,sample_rate,channels",
"-of", "json",
str(path),
])
data = json.loads(out.stdout)
stream = data["streams"][0]
return {
"codec_name": stream["codec_name"],
"sample_rate": stream["sample_rate"],
"channels": stream["channels"],
}
# Map from ffprobe demuxer name → ffmpeg encoder name. ffprobe reports "opus"
# for the OGG-Opus stream, but `ffmpeg -c:a opus` is the experimental encoder;
# the non-experimental one is `libopus`. Same for vorbis.
_ENCODER_ALIASES = {
"opus": "libopus",
"vorbis": "libvorbis",
}
def encoder_name(demuxer_name: str) -> str:
"""Translate a ffprobe codec name to the corresponding ffmpeg encoder name."""
return _ENCODER_ALIASES.get(demuxer_name, demuxer_name)
# ---------------------------------------------------------------------------
# Silence detection (ffmpeg silencedetect filter)
# ---------------------------------------------------------------------------
def detect_silence(
path: Path,
*,
noise_db: float = -30.0,
min_duration: float = 0.5,
) -> list[tuple[float, float]]:
"""Return (start, end) of every silence run, using ffmpeg's silencedetect filter.
Useful for finding natural breath pauses between sentences, which often bracket
the bad segments you're trying to excise. Combine with manual timestamps when
you want to be sure the splice window lines up with a pause.
"""
out = _run([
"ffmpeg", "-v", "error", "-i", str(path),
"-af", f"silencedetect=noise={noise_db}dB:d={min_duration}",
"-f", "null", "-",
])
starts: list[float] = []
ends: list[float] = []
for line in out.stdout.splitlines():
line = line.strip()
if line.startswith("silence_start:"):
starts.append(float(line.split(":", 1)[1].strip()))
elif line.startswith("silence_end:"):
parts = line.split("|")
if len(parts) >= 2:
ends.append(float(parts[0].split(":", 1)[1].strip()))
return list(zip(starts, ends))
# ---------------------------------------------------------------------------
# The engine
# ---------------------------------------------------------------------------
class SermonClean:
"""Top-level orchestrator. Holds the original audio path and the bad-segment list."""
def __init__(
self,
original: Path,
*,
workdir: Path | None = None,
allow_long_windows: bool = False,
) -> None:
self.original = Path(original)
if not self.original.exists():
raise FileNotFoundError(f"original audio not found: {self.original}")
self.codec = probe_codec(self.original)
self.duration = probe_duration(self.original)
self.allow_long_windows = allow_long_windows
self.workdir = Path(workdir) if workdir else Path(tempfile.mkdtemp(prefix="sermon-clean-"))
self.workdir.mkdir(parents=True, exist_ok=True)
self.segments: list[BadSegment] = []
# -------- segment list management --------
def add_segment(self, start: float, end: float, *, reason: str = "", replacement_text: str = "") -> BadSegment:
seg = BadSegment(start, end, reason=reason, replacement_text=replacement_text)
if self.allow_long_windows and (end - start) > 60:
# Patch back: re-create with relaxed check
object.__setattr__(seg, "end", end)
self.segments.append(seg)
self.segments.sort(key=lambda s: s.start)
return seg
def add_segment_str(self, start_str: str, end_str: str, **kwargs) -> BadSegment:
return self.add_segment(parse_timestamp(start_str), parse_timestamp(end_str), **kwargs)
def load_segments_from_json(self, path: Path) -> int:
"""Load segments from a JSON file. Returns the count loaded."""
data = json.loads(Path(path).read_text())
if not isinstance(data, list):
raise ValueError("segments JSON must be a list of {start, end, reason, replacement_text}")
loaded = 0
for entry in data:
self.add_segment(
float(entry["start"]),
float(entry["end"]),
reason=entry.get("reason", ""),
replacement_text=entry.get("replacement_text", ""),
)
loaded += 1
return loaded
def save_segments_to_json(self, path: Path) -> None:
Path(path).write_text(json.dumps([asdict(s) for s in self.segments], indent=2))
# -------- subtitle export --------
def export_segments_csv(self, path: Path) -> None:
"""Write the segments as a CSV (start_sec,end_sec,start_str,end_str,reason,replacement_text)."""
with open(path, "w") as f:
f.write("start_sec,end_sec,start_str,end_str,reason,replacement_text\n")
for s in self.segments:
reason = s.reason.replace('"', '""')
repl = s.replacement_text.replace('"', '""')
f.write(
f"{s.start:.3f},{s.end:.3f},"
f"{format_timestamp(s.start)},{format_timestamp(s.end)},"
f"\"{reason}\",\"{repl}\"\n"
)
# -------- trim + concat pipeline --------
def _encode_target(self, in_path: Path, out_path: Path) -> None:
"""Re-encode to the original audio's codec, sample rate, channels."""
_run([
"ffmpeg", "-y", "-v", "error",
"-i", str(in_path),
"-ar", str(self.codec["sample_rate"]),
"-ac", str(self.codec["channels"]),
"-c:a", encoder_name(self.codec["codec_name"]),
"-b:a", "128k",
str(out_path),
], check=True)
def trim_segments(self) -> list[Path]:
"""Cut the original audio into N+1 good segments (N bad windows)."""
if not self.segments:
raise ValueError("no bad segments defined — call add_segment() first")
for s in self.segments:
if s.end > self.duration:
raise ValueError(
f"segment end {s.end:.2f}s exceeds audio duration {self.duration:.2f}s"
)
# Build the N+1 keep windows: [0..s0], [s0..s1], ..., [sN..T]
keep_windows: list[tuple[float, float]] = []
prev_end = 0.0
for s in self.segments:
keep_windows.append((prev_end, s.start))
prev_end = s.end
keep_windows.append((prev_end, self.duration))
out_paths: list[Path] = []
for i, (a, b) in enumerate(keep_windows):
p = self.workdir / f"seg_{i:03d}_head.ogg"
if abs(b - a) < 0.01:
# Skip zero-length segments (would only matter if bad windows are adjacent)
continue
_run([
"ffmpeg", "-y", "-v", "error",
"-ss", f"{a:.3f}", "-to", f"{b:.3f}",
"-i", str(self.original),
"-ar", str(self.codec["sample_rate"]),
"-ac", str(self.codec["channels"]),
"-c:a", encoder_name(self.codec["codec_name"]),
"-b:a", "128k",
str(p),
], check=True)
out_paths.append(p)
return out_paths
def encode_replacements(self, replacements: list[Path]) -> list[Path]:
"""Re-encode each replacement MP3 to the original audio's codec."""
out: list[Path] = []
for i, path in enumerate(replacements):
p = self.workdir / f"rep_{i:03d}.ogg"
self._encode_target(Path(path), p)
out.append(p)
return out
def concat(
self,
segments: list[Path],
replacements: list[Path],
output: Path,
) -> SpliceResult:
"""Concat keep-segments + replacement clips into a single output file."""
if len(replacements) != len(self.segments):
raise ValueError(
f"need {len(self.segments)} replacements, got {len(replacements)}"
)
# Interleave: seg0, rep0, seg1, rep1, ..., segN
if len(segments) != len(self.segments) + 1:
raise ValueError(
f"need {len(self.segments) + 1} keep-segments, got {len(segments)}"
)
concat_list = self.workdir / "concat_list.txt"
with open(concat_list, "w") as f:
for s in segments[:1]:
f.write(f"file '{s.name}'\n")
for i, rep in enumerate(replacements):
f.write(f"file '{rep.name}'\n")
f.write(f"file '{segments[i+1].name}'\n")
_run([
"ffmpeg", "-y", "-v", "error",
"-f", "concat", "-safe", "0",
"-i", str(concat_list),
"-c", "copy",
str(output),
], check=True)
# Compute expected duration
expected = sum(probe_duration(p) for p in segments) + sum(probe_duration(p) for p in replacements)
actual = probe_duration(output)
return SpliceResult(
output_path=Path(output),
duration_seconds=actual,
n_segments=len(segments),
n_replacements=len(replacements),
original_duration=self.duration,
expected_duration=expected,
)
def clean(
self,
replacements: list[Path],
output: Path | None = None,
) -> SpliceResult:
"""End-to-end: trim, encode replacements, concat. Returns a SpliceResult."""
segs = self.trim_segments()
reps = self.encode_replacements(replacements)
if output is None:
output = self.workdir / f"{self.original.stem}-fixed{self.original.suffix}"
result = self.concat(segs, reps, output)
return result