test(wave136): 多轨混音纯逻辑抽离 + 71单测 #1050

Closed
xiaoxia wants to merge 1 commits from test/wave136-multi-track-mixer-pure into develop
2 changed files with 1104 additions and 0 deletions
+466
View File
@@ -0,0 +1,466 @@
"""多轨混音纯逻辑模块.
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
便于单元测试,也方便被其他模块复用。
"""
from __future__ import annotations
from typing import Any, Optional
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
def calculate_effective_range(
track_start: float,
track_duration: float,
audio_duration: float,
target_duration: float,
) -> tuple[float, float, float]:
"""计算轨道的有效时间范围.
处理:
- 轨道时长为 0 或负时用音频完整时长
- 轨道开始在目标时长外时跳过
- 轨道开始为负时截断开头
Args:
track_start: 轨道开始时间(秒),可为负
track_duration: 轨道持续时长(秒),<=0 表示用音频全长
audio_duration: 音频文件实际时长(秒)
target_duration: 目标总时长(秒)
Returns:
(effective_start, need_duration, trim_start)
- effective_start: 在目标时间轴上的开始位置(>=0)
- need_duration: 需要截取的音频长度
- trim_start: 从源音频的哪个位置开始截取
"""
if audio_duration <= 0:
return (0.0, 0.0, 0.0)
# 有效时长(轨道声明的时长,未被截断的)
if track_duration > 0:
effective_dur = min(track_duration, audio_duration)
else:
effective_dur = audio_duration
effective_start = track_start
trim_start = 0.0
# 负的开始时间:从源音频中间开始取,轨道前段被截掉
if effective_start < 0:
trim_start = -effective_start
# 可用时长 = 总时长 - 被截掉的前段
effective_dur = max(0.0, effective_dur - trim_start)
effective_start = 0.0
# 轨道完全在目标时长之外
if effective_start >= target_duration:
return (0.0, 0.0, 0.0)
# 轨道完全在 0 之前
if effective_start + effective_dur <= 0:
return (0.0, 0.0, 0.0)
# 实际需要的源时长
need_dur = min(effective_dur, target_duration - effective_start)
if need_dur <= 0:
return (0.0, 0.0, 0.0)
# 调整 trim_start 不能超过音频长度
if trim_start >= audio_duration:
return (0.0, 0.0, 0.0)
return (effective_start, need_dur, trim_start)
def is_track_visible(
track_start: float,
track_duration: float,
audio_duration: float,
target_duration: float,
) -> bool:
"""判断轨道是否在目标时长范围内可见(有声音).
Args:
track_start: 轨道开始时间
track_duration: 轨道持续时长
audio_duration: 音频时长
target_duration: 目标总时长
Returns:
是否可见
"""
_, need_dur, _ = calculate_effective_range(track_start, track_duration, audio_duration, target_duration)
return need_dur > 0
# ── 单轨滤镜链构建 ────────────────────────────────────────────────────────────
def build_track_filter_chain(
volume: float,
fade_in: float,
fade_out: float,
effective_start: float,
need_duration: float,
trim_start: float,
target_duration: float,
) -> str:
"""构建单轨道预处理滤镜链.
处理顺序:截断 → 重置时间戳 → 音量 → 淡入 → 淡出 → 延迟 → 最终截断 → 重置时间戳
Args:
volume: 音量 0.0~1.0
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
effective_start: 在目标轴上的开始时间
need_duration: 需要截取的时长
trim_start: 从源音频的哪个位置开始
target_duration: 目标总时长
Returns:
逗号分隔的滤镜字符串
"""
filter_parts: list[str] = []
# 1. 截断到有效范围
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB")
# 2. 音量调节
safe_volume = max(0.0, min(2.0, volume))
if abs(safe_volume - 1.0) > 0.001:
filter_parts.append(f"volume={safe_volume:.3f}")
# 3. 淡入(必须小于总时长才有效)
if fade_in > 0 and fade_in < need_duration:
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
# 4. 淡出
if fade_out > 0 and fade_out < need_duration:
fade_start = need_duration - fade_out
if fade_start > 0:
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
# 5. 时间偏移(开头静音填充)
if effective_start > 0.01:
delay_ms = int(effective_start * 1000)
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
# 6. 最终截断到目标总时长
filter_parts.append(f"atrim=0:{target_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB")
return ",".join(filter_parts)
# ── amix 混音滤镜构建 ────────────────────────────────────────────────────────
def build_amix_filter(num_inputs: int, duration_mode: str = "longest") -> str:
"""构建 amix 混音滤镜.
Args:
num_inputs: 输入轨道数量
duration_mode: 时长模式:longest / shortest / first
Returns:
amix 滤镜字符串
"""
if num_inputs <= 0:
return ""
# 校验 duration_mode
if duration_mode not in ("longest", "shortest", "first"):
duration_mode = "longest"
return f"amix=inputs={num_inputs}:duration={duration_mode}:dropout_transition=0"
def calculate_amix_volume_compensation(num_inputs: int) -> float:
"""计算 amix 后的音量补偿系数.
amix 会将 N 路输入每路乘以 1/N 来归一化,
所以需要乘以 N 来补偿(简单粗暴但有效)。
Args:
num_inputs: 输入轨道数量
Returns:
补偿系数
"""
if num_inputs <= 1:
return 1.0
return float(num_inputs)
def build_mix_filter_complex(
num_tracks: int,
has_main: bool = True,
duration_mode: str = "longest",
) -> str:
"""构建完整的混音 filter_complex.
Args:
num_tracks: 额外轨道数量
has_main: 是否有主音频
duration_mode: 时长模式
Returns:
filter_complex 字符串
"""
total_inputs = num_tracks + (1 if has_main else 0)
if total_inputs <= 0:
return ""
# 输入标签
input_labels = "".join(f"[{i}:a]" for i in range(total_inputs))
# amix
amix = build_amix_filter(total_inputs, duration_mode)
# 音量补偿
compensation = calculate_amix_volume_compensation(total_inputs)
volume_filter = ""
if abs(compensation - 1.0) > 0.001:
volume_filter = f",volume={compensation}"
return f"{input_labels}{amix}{volume_filter}[mixed]"
# ── 音量计算 ──────────────────────────────────────────────────────────────────
def normalize_volume(volume: float) -> float:
"""规范化音量值.
Args:
volume: 原始音量
Returns:
规范化后的音量(0.0 ~ 2.0)
"""
if volume is None:
return 1.0
try:
v = float(volume)
return max(0.0, min(2.0, v))
except (ValueError, TypeError):
return 1.0
def db_to_linear(db: float) -> float:
"""dB 转换为线性音量.
Args:
db: 分贝值
Returns:
线性音量值
"""
import math
return 10 ** (db / 20.0)
def linear_to_db(linear: float) -> float:
"""线性音量转换为 dB.
Args:
linear: 线性音量值
Returns:
分贝值
"""
import math
if linear <= 0:
return -float("inf")
return 20.0 * math.log10(linear)
# ── 轨道排序与过滤 ────────────────────────────────────────────────────────────
def sort_tracks_by_priority(
tracks: list[dict],
) -> list[dict]:
"""按优先级排序轨道.
priority 数字越小优先级越高(越先播放/越底层)。
相同优先级保持原顺序。
Args:
tracks: 轨道配置列表
Returns:
排序后的轨道列表
"""
return sorted(tracks, key=lambda t: int(t.get("priority", 100)))
def filter_enabled_tracks(tracks: list[dict]) -> list[dict]:
"""过滤出启用的轨道.
Args:
tracks: 轨道列表
Returns:
启用的轨道列表
"""
result = []
for t in tracks:
enabled = t.get("enabled", True)
if bool(enabled) and enabled != "false" and enabled != 0:
result.append(t)
return result
def count_track_types(tracks: list[dict]) -> dict[str, int]:
"""统计各类型轨道数量.
Args:
tracks: 轨道列表
Returns:
类型计数字典
"""
counts: dict[str, int] = {}
for t in tracks:
ttype = t.get("track_type", "unknown")
counts[ttype] = counts.get(ttype, 0) + 1
return counts
# ── 配置验证 ──────────────────────────────────────────────────────────────────
def validate_audio_track(track: dict) -> tuple[bool, list[str]]:
"""验证单条音轨配置.
Args:
track: 轨道配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
# 音频路径
audio_path = track.get("audio_path", "")
if not audio_path and not track.get("asset_id"):
errors.append("轨道需要 audio_path 或 asset_id")
# 音量范围
volume = track.get("volume", 1.0)
try:
v = float(volume)
if v < 0:
errors.append("volume 不能为负数")
if v > 2.0:
errors.append("volume 建议不超过 2.0")
except (ValueError, TypeError):
errors.append("volume 必须是数字")
# 淡入淡出
fade_in = track.get("fade_in", 0)
fade_out = track.get("fade_out", 0)
try:
if float(fade_in) < 0:
errors.append("fade_in 不能为负数")
except (ValueError, TypeError):
errors.append("fade_in 必须是数字")
try:
if float(fade_out) < 0:
errors.append("fade_out 不能为负数")
except (ValueError, TypeError):
errors.append("fade_out 必须是数字")
# 开始时间
start_time = track.get("start_time", 0)
try:
float(start_time) # 验证是否为数字
except (ValueError, TypeError):
errors.append("start_time 必须是数字")
return (len(errors) == 0, errors)
def validate_mix_config(config: dict) -> tuple[bool, list[str]]:
"""验证混音配置.
Args:
config: 混音配置
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
tracks = config.get("tracks", [])
if not tracks:
errors.append("至少需要一条轨道")
# 验证每条轨道
for i, track in enumerate(tracks):
ok, track_errors = validate_audio_track(track)
if not ok:
for err in track_errors:
errors.append(f"{i+1}轨:{err}")
# 目标时长
target_duration = config.get("target_duration", 0)
try:
if float(target_duration) < 0:
errors.append("target_duration 不能为负数")
except (ValueError, TypeError):
errors.append("target_duration 必须是数字")
return (len(errors) == 0, errors)
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def calculate_total_tracks(config: dict) -> int:
"""计算总轨道数(含主音频).
Args:
config: 混音配置
Returns:
总轨道数
"""
tracks = config.get("tracks", [])
has_main = config.get("has_main_audio", True)
count = len(tracks)
if has_main:
count += 1
return count
def estimate_mix_duration(tracks: list[dict]) -> float:
"""估算混音总时长(所有轨道的最晚结束时间).
Args:
tracks: 轨道列表,包含 start_time 和 duration
Returns:
估算总时长(秒)
"""
max_end = 0.0
for t in tracks:
try:
start = float(t.get("start_time", 0))
dur = float(t.get("duration", 0))
if dur > 0:
end = start + dur
if end > max_end:
max_end = end
except (ValueError, TypeError):
continue
return max_end
+638
View File
@@ -0,0 +1,638 @@
"""多轨混音纯逻辑单元测试."""
from __future__ import annotations
import math
import pytest
from video_processing.multi_track_mixer_pure import (
build_amix_filter,
build_mix_filter_complex,
build_track_filter_chain,
calculate_amix_volume_compensation,
calculate_effective_range,
calculate_total_tracks,
count_track_types,
db_to_linear,
estimate_mix_duration,
filter_enabled_tracks,
is_track_visible,
linear_to_db,
normalize_volume,
sort_tracks_by_priority,
validate_audio_track,
validate_mix_config,
)
# ─────────────────────────────────────────────────────────────────────────────
# 时间计算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateEffectiveRange:
"""有效时间范围计算测试."""
def test_normal_track(self):
"""正常轨道."""
start, dur, trim = calculate_effective_range(5, 10, 30, 60)
assert start == 5.0
assert dur == 10.0
assert trim == 0.0
def test_track_longer_than_audio(self):
"""轨道时长超过音频长度."""
start, dur, trim = calculate_effective_range(0, 100, 30, 60)
assert start == 0.0
assert dur == 30.0 # 用音频全长
def test_zero_track_duration(self):
"""轨道时长为 0(用音频全长)."""
start, dur, trim = calculate_effective_range(0, 0, 30, 60)
assert start == 0.0
assert dur == 30.0
def test_negative_start_time(self):
"""负开始时间(从音频中间取)."""
start, dur, trim = calculate_effective_range(-5, 20, 30, 60)
assert start == 0.0
assert dur == 15.0 # 20 - 5 = 15
assert trim == 5.0
def test_track_after_target(self):
"""轨道完全在目标之后."""
start, dur, trim = calculate_effective_range(100, 10, 30, 60)
assert dur == 0.0
def test_track_before_zero(self):
"""轨道完全在 0 之前."""
start, dur, trim = calculate_effective_range(-50, 10, 30, 60)
assert dur == 0.0
def test_zero_audio_duration(self):
"""音频时长为 0."""
start, dur, trim = calculate_effective_range(0, 10, 0, 60)
assert dur == 0.0
def test_track_extends_beyond_target(self):
"""轨道超出目标时长."""
start, dur, trim = calculate_effective_range(50, 20, 30, 60)
assert start == 50.0
assert dur == 10.0 # 60 - 50 = 10
def test_full_target_duration(self):
"""轨道覆盖整个目标时长."""
start, dur, trim = calculate_effective_range(0, 0, 100, 60)
assert start == 0.0
assert dur == 60.0
class TestIsTrackVisible:
"""轨道可见性测试."""
def test_visible_track(self):
"""可见轨道."""
assert is_track_visible(5, 10, 30, 60) is True
def test_invisible_after_target(self):
"""目标之后不可见."""
assert is_track_visible(100, 10, 30, 60) is False
def test_invisible_zero_duration(self):
"""零时长不可见."""
assert is_track_visible(0, 0, 0, 60) is False
# ─────────────────────────────────────────────────────────────────────────────
# 滤镜链构建测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildTrackFilterChain:
"""单轨滤镜链构建测试."""
def test_basic_structure(self):
"""基本结构:截断+音量+淡入淡出+延迟+截断."""
result = build_track_filter_chain(
volume=0.5,
fade_in=1.0,
fade_out=1.0,
effective_start=5.0,
need_duration=10.0,
trim_start=0.0,
target_duration=60.0,
)
assert "atrim=0.000:10.000" in result
assert "volume=0.500" in result
assert "afade=t=in:st=0:d=1.000" in result
assert "afade=t=out" in result
assert "adelay=5000|5000" in result
assert "atrim=0:60.000" in result
def test_volume_1_0_skipped(self):
"""音量为 1.0 不添加 volume 滤镜."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=0,
effective_start=0,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "volume=" not in result
def test_no_fade_in(self):
"""无淡入."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=2.0,
effective_start=0,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "afade=t=in" not in result
assert "afade=t=out" in result
def test_no_delay(self):
"""无延迟(effective_start 很小)."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=0,
effective_start=0.001,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "adelay" not in result
def test_with_delay(self):
"""有延迟."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=0,
effective_start=2.5,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "adelay=2500|2500" in result
def test_fade_in_longer_than_duration(self):
"""淡入超过总时长,不添加淡入."""
result = build_track_filter_chain(
volume=1.0,
fade_in=20,
fade_out=0,
effective_start=0,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "afade=t=in" not in result
def test_fade_out_at_start(self):
"""淡出从 0 开始(很短的音频)."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=15,
effective_start=0,
need_duration=10,
trim_start=0,
target_duration=60,
)
# fade_out > need_duration,不添加
assert "afade=t=out" not in result
def test_trim_start_nonzero(self):
"""从音频中间开始截取."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=0,
effective_start=0,
need_duration=5,
trim_start=3.0,
target_duration=60,
)
assert "atrim=3.000:8.000" in result # 3.0 to 3.0+5.0
# ─────────────────────────────────────────────────────────────────────────────
# amix 滤镜测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildAmixFilter:
"""amix 滤镜构建测试."""
def test_two_inputs(self):
"""两路输入."""
result = build_amix_filter(2)
assert "amix=inputs=2" in result
assert "duration=longest" in result
def test_five_inputs(self):
"""五路输入."""
result = build_amix_filter(5)
assert "amix=inputs=5" in result
def test_zero_inputs(self):
"""零输入."""
assert build_amix_filter(0) == ""
def test_duration_shortest(self):
"""shortest 模式."""
result = build_amix_filter(3, "shortest")
assert "duration=shortest" in result
def test_invalid_duration_mode(self):
"""无效模式,默认 longest."""
result = build_amix_filter(3, "invalid")
assert "duration=longest" in result
class TestCalculateAmixVolumeCompensation:
"""音量补偿计算测试."""
def test_single_track(self):
"""单轨,无需补偿."""
assert calculate_amix_volume_compensation(1) == 1.0
def test_two_tracks(self):
"""两轨,补偿 2x."""
assert calculate_amix_volume_compensation(2) == 2.0
def test_five_tracks(self):
"""五轨,补偿 5x."""
assert calculate_amix_volume_compensation(5) == 5.0
def test_zero_tracks(self):
"""零轨,返回 1."""
assert calculate_amix_volume_compensation(0) == 1.0
class TestBuildMixFilterComplex:
"""完整混音滤镜测试."""
def test_with_main_and_two_tracks(self):
"""主音频 + 2 条轨道."""
result = build_mix_filter_complex(2, has_main=True)
assert "[0:a][1:a][2:a]" in result # 3 路输入
assert "amix=inputs=3" in result
assert "volume=3" in result # 3x 补偿
assert "[mixed]" in result
def test_no_main_three_tracks(self):
"""无主音频,3 条轨道."""
result = build_mix_filter_complex(3, has_main=False)
assert "[0:a][1:a][2:a]" in result
assert "amix=inputs=3" in result
assert "[mixed]" in result
def test_zero_tracks_no_main(self):
"""无轨道无主音频."""
assert build_mix_filter_complex(0, has_main=False) == ""
# ─────────────────────────────────────────────────────────────────────────────
# 音量计算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestNormalizeVolume:
"""音量规范化测试."""
def test_normal_volume(self):
"""正常音量."""
assert normalize_volume(0.5) == 0.5
def test_none_default(self):
"""None 默认 1.0."""
assert normalize_volume(None) == 1.0
def test_below_zero_clamped(self):
"""负值钳制到 0."""
assert normalize_volume(-5) == 0.0
def test_above_max_clamped(self):
"""超过上限钳制."""
assert normalize_volume(3.0) == 2.0
def test_string_input(self):
"""字符串输入."""
assert normalize_volume("0.5") == 0.5
def test_invalid_string(self):
"""无效字符串默认 1.0."""
assert normalize_volume("abc") == 1.0
class TestDbConversion:
"""dB 转换测试."""
def test_0_db_is_unity(self):
"""0 dB = 1.0."""
assert db_to_linear(0) == pytest.approx(1.0)
def test_negative_db(self):
"""负 dB < 1."""
assert db_to_linear(-6) == pytest.approx(0.5, rel=0.01)
def test_positive_db(self):
"""正 dB > 1."""
assert db_to_linear(6) == pytest.approx(2.0, rel=0.01)
def test_round_trip(self):
"""往返转换."""
original = 0.5
db = linear_to_db(original)
result = db_to_linear(db)
assert result == pytest.approx(original)
def test_zero_linear_is_negative_inf(self):
"""零线性值 = -inf dB."""
assert math.isinf(linear_to_db(0))
assert linear_to_db(0) < 0
# ─────────────────────────────────────────────────────────────────────────────
# 轨道排序与过滤测试
# ─────────────────────────────────────────────────────────────────────────────
class TestSortTracksByPriority:
"""轨道优先级排序测试."""
def test_sorted_by_priority(self):
"""按优先级排序."""
tracks = [
{"priority": 10, "name": "high"},
{"priority": 1, "name": "highest"},
{"priority": 100, "name": "low"},
]
result = sort_tracks_by_priority(tracks)
assert result[0]["name"] == "highest"
assert result[1]["name"] == "high"
assert result[2]["name"] == "low"
def test_default_priority_100(self):
"""默认优先级 100."""
tracks = [
{"priority": 50, "name": "mid"},
{"name": "default"},
]
result = sort_tracks_by_priority(tracks)
assert result[0]["name"] == "mid"
assert result[1]["name"] == "default"
def test_same_preserves_order(self):
"""同优先级保持顺序."""
tracks = [
{"priority": 10, "name": "first"},
{"priority": 10, "name": "second"},
]
result = sort_tracks_by_priority(tracks)
assert result[0]["name"] == "first"
assert result[1]["name"] == "second"
def test_empty_list(self):
"""空列表."""
assert sort_tracks_by_priority([]) == []
class TestFilterEnabledTracks:
"""启用轨道过滤测试."""
def test_all_enabled(self):
"""全部启用."""
tracks = [{"enabled": True}, {"enabled": True}]
assert len(filter_enabled_tracks(tracks)) == 2
def test_mixed(self):
"""混合."""
tracks = [
{"enabled": True, "name": "a"},
{"enabled": False, "name": "b"},
]
result = filter_enabled_tracks(tracks)
assert len(result) == 1
assert result[0]["name"] == "a"
def test_default_enabled(self):
"""默认启用."""
tracks = [{"name": "a"}]
assert len(filter_enabled_tracks(tracks)) == 1
def test_empty_list(self):
"""空列表."""
assert filter_enabled_tracks([]) == []
class TestCountTrackTypes:
"""轨道类型统计测试."""
def test_mixed_types(self):
"""混合类型."""
tracks = [
{"track_type": "bgm"},
{"track_type": "voiceover"},
{"track_type": "bgm"},
{"track_type": "sfx"},
]
counts = count_track_types(tracks)
assert counts["bgm"] == 2
assert counts["voiceover"] == 1
assert counts["sfx"] == 1
def test_default_type(self):
"""默认类型."""
tracks = [{}]
counts = count_track_types(tracks)
assert counts["unknown"] == 1
def test_empty_list(self):
"""空列表."""
assert count_track_types([]) == {}
# ─────────────────────────────────────────────────────────────────────────────
# 配置验证测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateAudioTrack:
"""单轨验证测试."""
def test_valid_track(self):
"""合法轨道."""
ok, errors = validate_audio_track(
{
"audio_path": "/audio.mp3",
"volume": 0.8,
"fade_in": 1.0,
"fade_out": 2.0,
}
)
assert ok is True
assert len(errors) == 0
def test_missing_path(self):
"""缺路径."""
ok, errors = validate_audio_track({})
assert ok is False
assert any("audio_path" in e or "asset_id" in e for e in errors)
def test_negative_volume(self):
"""负音量."""
ok, errors = validate_audio_track(
{
"audio_path": "/a.mp3",
"volume": -1,
}
)
assert ok is False
assert any("volume" in e for e in errors)
def test_negative_fade_in(self):
"""负淡入."""
ok, errors = validate_audio_track(
{
"audio_path": "/a.mp3",
"fade_in": -1,
}
)
assert ok is False
assert any("fade_in" in e for e in errors)
def test_negative_fade_out(self):
"""负淡出."""
ok, errors = validate_audio_track(
{
"audio_path": "/a.mp3",
"fade_out": -1,
}
)
assert ok is False
assert any("fade_out" in e for e in errors)
def test_invalid_volume_type(self):
"""无效音量类型."""
ok, errors = validate_audio_track(
{
"audio_path": "/a.mp3",
"volume": "loud",
}
)
assert ok is False
assert any("volume" in e for e in errors)
def test_with_asset_id(self):
"""有 asset_id 无 audio_path 也合法."""
ok, errors = validate_audio_track({"asset_id": "123"})
assert ok is True
class TestValidateMixConfig:
"""混音配置验证测试."""
def test_valid_config(self):
"""合法配置."""
ok, errors = validate_mix_config(
{
"tracks": [
{"audio_path": "/a.mp3", "volume": 0.5},
{"audio_path": "/b.mp3", "volume": 0.8},
],
"target_duration": 60,
}
)
assert ok is True
def test_empty_tracks(self):
"""空轨道列表."""
ok, errors = validate_mix_config({"tracks": []})
assert ok is False
assert any("至少需要" in e for e in errors)
def test_invalid_track(self):
"""无效轨道."""
ok, errors = validate_mix_config(
{
"tracks": [
{"audio_path": "/a.mp3"},
{}, # 无效
],
}
)
assert ok is False
assert len(errors) >= 1
def test_negative_target_duration(self):
"""负目标时长."""
ok, errors = validate_mix_config(
{
"tracks": [{"audio_path": "/a.mp3"}],
"target_duration": -10,
}
)
assert ok is False
assert any("target_duration" in e for e in errors)
# ─────────────────────────────────────────────────────────────────────────────
# 工具函数测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateTotalTracks:
"""总轨道数计算测试."""
def test_with_main(self):
"""含主音频."""
assert calculate_total_tracks({"tracks": [1, 2, 3]}) == 4
def test_without_main(self):
"""不含主音频."""
assert (
calculate_total_tracks(
{
"tracks": [1, 2],
"has_main_audio": False,
}
)
== 2
)
def test_empty_tracks_with_main(self):
"""无轨道,只有主音频."""
assert calculate_total_tracks({"tracks": []}) == 1
class TestEstimateMixDuration:
"""混音时长估算测试."""
def test_multiple_tracks(self):
"""多轨道取最长结束时间."""
tracks = [
{"start_time": 0, "duration": 10},
{"start_time": 5, "duration": 20}, # 结束 25
{"start_time": 2, "duration": 5},
]
assert estimate_mix_duration(tracks) == pytest.approx(25.0)
def test_empty_list(self):
"""空列表."""
assert estimate_mix_duration([]) == 0.0
def test_zero_duration_tracks_ignored(self):
"""零时长轨道忽略."""
tracks = [
{"start_time": 0, "duration": 0},
{"start_time": 5, "duration": 10},
]
assert estimate_mix_duration(tracks) == pytest.approx(15.0)