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