487 lines
16 KiB
Python
487 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):
|
||
"""带侧链压缩(ducking) + loudnorm 响度标准化的混音"""
|
||
|
||
inputs = []
|
||
filter_complex = []
|
||
input_idx = 0
|
||
track_map = {} # track_name → filter_label
|
||
|
||
# 输入文件
|
||
if dialogue:
|
||
inputs.extend(["-i", dialogue])
|
||
track_map["dialogue"] = input_idx
|
||
input_idx += 1
|
||
if bgm:
|
||
inputs.extend(["-i", bgm])
|
||
track_map["bgm"] = input_idx
|
||
input_idx += 1
|
||
if sfx:
|
||
if isinstance(sfx, str):
|
||
sfx = [sfx]
|
||
for i, s in enumerate(sfx):
|
||
inputs.extend(["-i", s])
|
||
track_map[f"sfx{i}"] = input_idx
|
||
input_idx += 1
|
||
if original:
|
||
inputs.extend(["-i", original])
|
||
track_map["original"] = input_idx
|
||
input_idx += 1
|
||
|
||
# 侧链压缩: 对白作为 sidechain,BGM 被压缩
|
||
# sidechaincompress 参数: threshold 阈值, ratio 压缩比, attack 起音, release 释音
|
||
if "dialogue" in track_map and "bgm" in track_map:
|
||
d_idx = track_map["dialogue"]
|
||
b_idx = track_map["bgm"]
|
||
# 对白音轨通过
|
||
filter_complex.append(f"[{d_idx}:a]aformat=sample_fmts=fltp:channel_layouts=stereo[d_pre]")
|
||
filter_complex.append(f"[{b_idx}:a]aformat=sample_fmts=fltp:channel_layouts=stereo[b_pre]")
|
||
# 侧链压缩: BGM 被对白压缩
|
||
filter_complex.append(
|
||
f"[b_pre][d_pre]sidechaincompress="
|
||
f"threshold={ducking_threshold/20:.3f}:ratio=4:"
|
||
f"attack=25:release=200:level_sc={ducking_level/20:.3f}[bgm_comp]"
|
||
)
|
||
filter_complex.append(f"[d_pre]volume=1[dialogue_out]")
|
||
bgm_label = "bgm_comp"
|
||
else:
|
||
bgm_label = None
|
||
|
||
# 音效/原视频音轨
|
||
mix_inputs = []
|
||
if "dialogue" in track_map:
|
||
mix_inputs.append("[dialogue_out]" if bgm_label else f"[{track_map['dialogue']}:a]")
|
||
if bgm_label:
|
||
mix_inputs.append(f"[{bgm_label}]")
|
||
elif "bgm" in track_map:
|
||
mix_inputs.append(f"[{track_map['bgm']}:a]")
|
||
for key in track_map:
|
||
if key.startswith("sfx"):
|
||
filter_complex.append(f"[{track_map[key]}:a]volume=0.8[s_{key}]")
|
||
mix_inputs.append(f"[s_{key}]")
|
||
if "original" in track_map:
|
||
filter_complex.append(f"[{track_map['original']}:a]volume=0.4[orig_out]")
|
||
mix_inputs.append("[orig_out]")
|
||
|
||
# 混音
|
||
n_inputs = len(mix_inputs)
|
||
amix_inputs = "".join(mix_inputs)
|
||
filter_complex.append(
|
||
f"{amix_inputs}amix=inputs={n_inputs}:duration=first:dropout_transition=3,"
|
||
# 响度标准化: EBU R128, 目标 -14 LUFS (短视频平台标准)
|
||
f"loudnorm=I=-14:LRA=11:TP=-1.5,"
|
||
# 峰值限幅保护
|
||
f"alimiter=limit=-1.0dB:attack=5:release=50[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": list(track_map.keys()),
|
||
"warnings": [],
|
||
"method": "sidechain_compress+loudnorm"
|
||
}
|
||
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()
|