Files
sermon-clean/sermon_clean/cli.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

257 lines
11 KiB
Python

"""CLI for sermon-clean — the three-step audio editor.
Usage:
sermon-clean find sermon.ogg --silence-threshold -30 # shows silence gaps
sermon-clean cut sermon.ogg --segments segs.json --replacements rep/*.mp3 -o fixed.ogg
sermon-clean pipe sermon.ogg --bad "21:38-21:42, 1450.3-1453.1" \\
--replace "21:38-21:42:the actual sentence" \\
--replace "1450.3-1453.1:the corrected phrase"
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from .engine import (
SermonClean,
detect_silence,
format_timestamp,
probe_duration,
)
USAGE = """sermon-clean — find bad segments, cut, paste replacements
Three-step workflow:
1. FIND: locate bad segments
- manual: --bad "MM:SS-MM:SS[, MM:SS-MM:SS, ...]"
- silence: --find-silence (prints gaps between speech)
- explicit JSON file: --segments-file segs.json
2. CUT: Trim the original audio around the bad windows.
(happens automatically when you run 'paste' or 'pipe')
3. PASTE: Render replacement clips via ElevenLabs, or supply pre-rendered ones.
- ElevenLabs: --render-elevenlabs TEXT (one per --bad segment)
- Pre-rendered: --replacements rep/*.mp3 (one per --bad segment)
'pipe' runs all three in one command.
"""
def _parse_bad_spec(spec: str) -> list[tuple[float, float, str]]:
"""Parse 'MM:SS-MM:SS:TEXT, MM:SS-MM:SS:TEXT' into [(start, end, text), ...]."""
from .engine import parse_timestamp
out = []
for item in spec.split(","):
item = item.strip()
if not item:
continue
# Find the timestamp range via regex
if "-" not in item:
raise ValueError(f"bad segment spec missing '-': {item!r}")
# Find the colon in the second timestamp that separates MM:SS from the text
# Strategy: first occurrence of "-" splits range; rest is text
# We need to NOT split on the "-" inside the timestamps (none, but be safe)
# Actually timestamps use ":" only, not "-", so first "-" is safe.
dash_idx = item.index("-")
start_str = item[:dash_idx]
rest = item[dash_idx + 1:]
# The rest is either "MM:SS" or "MM:SS:TEXT"
# Find the second timestamp by parsing prefix until we have valid MM:SS
# Try 4-char prefix (MM:SS) then 7-char prefix (HH:MM:SS)
if ":" in rest:
# Find the colon after the minutes field
# First timestamp is MM:SS or HH:MM:SS
# Second timestamp ends at either ":" or end
# Try parsing increasing prefix lengths
for try_len in range(len(rest), 0, -1):
candidate = rest[:try_len]
try:
parse_timestamp(candidate)
start = parse_timestamp(start_str)
end = parse_timestamp(candidate)
text = rest[try_len + 1:].lstrip() if try_len < len(rest) - 1 else ""
# If there's a leading ":" after the timestamp, strip it
if text.startswith(":"):
text = text[1:].lstrip()
out.append((start, end, text))
break
except ValueError:
continue
else:
raise ValueError(f"could not parse second timestamp in: {item!r}")
else:
# Just "MM:SS" with no text
start = parse_timestamp(start_str)
end = parse_timestamp(rest)
out.append((start, end, ""))
return out
def cmd_find(args: argparse.Namespace) -> int:
"""Print metadata + (optional) silence gaps + (optional) segments from a JSON file."""
audio = Path(args.audio)
if not audio.exists():
print(f"error: {audio} not found", file=sys.stderr)
return 1
sc = SermonClean(audio)
print(f"file: {audio}")
print(f"duration: {format_timestamp(sc.duration)} ({sc.duration:.3f}s)")
print(f"codec: {sc.codec['codec_name']} / {sc.codec['sample_rate']}Hz / {sc.codec['channels']}ch")
if args.silence_threshold is not None:
gaps = detect_silence(audio, noise_db=args.silence_threshold, min_duration=args.silence_min_duration)
print(f"\nsilence gaps (noise<{args.silence_threshold}dB, dur>={args.silence_min_duration}s):")
for a, b in gaps:
print(f" {format_timestamp(a)} -> {format_timestamp(b)} ({b-a:.2f}s)")
if args.segments_file:
n = sc.load_segments_from_json(Path(args.segments_file))
print(f"\nloaded {n} segments from {args.segments_file}:")
for s in sc.segments:
print(f" {format_timestamp(s.start)} -> {format_timestamp(s.end)} ({s.duration:.2f}s) reason={s.reason!r}")
return 0
def cmd_cut(args: argparse.Namespace) -> int:
"""Just trim the original around the bad windows (no splice)."""
audio = Path(args.audio)
sc = SermonClean(audio)
if args.segments_file:
sc.load_segments_from_json(Path(args.segments_file))
elif args.bad:
for start, end, text in _parse_bad_spec(args.bad):
sc.add_segment(start, end, replacement_text=text)
else:
print("error: provide --bad or --segments-file", file=sys.stderr)
return 1
out = sc.trim_segments()
print(f"trimmed into {len(out)} segments in {sc.workdir}")
for p in out:
print(f" {p} ({probe_duration(p):.2f}s)")
return 0
def cmd_paste(args: argparse.Namespace) -> int:
"""Take pre-rendered replacement clips and splice them in."""
audio = Path(args.audio)
sc = SermonClean(audio)
if args.segments_file:
sc.load_segments_from_json(Path(args.segments_file))
elif args.bad:
for start, end, text in _parse_bad_spec(args.bad):
sc.add_segment(start, end, replacement_text=text)
else:
print("error: provide --bad or --segments-file", file=sys.stderr)
return 1
if not args.replacements:
print("error: provide --replacements (one per bad segment, in order)", file=sys.stderr)
return 1
import glob
paths = []
for pat in args.replacements:
paths.extend(sorted(glob.glob(pat)))
if len(paths) != len(sc.segments):
print(f"error: {len(paths)} replacement files but {len(sc.segments)} bad segments", file=sys.stderr)
return 1
output = Path(args.output) if args.output else audio.with_name(f"{audio.stem}-fixed{audio.suffix}")
result = sc.clean([Path(p) for p in paths], output)
print(f"output: {result.output_path}")
print(f"duration: {format_timestamp(result.duration_seconds)} (was {format_timestamp(result.original_duration)})")
print(f"duration check: {result.duration_check()}")
return 0
def cmd_pipe(args: argparse.Namespace) -> int:
"""Cut + paste in one step. Use ElevenLabs to render replacements if --elevenlabs-text given."""
audio = Path(args.audio)
sc = SermonClean(audio)
if args.segments_file:
sc.load_segments_from_json(Path(args.segments_file))
elif args.bad:
for start, end, text in _parse_bad_spec(args.bad):
sc.add_segment(start, end, replacement_text=text)
else:
print("error: provide --bad or --segments-file", file=sys.stderr)
return 1
if args.elevenlabs_text:
# Lazy import so the core module doesn't depend on ElevenLabs
try:
from .elevenlabs import render_replacements
except ImportError:
print("error: elevenlabs rendering requires `pip install requests`", file=sys.stderr)
return 1
reps = render_replacements(sc.segments, voice_id=args.voice_id, api_key=args.elevenlabs_key)
elif args.replacements:
import glob
paths = []
for pat in args.replacements:
paths.extend(sorted(glob.glob(pat)))
reps = [Path(p) for p in paths]
else:
print("error: provide --replacements OR --elevenlabs-text", file=sys.stderr)
return 1
if len(reps) != len(sc.segments):
print(f"error: {len(reps)} replacements vs {len(sc.segments)} segments", file=sys.stderr)
return 1
output = Path(args.output) if args.output else audio.with_name(f"{audio.stem}-fixed{audio.suffix}")
result = sc.clean(reps, output)
print(f"output: {result.output_path}")
print(f"duration: {format_timestamp(result.duration_seconds)} (was {format_timestamp(result.original_duration)})")
print(f"duration check: {result.duration_check()}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="sermon-clean",
description=USAGE,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="cmd", required=True)
# find
p_find = sub.add_parser("find", help="Show audio metadata; optionally find silence gaps and load segments")
p_find.add_argument("audio")
p_find.add_argument("--silence-threshold", type=float, default=None, help="noise_db for silencedetect (e.g. -30)")
p_find.add_argument("--silence-min-duration", type=float, default=0.5, help="minimum silence in seconds")
p_find.add_argument("--segments-file", help="JSON file with bad segments")
p_find.set_defaults(func=cmd_find)
# cut
p_cut = sub.add_parser("cut", help="Trim the original audio around bad windows (no splice)")
p_cut.add_argument("audio")
p_cut.add_argument("--segments-file")
p_cut.add_argument("--bad", help='comma-separated MM:SS-MM:SS[:TEXT] windows')
p_cut.set_defaults(func=cmd_cut)
# paste
p_paste = sub.add_parser("paste", help="Splice pre-rendered replacement clips into a trimmed original")
p_paste.add_argument("audio")
p_paste.add_argument("--segments-file")
p_paste.add_argument("--bad")
p_paste.add_argument("-o", "--output")
p_paste.add_argument("--replacements", nargs="+", help="glob(s) for replacement MP3s in order")
p_paste.set_defaults(func=cmd_paste)
# pipe (all in one)
p_pipe = sub.add_parser("pipe", help="Run cut + paste in one command")
p_pipe.add_argument("audio")
p_pipe.add_argument("--segments-file")
p_pipe.add_argument("--bad")
p_pipe.add_argument("-o", "--output")
p_pipe.add_argument("--replacements", nargs="+")
p_pipe.add_argument("--elevenlabs-text", action="store_true", help="use the --bad TEXT as ElevenLabs render input")
p_pipe.add_argument("--voice-id", help="ElevenLabs voice ID (or set ELEVENLABS_VOICE_ID)")
p_pipe.add_argument("--elevenlabs-key", help="ElevenLabs API key (or set ELEVENLABS_API_KEY)")
p_pipe.set_defaults(func=cmd_pipe)
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())