Files
xiaoxia-saas/packages/domain/audio_track_config.py

215 lines
7.3 KiB
Python
Executable File

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