09d2b12ea8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m7s
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 18m38s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m7s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 2m16s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m35s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
312 lines
9.3 KiB
Python
Executable File
312 lines
9.3 KiB
Python
Executable File
"""BGM 混音模块 — 背景音乐与主音频混合.
|
||
|
||
基于 FFmpeg 实现:
|
||
- BGM 音量调节
|
||
- 淡入淡出(afade)
|
||
- 循环播放(aloop,短 BGM 铺长视频)
|
||
- 人声闪避(sidechaincompress,有人声时BGM自动降低音量)
|
||
- amix 混音
|
||
|
||
作为 render_audio.py 的增强模块,在 mix_audio 后处理阶段被调用。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING
|
||
|
||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||
|
||
if TYPE_CHECKING:
|
||
from video_processing.render_audio import RenderContext
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class BGMConfig:
|
||
"""BGM 混音配置(内部使用,从 plan.config.bgm 转换而来)"""
|
||
|
||
bgm_path: str # BGM 本地文件路径
|
||
volume: float = 0.3 # 0.0 ~ 1.0
|
||
fade_in: float = 0.0 # 淡入时长(秒)
|
||
fade_out: float = 0.0 # 淡出时长(秒)
|
||
loop_enabled: bool = True # 是否循环铺满
|
||
sidechain_enabled: bool = False # 人声闪避
|
||
sidechain_ratio: float = 0.3 # 闪避时音量降低比例
|
||
sidechain_attack: float = 0.02 # 攻击时间
|
||
sidechain_release: float = 0.5 # 释放时间
|
||
sidechain_threshold: float = -25.0 # 触发阈值(dB)
|
||
|
||
@classmethod
|
||
def from_config_dict(cls, bgm_path: str, config: dict) -> "BGMConfig":
|
||
"""从 plan.config.bgm 字典创建 BGMConfig。"""
|
||
return cls(
|
||
bgm_path=bgm_path,
|
||
volume=float(config.get("volume", 0.3)),
|
||
fade_in=float(config.get("fade_in", 0.0)),
|
||
fade_out=float(config.get("fade_out", 0.0)),
|
||
loop_enabled=bool(config.get("loop_enabled", True)),
|
||
sidechain_enabled=bool(config.get("sidechain_enabled", False)),
|
||
sidechain_ratio=float(config.get("sidechain_ratio", 0.3)),
|
||
sidechain_attack=float(config.get("sidechain_attack", 0.02)),
|
||
sidechain_release=float(config.get("sidechain_release", 0.5)),
|
||
sidechain_threshold=float(config.get("sidechain_threshold", -25.0)),
|
||
)
|
||
|
||
|
||
# ── BGM 预处理 ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def prepare_bgm_track(
|
||
ctx: "RenderContext",
|
||
bgm: BGMConfig,
|
||
target_duration: float,
|
||
) -> Path:
|
||
"""预处理 BGM 轨道:循环/截断 + 音量 + 淡入淡出.
|
||
|
||
生成一个时长精确等于 target_duration 的 BGM 音频文件。
|
||
后续再与主音频混音。
|
||
|
||
Args:
|
||
ctx: 渲染上下文
|
||
bgm: BGM 配置
|
||
target_duration: 目标时长(秒),通常等于视频总时长
|
||
|
||
Returns:
|
||
处理后的 BGM 音频文件路径
|
||
"""
|
||
output_path = ctx.work_dir / f"bgm_processed_{ctx.plan_id}.aac"
|
||
|
||
if target_duration <= 0:
|
||
target_duration = 5.0 # 兜底
|
||
|
||
bgm_dur = probe_duration(bgm.bgm_path)
|
||
needs_loop = bgm.loop_enabled and bgm_dur > 0 and bgm_dur < target_duration * 0.9
|
||
|
||
# 构建滤镜链
|
||
filter_parts: list[str] = []
|
||
|
||
if needs_loop:
|
||
# 计算需要循环多少次才能铺满
|
||
loop_count = max(1, int(target_duration / bgm_dur) + 2)
|
||
# aloop 滤镜:循环指定次数
|
||
filter_parts.append(f"aloop=loop={loop_count}:size=0")
|
||
|
||
# 音量调节
|
||
volume = max(0.0, min(1.0, bgm.volume))
|
||
if abs(volume - 1.0) > 0.001:
|
||
filter_parts.append(f"volume={volume:.3f}")
|
||
|
||
# 淡入
|
||
if bgm.fade_in > 0:
|
||
filter_parts.append(f"afade=t=in:st=0:d={bgm.fade_in:.3f}")
|
||
|
||
# 淡出(从 target_duration - fade_out 开始)
|
||
if bgm.fade_out > 0 and target_duration > bgm.fade_out:
|
||
fade_start = target_duration - bgm.fade_out
|
||
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={bgm.fade_out:.3f}")
|
||
|
||
# 最终截断到目标时长
|
||
filter_parts.append(f"atrim=0:{target_duration:.3f}")
|
||
filter_parts.append("asetpts=N/SR/TB") # 重置时间戳
|
||
|
||
filter_str = ",".join(filter_parts)
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-i",
|
||
bgm.bgm_path,
|
||
"-filter:a",
|
||
filter_str,
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
str(output_path),
|
||
]
|
||
|
||
logger.info(
|
||
"[bgm] prepare BGM track: path=%s dur=%.2f target=%.2f loop=%s fade_in=%.2f fade_out=%.2f",
|
||
bgm.bgm_path[-40:],
|
||
bgm_dur,
|
||
target_duration,
|
||
needs_loop,
|
||
bgm.fade_in,
|
||
bgm.fade_out,
|
||
)
|
||
|
||
run_ffmpeg(command)
|
||
return output_path
|
||
|
||
|
||
# ── BGM + 主音频混音 ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def mix_bgm_with_main(
|
||
ctx: "RenderContext",
|
||
main_audio_path: Path,
|
||
bgm: BGMConfig,
|
||
target_duration: float,
|
||
) -> Path:
|
||
"""将 BGM 与主音频混合.
|
||
|
||
两种模式:
|
||
1. 普通混音(sidechain 关闭):amix 两路音频
|
||
2. 人声闪避(sidechain 开启):用 sidechaincompress 让 BGM 跟随主音频音量自动调整
|
||
|
||
Args:
|
||
ctx: 渲染上下文
|
||
main_audio_path: 主音频文件路径(人声/原始音频)
|
||
bgm: BGM 配置
|
||
target_duration: 目标时长
|
||
|
||
Returns:
|
||
混音后的音频文件路径
|
||
"""
|
||
output_path = ctx.work_dir / f"audio_with_bgm_{ctx.plan_id}.aac"
|
||
|
||
# 先预处理 BGM 轨道(循环/音量/淡入淡出/截断)
|
||
bgm_processed = prepare_bgm_track(ctx, bgm, target_duration)
|
||
|
||
if not bgm.sidechain_enabled:
|
||
# 普通 amix 混音
|
||
_mix_simple(main_audio_path, bgm_processed, output_path)
|
||
else:
|
||
# sidechain 人声闪避混音
|
||
_mix_sidechain(main_audio_path, bgm_processed, output_path, bgm)
|
||
|
||
return output_path
|
||
|
||
|
||
def _mix_simple(main_path: Path, bgm_path: Path, output_path: Path) -> None:
|
||
"""简单 amix 混音:主音频 + BGM = 输出.
|
||
|
||
主音频权重 1.0,BGM 已经在预处理阶段调好了音量。
|
||
amix 会自动归一化,需要用 volume 补偿。
|
||
"""
|
||
# 使用 amix:inputs=2,duration=first(以主音频时长为准)
|
||
# 然后用 volume=2 补偿 amix 的衰减(2路输入每路平均乘0.5)
|
||
filter_complex = "[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=0[outa];" "[outa]volume=2[final]"
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-i",
|
||
str(main_path),
|
||
"-i",
|
||
str(bgm_path),
|
||
"-filter_complex",
|
||
filter_complex,
|
||
"-map",
|
||
"[final]",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
str(output_path),
|
||
]
|
||
|
||
logger.info("[bgm] simple amix mix")
|
||
run_ffmpeg(command)
|
||
|
||
|
||
def _mix_sidechain(
|
||
main_path: Path,
|
||
bgm_path: Path,
|
||
output_path: Path,
|
||
bgm: BGMConfig,
|
||
) -> None:
|
||
"""sidechain 人声闪避混音.
|
||
|
||
原理:
|
||
- 主音频作为 sidechain 信号源
|
||
- BGM 轨道经过 sidechaincompress,根据主音频音量动态调整 BGM 音量
|
||
- 最后 amix 混音
|
||
|
||
FFmpeg sidechaincompress 参数:
|
||
- threshold: 触发阈值(dB),主音频超过此值时开始压缩
|
||
- ratio: 压缩比,越高压缩越狠
|
||
- attack: 攻击时间(秒)
|
||
- release: 释放时间(秒)
|
||
"""
|
||
# sidechain_ratio 表示闪避时 BGM 音量降低比例
|
||
# ratio = 1 / (1 - sidechain_ratio),但实际压缩比需要更精细调整
|
||
# 简化处理:把 ratio 映射到 2:1 ~ 10:1 范围
|
||
ratio = max(2.0, min(10.0, 1.0 / (1.0 - bgm.sidechain_ratio)))
|
||
|
||
filter_complex = (
|
||
# BGM 经过 sidechain 压缩,用主音频做触发
|
||
f"[1:a][0:a]sidechaincompress="
|
||
f"threshold={bgm.sidechain_threshold}dB:"
|
||
f"ratio={ratio:.1f}:"
|
||
f"attack={bgm.sidechain_attack:.3f}:"
|
||
f"release={bgm.sidechain_release:.3f}:"
|
||
f"knee=6[bgm_comp];"
|
||
# 主音频 + 压缩后的 BGM 混音
|
||
f"[0:a][bgm_comp]amix=inputs=2:duration=first:dropout_transition=0[outa];"
|
||
f"[outa]volume=1.5[final]" # 轻微补偿
|
||
)
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-i",
|
||
str(main_path),
|
||
"-i",
|
||
str(bgm_path),
|
||
"-filter_complex",
|
||
filter_complex,
|
||
"-map",
|
||
"[final]",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
str(output_path),
|
||
]
|
||
|
||
logger.info(
|
||
"[bgm] sidechain mix: threshold=%.1fdB ratio=%.1f attack=%.3f release=%.3f",
|
||
bgm.sidechain_threshold,
|
||
ratio,
|
||
bgm.sidechain_attack,
|
||
bgm.sidechain_release,
|
||
)
|
||
run_ffmpeg(command)
|
||
|
||
|
||
# ── 纯 BGM 模式(无主音频) ──────────────────────────────────────────────────
|
||
|
||
|
||
def build_bgm_only(
|
||
ctx: "RenderContext",
|
||
bgm: BGMConfig,
|
||
target_duration: float,
|
||
) -> Path:
|
||
"""只有 BGM、没有主音频时,直接生成 BGM 音频.
|
||
|
||
Args:
|
||
ctx: 渲染上下文
|
||
bgm: BGM 配置
|
||
target_duration: 目标时长
|
||
|
||
Returns:
|
||
BGM 音频文件路径
|
||
"""
|
||
output_path = ctx.work_dir / f"bgm_only_{ctx.plan_id}.aac"
|
||
|
||
if target_duration <= 0:
|
||
target_duration = 5.0
|
||
|
||
bgm_processed = prepare_bgm_track(ctx, bgm, target_duration)
|
||
|
||
# 直接复制
|
||
import shutil
|
||
|
||
shutil.copy2(bgm_processed, output_path)
|
||
return output_path
|