mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-05 15:20:40 +08:00
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:
49
tests/tools/test_audio_mixer_loudnorm_target.py
Normal file
49
tests/tools/test_audio_mixer_loudnorm_target.py
Normal 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)
|
||||
@@ -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]"
|
||||
|
||||
Reference in New Issue
Block a user