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).
401 lines
14 KiB
Python
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
|