Files
xiaoxia-saas/apps/worker/video_processing/bgm_mixer.py
xiaoxia 3cd26e98db
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 43s
CI/CD Pipeline / Unit Tests (push) Successful in 1m35s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m37s
CI/CD Pipeline / Build Production Runtime Images (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 / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
feat: BGM音轨混音能力(音量/淡入淡出/人声闪避/预设BGM库) (#291)
2026-07-14 10:37:24 +08:00

314 lines
9.4 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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] = []
input_looped: bool = False
if needs_loop:
# 计算需要循环多少次才能铺满
loop_count = max(1, int(target_duration / bgm_dur) + 2)
# aloop 滤镜:循环指定次数
filter_parts.append(f"aloop=loop={loop_count}:size=0")
input_looped = True
# 音量调节
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 补偿。
"""
# 使用 amixinputs=2duration=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