fix(audio_mixer): parameterize loudnorm LUFS target

audio_mixer hard-coded loudnorm I=-16 (Apple Podcasts) in both _mix
and _full_mix. sound-design.md targets -14 for YouTube/TikTok/IG, and
edit_decisions.metadata.loudnorm_target is the declarative form — but
the mixer never read it, so the executed loudness silently defaulted
to podcast levels regardless of the target platform.

- Add loudnorm_target to input_schema (default -16, clamped to [-40, 0]).
- Extract _loudnorm_filter() helper and use it in _mix and _full_mix so
  a director can forward edit_decisions.metadata.loudnorm_target (or a
  caller can pass it directly) to hit the right platform target.
- Add tests pinning: default -16, -14 honored, out-of-range clamped,
  non-numeric fallback, and the schema default.

Refs: docs/REVIEW-image-to-video-voice.md §8 #1

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ziyu Huang
2026-07-07 07:32:44 +08:00
parent a2652b4c12
commit 0ab9779a04
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]"