feat(audio): ffmpeg production pass — loop/loudnorm builders, runner, build script

Pure arg builders (audio_ops) + thin runner (audio_run) + build_audio_media.py.
Produces the 5 normalized soundtrack loops + a synthesized pink-noise bed under
the gitignored sample_media/audio tree. 6 tests pass (white-noise integration
runs against bundled imageio_ffmpeg).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 07:41:34 -07:00
parent 062a283de4
commit 39fb9108db
4 changed files with 226 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
"""Pure ffmpeg argument builders (no I/O) for the audio production pass.
The audio analogue of tools/pipeline/ffmpeg_ops.py: each function returns a
list[str] ready for ffmpeg, so command construction is unit-testable without
running ffmpeg. tools/pipeline/audio_run.py executes them. The ffmpeg binary
name is injected (default "ffmpeg")."""
from __future__ import annotations
_NOISE_COLORS = frozenset({"white", "pink", "brown", "blue", "violet"})
def audio_loop_args(src, dst, *, duration: float, overlap: float,
ff: str = "ffmpeg") -> list[str]:
"""Make `src` loop seamlessly by crossfading its tail over its head — the
audio mirror of ffmpeg_ops.crossfade_loop_args. Output length = duration -
overlap. Requires overlap < duration/2 so a non-empty middle remains."""
if overlap <= 0 or overlap >= duration / 2:
raise ValueError(f"overlap {overlap} must be in (0, duration/2={duration / 2})")
d, o = duration, overlap
fc = (
f"[0:a]atrim=0:{o},asetpts=N/SR/TB[head];"
f"[0:a]atrim={o}:{d - o},asetpts=N/SR/TB[mid];"
f"[0:a]atrim={d - o}:{d},asetpts=N/SR/TB[tail];"
f"[tail][head]acrossfade=d={o}:c1=tri:c2=tri[xf];"
f"[xf][mid]concat=n=2:v=0:a=1[out]"
)
return [
ff, "-y", "-i", str(src), "-filter_complex", fc,
"-map", "[out]", "-c:a", "libmp3lame", "-q:a", "4", str(dst),
]
def loudnorm_args(src, dst, *, i: float = -18.0, tp: float = -1.5,
lra: float = 11.0, ff: str = "ffmpeg") -> list[str]:
"""EBU R128 loudness normalize to the locked installation target so all five
soundtracks (and the noise bed) sit at one comfortable level."""
return [
ff, "-y", "-i", str(src),
"-af", f"loudnorm=I={i}:TP={tp}:LRA={lra}",
"-c:a", "libmp3lame", "-q:a", "4", str(dst),
]
def white_noise_args(dst, *, duration: float = 60.0, color: str = "pink",
ff: str = "ffmpeg") -> list[str]:
"""Synthesize a calm colored-noise bed (default pink) of `duration` seconds —
deterministic, zero licensing. Loops cleanly (steady-state noise has no seam)."""
if color not in _NOISE_COLORS:
raise ValueError(
f"unknown noise color {color!r}; expected one of {sorted(_NOISE_COLORS)}"
)
return [
ff, "-y", "-f", "lavfi",
"-i", f"anoisesrc=color={color}:amplitude=0.5:duration={duration}",
"-t", str(duration), "-c:a", "libmp3lame", "-q:a", "4", str(dst),
]
+61
View File
@@ -0,0 +1,61 @@
"""Thin runner: probe duration, then run the audio production pass (seamless
loop -> loudness normalize) and synthesize the white-noise bed. The pure arg
builders (tools.pipeline.audio_ops) are unit-tested; this glue is covered by the
opt-in integration test."""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from .audio_ops import audio_loop_args, loudnorm_args, white_noise_args
from .run import resolve_ffmpeg
def _probe_audio_duration(src, ff: str) -> float:
"""Audio length in seconds via ffmpeg (no system ffprobe): decode to null and
read the last reported time off stderr."""
proc = subprocess.run(
[ff, "-i", str(src), "-f", "null", "-"],
capture_output=True, text=True,
)
times = re.findall(r"time=(\d+):(\d+):(\d+\.\d+)", proc.stderr)
if not times:
raise ValueError(f"could not probe audio duration of {src}")
h, m, s = times[-1]
return int(h) * 3600 + int(m) * 60 + float(s)
def _run(args: list[str]) -> None:
subprocess.run(args, check=True, capture_output=True)
def process_soundtrack(src, dst, *, overlap: float = 2.0, ff: str | None = None) -> Path:
"""Produce a seamless, loudness-normalized loop from a sourced ambience clip.
Clips too short to crossfade-loop (<= 2*overlap) are normalized only."""
ff = ff or resolve_ffmpeg()
src, dst = Path(src), Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
duration = _probe_audio_duration(src, ff)
tmp = dst.with_suffix(".loop.tmp.mp3")
if duration > 2 * overlap + 0.5:
_run(audio_loop_args(src, tmp, duration=duration, overlap=overlap, ff=ff))
_run(loudnorm_args(tmp, dst, ff=ff))
tmp.unlink(missing_ok=True)
else:
_run(loudnorm_args(src, dst, ff=ff))
return dst
def generate_white_noise(dst, *, duration: float = 60.0, color: str = "pink",
ff: str | None = None) -> Path:
"""Synthesize + normalize the global white-noise bed."""
ff = ff or resolve_ffmpeg()
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
tmp = dst.with_suffix(".raw.tmp.mp3")
_run(white_noise_args(tmp, duration=duration, color=color, ff=ff))
_run(loudnorm_args(tmp, dst, ff=ff))
tmp.unlink(missing_ok=True)
return dst