diff --git a/apps/worker/video_processing/bgm_mixer_pure.py b/apps/worker/video_processing/bgm_mixer_pure.py new file mode 100755 index 000000000..cd3872f48 --- /dev/null +++ b/apps/worker/video_processing/bgm_mixer_pure.py @@ -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 diff --git a/tests/unit/test_bgm_mixer_pure.py b/tests/unit/test_bgm_mixer_pure.py new file mode 100755 index 000000000..1b4d43ed1 --- /dev/null +++ b/tests/unit/test_bgm_mixer_pure.py @@ -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