"""多轨道音频配置领域模型 — 纯逻辑,无外部依赖. 抽离自 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))