4590dc0fb9
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.
161 lines
5.3 KiB
Python
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
|