3cd26e98db
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 43s
CI/CD Pipeline / Unit Tests (push) Successful in 1m35s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m37s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
360 lines
12 KiB
Python
Executable File
360 lines
12 KiB
Python
Executable File
"""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
|