Files
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

272 lines
8.8 KiB
Python

"""Audio processing — normalize, silence stats, threshold auto-tune.
Three related operations that don't belong in engine.py (which is about
splicing segments) but also don't deserve their own module each.
- normalize: Apply EBU R128 two-pass loudness normalization so different
recordings have consistent volume.
- silence_stats: Quantitative summary of silence distribution — useful
for picking the right threshold for a given recording.
- threshold_tune: Auto-pick the silence threshold that best separates
speech from breath pauses for the given audio.
"""
from __future__ import annotations
import math
import re
import subprocess
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import List, Tuple
# ---------------------------------------------------------------------------
# normalize
# ---------------------------------------------------------------------------
def normalize_loudness(
src: Path,
out: Path,
*,
target_lufs: float = -16.0,
true_peak_db: float = -1.5,
loudness_range_lu: float = 11.0,
) -> dict:
"""Normalize audio to broadcast-standard EBU R128 loudness.
Two-pass: first pass measures the integrated loudness, second pass
applies the corrective gain. Default target is -16 LUFS (podcast/
YouTube standard). For speech-only sermon audio, -19 LUFS (Spotify
standard) is also reasonable.
Returns a dict with measured/achieved loudness metadata so callers
can log it.
"""
src = Path(src)
out = Path(out)
# Pass 1: measure. ffmpeg's loudnorm filter in print-only mode.
measure = subprocess.run([
"ffmpeg", "-v", "info",
"-i", str(src),
"-af", f"loudnorm=I={target_lufs}:TP={true_peak_db}:LRA={loudness_range_lu}:print_format=json",
"-f", "null", "-",
], capture_output=True, text=True)
# Parse the JSON block ffmpeg prints at the end of stderr.
# Looks like:
# {
# "input_i" : "-23.81",
# "input_tp" : "-3.02",
# ...
# }
measured = _parse_loudnorm_json(measure.stderr)
# Pass 2: apply. Reuse the measured values for linear mode (single-pass
# would do, but linear mode produces slightly better results for highly
# dynamic content).
apply = subprocess.run([
"ffmpeg", "-y", "-v", "error",
"-i", str(src),
"-af", (
f"loudnorm=I={target_lufs}:TP={true_peak_db}:LRA={loudness_range_lu}"
f":measured_I={measured['input_i']}"
f":measured_TP={measured['input_tp']}"
f":measured_LRA={measured['input_lra']}"
f":measured_thresh={measured['input_thresh']}"
f":offset={measured['target_offset']}"
f":linear=true:print_format=summary"
),
"-ar", "48000", # output 48kHz (industry standard for normalized audio)
str(out),
], capture_output=True, text=True)
if apply.returncode != 0:
raise RuntimeError(f"normalize failed: {apply.stderr}")
return {
"target_lufs": target_lufs,
"true_peak_db": true_peak_db,
"measured_input_i": measured.get("input_i"),
"measured_input_tp": measured.get("input_tp"),
"measured_input_lra": measured.get("input_lra"),
"applied_offset": measured.get("target_offset"),
"output_path": str(out),
}
_LOUDNORM_JSON_RE = re.compile(r"\{[^{}]*\"input_i\"[^{}]*\}", re.DOTALL)
def _parse_loudnorm_json(stderr: str) -> dict:
"""Extract the loudnorm JSON block from ffmpeg stderr.
ffmpeg prints this at the end after the audio is 'rendered' to null.
"""
m = _LOUDNORM_JSON_RE.search(stderr)
if not m:
raise RuntimeError(
f"could not find loudnorm JSON in ffmpeg output. "
f"stderr tail: {stderr[-500:]!r}"
)
import json
return json.loads(m.group(0))
# ---------------------------------------------------------------------------
# silence_stats
# ---------------------------------------------------------------------------
@dataclass
class SilenceStats:
"""Quantitative summary of silence distribution in an audio file."""
audio_path: str
duration_seconds: float
threshold_db: float
min_duration_seconds: float
n_silence_runs: int
total_silence_seconds: float
silence_fraction: float # total_silence / duration
mean_silence_seconds: float
median_silence_seconds: float
longest_silence_seconds: float
longest_silence_range: Tuple[float, float] # (start, end) of longest
silence_per_minute: float # mean silence runs per 60 seconds
def to_dict(self) -> dict:
d = asdict(self)
d["longest_silence_range"] = list(self.longest_silence_range)
return d
def silence_stats(
src: Path,
*,
threshold_db: float = -35.0,
min_duration_seconds: float = 0.3,
) -> SilenceStats:
"""Compute quantitative silence statistics for the audio.
Useful for:
- Comparing recordings (consistent silence fraction = consistent recording setup)
- Picking the right silence threshold (dense audio with mean silence <0.5s
probably needs a lower threshold than sparse audio with mean silence >2s)
- Detecting "dead air" recordings where the speaker lost their place
"""
from .waveform import find_silence_runs, _extract_pcm
src = Path(src)
samples = _extract_pcm(src)
duration = len(samples) / 8000.0
if duration <= 0:
raise ValueError(f"audio {src} has zero duration")
runs = find_silence_runs(
src,
threshold_db=threshold_db,
min_duration_seconds=min_duration_seconds,
)
if not runs:
return SilenceStats(
audio_path=str(src),
duration_seconds=duration,
threshold_db=threshold_db,
min_duration_seconds=min_duration_seconds,
n_silence_runs=0,
total_silence_seconds=0.0,
silence_fraction=0.0,
mean_silence_seconds=0.0,
median_silence_seconds=0.0,
longest_silence_seconds=0.0,
longest_silence_range=(0.0, 0.0),
silence_per_minute=0.0,
)
durations = sorted(b - a for a, b in runs)
total = sum(durations)
longest = durations[-1]
longest_range = next((a, b) for a, b in runs if b - a == longest)
return SilenceStats(
audio_path=str(src),
duration_seconds=duration,
threshold_db=threshold_db,
min_duration_seconds=min_duration_seconds,
n_silence_runs=len(runs),
total_silence_seconds=total,
silence_fraction=total / duration,
mean_silence_seconds=total / len(runs),
median_silence_seconds=_median(durations),
longest_silence_seconds=longest,
longest_silence_range=longest_range,
silence_per_minute=len(runs) / (duration / 60.0),
)
def _median(xs: List[float]) -> float:
if not xs:
return 0.0
n = len(xs)
mid = n // 2
if n % 2 == 0:
return (xs[mid - 1] + xs[mid]) / 2.0
return xs[mid]
# ---------------------------------------------------------------------------
# threshold_tune
# ---------------------------------------------------------------------------
def threshold_tune(
src: Path,
*,
candidates: List[float] = (-25.0, -30.0, -35.0, -40.0, -45.0, -50.0),
target_silence_per_minute: float = 4.0,
target_min_duration: float = 0.3,
) -> dict:
"""Auto-pick the silence threshold that best matches expected pause density.
For typical sermon audio, a speaker pauses ~3-5 times per minute for
breath/sentence-end. This scans a set of candidate thresholds and
picks the one whose silence count is closest to `target_silence_per_minute`.
Returns the picked threshold plus the per-candidate stats so callers
can show a tuning report.
"""
from .waveform import find_silence_runs
src = Path(src)
rows = []
for thresh in candidates:
runs = find_silence_runs(
src,
threshold_db=thresh,
min_duration_seconds=target_min_duration,
)
# We need duration to compute silence_per_minute
from .waveform import _extract_pcm
samples = _extract_pcm(src)
duration = len(samples) / 8000.0
spm = len(runs) / (duration / 60.0) if duration > 0 else 0.0
rows.append({
"threshold_db": thresh,
"n_runs": len(runs),
"silence_per_minute": round(spm, 2),
"distance_from_target": round(abs(spm - target_silence_per_minute), 2),
})
rows.sort(key=lambda r: r["distance_from_target"])
best = rows[0]
return {
"picked_threshold_db": best["threshold_db"],
"picked_silence_per_minute": best["silence_per_minute"],
"target_silence_per_minute": target_silence_per_minute,
"candidates": rows,
}