Files
xiaoxia-saas/tests/unit/test_bgm_mixer_pure.py

659 lines
24 KiB
Python
Executable File

"""BGM 混音纯逻辑单元测试."""
from __future__ import annotations
import pytest
from video_processing.bgm_mixer_pure import (
BGMPureConfig,
build_bgm_filter_chain,
build_sidechain_mix_filter,
build_simple_mix_filter,
calculate_fade_out_start,
calculate_loop_count,
calculate_sidechain_ratio,
estimate_bgm_processing_duration,
normalize_bgm_config,
should_loop_bgm,
validate_bgm_config,
)
# ─────────────────────────────────────────────────────────────────────────────
# should_loop_bgm 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestShouldLoopBGM:
"""BGM 循环判断测试."""
def test_need_loop_when_much_shorter(self):
"""BGM 远短于目标时长,需要循环."""
assert should_loop_bgm(10, 100, True) is True
def test_no_loop_when_long_enough(self):
"""BGM 够长,不需要循环."""
assert should_loop_bgm(100, 100, True) is False
def test_no_loop_when_just_slightly_shorter(self):
"""BGM 只差一点点(>90%),不循环."""
assert should_loop_bgm(95, 100, True) is False
def test_threshold_90_percent(self):
"""刚好 90% 阈值,不循环(<90% 才循环)."""
assert should_loop_bgm(90, 100, True) is False
def test_just_below_threshold(self):
"""略低于 90%,需要循环."""
assert should_loop_bgm(89, 100, True) is True
def test_loop_disabled(self):
"""禁用循环,即使 BGM 很短也不循环."""
assert should_loop_bgm(10, 100, False) is False
def test_zero_bgm_duration(self):
"""BGM 时长为 0,不循环."""
assert should_loop_bgm(0, 100, True) is False
def test_negative_bgm_duration(self):
"""BGM 时长为负,不循环."""
assert should_loop_bgm(-5, 100, True) is False
def test_zero_target_duration(self):
"""目标时长为 0,不循环."""
assert should_loop_bgm(10, 0, True) is False
def test_negative_target_duration(self):
"""目标时长为负,不循环."""
assert should_loop_bgm(10, -10, True) is False
# ─────────────────────────────────────────────────────────────────────────────
# calculate_loop_count 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateLoopCount:
"""循环次数计算测试."""
def test_exact_multiple(self):
"""刚好整数倍."""
# 100/10 = 10, +2 = 12
assert calculate_loop_count(10, 100) == 12
def test_not_exact_multiple(self):
"""不是整数倍."""
# 100/30 = 3, +2 = 5
assert calculate_loop_count(30, 100) == 5
def test_bgm_longer_than_target(self):
"""BGM 比目标长,至少 1 次."""
assert calculate_loop_count(200, 100) == 1
def test_zero_bgm_duration(self):
"""BGM 时长为 0,返回 1."""
assert calculate_loop_count(0, 100) == 1
def test_negative_bgm_duration(self):
"""BGM 时长为负,返回 1."""
assert calculate_loop_count(-5, 100) == 1
def test_zero_target_duration(self):
"""目标时长为 0,返回 1."""
assert calculate_loop_count(10, 0) == 1
def test_negative_target_duration(self):
"""目标时长为负,返回 1."""
assert calculate_loop_count(10, -10) == 1
def test_very_short_bgm(self):
"""非常短的 BGM,循环次数多."""
# 100/1 = 100, +2 = 102
assert calculate_loop_count(1, 100) == 102
# ─────────────────────────────────────────────────────────────────────────────
# build_bgm_filter_chain 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildBGMFilterChain:
"""BGM 预处理滤镜链构建测试."""
def test_basic_volume_only(self):
"""只有音量调节."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=0.5,
)
assert "volume=0.500" in result
assert "aloop" not in result
assert "afade=t=in" not in result
assert "afade=t=out" not in result
assert "atrim=0:100.000" in result
assert "asetpts=N/SR/TB" in result
def test_with_loop(self):
"""需要循环的情况."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.3,
loop_enabled=True,
)
assert "aloop=loop=" in result
assert "volume=0.300" in result
def test_no_loop_when_disabled(self):
"""禁用循环,即使 BGM 短也不循环."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.3,
loop_enabled=False,
)
assert "aloop" not in result
def test_fade_in_only(self):
"""只有淡入."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_in=2.5,
)
assert "afade=t=in:st=0:d=2.500" in result
assert "afade=t=out" not in result
assert "volume=" not in result # volume=1.0 不加
def test_fade_out_only(self):
"""只有淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_out=3.0,
)
assert "afade=t=out:st=97.000:d=3.000" in result
assert "afade=t=in" not in result
def test_fade_in_and_out(self):
"""淡入+淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_in=1.5,
fade_out=2.0,
)
assert "afade=t=in:st=0:d=1.500" in result
assert "afade=t=out:st=98.000:d=2.000" in result
def test_volume_1_0_skipped(self):
"""音量为 1.0 时不添加 volume 滤镜."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
)
assert "volume=" not in result
def test_volume_0(self):
"""音量为 0."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=0.0,
)
assert "volume=0.000" in result
def test_volume_clamped_high(self):
"""音量超过 1.0 被钳制."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.5,
)
assert "volume=1.000" not in result # 1.0不加
# 钳制到1.0后和1.0一样,不加volume滤镜
# 但因为abs(1.0 - 1.0) < 0.001,所以不添加
assert "volume=" not in result
def test_volume_clamped_low(self):
"""音量为负被钳制到 0."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=-0.5,
)
assert "volume=0.000" in result
def test_fade_out_longer_than_duration(self):
"""淡出时长超过总时长,不加淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=10,
volume=1.0,
fade_out=20.0,
)
assert "afade=t=out" not in result
def test_fade_out_equal_to_duration(self):
"""淡出时长等于总时长,不加淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=10,
volume=1.0,
fade_out=10.0,
)
assert "afade=t=out" not in result
def test_zero_target_duration_fallback(self):
"""目标时长为 0,兜底 5 秒."""
result = build_bgm_filter_chain(
bgm_duration=3,
target_duration=0,
volume=0.5,
)
assert "atrim=0:5.000" in result
def test_negative_target_duration_fallback(self):
"""目标时长为负,兜底 5 秒."""
result = build_bgm_filter_chain(
bgm_duration=3,
target_duration=-5,
volume=0.5,
)
assert "atrim=0:5.000" in result
def test_full_chain_with_all_effects(self):
"""完整滤镜链:循环+音量+淡入淡出+截断+重置."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.4,
fade_in=1.0,
fade_out=2.0,
loop_enabled=True,
)
parts = result.split(",")
# 顺序:aloop -> volume -> afade in -> afade out -> atrim -> asetpts
assert len(parts) >= 6
assert "aloop" in parts[0]
assert "volume" in parts[1]
assert "afade=t=in" in parts[2]
assert "afade=t=out" in parts[3]
assert "atrim" in parts[4]
assert "asetpts" in parts[5]
# ─────────────────────────────────────────────────────────────────────────────
# calculate_sidechain_ratio 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateSidechainRatio:
"""Sidechain 压缩比计算测试."""
def test_default_ratio_0_3(self):
"""默认 0.3."""
# 1 / (1 - 0.3) = 1.428... 但下限是 2.0
assert calculate_sidechain_ratio(0.3) == pytest.approx(2.0, rel=0.01)
def test_ratio_0_5(self):
"""比例 0.5."""
# 1 / (1 - 0.5) = 2.0
assert calculate_sidechain_ratio(0.5) == pytest.approx(2.0, rel=0.01)
def test_ratio_0_8(self):
"""比例 0.8."""
# 1 / (1 - 0.8) = 5.0
assert calculate_sidechain_ratio(0.8) == pytest.approx(5.0, rel=0.01)
def test_ratio_0_9(self):
"""比例 0.9."""
# 1 / (1 - 0.9) = 10.0
assert calculate_sidechain_ratio(0.9) == pytest.approx(10.0, rel=0.01)
def test_ratio_0(self):
"""比例 0,返回下限 2.0."""
assert calculate_sidechain_ratio(0.0) == 2.0
def test_ratio_negative(self):
"""比例为负,返回下限 2.0."""
assert calculate_sidechain_ratio(-0.5) == 2.0
def test_ratio_1_0(self):
"""比例 1.0,返回上限 10.0."""
assert calculate_sidechain_ratio(1.0) == 10.0
def test_ratio_greater_than_1(self):
"""比例超过 1.0,返回上限 10.0."""
assert calculate_sidechain_ratio(2.0) == 10.0
# ─────────────────────────────────────────────────────────────────────────────
# build_simple_mix_filter 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildSimpleMixFilter:
"""普通混音滤镜构建测试."""
def test_contains_amix(self):
"""包含 amix."""
result = build_simple_mix_filter()
assert "amix=inputs=2" in result
def test_contains_volume_compensation(self):
"""包含 volume=2 补偿."""
result = build_simple_mix_filter()
assert "volume=2" in result
def test_output_label(self):
"""输出标签为 [final]."""
result = build_simple_mix_filter()
assert "[final]" in result
def test_duration_first(self):
"""duration=first,以主音频时长为准."""
result = build_simple_mix_filter()
assert "duration=first" in result
# ─────────────────────────────────────────────────────────────────────────────
# build_sidechain_mix_filter 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildSidechainMixFilter:
"""Sidechain 混音滤镜构建测试."""
def test_contains_sidechaincompress(self):
"""包含 sidechaincompress."""
result = build_sidechain_mix_filter()
assert "sidechaincompress=" in result
def test_threshold_param(self):
"""threshold 参数正确."""
result = build_sidechain_mix_filter(threshold=-30.0)
assert "threshold=-30.0dB" in result
def test_attack_param(self):
"""attack 参数正确."""
result = build_sidechain_mix_filter(attack=0.05)
assert "attack=0.050" in result
def test_release_param(self):
"""release 参数正确."""
result = build_sidechain_mix_filter(release=0.8)
assert "release=0.800" in result
def test_knee_param(self):
"""knee=6 参数."""
result = build_sidechain_mix_filter()
assert "knee=6" in result
def test_contains_amix(self):
"""包含 amix 混音."""
result = build_sidechain_mix_filter()
assert "amix=inputs=2" in result
def test_volume_compensation(self):
"""volume=1.5 轻微补偿."""
result = build_sidechain_mix_filter()
assert "volume=1.5" in result
def test_bgmc_comp_label(self):
"""包含 [bgm_comp] 中间标签."""
result = build_sidechain_mix_filter()
assert "[bgm_comp]" in result
# ─────────────────────────────────────────────────────────────────────────────
# normalize_bgm_config 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestNormalizeBGMConfig:
"""配置规范化测试."""
def test_empty_dict_defaults(self):
"""空字典返回默认值."""
result = normalize_bgm_config({})
assert result["volume"] == 0.3
assert result["fade_in"] == 0.0
assert result["fade_out"] == 0.0
assert result["loop_enabled"] is True
assert result["sidechain_enabled"] is False
assert result["sidechain_ratio"] == 0.3
def test_volume_clamped(self):
"""音量钳制."""
result = normalize_bgm_config({"volume": 1.5})
assert result["volume"] == 1.0
result2 = normalize_bgm_config({"volume": -0.5})
assert result2["volume"] == 0.0
def test_fade_in_negative(self):
"""淡入为负钳制到 0."""
result = normalize_bgm_config({"fade_in": -1})
assert result["fade_in"] == 0.0
def test_fade_out_negative(self):
"""淡出为负钳制到 0."""
result = normalize_bgm_config({"fade_out": -1})
assert result["fade_out"] == 0.0
def test_sidechain_ratio_clamped(self):
"""sidechain_ratio 钳制."""
result = normalize_bgm_config({"sidechain_ratio": 1.5})
assert result["sidechain_ratio"] == 1.0
result2 = normalize_bgm_config({"sidechain_ratio": -0.1})
assert result2["sidechain_ratio"] == 0.0
def test_sidechain_attack_min(self):
"""attack 最小值 0.001."""
result = normalize_bgm_config({"sidechain_attack": 0})
assert result["sidechain_attack"] == 0.001
def test_sidechain_release_min(self):
"""release 最小值 0.01."""
result = normalize_bgm_config({"sidechain_release": 0})
assert result["sidechain_release"] == 0.01
def test_string_values_converted(self):
"""字符串数值被转换."""
result = normalize_bgm_config(
{
"volume": "0.5",
"fade_in": "2.0",
}
)
assert result["volume"] == 0.5
assert result["fade_in"] == 2.0
def test_loop_enabled_truthy(self):
"""loop_enabled 真值转换."""
result = normalize_bgm_config({"loop_enabled": 1})
assert result["loop_enabled"] is True
result2 = normalize_bgm_config({"loop_enabled": 0})
assert result2["loop_enabled"] is False
def test_preserves_unknown_keys(self):
"""未知 key 不保留."""
result = normalize_bgm_config({"unknown_key": "value", "volume": 0.5})
assert "unknown_key" not in result
assert result["volume"] == 0.5
# ─────────────────────────────────────────────────────────────────────────────
# validate_bgm_config 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateBGMConfig:
"""配置验证测试."""
def test_valid_config(self):
"""合法配置."""
ok, errors = validate_bgm_config(
{
"volume": 0.5,
"fade_in": 1.0,
"fade_out": 2.0,
"sidechain_ratio": 0.3,
}
)
assert ok is True
assert len(errors) == 0
def test_volume_not_number(self):
"""volume 不是数字."""
ok, errors = validate_bgm_config({"volume": "high"})
assert ok is False
assert any("volume" in e for e in errors)
def test_volume_out_of_range(self):
"""volume 超出范围."""
ok, errors = validate_bgm_config({"volume": 1.5})
assert ok is False
assert any("volume" in e for e in errors)
def test_fade_in_negative(self):
"""fade_in 为负."""
ok, errors = validate_bgm_config({"fade_in": -1})
assert ok is False
assert any("fade_in" in e for e in errors)
def test_fade_out_negative(self):
"""fade_out 为负."""
ok, errors = validate_bgm_config({"fade_out": -1})
assert ok is False
assert any("fade_out" in e for e in errors)
def test_sidechain_ratio_out_of_range(self):
"""sidechain_ratio 超出范围."""
ok, errors = validate_bgm_config({"sidechain_ratio": 2.0})
assert ok is False
assert any("sidechain_ratio" in e for e in errors)
def test_multiple_errors(self):
"""多个错误同时报告."""
ok, errors = validate_bgm_config(
{
"volume": 2.0,
"fade_in": -1,
"sidechain_ratio": -0.5,
}
)
assert ok is False
assert len(errors) >= 3
def test_empty_config_valid(self):
"""空配置(全用默认值)视为合法."""
ok, errors = validate_bgm_config({})
assert ok is True
assert len(errors) == 0
# ─────────────────────────────────────────────────────────────────────────────
# calculate_fade_out_start 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateFadeOutStart:
"""淡出开始时间计算测试."""
def test_normal_case(self):
"""正常情况."""
assert calculate_fade_out_start(100, 3) == pytest.approx(97.0)
def test_zero_fade_out(self):
"""淡出时长为 0,返回 None."""
assert calculate_fade_out_start(100, 0) is None
def test_negative_fade_out(self):
"""淡出时长为负,返回 None."""
assert calculate_fade_out_start(100, -1) is None
def test_zero_duration(self):
"""总时长为 0,返回 None."""
assert calculate_fade_out_start(0, 3) is None
def test_fade_out_longer_than_duration(self):
"""淡出超过总时长,返回 None."""
assert calculate_fade_out_start(10, 20) is None
def test_fade_out_equal_to_duration(self):
"""淡出等于总时长,返回 None."""
assert calculate_fade_out_start(10, 10) is None
# ─────────────────────────────────────────────────────────────────────────────
# estimate_bgm_processing_duration 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestEstimateBGMProcessingDuration:
"""BGM 处理时长估算测试."""
def test_normal_case_with_loop(self):
"""正常循环情况,输出目标时长."""
assert estimate_bgm_processing_duration(10, 100, True) == 100
def test_bgm_longer_no_loop(self):
"""BGM 够长,不循环,截断到目标时长."""
assert estimate_bgm_processing_duration(200, 100, False) == 100
def test_bgm_shorter_no_loop(self):
"""BGM 短但不循环,仍然截断到目标时长(实际会更短,但 atrim 会截断)."""
assert estimate_bgm_processing_duration(10, 100, False) == 100
def test_zero_target(self):
"""目标时长为 0,兜底 5 秒."""
assert estimate_bgm_processing_duration(10, 0, True) == 5.0
def test_negative_target(self):
"""目标时长为负,兜底 5 秒."""
assert estimate_bgm_processing_duration(10, -5, True) == 5.0
# ─────────────────────────────────────────────────────────────────────────────
# BGMPureConfig 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBGMPureConfig:
"""BGMPureConfig 数据类测试."""
def test_default_values(self):
"""默认值正确."""
config = BGMPureConfig()
assert config.volume == 0.3
assert config.fade_in == 0.0
assert config.fade_out == 0.0
assert config.loop_enabled is True
assert config.sidechain_enabled is False
assert config.sidechain_ratio == 0.3
assert config.sidechain_attack == 0.02
assert config.sidechain_release == 0.5
assert config.sidechain_threshold == -25.0
def test_custom_values(self):
"""自定义值."""
config = BGMPureConfig(
volume=0.7,
fade_in=1.0,
fade_out=2.0,
loop_enabled=False,
sidechain_enabled=True,
sidechain_ratio=0.5,
sidechain_attack=0.05,
sidechain_release=0.8,
sidechain_threshold=-30.0,
)
assert config.volume == 0.7
assert config.loop_enabled is False
assert config.sidechain_enabled is True
assert config.sidechain_threshold == -30.0