From cc4bde1c8483cfadae2fdc41bb482432f63087a1 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 27 Jul 2026 00:55:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(wave111):=20=E6=8A=BD=E7=A6=BBaudio=5F?= =?UTF-8?q?track=5Fconfig=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=2067?= =?UTF-8?q?=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 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 个单测覆盖数据模型 + 纯逻辑函数 --- .../video_processing/multi_track_mixer.py | 143 +---- packages/domain/audio_track_config.py | 214 ++++++++ tests/unit/test_audio_track_config.py | 488 ++++++++++++++++++ 3 files changed, 714 insertions(+), 131 deletions(-) create mode 100755 packages/domain/audio_track_config.py create mode 100755 tests/unit/test_audio_track_config.py diff --git a/apps/worker/video_processing/multi_track_mixer.py b/apps/worker/video_processing/multi_track_mixer.py index 8e76828c4..997521f3e 100755 --- a/apps/worker/video_processing/multi_track_mixer.py +++ b/apps/worker/video_processing/multi_track_mixer.py @@ -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: """校验音频文件路径安全性. diff --git a/packages/domain/audio_track_config.py b/packages/domain/audio_track_config.py new file mode 100755 index 000000000..e0fac11e1 --- /dev/null +++ b/packages/domain/audio_track_config.py @@ -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)) diff --git a/tests/unit/test_audio_track_config.py b/tests/unit/test_audio_track_config.py new file mode 100755 index 000000000..3264c08bd --- /dev/null +++ b/tests/unit/test_audio_track_config.py @@ -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 -- 2.54.0