Merge pull request #319 from ziyu4huang/fix/audio-mixer-loudnorm-target

fix(audio_mixer): parameterize loudnorm LUFS target (§8 #1)
This commit is contained in:
Calesthio
2026-07-06 21:35:29 -07:00
committed by GitHub
2 changed files with 84 additions and 2 deletions

View File

@@ -0,0 +1,49 @@
"""Regression for the loudnorm LUFS target parameterization (REVIEW §8 #1).
audio_mixer hard-coded loudnorm I=-16 (Apple Podcasts), but
sound-design.md targets -14 for YouTube/TikTok/IG and
edit_decisions.metadata.loudnorm_target is the declarative form. The
mismatch meant the executed loudness silently defaulted to podcast
levels regardless of the target platform. These tests pin the per-call
target resolution without invoking ffmpeg.
"""
from __future__ import annotations
import math
from tools.audio.audio_mixer import AudioMixer
def _filter(inputs: dict) -> str:
return AudioMixer._loudnorm_filter(inputs, "premix", "out")
def test_default_target_is_podcast_minus_16():
f = _filter({})
assert "I=-16.0" in f or "I=-16" in f
assert f.startswith("[premix]loudnorm=")
assert f.endswith("[out]")
def test_youtube_target_minus_14_is_honored():
f = _filter({"loudnorm_target": -14})
assert "I=-14.0" in f or "I=-14" in f
def test_out_of_range_target_is_clamped():
# A nonsense value must not produce a malformed ffmpeg arg.
f = _filter({"loudnorm_target": 99})
# Clamped to the 0 LUFS ceiling.
assert "I=0.0" in f
def test_non_numeric_target_falls_back_to_default():
f = _filter({"loudnorm_target": "not-a-number"})
assert "I=-16.0" in f or "I=-16" in f
def test_schema_exposes_loudnorm_target_default():
prop = AudioMixer().input_schema["properties"]["loudnorm_target"]
assert prop["type"] == "number"
assert math.isclose(prop["default"], -16)

View File

@@ -131,6 +131,20 @@ class AudioMixer(BaseTool):
},
},
"normalize": {"type": "boolean", "default": True},
"loudnorm_target": {
"type": "number",
"default": -16,
"minimum": -40,
"maximum": 0,
"description": (
"Integrated loudness target (LUFS) for the loudnorm filter when "
"normalize=true. Default -16 (Apple Podcasts). Pass -14 for "
"YouTube/TikTok/IG per sound-design.md. Matches the "
"edit_decisions.metadata.loudnorm_target convention — directors "
"should forward that field here so the executed loudness matches "
"the platform the asset targets."
),
},
"video_path": {
"type": "string",
"description": (
@@ -180,6 +194,25 @@ class AudioMixer(BaseTool):
"Listen to mixed output and verify speech clarity and music ducking",
]
@staticmethod
def _loudnorm_filter(inputs: dict[str, Any], in_label: str, out_label: str) -> str:
"""Build a loudnorm filter graph edge honoring the per-call LUFS target.
The integrated loudness target (``I=``) was historically hard-coded to
-16 (podcast/Apple). sound-design.md targets -14 for YouTube/TikTok/IG,
and edit_decisions.metadata.loudnorm_target is the declarative form.
Forward that value (or pass loudnorm_target directly) so the executed
loudness matches the target platform instead of silently defaulting.
"""
target = inputs.get("loudnorm_target", -16)
try:
target = float(target)
except (TypeError, ValueError):
target = -16.0
# Clamp to a sane loudness range to avoid malformed ffmpeg args.
target = max(-40.0, min(0.0, target))
return f"[{in_label}]loudnorm=I={target}:LRA=11:TP=-1.5[{out_label}]"
def execute(self, inputs: dict[str, Any]) -> ToolResult:
operation = inputs["operation"]
start = time.time()
@@ -251,7 +284,7 @@ class AudioMixer(BaseTool):
)
if normalize:
filter_parts.append("[mixed]loudnorm=I=-16:LRA=11:TP=-1.5[out]")
filter_parts.append(self._loudnorm_filter(inputs, "mixed", "out"))
out_label = "[out]"
else:
out_label = "[mixed]"
@@ -558,7 +591,7 @@ class AudioMixer(BaseTool):
# Normalize
if normalize:
filter_parts.append("[premix]loudnorm=I=-16:LRA=11:TP=-1.5[out]")
filter_parts.append(self._loudnorm_filter(inputs, "premix", "out"))
out_label = "[out]"
else:
out_label = "[premix]"