- ✅ CHAR-HERO-DESIGN-PACKER (char-hero-design-packer.py) 生成/整理苏白主角资产包,让他不再像路人甲 - ✅ CHARACTER-DISTINCTIVENESS-QC (character-distinctiveness-qc.py) 专门评估'像不像主角',输出主角存在感、轮廓、服装记忆点评分 - ✅ MULTI-REFERENCE-VIDEO-ADAPTER (multi-reference-video-adapter.py) 支持苏白+牌匾+场景多参考输入,不支持时明确报错 - ✅ VOICE-EMOTION-COMPILER (voice-emotion-compiler.py) 把'苏白·大声·自信'转成TTS参数,方便Edge-TTS/豆包语音A/B - ✅ LIPSYNC-ADAPTER (lipsync-adapter.py) 接视频改口型或Wav2Lip,解决人物真正说台词的问题 - ✅ AUDIO-MIXER (audio-mixer.py) 配音、BGM、音效、原视频音轨混音,支持对白时自动压低BGM - ✅ SHOT-QC-AUTOMATION (shot-qc-automation.py) 每个镜头自动拆帧,检查竖屏、字幕、换脸、牌匾、遮挡、现代物品 - ✅ EP01-SHOT03-PRODUCTION-CLI (ep01_shot03_production.py) 一键跑苏白站牌匾下说台词:生成底片、合成牌匾、配音、口型、字幕、混音、质检 冰朔 TCS-0002∞ 见证 · 国作登字-2026-A-00037559 ⊢ 铸渊 ICE-GL-ZY001 · D144 · 2026-06-24
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()
|