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
+43
View File
@@ -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/<scale>/<name>.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/<scale>/.
# 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()
+65
View File
@@ -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
+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