冰朔 86f9708059
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
D144 · 视频AI系统8大模块开发完成
-  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
2026-06-24 12:50:51 +08:00

476 lines
16 KiB
Python
Raw 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.

#!/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()