"""BGM 混音单元测试. 测试: - BGMConfig 配置解析与边界值 - 预设 BGM 库查询 - 纯 BGM 音频生成(端到端 ffmpeg) - BGM + 主音频混音(端到端 ffmpeg) - 淡入淡出效果 - 音量边界(0 和 1) - sidechain 人声闪避 """ import sys import tempfile from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker")) sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) import pytest from video_processing.bgm_mixer import BGMConfig, build_bgm_only, mix_bgm_with_main, prepare_bgm_track from video_processing.render_audio import RenderContext # ── Fixtures ────────────────────────────────────────────────────────────────── @pytest.fixture def work_dir(tmp_path): return tmp_path @pytest.fixture def ctx(work_dir): return RenderContext(work_dir=work_dir, plan_id="test_plan") @pytest.fixture def main_audio_path(work_dir): """生成 10 秒测试主音频(正弦波模拟人声)。""" import subprocess path = work_dir / "main.aac" # 生成 10 秒 440Hz 正弦波模拟主音频 subprocess.run( [ "ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=10:sample_rate=44100", "-c:a", "aac", "-b:a", "128k", str(path), ], capture_output=True, check=True, timeout=30, ) return str(path) @pytest.fixture def bgm_audio_path(work_dir): """生成 5 秒测试 BGM(更低频率模拟背景音乐)。""" import subprocess path = work_dir / "bgm.aac" # 生成 5 秒 220Hz 正弦波模拟 BGM subprocess.run( [ "ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=220:duration=5:sample_rate=44100", "-c:a", "aac", "-b:a", "128k", str(path), ], capture_output=True, check=True, timeout=30, ) return str(path) # ── BGMConfig 测试 ─────────────────────────────────────────────────────────── class TestBGMConfig: """BGMConfig 配置解析测试。""" def test_default_values(self): cfg = BGMConfig(bgm_path="/tmp/bgm.mp3") assert cfg.volume == 0.3 assert cfg.fade_in == 0.0 assert cfg.fade_out == 0.0 assert cfg.loop_enabled is True assert cfg.sidechain_enabled is False assert cfg.sidechain_ratio == 0.3 def test_from_config_dict(self): config_dict = { "enabled": True, "volume": 0.5, "fade_in": 2.0, "fade_out": 3.0, "loop_enabled": False, "sidechain_enabled": True, "sidechain_ratio": 0.5, } cfg = BGMConfig.from_config_dict("/bgm.mp3", config_dict) assert cfg.bgm_path == "/bgm.mp3" assert cfg.volume == 0.5 assert cfg.fade_in == 2.0 assert cfg.fade_out == 3.0 assert cfg.loop_enabled is False assert cfg.sidechain_enabled is True assert cfg.sidechain_ratio == 0.5 def test_volume_clamped_by_config_schema(self): """音量边界由 Pydantic Schema 在入口层保证,内部直接使用。""" from packages.domain.config_schemas import BGMConfig as BGMConfigSchema # 边界值测试 cfg = BGMConfigSchema(enabled=True, volume=0.0) assert cfg.volume == 0.0 cfg = BGMConfigSchema(enabled=True, volume=1.0) assert cfg.volume == 1.0 def test_fade_boundaries(self): from packages.domain.config_schemas import BGMConfig as BGMConfigSchema # 0 是合法值 cfg = BGMConfigSchema(fade_in=0, fade_out=0) assert cfg.fade_in == 0.0 assert cfg.fade_out == 0.0 # ── 预设 BGM 库测试 ───────────────────────────────────────────────────────── class TestPresetBGM: """预设 BGM 库查询测试。""" def test_total_count(self): from packages.domain.preset_bgm import PRESET_BGM_LIBRARY assert len(PRESET_BGM_LIBRARY) >= 10 def test_get_preset_by_id(self): from packages.domain.preset_bgm import get_preset_bgm bgm = get_preset_bgm("bgm_upbeat_001") assert bgm is not None assert bgm.name == "阳光清晨" assert bgm.style == "upbeat" def test_get_preset_not_found(self): from packages.domain.preset_bgm import get_preset_bgm assert get_preset_bgm("nonexistent") is None def test_list_by_style(self): from packages.domain.preset_bgm import list_preset_bgm_by_style upbeat = list_preset_bgm_by_style("upbeat") assert len(upbeat) >= 3 assert all(b.style == "upbeat" for b in upbeat) def test_search_by_keyword(self): from packages.domain.preset_bgm import search_preset_bgm results = search_preset_bgm("钢琴") assert len(results) >= 2 assert any("钢琴" in b.tags for b in results) def test_all_presets_have_basic_fields(self): from packages.domain.preset_bgm import PRESET_BGM_LIBRARY for bgm in PRESET_BGM_LIBRARY: assert bgm.id, f"{bgm.name} 缺少 id" assert bgm.name, "缺少 name" assert bgm.style, f"{bgm.name} 缺少 style" assert bgm.duration > 0, f"{bgm.name} 时长无效" # ── BGM 处理端到端测试 ────────────────────────────────────────────────────── class TestPrepareBGMTrack: """prepare_bgm_track 端到端测试。""" def test_bgm_without_loop_short_duration(self, ctx, bgm_audio_path): """BGM 比目标时长短且不循环 → 截断到目标时长(但前面没有足够内容)。""" bgm = BGMConfig(bgm_path=bgm_audio_path, volume=0.5, loop_enabled=False) result = prepare_bgm_track(ctx, bgm, target_duration=3.0) assert result.exists() assert result.stat().st_size > 0 def test_bgm_with_loop_longer_duration(self, ctx, bgm_audio_path): """BGM 比目标时长短,循环铺满。""" bgm = BGMConfig(bgm_path=bgm_audio_path, volume=0.3, loop_enabled=True) # BGM 5 秒,目标 12 秒,需要循环 3 次 result = prepare_bgm_track(ctx, bgm, target_duration=12.0) assert result.exists() assert result.stat().st_size > 0 def test_bgm_fade_in_and_fade_out(self, ctx, bgm_audio_path): """BGM 淡入淡出效果。""" bgm = BGMConfig( bgm_path=bgm_audio_path, volume=0.5, fade_in=1.0, fade_out=1.0, loop_enabled=False, ) result = prepare_bgm_track(ctx, bgm, target_duration=4.0) assert result.exists() assert result.stat().st_size > 0 def test_volume_zero(self, ctx, bgm_audio_path): """音量为 0 时仍能正常处理。""" bgm = BGMConfig(bgm_path=bgm_audio_path, volume=0.0, loop_enabled=False) result = prepare_bgm_track(ctx, bgm, target_duration=3.0) assert result.exists() assert result.stat().st_size > 0 def test_volume_one(self, ctx, bgm_audio_path): """音量为 1(最大)时正常处理。""" bgm = BGMConfig(bgm_path=bgm_audio_path, volume=1.0, loop_enabled=False) result = prepare_bgm_track(ctx, bgm, target_duration=3.0) assert result.exists() assert result.stat().st_size > 0 class TestMixBGMMain: """BGM + 主音频混音端到端测试。""" def test_simple_mix(self, ctx, main_audio_path, bgm_audio_path): """普通 amix 混音(无 sidechain)。""" bgm = BGMConfig( bgm_path=bgm_audio_path, volume=0.3, loop_enabled=True, sidechain_enabled=False, ) result = mix_bgm_with_main(ctx, Path(main_audio_path), bgm, target_duration=8.0) assert result.exists() assert result.stat().st_size > 0 def test_sidechain_mix(self, ctx, main_audio_path, bgm_audio_path): """sidechain 人声闪避混音。""" bgm = BGMConfig( bgm_path=bgm_audio_path, volume=0.5, loop_enabled=True, sidechain_enabled=True, sidechain_ratio=0.3, sidechain_threshold=-25.0, sidechain_attack=0.02, sidechain_release=0.5, ) result = mix_bgm_with_main(ctx, Path(main_audio_path), bgm, target_duration=8.0) assert result.exists() assert result.stat().st_size > 0 def test_sidechain_max_ratio(self, ctx, main_audio_path, bgm_audio_path): """sidechain 最大闪避比例。""" bgm = BGMConfig( bgm_path=bgm_audio_path, volume=0.5, loop_enabled=True, sidechain_enabled=True, sidechain_ratio=0.9, # 降低 90% ) result = mix_bgm_with_main(ctx, Path(main_audio_path), bgm, target_duration=5.0) assert result.exists() assert result.stat().st_size > 0 class TestBuildBGMOnly: """纯 BGM 模式测试。""" def test_build_bgm_only(self, ctx, bgm_audio_path): """只有 BGM、没有主音频时生成纯 BGM 音频。""" bgm = BGMConfig( bgm_path=bgm_audio_path, volume=0.3, fade_in=1.0, fade_out=1.0, loop_enabled=True, ) result = build_bgm_only(ctx, bgm, target_duration=15.0) assert result.exists() assert result.stat().st_size > 0 # ── Config Schema 集成测试 ─────────────────────────────────────────────────── class TestConfigSchemaIntegration: """config schema 与渲染配置的集成测试。""" def test_full_bgm_config(self): """完整 BGM 配置能正确解析。""" from packages.domain.config_schemas import EditPlanConfigSchema, normalize_plan_config config = normalize_plan_config( { "bgm": { "enabled": True, "source": "library", "asset_id": "bgm-asset-001", "volume": 0.4, "fade_in": 2.5, "fade_out": 3.0, "loop_enabled": True, "sidechain_enabled": True, "sidechain_ratio": 0.4, } } ) bgm = config["bgm"] assert bgm["enabled"] is True assert bgm["volume"] == 0.4 assert bgm["fade_in"] == 2.5 assert bgm["fade_out"] == 3.0 assert bgm["loop_enabled"] is True assert bgm["sidechain_enabled"] is True assert bgm["sidechain_ratio"] == 0.4 # 默认值保留 assert bgm["sidechain_attack"] == 0.02 assert bgm["sidechain_release"] == 0.5 assert bgm["sidechain_threshold"] == -25.0 def test_bgm_disabled_by_default(self): """默认 BGM 是关闭的。""" from packages.domain.config_schemas import normalize_plan_config config = normalize_plan_config({}) assert config["bgm"]["enabled"] is False