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:
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user