Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc4bde1c84 |
@@ -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:
|
||||
"""校验音频文件路径安全性.
|
||||
|
||||
|
||||
Executable
+214
@@ -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))
|
||||
Executable
+488
@@ -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
|
||||
Reference in New Issue
Block a user