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:
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user