From febc9244d3bb62fa2d287f117b4ccec73a7cab56 Mon Sep 17 00:00:00 2001 From: 0xDevNinja Date: Thu, 2 Jul 2026 16:41:58 +0530 Subject: [PATCH] fix(audio_mixer): drop dangling speech_dup pad in full_mix ducking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit full_mix with ducking enabled (the default) failed for a single narration track + one music bed — the most common shape — because the ducking branch appended an acopy[speech_dup] filter whose output pad was never consumed, leaving the filtergraph with a dangling output that ffmpeg rejects. For a single speech track speech_out is '[a0]' (starts with '[a'), so the guarded append fired; the compensating pop() only removes the empty-string case from the multi-speech branch, so the dead pad survived exactly in the single-narration case. The speech stream is already re-derived for the final mix via [speech_out], and ffmpeg auto-splits the reused input label, so the duplicate is unnecessary. Multi-speech and SFX paths are unaffected. Adds regression tests for single- and multi-narration full_mix with ducking. Closes #265 --- tests/tools/test_audio_mixer_ducking.py | 89 +++++++++++++++++++++++++ tools/audio/audio_mixer.py | 21 ++---- 2 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 tests/tools/test_audio_mixer_ducking.py diff --git a/tests/tools/test_audio_mixer_ducking.py b/tests/tools/test_audio_mixer_ducking.py new file mode 100644 index 00000000..ec060e81 --- /dev/null +++ b/tests/tools/test_audio_mixer_ducking.py @@ -0,0 +1,89 @@ +"""Regression tests for audio_mixer full_mix ducking filtergraph. + +The ducking branch built an `acopy[speech_dup]` filter whose output pad was +never consumed, leaving the FFmpeg filtergraph with a dangling output. FFmpeg +rejects that, so `full_mix` with the most common shape — a single narration +track plus one music bed, with ducking enabled (the default) — always failed. +""" + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from tools.audio.audio_mixer import AudioMixer # noqa: E402 + +pytestmark = pytest.mark.skipif( + shutil.which("ffmpeg") is None, reason="ffmpeg required for full_mix" +) + + +def _sine(path: Path, freq: int, dur: int) -> None: + subprocess.run( + ["ffmpeg", "-y", "-f", "lavfi", "-i", f"sine=frequency={freq}:duration={dur}", str(path)], + capture_output=True, + check=True, + timeout=30, + ) + + +def _has_audio(path: Path) -> bool: + out = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "a", + "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)], + capture_output=True, text=True, timeout=30, + ) + return "audio" in out.stdout + + +def test_full_mix_single_narration_plus_music_with_ducking(tmp_path): + speech = tmp_path / "speech.wav" + music = tmp_path / "music.wav" + _sine(speech, 440, 2) + _sine(music, 220, 3) + out = tmp_path / "mixed.wav" + + result = AudioMixer().execute( + { + "operation": "full_mix", + "tracks": [ + {"path": str(speech), "role": "speech"}, + {"path": str(music), "role": "music"}, + ], + "ducking": {"enabled": True}, + "output_path": str(out), + } + ) + + assert result.success is True, result.error + assert out.exists() and _has_audio(out) + + +def test_full_mix_multi_narration_plus_music_with_ducking(tmp_path): + s1, s2 = tmp_path / "s1.wav", tmp_path / "s2.wav" + music = tmp_path / "music.wav" + _sine(s1, 440, 2) + _sine(s2, 330, 2) + _sine(music, 220, 3) + out = tmp_path / "mixed_multi.wav" + + result = AudioMixer().execute( + { + "operation": "full_mix", + "tracks": [ + {"path": str(s1), "role": "speech"}, + {"path": str(s2), "role": "speech"}, + {"path": str(music), "role": "music"}, + ], + "ducking": {"enabled": True}, + "output_path": str(out), + } + ) + + assert result.success is True, result.error + assert out.exists() and _has_audio(out) diff --git a/tools/audio/audio_mixer.py b/tools/audio/audio_mixer.py index dba08b0b..44f29499 100644 --- a/tools/audio/audio_mixer.py +++ b/tools/audio/audio_mixer.py @@ -530,20 +530,13 @@ class AudioMixer(BaseTool): f"[ducked_music]volume={music_vol * 3}[music_out]" ) - # Duplicate speech for final mix (sidechain consumes it as key) - filter_parts.append( - f"{speech_out}acopy[speech_dup]" if speech_out.startswith("[a") else "" - ) - # Re-mix speech path: we need speech audio in the output too - # Simpler approach: use amix on original speech and ducked music - # Reset: use a cleaner approach — amerge the speech mix and ducked music - # Actually, let's rebuild. The sidechain approach above uses speech as - # the key signal but doesn't consume it from the output chain. - # FFmpeg sidechaincompress: input 0 = audio to compress, input 1 = key signal - # So music is compressed, speech signal is the key. We need to mix them. - # Remove the last filter_part (the acopy that may be empty) - if filter_parts and filter_parts[-1] == "": - filter_parts.pop() + # sidechaincompress uses the speech signal only as the ducking key — + # it does not emit speech to the output. Re-derive the speech stream + # for the final mix below. (An earlier version also appended an + # `acopy[speech_dup]` here, but that pad was never consumed and left + # the filtergraph with a dangling output, which ffmpeg rejects — so + # single-narration + music full_mix always failed. FFmpeg auto-splits + # the reused input label, so no explicit duplicate is needed.) # Build speech mix for output separately if len(speech_tracks) > 1: