215 lines
7.3 KiB
Python
Executable File
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))
|