From 0ab9779a041a1fa5105281d007686b5d33572207 Mon Sep 17 00:00:00 2001 From: Ziyu Huang Date: Tue, 7 Jul 2026 07:32:44 +0800 Subject: [PATCH] fix(audio_mixer): parameterize loudnorm LUFS target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tools/test_audio_mixer_loudnorm_target.py | 49 +++++++++++++++++++ tools/audio/audio_mixer.py | 37 +++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_audio_mixer_loudnorm_target.py diff --git a/tests/tools/test_audio_mixer_loudnorm_target.py b/tests/tools/test_audio_mixer_loudnorm_target.py new file mode 100644 index 00000000..0e4f62eb --- /dev/null +++ b/tests/tools/test_audio_mixer_loudnorm_target.py @@ -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) diff --git a/tools/audio/audio_mixer.py b/tools/audio/audio_mixer.py index dd58d4a5..f3520236 100644 --- a/tools/audio/audio_mixer.py +++ b/tools/audio/audio_mixer.py @@ -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]"