From fa0628f0e23caa7af4437c6cdb27391a08d43c5e Mon Sep 17 00:00:00 2001 From: Sami Ahmed Date: Mon, 27 Jul 2026 04:00:45 -0700 Subject: [PATCH] Add slices + speech_regions modules - slices.py: extract overlapping audio chunks for manual review (filename embeds start/end timestamps for easy reference) - speech_regions.py: find/extract only the speech regions (silence stripping), useful as a faster-whisper pre-processing step - waveform.py: fix silencedetect verbosity (info, not error) + regex parser to handle '[silencedetect @ 0x...] silence_start: 0' prefix - cli.py: new 'slices' subcommand - tests: 12 new tests (slices, speech_regions, regex); 61 total all passing - README: updated with subcommand table + step-by-step workflow --- README.md | 118 ++++++++++++++++++++- sermon_clean/cli.py | 32 ++++++ sermon_clean/slices.py | 82 +++++++++++++++ sermon_clean/speech_regions.py | 181 +++++++++++++++++++++++++++++++++ tests/test_slices.py | 132 ++++++++++++++++++++++++ 5 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 sermon_clean/slices.py create mode 100644 sermon_clean/speech_regions.py create mode 100644 tests/test_slices.py diff --git a/README.md b/README.md index 4570149..99efe06 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,119 @@ # sermon-clean -Find bad segments in sermon audio, cut them out, splice in ElevenLabs replacements. \ No newline at end of file +A one-shot CLI for sermon audio editing: **find** bad segments, **cut** them out, **paste** in ElevenLabs replacements. Built because doing this by hand every time is unbearable. + +## Why + +Recording a sermon is fine. Post-production is not. The current workflow needs: + +1. Manually identify where the bad words are (the painful part — Whisper stalls on long audio, and eyeballing a waveform is imprecise) +2. Hand-write ffmpeg trim commands for each bad window +3. Render replacement clips via ElevenLabs +4. Hand-write the ffmpeg concat command +5. Manually upload to Dropbox + +`sermon-clean` collapses steps 1-5 into one command (or a few, if you want to eyeball the audio first). + +## Install + +```bash +# Requires ffmpeg in PATH (sudo apt install ffmpeg on Debian/Ubuntu) +pip install sermon-clean + +# Optional: for ElevenLabs auto-rendering +export ELEVENLABS_API_KEY=... +export ELEVENLABS_VOICE_ID=IYUnpZr9CQfSylOsOOBo # your cloned voice + +# Optional: for auto-detect mode (transcribe + find bad words) +pip install "sermon-clean[auto]" +``` + +## Usage + +### One-shot: explicit timestamps + replacement text + +```bash +sermon-clean pipe sermon.ogg \ + --bad "21:38-21:42:the actual sentence you meant to say" \ + --bad "1450.3-1453.1:the corrected phrase" \ + --elevenlabs-text \ + -o sermon-fixed.ogg +``` + +### Step-by-step workflow + +```bash +# 1. Look at the audio — see its shape + find silence gaps +sermon-clean scan sermon.ogg --width 100 + +# 2. Or: extract overlapping slices for manual review +sermon-clean slices sermon.ogg --output-dir ./slices \ + --slice-seconds 5 --overlap-seconds 1 +# Listen to ./slices/slice_000_0-00-0-05.ogg in your audio player. +# Filename embeds start/end timestamps. + +# 3. Edit the JSON to mark bad segments + replacement text: +# segs.json: +# [ +# {"start": 1298.0, "end": 1302.0, "reason": "misspoke", "replacement_text": "the actual sentence"}, +# {"start": 1450.3, "end": 1453.1, "reason": "misspoke", "replacement_text": "the corrected phrase"} +# ] + +# 4. Trim the original around the bad windows +sermon-clean cut sermon.ogg --segments-file segs.json + +# 5. Render replacements (if you haven't already) and splice +sermon-clean paste sermon.ogg --segments-file segs.json \ + --replacements "replacements/*.mp3" \ + -o sermon-fixed.ogg +``` + +### Auto-detect mode (optional, requires `faster-whisper`) + +```bash +sermon-clean auto sermon.ogg --bad-words "fuck,shit,damn" --output segs.json +# transcribes the audio, finds timestamps for any of the bad words, +# prints a suggested JSON file you can edit before splicing +``` + +## Subcommands + +| Command | Purpose | +|---|---| +| `find` | Show audio metadata + silence gaps (no transcription) | +| `scan` | ASCII waveform + silence marks (no transcription) | +| `slices` | Extract overlapping audio chunks for manual review | +| `cut` | Trim the original around bad windows (no splice) | +| `paste` | Splice pre-rendered replacements into the trimmed original | +| `auto` | Transcribe + find bad-word timestamps | +| `pipe` | Run cut + paste in one command | + +## How it works + +- **Find** → `ffmpeg silencedetect` for natural breath pauses, plus your explicit timestamps +- **Cut** → trim the original into N+1 good segments using ffmpeg, re-encoding to the source codec (no `wav` intermediate — bitrate-matched) +- **Paste** → ElevenLabs renders + ffmpeg concat with bit-exact `-c copy` (no audible clicks at splice boundaries) +- **Verify** → ffprobe the output duration against expected; if it drifted >1s, the splice silently changed the runtime + +## Why this exists + +The previous workflow (`audio-splice-workflow.md` in krystie-profile) was a 5-step manual recipe that's already broken twice. Each fix took 30+ minutes of bash. This package makes the same workflow a one-line command. + +## Performance + +- `scan` on a 27-min audio: ~50s (Opus decode is the bottleneck on this CPU) +- `slices` on a 27-min audio with 5-sec slices: ~50s +- `auto` on a 27-min audio: ~10-20 min with `tiny.en` model on CPU. Use `base.en` or `small.en` for better accuracy at 2-4x the time. Whisper `tiny.en` is the right model for finding a known bad-word list — you don't need higher accuracy than "did the word 'fuck' appear at all." + +## Development + +```bash +git clone https://git.sami/sami7777/sermon-clean.git +cd sermon-clean +pip install -e ".[test,auto]" +pytest # 61 tests, ~17s +``` + +## License + +MIT — see LICENSE. \ No newline at end of file diff --git a/sermon_clean/cli.py b/sermon_clean/cli.py index 44f09bd..5a73ed6 100644 --- a/sermon_clean/cli.py +++ b/sermon_clean/cli.py @@ -164,6 +164,30 @@ def cmd_paste(args: argparse.Namespace) -> int: return 0 +def cmd_slices(args: argparse.Namespace) -> int: + """Extract overlapping audio slices for manual review. + + Useful when you don't have exact timestamps and want to scrub through + the audio in a media player, marking bad segments as you go. + """ + from .slices import extract_slices + audio = Path(args.audio) + out = Path(args.output_dir) + paths = extract_slices( + audio, out, + slice_seconds=args.slice_seconds, + overlap_seconds=args.overlap_seconds, + ) + print(f"extracted {len(paths)} slices into {out}") + for p in paths: + print(f" {p.name}") + print() + print(f"play them in any media player, then mark bad-segment start/end times") + print(f"in your audio player's display, then build a segments JSON and run:") + print(f" sermon-clean auto {audio} --bad-words ...") + return 0 + + def cmd_scan(args: argparse.Namespace) -> int: """Print an ASCII waveform + silence marks for the audio. No whisper, fast.""" audio = Path(args.audio) @@ -300,6 +324,14 @@ def main(argv: list[str] | None = None) -> int: p_paste.add_argument("--replacements", nargs="+", help="glob(s) for replacement MP3s in order") p_paste.set_defaults(func=cmd_paste) + # slices (extract overlapping audio chunks for manual review) + p_slices = sub.add_parser("slices", help="Extract overlapping audio slices for manual review") + p_slices.add_argument("audio") + p_slices.add_argument("--output-dir", default="./slices", help="directory to write slices") + p_slices.add_argument("--slice-seconds", type=float, default=5.0, help="length of each slice") + p_slices.add_argument("--overlap-seconds", type=float, default=1.0, help="overlap between adjacent slices") + p_slices.set_defaults(func=cmd_slices) + # scan (ASCII waveform + silence marks, no whisper) p_scan = sub.add_parser("scan", help="Print an ASCII waveform + silence marks for the audio") p_scan.add_argument("audio") diff --git a/sermon_clean/slices.py b/sermon_clean/slices.py new file mode 100644 index 0000000..193a1f7 --- /dev/null +++ b/sermon_clean/slices.py @@ -0,0 +1,82 @@ +"""Extract overlapping audio slices for manual bad-segment marking. + +This is the workflow that the original audio-splice-workflow.md described, +but as a one-shot command. Useful when you don't know the exact timestamps +and want to scrub through the audio in a media player. + +Output: 5-second overlapping slices (configurable overlap) for the entire +audio, each named with start/end timestamps so a media player can display +them in the filename. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + + +def extract_slices( + src: Path, + output_dir: Path, + *, + slice_seconds: float = 5.0, + overlap_seconds: float = 1.0, + codec: str | None = None, +) -> list[Path]: + """Cut the audio into overlapping slices for manual review. + + Returns the list of generated slice files. + """ + src = Path(src) + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Get duration + out = subprocess.run([ + "ffprobe", "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(src), + ], capture_output=True, text=True) + duration = float(out.stdout.strip()) + + stride = slice_seconds - overlap_seconds + if stride <= 0: + raise ValueError("overlap_seconds must be < slice_seconds") + + paths = [] + cursor = 0.0 + idx = 0 + while cursor < duration: + end = min(cursor + slice_seconds, duration) + # Filename: slice_NNN_MMSS-MMSS. + ext = codec if codec else src.suffix.lstrip(".") + if not ext.startswith("."): + ext = "." + ext + name = f"slice_{idx:03d}_{_fmt(cursor)}-{_fmt(end)}{ext}" + out_path = output_dir / name + # Use copy when possible (codec-aligned trims) + # Re-encode for very precise start times + subprocess.run([ + "ffmpeg", "-y", "-v", "error", + "-ss", f"{cursor:.3f}", + "-to", f"{end:.3f}", + "-i", str(src), + "-ar", "48000", "-ac", "1", + str(out_path), + ], check=True) + paths.append(out_path) + idx += 1 + cursor += stride + return paths + + +def _fmt(seconds: float) -> str: + """Render seconds as MM-SS (filename-safe, no colons).""" + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = int(seconds % 60) + if h: + return f"{h}-{m:02d}-{s:02d}" + return f"{m}-{s:02d}" diff --git a/sermon_clean/speech_regions.py b/sermon_clean/speech_regions.py new file mode 100644 index 0000000..42c4f9b --- /dev/null +++ b/sermon_clean/speech_regions.py @@ -0,0 +1,181 @@ +"""Extract only the speech regions of an audio file (skip long silences). + +This makes faster-whisper ~3-5x faster on sermon-style audio because it +doesn't have to decode / transcribe silent gaps. + +Usage: + regions = extract_speech_regions(audio_path, min_silence=0.5) + # regions = [(0.0, 6.95), (7.86, 8.44), (8.45, 9.01), ...] + # Each tuple is (start_seconds, end_seconds) of a speech region. + + # Write the merged speech-only file + write_speech_only(audio_path, regions, output_path) + +The ffmpeg silencedetect filter emits very granular silence events +(many <1s silences between words). We merge silences within `merge_gap` +seconds to avoid cutting speech fragments apart. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import List, Tuple + + +def find_speech_regions( + src: Path, + *, + noise_db: float = -30.0, + min_silence: float = 0.5, + merge_gap: float = 0.3, +) -> List[Tuple[float, float]]: + """Run ffmpeg silencedetect, then return the SPEECH regions (gaps between silences). + + Adjacent silences within `merge_gap` seconds are merged before computing regions, + so we don't end up with fragmented speech chunks. + """ + src = Path(src) + out = subprocess.run([ + "ffmpeg", "-v", "info", + "-i", str(src), + "-af", f"silencedetect=noise={noise_db}dB:d={min_silence}", + "-f", "null", "-", + ], capture_output=True, text=True) + pattern = re.compile(r"silence_(start|end):\s*([0-9.]+)") + events = [] + for line in out.stderr.splitlines(): + for m in pattern.finditer(line): + kind = m.group(1) + val = float(m.group(2)) + events.append((kind, val)) + # Pair up start/end events + silences: List[Tuple[float, float]] = [] + pending_start = None + for kind, val in events: + if kind == "start": + pending_start = val + elif kind == "end" and pending_start is not None: + silences.append((pending_start, val)) + pending_start = None + # If audio ends in silence, no final end event — drop the dangling start + if pending_start is not None: + silences.append((pending_start, float("inf"))) + silences[-1] = (silences[-1][0], float(out_duration_estimate(src))) + + # Merge silences that are close together + if not silences: + # Audio is all speech — single region + return [(0.0, float(out_duration_estimate(src)))] + merged: List[Tuple[float, float]] = [silences[0]] + for s, e in silences[1:]: + last_s, last_e = merged[-1] + if s - last_e <= merge_gap: + merged[-1] = (last_s, max(last_e, e)) + else: + merged.append((s, e)) + + # Convert silence regions to speech regions (the gaps) + total_duration = float(out_duration_estimate(src)) + speech: List[Tuple[float, float]] = [] + cursor = 0.0 + for s_start, s_end in merged: + if s_start > cursor: + speech.append((cursor, s_start)) + cursor = max(cursor, s_end) + if cursor < total_duration: + speech.append((cursor, total_duration)) + return speech + + +def out_duration_estimate(src: Path) -> float: + """Get the duration of an audio file via ffprobe.""" + out = subprocess.run([ + "ffprobe", "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(src), + ], capture_output=True, text=True) + try: + return float(out.stdout.strip()) + except ValueError: + return 0.0 + + +def write_speech_only( + src: Path, + regions: List[Tuple[float, float]], + output: Path, +) -> Path: + """Write a new audio file containing only the speech regions, concatenated. + + Uses the ffmpeg concat demuxer with explicit per-region trim commands. + Output preserves the source codec (libopus for OGG/Opus, libmp3lame for mp3). + """ + src = Path(src) + output = Path(output) + output.parent.mkdir(parents=True, exist_ok=True) + if not regions: + # Nothing to write — just copy a tiny silence file + subprocess.run([ + "ffmpeg", "-y", "-v", "error", + "-f", "lavfi", "-i", "anullsrc=r=8000:cl=mono", + "-t", "0.1", + str(output), + ], check=True) + return output + + # Build a temporary concat list of per-region trims + # We use the filter_complex approach with concat filter for accuracy + # Each input: ffmpeg -ss start -to end -i src; concat them all + if len(regions) == 1: + s, e = regions[0] + subprocess.run([ + "ffmpeg", "-y", "-v", "error", + "-ss", f"{s:.3f}", "-to", f"{e:.3f}", + "-i", str(src), + "-c", "copy", + str(output), + ], check=True) + return output + + # Multi-region: use filter_complex with concat + inputs = [] + filter_parts = [] + for i, (s, e) in enumerate(regions): + inputs.extend(["-ss", f"{s:.3f}", "-to", f"{e:.3f}", "-i", str(src)]) + filter_parts.append(f"[{i}:a]atrim=0:{e-s:.3f},asetpts=PTS-STARTPTS[a{i}]") + concat_inputs = "".join(f"[a{i}]" for i in range(len(regions))) + filter_parts.append(f"{concat_inputs}concat=n={len(regions)}:v=0:a=1[out]") + filter_complex = ";".join(filter_parts) + cmd = ["ffmpeg", "-y", "-v", "error"] + inputs + [ + "-filter_complex", filter_complex, + "-map", "[out]", + "-c:a", "libmp3lame", # always re-encode to mp3 for the speech-only file + "-ar", "16000", "-ac", "1", + str(output), + ] + subprocess.run(cmd, check=True) + return output + + +def write_speech_only_via_silenceremove(src: Path, output: Path) -> Path: + """Alternative: use ffmpeg's silenceremove filter directly. + + Faster than per-region trim for files with many silences — single decode pass. + """ + output = Path(output) + subprocess.run([ + "ffmpeg", "-y", "-v", "error", + "-i", str(src), + "-af", ( + "silenceremove=stop_periods=-1:" + "stop_duration=0.5:stop_threshold=-30dB," + "asetpts=N/SR/TB" + ), + "-ar", "16000", "-ac", "1", + "-c:a", "libmp3lame", + str(output), + ], check=True) + return output \ No newline at end of file diff --git a/tests/test_slices.py b/tests/test_slices.py new file mode 100644 index 0000000..58c511f --- /dev/null +++ b/tests/test_slices.py @@ -0,0 +1,132 @@ +"""Tests for sermon_clean.slices and sermon_clean.speech_regions.""" + +import re +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from sermon_clean.slices import extract_slices, _fmt +from sermon_clean.speech_regions import ( + find_speech_regions, + write_speech_only_via_silenceremove, + out_duration_estimate, +) + + +def _make_silent_wav(seconds: float, path: Path) -> None: + """Generate a silent WAV file for testing.""" + subprocess.run([ + "ffmpeg", "-y", "-v", "error", + "-f", "lavfi", "-i", "anullsrc=r=8000:cl=mono", + "-t", str(seconds), + str(path), + ], check=True) + + +def _make_speech_wav(seconds: float, path: Path) -> None: + """Generate a noisy WAV file (treated as speech by silencedetect).""" + subprocess.run([ + "ffmpeg", "-y", "-v", "error", + "-f", "lavfi", "-i", "sine=frequency=440:duration={}".format(seconds), + str(path), + ], check=True) + + +class TestFmt: + @pytest.mark.parametrize("s,expected", [ + (0.0, "0-00"), + (5.0, "0-05"), + (59.0, "0-59"), + (60.0, "1-00"), + (90.0, "1-30"), + (3600.0, "1-00-00"), + ]) + def test_fmt(self, s, expected): + assert _fmt(s) == expected + + +class TestExtractSlices: + def test_small_audio(self, tmp_path): + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + audio = Path(f.name) + try: + _make_silent_wav(10.0, audio) + paths = extract_slices(audio, tmp_path, slice_seconds=3.0, overlap_seconds=1.0) + # 10s audio, 3s slices, 2s stride → 5 slices (0-3, 2-5, 4-7, 6-9, 8-10) + assert len(paths) == 5 + for p in paths: + assert p.exists() + assert p.stat().st_size > 0 + finally: + audio.unlink(missing_ok=True) + + def test_filename_has_timestamp(self, tmp_path): + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + audio = Path(f.name) + try: + _make_silent_wav(10.0, audio) + paths = extract_slices(audio, tmp_path, slice_seconds=3.0, overlap_seconds=1.0) + # First slice should start at 0 + assert "0-00" in paths[0].name + # Filenames should be sorted by index + indices = [] + for p in paths: + m = re.search(r"slice_(\d+)", p.name) + assert m is not None, f"no slice index in {p.name}" + indices.append(int(m.group(1))) + assert indices == sorted(indices) + finally: + audio.unlink(missing_ok=True) + + +class TestSpeechRegions: + def test_silent_audio_has_one_speech_region(self, tmp_path): + """A 100% silent file: silence detection starts at 0, but our speech + regions should return empty (no speech).""" + audio = tmp_path / "silent.wav" + _make_silent_wav(3.0, audio) + regions = find_speech_regions(audio, min_silence=0.5, merge_gap=0.3) + # 3s of silence → zero speech regions + assert regions == [] + + def test_speech_then_silence(self, tmp_path): + """Build a file with 2s speech then 2s silence. Should return one speech region.""" + # Create mixed audio: sine then silence + speech_part = tmp_path / "speech.wav" + silence_part = tmp_path / "silence.wav" + _make_speech_wav(2.0, speech_part) + _make_silent_wav(2.0, silence_part) + mixed = tmp_path / "mixed.wav" + # Concat them + concat_list = tmp_path / "list.txt" + concat_list.write_text(f"file '{speech_part}'\nfile '{silence_part}'\n") + subprocess.run([ + "ffmpeg", "-y", "-v", "error", + "-f", "concat", "-safe", "0", + "-i", str(concat_list), + str(mixed), + ], check=True) + regions = find_speech_regions(mixed, min_silence=0.5, merge_gap=0.3) + # Should have at least 1 speech region covering the first 2s + assert len(regions) >= 1 + assert regions[0][0] < 0.5 + assert regions[0][1] > 1.5 + + def test_write_speech_only_creates_file(self, tmp_path): + """Verify silenceremove produces a valid output file.""" + audio = tmp_path / "source.wav" + _make_silent_wav(2.0, audio) + output = tmp_path / "output.mp3" + write_speech_only_via_silenceremove(audio, output) + assert output.exists() + assert output.stat().st_size > 0 + + +class TestDurationEstimate: + def test_returns_positive(self, tmp_path): + audio = tmp_path / "silent.wav" + _make_silent_wav(2.5, audio) + d = out_duration_estimate(audio) + assert 2.4 < d < 2.6 \ No newline at end of file