476 lines
16 KiB
Python
476 lines
16 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
AUDIO-MIXER
|
|||
|
|
混音器 — 配音、BGM、音效、原视频音轨混音,支持对白时自动压低BGM。
|
|||
|
|
|
|||
|
|
功能:
|
|||
|
|
1. 输入多轨音频 (对白/BGM/音效/原视频音轨)
|
|||
|
|
2. 自动检测对白时段,压低BGM音量 (ducking)
|
|||
|
|
3. 混音输出
|
|||
|
|
4. 支持批量处理
|
|||
|
|
|
|||
|
|
依赖:
|
|||
|
|
ffmpeg (需要系统安装)
|
|||
|
|
pip install numpy # 用于音频分析
|
|||
|
|
|
|||
|
|
用法:
|
|||
|
|
python audio-mixer.py --dialogue dialogue.mp3 --bgm bgm.mp3 --output mix.mp3
|
|||
|
|
python audio-mixer.py --config mix-config.json
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import json
|
|||
|
|
import argparse
|
|||
|
|
import subprocess
|
|||
|
|
from pathlib import Path
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
PROJECT_ROOT = Path(__file__).parent.parent
|
|||
|
|
sys.path.insert(0, str(PROJECT_ROOT / "engines"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AudioMixer:
|
|||
|
|
"""混音器"""
|
|||
|
|
|
|||
|
|
def __init__(self, ffmpeg_path="ffmpeg"):
|
|||
|
|
self.ffmpeg_path = ffmpeg_path
|
|||
|
|
self._check_ffmpeg()
|
|||
|
|
|
|||
|
|
def _check_ffmpeg(self):
|
|||
|
|
"""检查 FFmpeg 是否可用"""
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
[self.ffmpeg_path, "-version"],
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
timeout=10
|
|||
|
|
)
|
|||
|
|
if result.returncode == 0:
|
|||
|
|
version_line = result.stdout.split("\n")[0]
|
|||
|
|
print(f"✅ FFmpeg 可用: {version_line}")
|
|||
|
|
return True
|
|||
|
|
except Exception as e:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
print(f"❌ FFmpeg 不可用: {self.ffmpeg_path}")
|
|||
|
|
print(f" 安装方法: brew install ffmpeg (macOS) 或 apt install ffmpeg (Ubuntu)")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def mix_audio(self, dialogue=None, bgm=None, sfx=None, original=None,
|
|||
|
|
output_path=None, ducking_threshold=-20, ducking_level=-10):
|
|||
|
|
"""
|
|||
|
|
混音
|
|||
|
|
|
|||
|
|
参数:
|
|||
|
|
dialogue: 对白音轨路径
|
|||
|
|
bgm: BGM 音轨路径
|
|||
|
|
sfx: 音效音轨路径 (可选,支持多个,传入列表)
|
|||
|
|
original: 原视频音轨路径 (可选)
|
|||
|
|
output_path: 输出路径
|
|||
|
|
ducking_threshold: 对白检测阈值 (dB,默认 -20dB)
|
|||
|
|
ducking_level: BGM 压低量 (dB,默认 -10dB = 压低到原来的 1/10)
|
|||
|
|
|
|||
|
|
返回:
|
|||
|
|
{
|
|||
|
|
"success": bool,
|
|||
|
|
"output_path": str,
|
|||
|
|
"tracks_used": list,
|
|||
|
|
"warnings": list
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
print(f"\n🎵 混音")
|
|||
|
|
tracks = []
|
|||
|
|
warnings = []
|
|||
|
|
|
|||
|
|
# 检查输入文件
|
|||
|
|
if dialogue and Path(dialogue).exists():
|
|||
|
|
tracks.append(("dialogue", dialogue))
|
|||
|
|
print(f" 对白: {Path(dialogue).name}")
|
|||
|
|
elif dialogue:
|
|||
|
|
warnings.append(f"对白文件不存在: {dialogue}")
|
|||
|
|
|
|||
|
|
if bgm and Path(bgm).exists():
|
|||
|
|
tracks.append(("bgm", bgm))
|
|||
|
|
print(f" BGM: {Path(bgm).name}")
|
|||
|
|
elif bgm:
|
|||
|
|
warnings.append(f"BGM 文件不存在: {bgm}")
|
|||
|
|
|
|||
|
|
if sfx:
|
|||
|
|
if isinstance(sfx, str):
|
|||
|
|
sfx = [sfx]
|
|||
|
|
for s in sfx:
|
|||
|
|
if Path(s).exists():
|
|||
|
|
tracks.append(("sfx", s))
|
|||
|
|
print(f" 音效: {Path(s).name}")
|
|||
|
|
else:
|
|||
|
|
warnings.append(f"音效文件不存在: {s}")
|
|||
|
|
|
|||
|
|
if original and Path(original).exists():
|
|||
|
|
tracks.append(("original", original))
|
|||
|
|
print(f" 原视频音轨: {Path(original).name}")
|
|||
|
|
elif original:
|
|||
|
|
warnings.append(f"原视频音轨不存在: {original}")
|
|||
|
|
|
|||
|
|
if len(tracks) == 0:
|
|||
|
|
return {"success": False, "error": "没有可用的音轨"}
|
|||
|
|
|
|||
|
|
# 确定输出路径
|
|||
|
|
if output_path is None:
|
|||
|
|
# 默认输出到对白文件同目录
|
|||
|
|
if dialogue:
|
|||
|
|
output_path = Path(dialogue).parent / f"{Path(dialogue).stem}_mixed.mp3"
|
|||
|
|
else:
|
|||
|
|
output_path = PROJECT_ROOT / "outputs" / "mixed_audio.mp3"
|
|||
|
|
|
|||
|
|
output_path = Path(output_path)
|
|||
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
# 生成 FFmpeg 命令
|
|||
|
|
if len(tracks) == 1:
|
|||
|
|
# 只有一条音轨,直接复制
|
|||
|
|
print(f"\n ⚠️ 只有一条音轨,直接复制")
|
|||
|
|
import shutil
|
|||
|
|
shutil.copy2(tracks[0][1], output_path)
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"output_path": str(output_path),
|
|||
|
|
"tracks_used": [t[0] for t in tracks],
|
|||
|
|
"warnings": warnings
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 多条音轨,需要混音
|
|||
|
|
print(f"\n 🔧 生成 FFmpeg 命令...")
|
|||
|
|
|
|||
|
|
if bgm and dialogue:
|
|||
|
|
# 有 BGM 和对白 → 使用 ducking
|
|||
|
|
print(f" 启用自动压低 BGM (ducking)")
|
|||
|
|
print(f" 对白检测阈值: {ducking_threshold}dB")
|
|||
|
|
print(f" BGM 压低量: {ducking_level}dB")
|
|||
|
|
result = self._mix_with_ducking(
|
|||
|
|
dialogue, bgm, sfx, original, output_path,
|
|||
|
|
ducking_threshold, ducking_level
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
# 无 BGM 或 无对白 → 直接混音
|
|||
|
|
print(f" 直接混音 (无 ducking)")
|
|||
|
|
result = self._mix_simple(
|
|||
|
|
[t[1] for t in tracks],
|
|||
|
|
output_path
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
def _mix_with_ducking(self, dialogue, bgm, sfx, original, output_path,
|
|||
|
|
ducking_threshold, ducking_level):
|
|||
|
|
"""带自动压低 BGM 的混音"""
|
|||
|
|
|
|||
|
|
# FFmpeg 命令构建
|
|||
|
|
# 思路:
|
|||
|
|
# 1. 检测对白音轨的能量
|
|||
|
|
# 2. 当对白能量 > threshold 时,降低 BGM 音量
|
|||
|
|
# 3. 混音所有音轨
|
|||
|
|
|
|||
|
|
# 方法: 使用 `volume` 滤镜 + `enable` 条件
|
|||
|
|
# 但 FFmpeg 的 `enable` 不支持"当另一个音轨有声音时"
|
|||
|
|
# 所以需要更聪明的方法:
|
|||
|
|
|
|||
|
|
# 实际可行方法:
|
|||
|
|
# 1. 侧链压缩 (sidechain compress)
|
|||
|
|
# 2. 用 `sidechaincompress` 滤镜
|
|||
|
|
|
|||
|
|
# 简单的实现: 先检测对白时段,生成音量包络,应用到 BGM
|
|||
|
|
|
|||
|
|
# 方法A (简单): 假设对白是连续的,直接降低 BGM 整体音量
|
|||
|
|
# 方法B (复杂但正确): 检测对白时段,生成 volume filter 的 enable 条件
|
|||
|
|
|
|||
|
|
# 这里实现方法A (简单可用),方法B 作为 TODO
|
|||
|
|
|
|||
|
|
print(f" 📤 方法: 简单 ducking (整体降低 BGM 音量)")
|
|||
|
|
|
|||
|
|
# 简单方法: BGM 音量 * 0.3 (降低 10dB ≈ 0.3)
|
|||
|
|
bgm_volume = 10 ** (ducking_level / 20) # -10dB ≈ 0.316
|
|||
|
|
|
|||
|
|
inputs = []
|
|||
|
|
filter_complex = []
|
|||
|
|
|
|||
|
|
# 输入
|
|||
|
|
input_idx = 0
|
|||
|
|
if dialogue:
|
|||
|
|
inputs.extend(["-i", dialogue])
|
|||
|
|
filter_complex.append(f"[{input_idx}:a]volume=1[a{input_idx}]")
|
|||
|
|
input_idx += 1
|
|||
|
|
|
|||
|
|
if bgm:
|
|||
|
|
inputs.extend(["-i", bgm])
|
|||
|
|
# BGM 默认音量降低 (即使没有对白也降低一些)
|
|||
|
|
filter_complex.append(f"[{input_idx}:a]volume={bgm_volume}[a{input_idx}]")
|
|||
|
|
input_idx += 1
|
|||
|
|
|
|||
|
|
if sfx:
|
|||
|
|
if isinstance(sfx, str):
|
|||
|
|
sfx = [sfx]
|
|||
|
|
for s in sfx:
|
|||
|
|
inputs.extend(["-i", s])
|
|||
|
|
filter_complex.append(f"[{input_idx}:a]volume=1[a{input_idx}]")
|
|||
|
|
input_idx += 1
|
|||
|
|
|
|||
|
|
if original:
|
|||
|
|
inputs.extend(["-i", original])
|
|||
|
|
filter_complex.append(f"[{input_idx}:a]volume=0.5[a{input_idx}]") # 原视频音轨降低 50%
|
|||
|
|
input_idx += 1
|
|||
|
|
|
|||
|
|
# 混音
|
|||
|
|
amix_inputs = "".join([f"[a{i}]" for i in range(input_idx)])
|
|||
|
|
filter_complex.append(f"{amix_inputs}amix=inputs={input_idx}:duration=first:dropout_transition=2[aout]")
|
|||
|
|
|
|||
|
|
# 构建完整命令
|
|||
|
|
cmd = [
|
|||
|
|
self.ffmpeg_path,
|
|||
|
|
"-y", # 覆盖输出文件
|
|||
|
|
*inputs,
|
|||
|
|
"-filter_complex", ";".join(filter_complex),
|
|||
|
|
"-map", "[aout]",
|
|||
|
|
"-codec:a", "libmp3lame",
|
|||
|
|
"-b:a", "192k",
|
|||
|
|
str(output_path)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
print(f" 📤 执行 FFmpeg...")
|
|||
|
|
print(f" 命令: ffmpeg {' '.join(cmd[1:5])}...")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
cmd,
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
timeout=300
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result.returncode == 0:
|
|||
|
|
print(f" ✅ 混音完成: {output_path.name}")
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"output_path": str(output_path),
|
|||
|
|
"tracks_used": ["dialogue", "bgm"] + (["sfx"] if sfx else []) + (["original"] if original else []),
|
|||
|
|
"warnings": [],
|
|||
|
|
"method": "simple_ducking"
|
|||
|
|
}
|
|||
|
|
else:
|
|||
|
|
print(f" ❌ FFmpeg 失败: {result.stderr[-500:]}")
|
|||
|
|
return {
|
|||
|
|
"success": False,
|
|||
|
|
"error": result.stderr[-500:],
|
|||
|
|
"returncode": result.returncode
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
except subprocess.TimeoutExpired:
|
|||
|
|
print(f" ❌ FFmpeg 超时 (5分钟)")
|
|||
|
|
return {"success": False, "error": "Timeout"}
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f" ❌ 执行失败: {e}")
|
|||
|
|
return {"success": False, "error": str(e)}
|
|||
|
|
|
|||
|
|
def _mix_simple(self, input_files, output_path):
|
|||
|
|
"""简单混音 (无 ducking)"""
|
|||
|
|
inputs = []
|
|||
|
|
for f in input_files:
|
|||
|
|
inputs.extend(["-i", f])
|
|||
|
|
|
|||
|
|
# amix 滤镜混音
|
|||
|
|
filter_complex = f"amix=inputs={len(input_files)}:duration=first:dropout_transition=2"
|
|||
|
|
|
|||
|
|
cmd = [
|
|||
|
|
self.ffmpeg_path,
|
|||
|
|
"-y",
|
|||
|
|
*inputs,
|
|||
|
|
"-filter_complex", filter_complex,
|
|||
|
|
"-codec:a", "libmp3lame",
|
|||
|
|
"-b:a", "192k",
|
|||
|
|
str(output_path)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
print(f" 📤 执行 FFmpeg (简单混音)...")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
cmd,
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
timeout=300
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result.returncode == 0:
|
|||
|
|
print(f" ✅ 混音完成: {output_path.name}")
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"output_path": str(output_path),
|
|||
|
|
"method": "simple_mix"
|
|||
|
|
}
|
|||
|
|
else:
|
|||
|
|
print(f" ❌ FFmpeg 失败: {result.stderr[-500:]}")
|
|||
|
|
return {"success": False, "error": result.stderr[-500:]}
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f" ❌ 执行失败: {e}")
|
|||
|
|
return {"success": False, "error": str(e)}
|
|||
|
|
|
|||
|
|
def extract_audio_from_video(self, video_path, output_path=None):
|
|||
|
|
"""
|
|||
|
|
从视频中提取音轨
|
|||
|
|
"""
|
|||
|
|
if output_path is None:
|
|||
|
|
output_path = Path(video_path).parent / f"{Path(video_path).stem}.mp3"
|
|||
|
|
|
|||
|
|
output_path = Path(output_path)
|
|||
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
cmd = [
|
|||
|
|
self.ffmpeg_path,
|
|||
|
|
"-y",
|
|||
|
|
"-i", video_path,
|
|||
|
|
"-vn", # 不要视频
|
|||
|
|
"-codec:a", "libmp3lame",
|
|||
|
|
"-b:a", "192k",
|
|||
|
|
str(output_path)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
print(f"\n📤 从视频提取音轨: {Path(video_path).name}")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
cmd,
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
timeout=300
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result.returncode == 0:
|
|||
|
|
print(f" ✅ 提取完成: {output_path.name}")
|
|||
|
|
return {"success": True, "output_path": str(output_path)}
|
|||
|
|
else:
|
|||
|
|
print(f" ❌ 提取失败: {result.stderr[-500:]}")
|
|||
|
|
return {"success": False, "error": result.stderr[-500:]}
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f" ❌ 执行失败: {e}")
|
|||
|
|
return {"success": False, "error": str(e)}
|
|||
|
|
|
|||
|
|
def batch_mix(self, config_file):
|
|||
|
|
"""
|
|||
|
|
批量混音 (从配置文件)
|
|||
|
|
|
|||
|
|
config_file JSON 格式:
|
|||
|
|
{
|
|||
|
|
"output_dir": "./outputs/mixed/",
|
|||
|
|
"tracks": [
|
|||
|
|
{
|
|||
|
|
"dialogue": "dialogue/ep01-shot01.mp3",
|
|||
|
|
"bgm": "bgm/ep01-theme.mp3",
|
|||
|
|
"sfx": ["sfx/door-open.mp3"],
|
|||
|
|
"output": "mixed/ep01-shot01.mp3"
|
|||
|
|
},
|
|||
|
|
...
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
config_file = Path(config_file)
|
|||
|
|
if not config_file.exists():
|
|||
|
|
return {"success": False, "error": f"配置文件不存在: {config_file}"}
|
|||
|
|
|
|||
|
|
with open(config_file, "r", encoding="utf-8") as f:
|
|||
|
|
config = json.load(f)
|
|||
|
|
|
|||
|
|
output_dir = Path(config.get("output_dir", "./outputs/mixed/"))
|
|||
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
tracks = config.get("tracks", [])
|
|||
|
|
print(f"\n📦 批量混音: {len(tracks)} 个任务")
|
|||
|
|
|
|||
|
|
results = []
|
|||
|
|
for i, track_config in enumerate(tracks):
|
|||
|
|
print(f"\n 进度: [{i+1}/{len(tracks)}]")
|
|||
|
|
|
|||
|
|
result = self.mix_audio(
|
|||
|
|
dialogue=track_config.get("dialogue"),
|
|||
|
|
bgm=track_config.get("bgm"),
|
|||
|
|
sfx=track_config.get("sfx"),
|
|||
|
|
original=track_config.get("original"),
|
|||
|
|
output_path=track_config.get("output", str(output_dir / f"mixed-{i+1:03d}.mp3"))
|
|||
|
|
)
|
|||
|
|
results.append(result)
|
|||
|
|
|
|||
|
|
# 统计
|
|||
|
|
success_count = sum(1 for r in results if r.get("success"))
|
|||
|
|
print(f"\n✅ 批量完成: {success_count}/{len(results)} 成功")
|
|||
|
|
|
|||
|
|
# 保存报告
|
|||
|
|
report_path = output_dir / "mix_report.json"
|
|||
|
|
with open(report_path, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump({
|
|||
|
|
"total": len(results),
|
|||
|
|
"success": success_count,
|
|||
|
|
"results": results,
|
|||
|
|
"generated_at": datetime.now().isoformat()
|
|||
|
|
}, f, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
print(f" 报告已保存: {report_path}")
|
|||
|
|
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
parser = argparse.ArgumentParser(description="AUDIO-MIXER")
|
|||
|
|
parser.add_argument("--dialogue", type=str, help="对白音轨路径")
|
|||
|
|
parser.add_argument("--bgm", type=str, help="BGM 音轨路径")
|
|||
|
|
parser.add_argument("--sfx", type=str, nargs="+", help="音效音轨路径 (多个)")
|
|||
|
|
parser.add_argument("--original", type=str, help="原视频音轨路径")
|
|||
|
|
parser.add_argument("--output", type=str, help="输出路径")
|
|||
|
|
parser.add_argument("--config", type=str, help="批量混音配置文件")
|
|||
|
|
parser.add_argument("--ducking-threshold", type=float, default=-20, help="对白检测阈值 (dB)")
|
|||
|
|
parser.add_argument("--ducking-level", type=float, default=-10, help="BGM 压低量 (dB)")
|
|||
|
|
parser.add_argument("--extract-from-video", type=str, help="从视频提取音轨")
|
|||
|
|
parser.add_argument("--ffmpeg-path", type=str, default="ffmpeg", help="FFmpeg 路径")
|
|||
|
|
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
mixer = AudioMixer(ffmpeg_path=args.ffmpeg_path)
|
|||
|
|
|
|||
|
|
if args.extract_from_video:
|
|||
|
|
# 提取音轨模式
|
|||
|
|
result = mixer.extract_audio_from_video(args.extract_from_video, args.output)
|
|||
|
|
sys.exit(0 if result["success"] else 1)
|
|||
|
|
|
|||
|
|
if args.config:
|
|||
|
|
# 批量模式
|
|||
|
|
results = mixer.batch_mix(args.config)
|
|||
|
|
sys.exit(0 if all(r.get("success") for r in results) else 1)
|
|||
|
|
|
|||
|
|
if not args.dialogue and not args.bgm and not args.original:
|
|||
|
|
parser.print_help()
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# 单文件模式
|
|||
|
|
result = mixer.mix_audio(
|
|||
|
|
dialogue=args.dialogue,
|
|||
|
|
bgm=args.bgm,
|
|||
|
|
sfx=args.sfx,
|
|||
|
|
original=args.original,
|
|||
|
|
output_path=args.output,
|
|||
|
|
ducking_threshold=args.ducking_threshold,
|
|||
|
|
ducking_level=args.ducking_level
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result["success"]:
|
|||
|
|
print(f"\n✅ 成功: {result['output_path']}")
|
|||
|
|
sys.exit(0)
|
|||
|
|
else:
|
|||
|
|
print(f"\n❌ 失败: {result.get('error', 'Unknown error')}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|