Files
CI Bot 4c31026f81
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m29s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 4m7s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m34s
CI/CD Pipeline / Unit Tests (push) Failing after 8m43s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 17s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
style: auto-format with black + isort + prettier [skip ci-format-check]
2026-07-29 02:18:43 +00:00

464 lines
13 KiB
Python
Executable File

"""多轨混音纯逻辑模块.
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
便于单元测试,也方便被其他模块复用。
"""
from __future__ import annotations
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
def calculate_effective_range(
track_start: float,
track_duration: float,
audio_duration: float,
target_duration: float,
) -> tuple[float, float, float]:
"""计算轨道的有效时间范围.
处理:
- 轨道时长为 0 或负时用音频完整时长
- 轨道开始在目标时长外时跳过
- 轨道开始为负时截断开头
Args:
track_start: 轨道开始时间(秒),可为负
track_duration: 轨道持续时长(秒),<=0 表示用音频全长
audio_duration: 音频文件实际时长(秒)
target_duration: 目标总时长(秒)
Returns:
(effective_start, need_duration, trim_start)
- effective_start: 在目标时间轴上的开始位置(>=0)
- need_duration: 需要截取的音频长度
- trim_start: 从源音频的哪个位置开始截取
"""
if audio_duration <= 0:
return (0.0, 0.0, 0.0)
# 有效时长(轨道声明的时长,未被截断的)
if track_duration > 0:
effective_dur = min(track_duration, audio_duration)
else:
effective_dur = audio_duration
effective_start = track_start
trim_start = 0.0
# 负的开始时间:从源音频中间开始取,轨道前段被截掉
if effective_start < 0:
trim_start = -effective_start
# 可用时长 = 总时长 - 被截掉的前段
effective_dur = max(0.0, effective_dur - trim_start)
effective_start = 0.0
# 轨道完全在目标时长之外
if effective_start >= target_duration:
return (0.0, 0.0, 0.0)
# 轨道完全在 0 之前
if effective_start + effective_dur <= 0:
return (0.0, 0.0, 0.0)
# 实际需要的源时长
need_dur = min(effective_dur, target_duration - effective_start)
if need_dur <= 0:
return (0.0, 0.0, 0.0)
# 调整 trim_start 不能超过音频长度
if trim_start >= audio_duration:
return (0.0, 0.0, 0.0)
return (effective_start, need_dur, trim_start)
def is_track_visible(
track_start: float,
track_duration: float,
audio_duration: float,
target_duration: float,
) -> bool:
"""判断轨道是否在目标时长范围内可见(有声音).
Args:
track_start: 轨道开始时间
track_duration: 轨道持续时长
audio_duration: 音频时长
target_duration: 目标总时长
Returns:
是否可见
"""
_, need_dur, _ = calculate_effective_range(track_start, track_duration, audio_duration, target_duration)
return need_dur > 0
# ── 单轨滤镜链构建 ────────────────────────────────────────────────────────────
def build_track_filter_chain(
volume: float,
fade_in: float,
fade_out: float,
effective_start: float,
need_duration: float,
trim_start: float,
target_duration: float,
) -> str:
"""构建单轨道预处理滤镜链.
处理顺序:截断 → 重置时间戳 → 音量 → 淡入 → 淡出 → 延迟 → 最终截断 → 重置时间戳
Args:
volume: 音量 0.0~1.0
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
effective_start: 在目标轴上的开始时间
need_duration: 需要截取的时长
trim_start: 从源音频的哪个位置开始
target_duration: 目标总时长
Returns:
逗号分隔的滤镜字符串
"""
filter_parts: list[str] = []
# 1. 截断到有效范围
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB")
# 2. 音量调节
safe_volume = max(0.0, min(2.0, volume))
if abs(safe_volume - 1.0) > 0.001:
filter_parts.append(f"volume={safe_volume:.3f}")
# 3. 淡入(必须小于总时长才有效)
if fade_in > 0 and fade_in < need_duration:
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
# 4. 淡出
if fade_out > 0 and fade_out < need_duration:
fade_start = need_duration - fade_out
if fade_start > 0:
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
# 5. 时间偏移(开头静音填充)
if effective_start > 0.01:
delay_ms = int(effective_start * 1000)
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
# 6. 最终截断到目标总时长
filter_parts.append(f"atrim=0:{target_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB")
return ",".join(filter_parts)
# ── amix 混音滤镜构建 ────────────────────────────────────────────────────────
def build_amix_filter(num_inputs: int, duration_mode: str = "longest") -> str:
"""构建 amix 混音滤镜.
Args:
num_inputs: 输入轨道数量
duration_mode: 时长模式:longest / shortest / first
Returns:
amix 滤镜字符串
"""
if num_inputs <= 0:
return ""
# 校验 duration_mode
if duration_mode not in ("longest", "shortest", "first"):
duration_mode = "longest"
return f"amix=inputs={num_inputs}:duration={duration_mode}:dropout_transition=0"
def calculate_amix_volume_compensation(num_inputs: int) -> float:
"""计算 amix 后的音量补偿系数.
amix 会将 N 路输入每路乘以 1/N 来归一化,
所以需要乘以 N 来补偿(简单粗暴但有效)。
Args:
num_inputs: 输入轨道数量
Returns:
补偿系数
"""
if num_inputs <= 1:
return 1.0
return float(num_inputs)
def build_mix_filter_complex(
num_tracks: int,
has_main: bool = True,
duration_mode: str = "longest",
) -> str:
"""构建完整的混音 filter_complex.
Args:
num_tracks: 额外轨道数量
has_main: 是否有主音频
duration_mode: 时长模式
Returns:
filter_complex 字符串
"""
total_inputs = num_tracks + (1 if has_main else 0)
if total_inputs <= 0:
return ""
# 输入标签
input_labels = "".join(f"[{i}:a]" for i in range(total_inputs))
# amix
amix = build_amix_filter(total_inputs, duration_mode)
# 音量补偿
compensation = calculate_amix_volume_compensation(total_inputs)
volume_filter = ""
if abs(compensation - 1.0) > 0.001:
volume_filter = f",volume={compensation}"
return f"{input_labels}{amix}{volume_filter}[mixed]"
# ── 音量计算 ──────────────────────────────────────────────────────────────────
def normalize_volume(volume: float) -> float:
"""规范化音量值.
Args:
volume: 原始音量
Returns:
规范化后的音量(0.0 ~ 2.0)
"""
if volume is None:
return 1.0
try:
v = float(volume)
return max(0.0, min(2.0, v))
except (ValueError, TypeError):
return 1.0
def db_to_linear(db: float) -> float:
"""dB 转换为线性音量.
Args:
db: 分贝值
Returns:
线性音量值
"""
return 10 ** (db / 20.0)
def linear_to_db(linear: float) -> float:
"""线性音量转换为 dB.
Args:
linear: 线性音量值
Returns:
分贝值
"""
import math
if linear <= 0:
return -float("inf")
return 20.0 * math.log10(linear)
# ── 轨道排序与过滤 ────────────────────────────────────────────────────────────
def sort_tracks_by_priority(
tracks: list[dict],
) -> list[dict]:
"""按优先级排序轨道.
priority 数字越小优先级越高(越先播放/越底层)。
相同优先级保持原顺序。
Args:
tracks: 轨道配置列表
Returns:
排序后的轨道列表
"""
return sorted(tracks, key=lambda t: int(t.get("priority", 100)))
def filter_enabled_tracks(tracks: list[dict]) -> list[dict]:
"""过滤出启用的轨道.
Args:
tracks: 轨道列表
Returns:
启用的轨道列表
"""
result = []
for t in tracks:
enabled = t.get("enabled", True)
if bool(enabled) and enabled != "false" and enabled != 0:
result.append(t)
return result
def count_track_types(tracks: list[dict]) -> dict[str, int]:
"""统计各类型轨道数量.
Args:
tracks: 轨道列表
Returns:
类型计数字典
"""
counts: dict[str, int] = {}
for t in tracks:
ttype = t.get("track_type", "unknown")
counts[ttype] = counts.get(ttype, 0) + 1
return counts
# ── 配置验证 ──────────────────────────────────────────────────────────────────
def validate_audio_track(track: dict) -> tuple[bool, list[str]]:
"""验证单条音轨配置.
Args:
track: 轨道配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
# 音频路径
audio_path = track.get("audio_path", "")
if not audio_path and not track.get("asset_id"):
errors.append("轨道需要 audio_path 或 asset_id")
# 音量范围
volume = track.get("volume", 1.0)
try:
v = float(volume)
if v < 0:
errors.append("volume 不能为负数")
if v > 2.0:
errors.append("volume 建议不超过 2.0")
except (ValueError, TypeError):
errors.append("volume 必须是数字")
# 淡入淡出
fade_in = track.get("fade_in", 0)
fade_out = track.get("fade_out", 0)
try:
if float(fade_in) < 0:
errors.append("fade_in 不能为负数")
except (ValueError, TypeError):
errors.append("fade_in 必须是数字")
try:
if float(fade_out) < 0:
errors.append("fade_out 不能为负数")
except (ValueError, TypeError):
errors.append("fade_out 必须是数字")
# 开始时间
start_time = track.get("start_time", 0)
try:
float(start_time) # 验证是否为数字
except (ValueError, TypeError):
errors.append("start_time 必须是数字")
return (len(errors) == 0, errors)
def validate_mix_config(config: dict) -> tuple[bool, list[str]]:
"""验证混音配置.
Args:
config: 混音配置
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
tracks = config.get("tracks", [])
if not tracks:
errors.append("至少需要一条轨道")
# 验证每条轨道
for i, track in enumerate(tracks):
ok, track_errors = validate_audio_track(track)
if not ok:
for err in track_errors:
errors.append(f"第{i+1}轨:{err}")
# 目标时长
target_duration = config.get("target_duration", 0)
try:
if float(target_duration) < 0:
errors.append("target_duration 不能为负数")
except (ValueError, TypeError):
errors.append("target_duration 必须是数字")
return (len(errors) == 0, errors)
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def calculate_total_tracks(config: dict) -> int:
"""计算总轨道数(含主音频).
Args:
config: 混音配置
Returns:
总轨道数
"""
tracks = config.get("tracks", [])
has_main = config.get("has_main_audio", True)
count = len(tracks)
if has_main:
count += 1
return count
def estimate_mix_duration(tracks: list[dict]) -> float:
"""估算混音总时长(所有轨道的最晚结束时间).
Args:
tracks: 轨道列表,包含 start_time 和 duration
Returns:
估算总时长(秒)
"""
max_end = 0.0
for t in tracks:
try:
start = float(t.get("start_time", 0))
dur = float(t.get("duration", 0))
if dur > 0:
end = start + dur
if end > max_end:
max_end = end
except (ValueError, TypeError):
continue
return max_end