test(wave133): BGM混音纯逻辑抽离 + 84单测 #1046

Closed
xiaoxia wants to merge 1 commits from test/wave133-bgm-mixer-pure into develop
2 changed files with 1009 additions and 0 deletions
+351
View File
@@ -0,0 +1,351 @@
"""BGM 混音纯逻辑模块.
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
便于单元测试,也方便被其他模块复用。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass
class BGMPureConfig:
"""BGM 混音配置(纯数据类)."""
volume: float = 0.3
fade_in: float = 0.0
fade_out: float = 0.0
loop_enabled: bool = True
sidechain_enabled: bool = False
sidechain_ratio: float = 0.3
sidechain_attack: float = 0.02
sidechain_release: float = 0.5
sidechain_threshold: float = -25.0
# ── 循环判断与计算 ─────────────────────────────────────────────────────────────
def should_loop_bgm(
bgm_duration: float,
target_duration: float,
loop_enabled: bool = True,
) -> bool:
"""判断是否需要循环 BGM.
当 BGM 时长小于目标时长的 90% 时才循环,
避免 BGM 只差一点点就铺满还要循环一次的情况。
Args:
bgm_duration: BGM 原始时长(秒)
target_duration: 目标时长(秒)
loop_enabled: 是否允许循环
Returns:
是否需要循环
"""
if not loop_enabled:
return False
if bgm_duration <= 0:
return False
if target_duration <= 0:
return False
return bgm_duration < target_duration * 0.9
def calculate_loop_count(bgm_duration: float, target_duration: float) -> int:
"""计算需要循环的次数.
多算 2 次作为余量,避免末尾因为精度问题不够长。
Args:
bgm_duration: BGM 原始时长(秒)
target_duration: 目标时长(秒)
Returns:
循环次数,至少 1
"""
if bgm_duration <= 0:
return 1
if target_duration <= 0:
return 1
if bgm_duration >= target_duration:
return 1
return max(1, int(target_duration / bgm_duration) + 2)
# ── BGM 预处理滤镜链构建 ──────────────────────────────────────────────────────
def build_bgm_filter_chain(
bgm_duration: float,
target_duration: float,
volume: float = 0.3,
fade_in: float = 0.0,
fade_out: float = 0.0,
loop_enabled: bool = True,
) -> str:
"""构建 BGM 预处理滤镜链.
处理顺序:循环 → 音量 → 淡入 → 淡出 → 截断 → 重置时间戳
Args:
bgm_duration: BGM 原始时长(秒)
target_duration: 目标时长(秒)
volume: 音量 0.0~1.0
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
loop_enabled: 是否允许循环
Returns:
FFmpeg filter_complex 字符串(逗号分隔)
"""
# 兜底:目标时长不能为 0 或负数
safe_target = max(5.0, target_duration) if target_duration <= 0 else target_duration
filter_parts: list[str] = []
# 1. 循环
needs_loop = should_loop_bgm(bgm_duration, safe_target, loop_enabled)
if needs_loop:
loop_count = calculate_loop_count(bgm_duration, safe_target)
filter_parts.append(f"aloop=loop={loop_count}:size=0")
# 2. 音量调节(钳制到 0~1
safe_volume = max(0.0, min(1.0, volume))
if abs(safe_volume - 1.0) > 0.001:
filter_parts.append(f"volume={safe_volume:.3f}")
# 3. 淡入
if fade_in > 0:
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
# 4. 淡出(从 target_duration - fade_out 开始)
if fade_out > 0 and safe_target > fade_out:
fade_start = safe_target - fade_out
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
# 5. 截断到目标时长
filter_parts.append(f"atrim=0:{safe_target:.3f}")
# 6. 重置时间戳
filter_parts.append("asetpts=N/SR/TB")
return ",".join(filter_parts)
# ── 混音滤镜构建 ──────────────────────────────────────────────────────────────
def calculate_sidechain_ratio(sidechain_ratio: float) -> float:
"""计算 sidechain 压缩比.
sidechain_ratio 表示闪避时 BGM 音量降低比例(0~1),
映射到 FFmpeg sidechaincompress 的 ratio 参数(2:1 ~ 10:1)。
ratio = 1 / (1 - sidechain_ratio)
Args:
sidechain_ratio: 闪避比例 0.0~1.0
Returns:
FFmpeg ratio 值(2.0 ~ 10.0
"""
if sidechain_ratio <= 0:
return 2.0
if sidechain_ratio >= 1.0:
return 10.0
raw_ratio = 1.0 / (1.0 - sidechain_ratio)
return max(2.0, min(10.0, raw_ratio))
def build_simple_mix_filter() -> str:
"""构建普通 amix 混音滤镜.
两路输入:[0:a] 主音频,[1:a] BGM
主音频权重 1.0,BGM 已在预处理阶段调好音量。
amix 会自动归一化,用 volume=2 补偿衰减。
Returns:
filter_complex 字符串
"""
return "[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=0[outa];" "[outa]volume=2[final]"
def build_sidechain_mix_filter(
threshold: float = -25.0,
ratio: float = 0.3,
attack: float = 0.02,
release: float = 0.5,
) -> str:
"""构建 sidechain 人声闪避混音滤镜.
流程:
1. BGM[1:a] 经过 sidechaincompress,用主音频[0:a]做触发
2. 主音频 + 压缩后的 BGM amix 混音
3. volume=1.5 轻微补偿
Args:
threshold: 触发阈值(dB
ratio: 闪避比例 0.0~1.0(会被转换为 FFmpeg ratio
attack: 攻击时间(秒)
release: 释放时间(秒)
Returns:
filter_complex 字符串
"""
ffmpeg_ratio = calculate_sidechain_ratio(ratio)
return (
f"[1:a][0:a]sidechaincompress="
f"threshold={threshold}dB:"
f"ratio={ffmpeg_ratio:.1f}:"
f"attack={attack:.3f}:"
f"release={release:.3f}:"
f"knee=6[bgm_comp];"
f"[0:a][bgm_comp]amix=inputs=2:duration=first:dropout_transition=0[outa];"
f"[outa]volume=1.5[final]"
)
# ── 配置验证与规范化 ──────────────────────────────────────────────────────────
def normalize_bgm_config(config: dict) -> dict:
"""规范化 BGM 配置字典.
将各种类型的输入值转换为正确的类型,
并进行边界钳制。
Args:
config: 原始配置字典
Returns:
规范化后的配置字典
"""
result: dict = {}
# volume: 0.0 ~ 1.0
result["volume"] = max(0.0, min(1.0, float(config.get("volume", 0.3))))
# fade_in: >= 0
result["fade_in"] = max(0.0, float(config.get("fade_in", 0.0)))
# fade_out: >= 0
result["fade_out"] = max(0.0, float(config.get("fade_out", 0.0)))
# loop_enabled: bool
result["loop_enabled"] = bool(config.get("loop_enabled", True))
# sidechain_enabled: bool
result["sidechain_enabled"] = bool(config.get("sidechain_enabled", False))
# sidechain_ratio: 0.0 ~ 1.0
result["sidechain_ratio"] = max(0.0, min(1.0, float(config.get("sidechain_ratio", 0.3))))
# sidechain_attack: > 0
result["sidechain_attack"] = max(0.001, float(config.get("sidechain_attack", 0.02)))
# sidechain_release: > 0
result["sidechain_release"] = max(0.01, float(config.get("sidechain_release", 0.5)))
# sidechain_threshold: dB
result["sidechain_threshold"] = float(config.get("sidechain_threshold", -25.0))
return result
def validate_bgm_config(config: dict) -> tuple[bool, list[str]]:
"""验证 BGM 配置是否合法.
Args:
config: 配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
volume = config.get("volume", 0.3)
if not isinstance(volume, (int, float)):
errors.append("volume 必须是数字")
elif volume < 0 or volume > 1:
errors.append("volume 必须在 0~1 之间")
fade_in = config.get("fade_in", 0)
if not isinstance(fade_in, (int, float)):
errors.append("fade_in 必须是数字")
elif fade_in < 0:
errors.append("fade_in 不能为负数")
fade_out = config.get("fade_out", 0)
if not isinstance(fade_out, (int, float)):
errors.append("fade_out 必须是数字")
elif fade_out < 0:
errors.append("fade_out 不能为负数")
sidechain_ratio = config.get("sidechain_ratio", 0.3)
if not isinstance(sidechain_ratio, (int, float)):
errors.append("sidechain_ratio 必须是数字")
elif sidechain_ratio < 0 or sidechain_ratio > 1:
errors.append("sidechain_ratio 必须在 0~1 之间")
return (len(errors) == 0, errors)
# ── 时长相关工具 ──────────────────────────────────────────────────────────────
def calculate_fade_out_start(
target_duration: float,
fade_out: float,
) -> Optional[float]:
"""计算淡出开始时间.
如果淡出时长大于等于目标时长,返回 None(不做淡出)。
Args:
target_duration: 目标时长(秒)
fade_out: 淡出时长(秒)
Returns:
淡出开始时间(秒),如果不需要淡出返回 None
"""
if fade_out <= 0:
return None
if target_duration <= 0:
return None
if fade_out >= target_duration:
return None
return target_duration - fade_out
def estimate_bgm_processing_duration(
bgm_duration: float,
target_duration: float,
loop_enabled: bool = True,
) -> float:
"""估算 BGM 预处理后的实际输出时长.
正常情况下应该等于 target_duration
但在某些边界情况下可能不同。
Args:
bgm_duration: BGM 原始时长
target_duration: 目标时长
loop_enabled: 是否允许循环
Returns:
预估输出时长(秒)
"""
if target_duration <= 0:
return 5.0 # 兜底时长
# 不需要循环的情况:如果 BGM 够长,截断到 target_duration
if not loop_enabled and bgm_duration >= target_duration:
return target_duration
# 需要循环或 BGM 太短:截断到 target_duration
return target_duration
+658
View File
@@ -0,0 +1,658 @@
"""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