fix(audio_mixer): drop dangling speech_dup pad in full_mix ducking

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
This commit is contained in:
0xDevNinja
2026-07-02 16:41:58 +05:30
parent dc1cbca657
commit febc9244d3
2 changed files with 96 additions and 14 deletions

View File

@@ -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)

View File

@@ -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: