Compare commits

...

1 Commits

Author SHA1 Message Date
CI Bot cc4bde1c84 refactor(wave111): 抽离audio_track_config领域模型 + 67单测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 22s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 39s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 40s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 51s
AI Code Review / AI Code Review (pull_request) Successful in 1m37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m35s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m5s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 26s
- 新建 packages/domain/audio_track_config.py:AudioTrack/MultiTrackMixConfig
  数据类 + from_dict/from_config_dict + validate/is_effective/has_effect
  + clamp_volume/is_valid_audio_extension 工具函数 + 常量
- multi_track_mixer.py: 478→359行 (-119行, -25%),复用audio_track_config
  保留模块级导出,完全向后兼容
- 新增 67 个单测覆盖数据模型 + 纯逻辑函数
2026-07-27 00:55:13 +08:00
3 changed files with 714 additions and 131 deletions
+12 -131
View File
@@ -16,10 +16,21 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from packages.domain.audio_track_config import ( # noqa: F401 — 向后兼容
ALLOWED_AUDIO_EXTENSIONS,
DEFAULT_VOLUMES,
MAX_AUDIO_TRACKS,
AudioTrack,
MultiTrackMixConfig,
TRACK_TYPE_AMBIENT,
TRACK_TYPE_BGM,
TRACK_TYPE_MAIN,
TRACK_TYPE_SFX,
TRACK_TYPE_VOICEOVER,
)
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
@@ -29,139 +40,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
TRACK_TYPE_BGM = "bgm" # 背景音乐
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
TRACK_TYPE_SFX = "sfx" # 音效
TRACK_TYPE_AMBIENT = "ambient" # 环境音
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
# 各轨道默认音量(相对主音频)
DEFAULT_VOLUMES = {
TRACK_TYPE_MAIN: 1.0,
TRACK_TYPE_BGM: 0.3,
TRACK_TYPE_VOICEOVER: 1.0,
TRACK_TYPE_SFX: 0.7,
TRACK_TYPE_AMBIENT: 0.2,
}
@dataclass
class AudioTrack:
"""单条音频轨道配置."""
track_id: str # 轨道唯一标识
track_type: str # 轨道类型(main/bgm/voiceover/sfx/ambient
audio_path: str # 音频文件路径
volume: float = 1.0 # 音量 0.0 ~ 2.0
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
duration: float = 0.0 # 持续时长(0表示到文件末尾)
enabled: bool = True # 是否启用
@classmethod
def from_dict(cls, track: dict) -> "AudioTrack":
"""从字典创建 AudioTrack,带安全类型转换."""
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
try:
volume = float(track.get("volume", default_vol))
except (TypeError, ValueError):
volume = default_vol
volume = max(0.0, min(2.0, volume))
try:
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
except (TypeError, ValueError):
fade_in = 0.0
try:
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
except (TypeError, ValueError):
fade_out = 0.0
try:
start_time = max(0.0, float(track.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(track.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
track_id=str(track.get("track_id", "")),
track_type=track_type,
audio_path=str(track.get("audio_path", "")),
volume=volume,
fade_in=fade_in,
fade_out=fade_out,
start_time=start_time,
duration=duration,
enabled=bool(track.get("enabled", True)),
)
@dataclass
class MultiTrackMixConfig:
"""多轨道混音配置."""
tracks: list[AudioTrack] = field(default_factory=list)
master_volume: float = 1.0 # 主输出音量
normalize: bool = True # 是否自动归一化补偿
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
@classmethod
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
"""从 plan.config.audio_tracks 字典创建配置."""
if not config or not isinstance(config, dict):
return cls()
tracks_raw = config.get("tracks", [])
tracks: list[AudioTrack] = []
if isinstance(tracks_raw, list):
for t in tracks_raw:
if isinstance(t, dict) and t.get("audio_path"):
try:
track = AudioTrack.from_dict(t)
if track.enabled and track.audio_path:
tracks.append(track)
except Exception:
logger.warning("[multi-track] skip invalid track config: %s", t)
continue
try:
master_volume = float(config.get("master_volume", 1.0))
master_volume = max(0.0, min(2.0, master_volume))
except (TypeError, ValueError):
master_volume = 1.0
return cls(
tracks=tracks,
master_volume=master_volume,
normalize=bool(config.get("normalize", True)),
max_output_volume=float(config.get("max_output_volume", 1.5)),
)
@property
def has_effect(self) -> bool:
"""是否有有效轨道需要混音."""
return len([t for t in self.tracks if t.enabled and t.audio_path]) > 0
# ── 路径安全校验 ────────────────────────────────────────────────────────────
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
"""校验音频文件路径安全性.
+214
View File
@@ -0,0 +1,214 @@
"""多轨道音频配置领域模型 — 纯逻辑,无外部依赖.
抽离自 multi_track_mixer.py 的数据类、常量和纯逻辑函数,
方便单测覆盖,同时保持向后兼容。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
TRACK_TYPE_BGM = "bgm" # 背景音乐
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
TRACK_TYPE_SFX = "sfx" # 音效
TRACK_TYPE_AMBIENT = "ambient" # 环境音
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
# 各轨道默认音量(相对主音频)
DEFAULT_VOLUMES = {
TRACK_TYPE_MAIN: 1.0,
TRACK_TYPE_BGM: 0.3,
TRACK_TYPE_VOICEOVER: 1.0,
TRACK_TYPE_SFX: 0.7,
TRACK_TYPE_AMBIENT: 0.2,
}
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
_VALID_TRACK_TYPES = {
TRACK_TYPE_MAIN,
TRACK_TYPE_BGM,
TRACK_TYPE_VOICEOVER,
TRACK_TYPE_SFX,
TRACK_TYPE_AMBIENT,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class AudioTrack:
"""单条音频轨道配置."""
track_id: str = "" # 轨道唯一标识
track_type: str = TRACK_TYPE_SFX # 轨道类型
audio_path: str = "" # 音频文件路径
volume: float = 1.0 # 音量 0.0 ~ 2.0
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
duration: float = 0.0 # 持续时长(0表示到文件末尾)
enabled: bool = True # 是否启用
@classmethod
def from_dict(cls, track: dict) -> "AudioTrack":
"""从字典创建 AudioTrack,带安全类型转换."""
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
try:
volume = float(track.get("volume", default_vol))
except (TypeError, ValueError):
volume = default_vol
volume = max(0.0, min(2.0, volume))
try:
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
except (TypeError, ValueError):
fade_in = 0.0
try:
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
except (TypeError, ValueError):
fade_out = 0.0
try:
start_time = max(0.0, float(track.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(track.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
track_id=str(track.get("track_id", "")),
track_type=track_type,
audio_path=str(track.get("audio_path", "")),
volume=volume,
fade_in=fade_in,
fade_out=fade_out,
start_time=start_time,
duration=duration,
enabled=bool(track.get("enabled", True)),
)
def validate(self) -> tuple[bool, str]:
"""校验配置合法性,返回 (是否合法, 错误信息)."""
if not self.audio_path:
return False, "audio_path不能为空"
if self.volume < 0.0 or self.volume > 2.0:
return False, f"volume必须在0-2之间: {self.volume}"
if self.fade_in < 0:
return False, f"fade_in不能为负数: {self.fade_in}"
if self.fade_out < 0:
return False, f"fade_out不能为负数: {self.fade_out}"
if self.start_time < 0:
return False, f"start_time不能为负数: {self.start_time}"
if self.duration < 0:
return False, f"duration不能为负数: {self.duration}"
return True, ""
@property
def is_effective(self) -> bool:
"""是否为有效轨道(启用+有路径)."""
return self.enabled and bool(self.audio_path)
@dataclass
class MultiTrackMixConfig:
"""多轨道混音配置."""
tracks: list[AudioTrack] = field(default_factory=list)
master_volume: float = 1.0 # 主输出音量
normalize: bool = True # 是否自动归一化补偿
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
@classmethod
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
"""从 plan.config.audio_tracks 字典创建配置."""
if not config or not isinstance(config, dict):
return cls()
tracks_raw = config.get("tracks", [])
tracks: list[AudioTrack] = []
if isinstance(tracks_raw, list):
for t in tracks_raw:
if isinstance(t, dict) and t.get("audio_path"):
try:
track = AudioTrack.from_dict(t)
if track.enabled and track.audio_path:
tracks.append(track)
except Exception:
logger.warning("[multi-track] skip invalid track config: %s", t)
continue
try:
master_volume = float(config.get("master_volume", 1.0))
master_volume = max(0.0, min(2.0, master_volume))
except (TypeError, ValueError):
master_volume = 1.0
try:
max_output_volume = float(config.get("max_output_volume", 1.5))
except (TypeError, ValueError):
max_output_volume = 1.5
return cls(
tracks=tracks,
master_volume=master_volume,
normalize=bool(config.get("normalize", True)),
max_output_volume=max_output_volume,
)
@property
def has_effect(self) -> bool:
"""是否有有效轨道需要混音."""
return len([t for t in self.tracks if t.is_effective]) > 0
@property
def effective_track_count(self) -> int:
"""有效轨道数量."""
return len([t for t in self.tracks if t.is_effective])
@property
def main_tracks(self) -> list[AudioTrack]:
"""主音轨列表."""
return [t for t in self.tracks if t.track_type == TRACK_TYPE_MAIN and t.is_effective]
@property
def bgm_tracks(self) -> list[AudioTrack]:
"""BGM轨道列表."""
return [t for t in self.tracks if t.track_type == TRACK_TYPE_BGM and t.is_effective]
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
def is_valid_audio_extension(filename: str) -> bool:
"""检查文件扩展名是否为支持的音频格式."""
ext = Path(filename).suffix.lower()
return ext in ALLOWED_AUDIO_EXTENSIONS
def clamp_volume(volume: float, min_vol: float = 0.0, max_vol: float = 2.0) -> float:
"""限制音量在合法范围内."""
return max(min_vol, min(max_vol, volume))
+488
View File
@@ -0,0 +1,488 @@
"""audio_track_config 模块单测 — 纯逻辑,无外部依赖."""
from __future__ import annotations
import pytest
from packages.domain.audio_track_config import (
ALLOWED_AUDIO_EXTENSIONS,
DEFAULT_VOLUMES,
MAX_AUDIO_TRACKS,
AudioTrack,
MultiTrackMixConfig,
TRACK_TYPE_AMBIENT,
TRACK_TYPE_BGM,
TRACK_TYPE_MAIN,
TRACK_TYPE_SFX,
TRACK_TYPE_VOICEOVER,
clamp_volume,
is_valid_audio_extension,
)
# ── 常量 ──────────────────────────────────────────────────────────────────────
class TestConstants:
def test_track_type_constants(self):
assert TRACK_TYPE_MAIN == "main"
assert TRACK_TYPE_BGM == "bgm"
assert TRACK_TYPE_VOICEOVER == "voiceover"
assert TRACK_TYPE_SFX == "sfx"
assert TRACK_TYPE_AMBIENT == "ambient"
def test_default_volumes_keys(self):
assert set(DEFAULT_VOLUMES.keys()) == {
TRACK_TYPE_MAIN,
TRACK_TYPE_BGM,
TRACK_TYPE_VOICEOVER,
TRACK_TYPE_SFX,
TRACK_TYPE_AMBIENT,
}
def test_default_volumes_values(self):
assert DEFAULT_VOLUMES[TRACK_TYPE_MAIN] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_BGM] == 0.3
assert DEFAULT_VOLUMES[TRACK_TYPE_VOICEOVER] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_SFX] == 0.7
assert DEFAULT_VOLUMES[TRACK_TYPE_AMBIENT] == 0.2
def test_max_audio_tracks(self):
assert MAX_AUDIO_TRACKS == 8
def test_allowed_extensions(self):
assert ".mp3" in ALLOWED_AUDIO_EXTENSIONS
assert ".wav" in ALLOWED_AUDIO_EXTENSIONS
assert ".aac" in ALLOWED_AUDIO_EXTENSIONS
assert ".ogg" in ALLOWED_AUDIO_EXTENSIONS
assert ".flac" in ALLOWED_AUDIO_EXTENSIONS
assert ".m4a" in ALLOWED_AUDIO_EXTENSIONS
assert ".wma" in ALLOWED_AUDIO_EXTENSIONS
assert ".mp4" not in ALLOWED_AUDIO_EXTENSIONS
assert ".txt" not in ALLOWED_AUDIO_EXTENSIONS
# ── AudioTrack 默认值 ────────────────────────────────────────────────────────
class TestAudioTrackDefaults:
def test_default_values(self):
track = AudioTrack()
assert track.track_id == ""
assert track.track_type == TRACK_TYPE_SFX
assert track.audio_path == ""
assert track.volume == 1.0
assert track.fade_in == 0.0
assert track.fade_out == 0.0
assert track.start_time == 0.0
assert track.duration == 0.0
assert track.enabled is True
# ── AudioTrack.from_dict ─────────────────────────────────────────────────────
class TestAudioTrackFromDict:
def test_full_fields(self):
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/tmp/a.mp3",
"volume": 0.5,
"fade_in": 1.5,
"fade_out": 2.0,
"start_time": 3.0,
"duration": 10.0,
"enabled": True,
}
)
assert track.track_id == "t1"
assert track.track_type == "bgm"
assert track.audio_path == "/tmp/a.mp3"
assert track.volume == 0.5
assert track.fade_in == 1.5
assert track.fade_out == 2.0
assert track.start_time == 3.0
assert track.duration == 10.0
assert track.enabled is True
def test_empty_dict_defaults(self):
track = AudioTrack.from_dict({})
assert track.track_type == TRACK_TYPE_SFX
assert track.volume == DEFAULT_VOLUMES[TRACK_TYPE_SFX]
assert track.fade_in == 0.0
assert track.enabled is True
def test_volume_clamped_to_zero(self):
track = AudioTrack.from_dict({"volume": -0.5})
assert track.volume == 0.0
def test_volume_clamped_to_two(self):
track = AudioTrack.from_dict({"volume": 3.0})
assert track.volume == 2.0
def test_invalid_volume_fallback_to_default(self):
track = AudioTrack.from_dict({"track_type": "bgm", "volume": "invalid"})
assert track.volume == DEFAULT_VOLUMES[TRACK_TYPE_BGM]
def test_negative_fade_clamped_to_zero(self):
track = AudioTrack.from_dict({"fade_in": -1.0, "fade_out": -2.0})
assert track.fade_in == 0.0
assert track.fade_out == 0.0
def test_invalid_fade_fallback(self):
track = AudioTrack.from_dict({"fade_in": "bad", "fade_out": "bad"})
assert track.fade_in == 0.0
assert track.fade_out == 0.0
def test_negative_start_time_clamped(self):
track = AudioTrack.from_dict({"start_time": -5.0})
assert track.start_time == 0.0
def test_invalid_start_time_fallback(self):
track = AudioTrack.from_dict({"start_time": "bad"})
assert track.start_time == 0.0
def test_negative_duration_clamped(self):
track = AudioTrack.from_dict({"duration": -3.0})
assert track.duration == 0.0
def test_invalid_duration_fallback(self):
track = AudioTrack.from_dict({"duration": "bad"})
assert track.duration == 0.0
def test_enabled_false(self):
track = AudioTrack.from_dict({"enabled": False})
assert track.enabled is False
def test_bgm_default_volume(self):
track = AudioTrack.from_dict({"track_type": "bgm"})
assert track.volume == 0.3
def test_unknown_track_type_default_volume(self):
track = AudioTrack.from_dict({"track_type": "unknown_type"})
assert track.volume == 1.0
def test_string_numeric_values(self):
track = AudioTrack.from_dict(
{
"volume": "0.8",
"fade_in": "1.0",
"start_time": "2.5",
}
)
assert track.volume == 0.8
assert track.fade_in == 1.0
assert track.start_time == 2.5
# ── AudioTrack.validate ──────────────────────────────────────────────────────
class TestAudioTrackValidate:
def test_valid_track(self):
track = AudioTrack(audio_path="/tmp/a.mp3")
ok, err = track.validate()
assert ok is True
assert err == ""
def test_empty_audio_path_invalid(self):
track = AudioTrack(audio_path="")
ok, err = track.validate()
assert ok is False
assert "audio_path" in err
def test_volume_below_zero_invalid(self):
# from_dict 会 clamp,但直接构造可以测试
track = AudioTrack(audio_path="a.mp3")
object.__setattr__(track, "volume", -0.1)
ok, err = track.validate()
assert ok is False
assert "volume" in err
def test_volume_above_two_invalid(self):
track = AudioTrack(audio_path="a.mp3")
object.__setattr__(track, "volume", 2.1)
ok, err = track.validate()
assert ok is False
assert "volume" in err
def test_volume_boundary_zero_valid(self):
track = AudioTrack(audio_path="a.mp3")
object.__setattr__(track, "volume", 0.0)
ok, _ = track.validate()
assert ok is True
def test_volume_boundary_two_valid(self):
track = AudioTrack(audio_path="a.mp3")
object.__setattr__(track, "volume", 2.0)
ok, _ = track.validate()
assert ok is True
def test_negative_fade_in_invalid(self):
track = AudioTrack(audio_path="a.mp3")
object.__setattr__(track, "fade_in", -1.0)
ok, err = track.validate()
assert ok is False
assert "fade_in" in err
def test_negative_fade_out_invalid(self):
track = AudioTrack(audio_path="a.mp3")
object.__setattr__(track, "fade_out", -1.0)
ok, err = track.validate()
assert ok is False
assert "fade_out" in err
def test_negative_start_time_invalid(self):
track = AudioTrack(audio_path="a.mp3")
object.__setattr__(track, "start_time", -0.5)
ok, err = track.validate()
assert ok is False
assert "start_time" in err
def test_negative_duration_invalid(self):
track = AudioTrack(audio_path="a.mp3")
object.__setattr__(track, "duration", -1.0)
ok, err = track.validate()
assert ok is False
assert "duration" in err
# ── AudioTrack.is_effective ──────────────────────────────────────────────────
class TestAudioTrackIsEffective:
def test_enabled_with_path(self):
track = AudioTrack(audio_path="/tmp/a.mp3", enabled=True)
assert track.is_effective is True
def test_disabled_with_path(self):
track = AudioTrack(audio_path="/tmp/a.mp3", enabled=False)
assert track.is_effective is False
def test_enabled_empty_path(self):
track = AudioTrack(audio_path="", enabled=True)
assert track.is_effective is False
def test_disabled_empty_path(self):
track = AudioTrack(audio_path="", enabled=False)
assert track.is_effective is False
# ── MultiTrackMixConfig.from_config_dict ─────────────────────────────────────
class TestMultiTrackMixConfigFromDict:
def test_none_config_empty(self):
cfg = MultiTrackMixConfig.from_config_dict(None)
assert cfg.tracks == []
assert cfg.master_volume == 1.0
assert cfg.normalize is True
assert cfg.max_output_volume == 1.5
def test_empty_dict_defaults(self):
cfg = MultiTrackMixConfig.from_config_dict({})
assert cfg.tracks == []
assert cfg.master_volume == 1.0
assert cfg.normalize is True
def test_single_track(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [{"audio_path": "a.mp3", "track_type": "bgm", "volume": 0.5}],
}
)
assert len(cfg.tracks) == 1
assert cfg.tracks[0].audio_path == "a.mp3"
assert cfg.tracks[0].track_type == "bgm"
assert cfg.tracks[0].volume == 0.5
def test_multiple_tracks(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"audio_path": "main.wav", "track_type": "main"},
{"audio_path": "bgm.mp3", "track_type": "bgm"},
{"audio_path": "sfx.wav", "track_type": "sfx"},
],
}
)
assert len(cfg.tracks) == 3
types = [t.track_type for t in cfg.tracks]
assert "main" in types
assert "bgm" in types
assert "sfx" in types
def test_skip_disabled_track(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"audio_path": "a.mp3", "enabled": True},
{"audio_path": "b.mp3", "enabled": False},
],
}
)
assert len(cfg.tracks) == 1
assert cfg.tracks[0].audio_path == "a.mp3"
def test_skip_missing_audio_path(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"audio_path": "a.mp3"},
{"track_type": "bgm"}, # 无audio_path
],
}
)
assert len(cfg.tracks) == 1
def test_invalid_track_skipped(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"audio_path": "a.mp3"},
"not_a_dict",
{"audio_path": 123, "volume": "bad"}, # 类型不对
],
}
)
# 第二个不是dict跳过,第三个audio_path会被转成字符串"123"
# 但 track_type 非dict的话在 isinstance(t, dict) 判断就被跳过
assert len(cfg.tracks) >= 1
def test_master_volume_clamped(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"tracks": [],
"master_volume": 3.0,
}
)
assert cfg.master_volume == 2.0
def test_master_volume_negative_clamped(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"master_volume": -1.0,
}
)
assert cfg.master_volume == 0.0
def test_invalid_master_volume_fallback(self):
cfg = MultiTrackMixConfig.from_config_dict(
{
"master_volume": "invalid",
}
)
assert cfg.master_volume == 1.0
def test_normalize_false(self):
cfg = MultiTrackMixConfig.from_config_dict({"normalize": False})
assert cfg.normalize is False
def test_custom_max_output_volume(self):
cfg = MultiTrackMixConfig.from_config_dict({"max_output_volume": 2.0})
assert cfg.max_output_volume == 2.0
def test_invalid_max_output_volume_fallback(self):
cfg = MultiTrackMixConfig.from_config_dict({"max_output_volume": "bad"})
assert cfg.max_output_volume == 1.5
def test_not_dict_config(self):
cfg = MultiTrackMixConfig.from_config_dict("not a dict")
assert cfg.tracks == []
assert cfg.master_volume == 1.0
# ── MultiTrackMixConfig 属性 ─────────────────────────────────────────────────
class TestMultiTrackMixConfigProperties:
def test_has_effect_empty(self):
cfg = MultiTrackMixConfig()
assert cfg.has_effect is False
def test_has_effect_with_tracks(self):
cfg = MultiTrackMixConfig(
tracks=[
AudioTrack(audio_path="a.mp3", enabled=True),
]
)
assert cfg.has_effect is True
def test_has_effect_all_disabled(self):
cfg = MultiTrackMixConfig(
tracks=[
AudioTrack(audio_path="a.mp3", enabled=False),
]
)
assert cfg.has_effect is False
def test_effective_track_count(self):
cfg = MultiTrackMixConfig(
tracks=[
AudioTrack(audio_path="a.mp3", enabled=True),
AudioTrack(audio_path="b.mp3", enabled=False),
AudioTrack(audio_path="c.mp3", enabled=True),
AudioTrack(audio_path="", enabled=True),
]
)
assert cfg.effective_track_count == 2
def test_main_tracks(self):
cfg = MultiTrackMixConfig(
tracks=[
AudioTrack(audio_path="m1.mp3", track_type=TRACK_TYPE_MAIN),
AudioTrack(audio_path="b1.mp3", track_type=TRACK_TYPE_BGM),
AudioTrack(audio_path="m2.mp3", track_type=TRACK_TYPE_MAIN, enabled=False),
]
)
mains = cfg.main_tracks
assert len(mains) == 1
assert mains[0].audio_path == "m1.mp3"
def test_bgm_tracks(self):
cfg = MultiTrackMixConfig(
tracks=[
AudioTrack(audio_path="b1.mp3", track_type=TRACK_TYPE_BGM),
AudioTrack(audio_path="b2.mp3", track_type=TRACK_TYPE_BGM),
AudioTrack(audio_path="v1.mp3", track_type=TRACK_TYPE_VOICEOVER),
]
)
assert len(cfg.bgm_tracks) == 2
# ── 工具函数 ─────────────────────────────────────────────────────────────────
class TestUtils:
@pytest.mark.parametrize(
"name,expected",
[
("song.mp3", True),
("audio.WAV", True),
("track.m4a", True),
("video.mp4", False),
("text.txt", False),
("", False),
("/path/to/music.flac", True),
("sound.OGG", True),
],
)
def test_is_valid_audio_extension(self, name, expected):
assert is_valid_audio_extension(name) is expected
def test_clamp_volume_within_range(self):
assert clamp_volume(1.0) == 1.0
assert clamp_volume(0.0) == 0.0
assert clamp_volume(2.0) == 2.0
def test_clamp_volume_below_min(self):
assert clamp_volume(-0.5) == 0.0
def test_clamp_volume_above_max(self):
assert clamp_volume(3.0) == 2.0
def test_clamp_volume_custom_range(self):
assert clamp_volume(0.5, 0.2, 0.8) == 0.5
assert clamp_volume(0.1, 0.2, 0.8) == 0.2
assert clamp_volume(1.0, 0.2, 0.8) == 0.8