From 39fb9108dba1db79a7a07bdff507a9429556a8ae Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 07:41:34 -0700 Subject: [PATCH] =?UTF-8?q?feat(audio):=20ffmpeg=20production=20pass=20?= =?UTF-8?q?=E2=80=94=20loop/loudnorm=20builders,=20runner,=20build=20scrip?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- simulator/build_audio_media.py | 43 +++++++++++++++++++++ tests/test_pipeline_audio_ops.py | 65 ++++++++++++++++++++++++++++++++ tools/pipeline/audio_ops.py | 57 ++++++++++++++++++++++++++++ tools/pipeline/audio_run.py | 61 ++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 simulator/build_audio_media.py create mode 100644 tests/test_pipeline_audio_ops.py create mode 100644 tools/pipeline/audio_ops.py create mode 100644 tools/pipeline/audio_run.py diff --git a/simulator/build_audio_media.py b/simulator/build_audio_media.py new file mode 100644 index 0000000..ce3a5c2 --- /dev/null +++ b/simulator/build_audio_media.py @@ -0,0 +1,43 @@ +"""Production pass for the 5 per-altitude soundtracks + the white-noise bed +(audio spec §5.3). The audio analogue of build_pool_manifest.py --media: read the +sourced ambience clips (re-downloadable per docs/audio-candidate-pool.md), make +each a seamless loudness-normalized loop, and synthesize the pink-noise bed. + +Media is gitignored; this script is the reproducible record. Outputs land under +simulator/sample_media/audio//.loop.mp3 and audio/noise/pink.mp3, +served at /media/audio/... Run: python simulator/build_audio_media.py +""" + +from __future__ import annotations + +from pathlib import Path + +from tools.pipeline.audio_run import generate_white_noise, process_soundtrack + +AUDIO = Path(__file__).parent / "sample_media" / "audio" + +# scale -> (sourced raw file, production output file), both under audio//. +# Sources per docs/audio-candidate-pool.md (the "✓ KEEP" picks). +SOURCES: dict[str, tuple[str, str]] = { + "cosmos": ("pillars.mp3", "pillars.loop.mp3"), + "orbit": ("spaceamb.mp3", "spaceamb.loop.mp3"), + "coast": ("waves.mp3", "waves.loop.mp3"), + "reef": ("soundscape.mp3", "soundscape.loop.mp3"), + "abyss": ("whale.wav", "whale.loop.mp3"), +} + + +def main() -> None: + for scale, (raw, out) in SOURCES.items(): + src = AUDIO / scale / raw + if not src.exists(): + print(f" SKIP {scale}: source missing ({src}) — re-source per audio-candidate-pool.md") + continue + dst = process_soundtrack(src, AUDIO / scale / out) + print(f" produced {dst.relative_to(AUDIO.parent)}") + noise = generate_white_noise(AUDIO / "noise" / "pink.mp3") + print(f" produced {noise.relative_to(AUDIO.parent)}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_pipeline_audio_ops.py b/tests/test_pipeline_audio_ops.py new file mode 100644 index 0000000..a25664d --- /dev/null +++ b/tests/test_pipeline_audio_ops.py @@ -0,0 +1,65 @@ +"""Unit tests for the pure audio ffmpeg arg builders (no ffmpeg run), plus an +opt-in integration test that actually synthesizes a noise bed when ffmpeg is +present.""" + +import shutil + +import pytest + +from tools.pipeline.audio_ops import audio_loop_args, loudnorm_args, white_noise_args + + +def test_loop_args_mirror_the_video_crossfade_recipe(): + args = audio_loop_args("in.mp3", "out.mp3", duration=30.0, overlap=2.0, ff="FF") + assert args[0] == "FF" + joined = " ".join(args) + # tail crossfades over head, then concats the middle — the audio analogue of + # crossfade_loop_args. Output length = duration - overlap. + assert "acrossfade=d=2.0" in joined + assert "atrim=0:2.0" in joined # head + assert "atrim=28.0:30.0" in joined # tail (d-overlap : d) + assert "concat=n=2:v=0:a=1" in joined + assert "-map" in args and "[out]" in args + assert "-q:a" in args and "4" in args + + +def test_loop_args_reject_overlap_past_half(): + with pytest.raises(ValueError): + audio_loop_args("in.mp3", "out.mp3", duration=10.0, overlap=5.0) + + +def test_loudnorm_args_carry_the_locked_targets(): + args = loudnorm_args("in.mp3", "out.mp3", ff="FF") + joined = " ".join(args) + assert "loudnorm=I=-18.0:TP=-1.5:LRA=11.0" in joined + assert args[0] == "FF" and args[-1] == "out.mp3" + + +def test_white_noise_args_are_pink_and_clean(): + args = white_noise_args("noise.mp3", duration=60.0, color="pink", ff="FF") + joined = " ".join(args) + assert "anoisesrc=color=pink" in joined + assert "-t" in args and "60.0" in args + assert args[-1] == "noise.mp3" + + +def test_white_noise_rejects_unknown_color(): + with pytest.raises(ValueError): + white_noise_args("noise.mp3", color="ultraviolet") + + +def _have_ffmpeg() -> bool: + if shutil.which("ffmpeg"): + return True + try: + import imageio_ffmpeg # noqa: F401 + return True + except Exception: + return False + + +@pytest.mark.skipif(not _have_ffmpeg(), reason="no ffmpeg available") +def test_generate_white_noise_writes_a_playable_loop(tmp_path): + from tools.pipeline.audio_run import generate_white_noise + out = generate_white_noise(tmp_path / "pink.mp3", duration=2.0) + assert out.exists() and out.stat().st_size > 0 diff --git a/tools/pipeline/audio_ops.py b/tools/pipeline/audio_ops.py new file mode 100644 index 0000000..6e0f79d --- /dev/null +++ b/tools/pipeline/audio_ops.py @@ -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), + ] diff --git a/tools/pipeline/audio_run.py b/tools/pipeline/audio_run.py new file mode 100644 index 0000000..b63b51f --- /dev/null +++ b/tools/pipeline/audio_run.py @@ -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