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.
77 lines
2.2 KiB
Python
77 lines
2.2 KiB
Python
"""Light noise reduction via ffmpeg's afftdn filter.
|
|
|
|
Use cases:
|
|
- Recordings with hiss (cheap mic preamp, RF interference)
|
|
- Recordings with AC hum (50/60Hz line noise — there's a separate `hum` filter
|
|
for that, not implemented here)
|
|
- Tapes digitized with analog tape hiss
|
|
|
|
The `afftdn` filter is ffmpeg's built-in adaptive FFT denoiser. It's
|
|
lightweight (CPU-friendly) and good for gentle hiss removal without
|
|
artifacting speech. For heavy noise, consider a real denoiser like
|
|
RNNoise or DeepFilterNet — out of scope for sermon-clean.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
@dataclass
|
|
class DenoiseResult:
|
|
output_path: Path
|
|
noise_floor_db: float
|
|
noise_reduction_db: float
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"output_path": str(self.output_path),
|
|
"noise_floor_db": self.noise_floor_db,
|
|
"noise_reduction_db": self.noise_reduction_db,
|
|
}
|
|
|
|
|
|
def denoise(
|
|
src: Path,
|
|
out: Path,
|
|
*,
|
|
noise_reduction_db: float = 12.0,
|
|
noise_floor_db: float = -50.0,
|
|
) -> DenoiseResult:
|
|
"""Apply FFT-based noise reduction.
|
|
|
|
Args:
|
|
src: input audio file
|
|
out: output audio file
|
|
noise_reduction_db: how much to attenuate the noise component (dB).
|
|
Higher = more aggressive. 12dB is a reasonable default; 20dB
|
|
starts to artifact speech.
|
|
noise_floor_db: expected level of the noise floor below which everything
|
|
is considered noise. -50dB is conservative. If your recording has
|
|
louder noise (e.g. AC hum at -35dB), set this higher.
|
|
|
|
Returns:
|
|
DenoiseResult with the measured parameters.
|
|
"""
|
|
src = Path(src)
|
|
out = Path(out)
|
|
|
|
proc = subprocess.run([
|
|
"ffmpeg", "-y", "-v", "error",
|
|
"-i", str(src),
|
|
"-af", f"afftdn=nr={noise_reduction_db}:nf={noise_floor_db}",
|
|
"-ar", "48000", # output 48kHz to match normalize convention
|
|
str(out),
|
|
], capture_output=True, text=True)
|
|
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"denoise failed: {proc.stderr}")
|
|
|
|
return DenoiseResult(
|
|
output_path=out,
|
|
noise_floor_db=noise_floor_db,
|
|
noise_reduction_db=noise_reduction_db,
|
|
)
|