mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-11 01:41:43 +08:00
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:
89
tests/tools/test_audio_mixer_ducking.py
Normal file
89
tests/tools/test_audio_mixer_ducking.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user