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
This commit is contained in:
2026-07-27 04:00:45 -07:00
parent 7db2832034
commit fa0628f0e2
5 changed files with 544 additions and 1 deletions
+117 -1
View File
@@ -1,3 +1,119 @@
# sermon-clean
Find bad segments in sermon audio, cut them out, splice in ElevenLabs replacements.
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.
+32
View File
@@ -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")
+82
View File
@@ -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>
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}"
+181
View File
@@ -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
+132
View File
@@ -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