Files
sermon-clean/sermon_clean/batch.py
T
Hermes Agent 4590dc0fb9 Add 5 subcommands: normalize, silence-stats, threshold-tune, denoise, batch
Sermon-clean v0.2.0. Five new subcommands that round out the editor:

- normalize (norm): Apply EBU R128 two-pass loudness normalization.
  Default -16 LUFS (podcast/YouTube). Configurable target LUFS, true
  peak, and loudness range. Output reports measured input loudness
  and applied gain offset.

- silence-stats (ss): Quantitative summary of silence distribution —
  count, total/mean/median/longest silence, silence fraction, and
  silence runs per minute. Outputs JSON via --json. Useful for
  comparing recordings and picking the right threshold.

- threshold-tune (tt): Auto-pick the silence threshold for the audio.
  Scans a set of candidate thresholds (default -25..-50), scores each
  against the target silence-runs-per-minute (default 4.0), picks the
  closest match. Shows the full scoring table.

- denoise: Apply ffmpeg's afftdn filter for light FFT-based noise
  reduction. Configurable noise reduction dB (default 12) and noise
  floor dB (default -50). Output at 48kHz to match normalize.

- batch: Run any of the subcommands across many files via glob.
  Output goes to --output-dir with --suffix (default '-fixed') and
  optional --extension override. Failures are collected, not raised
  — one bad file doesn't kill the whole batch.

Implementation:
- sermon_clean/processing.py: normalize_loudness + SilenceStats
  dataclass + silence_stats + threshold_tune.
- sermon_clean/denoise.py: DenoiseResult + denoise.
- sermon_clean/batch.py: run_batch + _expand_globs + _make_output_path.
- sermon_clean/cli.py: 5 new cmd_* functions + 5 subparser registrations.

Tests:
- tests/test_processing.py (7 tests): silence-stats on silent vs loud,
  threshold-tune picks closest, normalize produces output + measures loud.
- tests/test_denoise_batch.py (11 tests): denoise roundtrip, batch
  helpers (glob expansion, output naming), batch run with normalize
  + denoise, unknown subcommand raises, one-bad-file-in-batch continues.

Total: 82/82 tests passing in 48s (was 64/64). Bumped version to 0.2.0.

README updated: step-by-step workflow adds 1d-1g; subcommand table
adds the 5 new commands; new 'Batch processing' section.
2026-07-27 13:31:18 -07:00

161 lines
5.3 KiB
Python

"""Batch processing — run any sermon-clean subcommand across many files.
Real workflow: a preacher records 4-5 sermons on Sunday, all need the same
post-processing. Running each one by hand is tedious and error-prone.
`batch` takes a glob pattern and runs the requested subcommand on each
file. Output goes into a sibling directory tree with `<stem>-<op><ext>`
filenames. Errors are collected, not raised, so one bad file doesn't
kill the whole batch.
"""
from __future__ import annotations
import glob
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
@dataclass
class BatchItem:
src: Path
output: Path
returncode: int = 0
stderr: str = ""
stdout: str = ""
@property
def ok(self) -> bool:
return self.returncode == 0
@dataclass
class BatchResult:
items: List[BatchItem] = field(default_factory=list)
n_ok: int = 0
n_failed: int = 0
def summary(self) -> str:
lines = [f"batch: {self.n_ok} ok, {self.n_failed} failed (of {len(self.items)} total)"]
for item in self.items:
marker = "" if item.ok else ""
lines.append(f" {marker} {item.src.name} -> {item.output}")
if not item.ok and item.stderr:
# First non-empty stderr line for diagnosis
for line in item.stderr.splitlines():
if line.strip():
lines.append(f" stderr: {line.strip()}")
break
return "\n".join(lines)
def _expand_globs(patterns: List[str]) -> List[Path]:
"""Expand a list of glob patterns into a sorted, deduped list of files."""
seen = set()
out = []
for pattern in patterns:
for path in sorted(glob.glob(pattern, recursive=True)):
p = Path(path)
if p.is_file() and p.resolve() not in seen:
seen.add(p.resolve())
out.append(p)
return out
def _make_output_path(src: Path, output_dir: Path, suffix: str, ext: Optional[str]) -> Path:
"""Compute the output path for a given input.
Filename pattern: <stem><suffix><ext>, where ext defaults to src.suffix.
"""
out_ext = ext if ext is not None else src.suffix
out_name = f"{src.stem}{suffix}{out_ext}"
return output_dir / out_name
# Valid subcommands for batch. Keep this in sync with cli.py.
BATCH_SUBCOMMANDS = {"find", "scan", "si", "silence-index", "mb", "multiband",
"slices", "cut", "paste", "auto", "pipe",
"normalize", "silence-stats", "threshold-tune", "denoise"}
def run_batch(
subcommand: str,
sources: List[Path],
output_dir: Path,
*,
output_suffix: str = "-fixed",
output_extension: Optional[str] = None,
extra_args: Optional[List[str]] = None,
) -> BatchResult:
"""Run `sermon-clean <subcommand>` over each source file.
Args:
subcommand: which sermon-clean subcommand to invoke.
sources: list of input audio files.
output_dir: directory to write outputs to. Created if missing.
output_suffix: filename suffix for outputs (e.g. '-normalized').
Ignored for subcommands that don't produce an output file
(find/scan/si/mb/silence-stats/threshold-tune/slices).
output_extension: extension override (e.g. '.wav'). Defaults to
src.suffix.
extra_args: additional args to pass to the subcommand. Note that
`--suffix` is reserved by `batch` itself — strip it if you
forward it via this param. (We don't currently forward anything
from the batch CLI to subcommands other than the implicit
`-o <output>`.)
Returns:
BatchResult with per-file success/failure.
Raises:
ValueError: if subcommand is not a recognized sermon-clean command.
"""
if subcommand not in BATCH_SUBCOMMANDS:
raise ValueError(
f"unknown subcommand for batch: {subcommand!r}. "
f"valid: {sorted(BATCH_SUBCOMMANDS)}"
)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Find the sermon-clean executable in PATH or fall back to python -m
sc_bin = shutil.which("sermon-clean")
if sc_bin:
cmd_base = [sc_bin]
else:
import sys
cmd_base = [sys.executable, "-m", "sermon_clean.cli"]
result = BatchResult()
extra_args = extra_args or []
for src in sources:
src = Path(src)
out = _make_output_path(src, output_dir, output_suffix, output_extension)
# Build the per-file argv. -o / --output is added only for subcommands
# that produce a single output file.
output_subcommands = {"cut", "paste", "normalize", "denoise", "pipe"}
argv = cmd_base + [subcommand, str(src)] + extra_args
if subcommand in output_subcommands:
argv += ["-o", str(out)]
proc = subprocess.run(argv, capture_output=True, text=True)
result.items.append(BatchItem(
src=src,
output=out,
returncode=proc.returncode,
stdout=proc.stdout,
stderr=proc.stderr,
))
if proc.returncode == 0:
result.n_ok += 1
else:
result.n_failed += 1
return result