"""Audio post-processing around ffmpeg. convert — WAV to OGG (libvorbis, quality 6) normalize — LUFS normalize to -16 LUFS (broadcast standard) trim — remove leading/trailing silence pipeline — trim + normalize + convert (the full post-processing chain) Every operation writes a new file and never overwrites its input. Each returns a result dict — the same keys the old script printed as JSON — so callers read data rather than parse output. ffmpeg runs through `core/process.run`, the one guarded exec (D-263): a non-zero exit becomes a ReachError naming the command, and a missing ffmpeg says what to install. The ffmpeg argument lists are unchanged from tooling/db/audio_post.py (T-1290); the decoded audio of a pipeline run is identical before and after the port. """ from __future__ import annotations import os from tooling.core import console, process FFMPEG_MISSING = "install ffmpeg (brew install ffmpeg), or check PATH in a non-interactive shell" def trim_filter(threshold: int) -> str: """The silence-removal filter chain: trim the start, reverse, trim, reverse.""" return ( "silenceremove=start_periods=1:start_silence=0.05" f":start_threshold={threshold}dB," "areverse," "silenceremove=start_periods=1:start_silence=0.05" f":start_threshold={threshold}dB," "areverse" ) def ffmpeg_argv(args: list[str]) -> list[str]: """The full argv for one ffmpeg call — pure, so it can be tested unrun.""" return ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *args] def run_ffmpeg(args: list[str], description: str) -> None: console.event(description) process.run(ffmpeg_argv(args), missing_fix=FFMPEG_MISSING) def convert(input_path: str, output: str | None = None, quality: int = 6) -> dict: """WAV → OGG (libvorbis).""" output = output or input_path.rsplit(".", 1)[0] + ".ogg" run_ffmpeg( ["-i", input_path, "-c:a", "libvorbis", "-q:a", str(quality), output], f"converting {os.path.basename(input_path)} → {os.path.basename(output)}", ) return {"ok": True, "output": output, "size_bytes": os.path.getsize(output)} def normalize(input_path: str, output: str | None = None, lufs: float = -16) -> dict: """LUFS-normalize an audio file.""" output = output or _suffixed(input_path, "_norm") run_ffmpeg( ["-i", input_path, "-af", f"loudnorm=I={lufs}:LRA=11:TP=-1", output], f"normalizing to {lufs} LUFS", ) return {"ok": True, "output": output} def trim(input_path: str, output: str | None = None, threshold: int = -50) -> dict: """Trim leading and trailing silence.""" output = output or _suffixed(input_path, "_trimmed") run_ffmpeg( ["-i", input_path, "-af", trim_filter(threshold), output], f"trimming silence (threshold: {threshold}dB)", ) return {"ok": True, "output": output} def pipeline( input_path: str, output: str | None = None, quality: int = 6, lufs: float = -16, threshold: int = -50, ) -> dict: """Full post-processing: trim → normalize → convert to OGG.""" base = input_path.rsplit(".", 1)[0] trimmed = base + "_trimmed.wav" normalized = base + "_norm.wav" output = output or base + ".ogg" try: run_ffmpeg(["-i", input_path, "-af", trim_filter(threshold), trimmed], "step 1/3: trimming silence") run_ffmpeg( ["-i", trimmed, "-af", f"loudnorm=I={lufs}:LRA=11:TP=-1", normalized], f"step 2/3: normalizing to {lufs} LUFS", ) run_ffmpeg( ["-i", normalized, "-c:a", "libvorbis", "-q:a", str(quality), output], "step 3/3: converting to OGG", ) finally: # The old script left the intermediates behind when a step failed. for leftover in (trimmed, normalized): if os.path.exists(leftover): os.remove(leftover) return {"ok": True, "output": output, "size_bytes": os.path.getsize(output)} def _suffixed(path: str, suffix: str) -> str: base, ext = os.path.splitext(path) return base + suffix + ext