464 lines
17 KiB
Python
464 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
《林夏》TTS 批量生产脚本 — VoxCPM2 版
|
||
|
||
用法:
|
||
# 先确保 VoxCPM2 服务已启动:
|
||
bash audio/check_tts_env.sh
|
||
|
||
# 使用 voice/ 目录 (WAV) 的参考音色
|
||
python audio/batch_tts_voxcpm.py --source voice
|
||
|
||
# 使用 voice_mp3/ 目录 (MP3) 的参考音色
|
||
python audio/batch_tts_voxcpm.py --source voice_mp3
|
||
|
||
# 只生产指定角色
|
||
python audio/batch_tts_voxcpm.py --source voice --role mom --test
|
||
|
||
# 干跑(只看任务列表)
|
||
python audio/batch_tts_voxcpm.py --source voice --dry-run
|
||
"""
|
||
import os
|
||
import sys
|
||
import time
|
||
import argparse
|
||
import subprocess
|
||
import tempfile
|
||
import base64
|
||
import json
|
||
import glob
|
||
|
||
import requests
|
||
|
||
# ──────────────────────────────────────────────────────────────
|
||
# 配置
|
||
# ──────────────────────────────────────────────────────────────
|
||
PROJECT_DIR = "/home/xsl/blind"
|
||
OUT_DIR = f"{PROJECT_DIR}/audio/00_raw/tts"
|
||
|
||
HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1")
|
||
PORT = os.environ.get("VOXCPM_PORT", "8000")
|
||
BASE_URL = f"http://{HOST}:{PORT}"
|
||
|
||
# ──────────────────────────────────────────────────────────────
|
||
# 角色配置
|
||
# dir: voice/ 下的子目录名
|
||
# speed: 语速倍率(通过 ffmpeg atempo 后处理)
|
||
# ──────────────────────────────────────────────────────────────
|
||
ROLES = {
|
||
"mom": {
|
||
"dir": "mom",
|
||
"speed": 0.92,
|
||
},
|
||
"azhe": {
|
||
"dir": "azhe",
|
||
"speed": 1.05,
|
||
},
|
||
"xiaomei": {
|
||
"dir": "xiaomei",
|
||
"speed": 1.10,
|
||
},
|
||
"xiaomei_drunk": {
|
||
"dir": "xiaomei",
|
||
"speed": 0.95,
|
||
},
|
||
"zhounan": {
|
||
"dir": "zhounan",
|
||
"speed": 0.95,
|
||
},
|
||
"hr": {
|
||
"dir": "hr",
|
||
"speed": 1.00,
|
||
},
|
||
"anan": {
|
||
"dir": "anan",
|
||
"speed": 1.15,
|
||
},
|
||
"dorm_a": {
|
||
"dir": "dorm_a",
|
||
"speed": 1.10,
|
||
},
|
||
"dorm_b": {
|
||
"dir": "dorm_b",
|
||
"speed": 1.00,
|
||
},
|
||
"dorm_c": {
|
||
"dir": "dorm_c",
|
||
"speed": 0.95,
|
||
},
|
||
"delivery": {
|
||
"dir": "delivery",
|
||
"speed": 1.20,
|
||
},
|
||
"self_3y": {
|
||
"dir": "xiaomei",
|
||
"speed": 1.15,
|
||
},
|
||
}
|
||
|
||
# ──────────────────────────────────────────────────────────────
|
||
# 台词表(对应林夏.md)
|
||
# ──────────────────────────────────────────────────────────────
|
||
LINES = [
|
||
# ── 01 · 21:03 妈妈电话 ──
|
||
("lv11_2103_mom_call_01",
|
||
"mom",
|
||
"今晚煮了你爱吃的排骨,给你拍张照啊。下周五回来一趟,你高中物理老师的儿子,挺好的。"),
|
||
|
||
# ── 02 · 21:15 小美三连 ──
|
||
("lv11_2115_xiaomei_voice_01", "xiaomei", "在吗"),
|
||
("lv11_2115_xiaomei_voice_02", "xiaomei", "你被甩了我都听说了"),
|
||
("lv11_2115_xiaomei_voice_03", "xiaomei", "明天晚上喝酒,海底捞旁边那家,不许放鸽子"),
|
||
|
||
# ── 03 · 21:20 外卖小哥 ──
|
||
("lv11_2120_delivery_call_01",
|
||
"delivery",
|
||
"你好你的麻辣烫到了,放门口可以吗?"),
|
||
|
||
# ── 04 · 21:30 阿哲语音 ──
|
||
("lv11_2130_azhe_voice_01",
|
||
"azhe",
|
||
"嗯……夏夏。我那个AirPods充电盒,好像落你抽屉里了。"
|
||
"你方便的时候帮我寄一下吗,工位还要用。地址我等下发你。"),
|
||
|
||
# ── 05 · 21:45 HR 王姐 ──
|
||
("lv11_2145_hr_voice_01",
|
||
"hr",
|
||
"夏夏啊,离职流程你下周三之前办完哈,公积金那边记得跟进。"
|
||
"辛苦啦,外面有合适的工作我帮你留意。"),
|
||
|
||
# ── 06 · 22:00 安安 ──
|
||
("lv11_2200_anan_voice_01",
|
||
"anan",
|
||
"夏夏!我在helens!要不要过来!我请客!"),
|
||
|
||
# ── 07 · 22:15 寝室群 ──
|
||
("lv11_2215_dorm_a_voice_01", "dorm_a", "哎你们看,我今天升职了!"),
|
||
("lv11_2215_dorm_b_voice_01", "dorm_b", "真的啊!恭喜恭喜!太厉害了!"),
|
||
("lv11_2215_dorm_c_voice_01", "dorm_c", "厉害!那要请客啊!"),
|
||
("lv11_2215_dorm_a_voice_02", "dorm_a", "哈哈哈必须的!夏夏你怎么不说话呀"),
|
||
("lv11_2215_dorm_b_voice_02", "dorm_b", "夏夏在忙吧,别打扰她了"),
|
||
|
||
# ── 09 · 22:45 小美醉语音(分 4 段)──
|
||
("lv11_2245_xiaomei_voice_01_seg1",
|
||
"xiaomei_drunk",
|
||
"夏夏夏夏夏。你睡了吗?我跟你说,我跟你说啊……今天我喝多了……"),
|
||
("lv11_2245_xiaomei_voice_01_seg2",
|
||
"xiaomei_drunk",
|
||
"你别嫌我烦哈,我跟你说……其实啊……阿哲他,大三那年,他来追过我。"
|
||
"我没答应他。我那时候就觉得他这人,呵,差点意思。"),
|
||
("lv11_2245_xiaomei_voice_01_seg3",
|
||
"xiaomei_drunk",
|
||
"你跟他在一起的时候,我没说,因为说了也没意义嘛对吧。"
|
||
"现在他甩了你,我才敢说。"),
|
||
("lv11_2245_xiaomei_voice_01_seg4",
|
||
"xiaomei_drunk",
|
||
"夏夏,你听到了吗?你别恨我啊。……喂?算了。"),
|
||
|
||
# ── 10 · 23:00 妈妈文字 ──
|
||
("lv11_2300_mom_text_01", "mom", "睡了吗?"),
|
||
|
||
# ── 11 · 23:15 阿哲电话 ──
|
||
("lv11_2315_azhe_call_01", "azhe", "我能过去拿吗?就5分钟"),
|
||
|
||
# ── 12 · 23:30 周南第一条 ──
|
||
("lv11_2330_zhounan_voice_01",
|
||
"zhounan",
|
||
"林夏,是我,周南。你应该不记得我了吧,初三坐你后桌那个"),
|
||
|
||
# ── 13 · 23:45 周南长讲述(4 段)──
|
||
("lv11_2345_zhounan_voice_01",
|
||
"zhounan",
|
||
"其实我一直想找机会跟你说声谢谢。初三那年你借我笔记,那次考试我才没挂科。"),
|
||
("lv11_2345_zhounan_voice_02",
|
||
"zhounan",
|
||
"后来高考,我没考好。复读了一年,去了一个二本。然后就回老家了,没什么好说的。"),
|
||
("lv11_2345_zhounan_voice_03",
|
||
"zhounan",
|
||
"今年第一次来北京出差。来之前我想着,要不要找你。找了很久,才在一个校友群里看到你。"
|
||
"你现在过得怎么样?"),
|
||
("lv11_2345_zhounan_voice_04",
|
||
"zhounan",
|
||
"今天我生日。本来就想跟你说一声,对不起这么晚。"),
|
||
|
||
# ── 14 · 00:15 妈妈电话 ──
|
||
("lv11_0015_mom_call_01",
|
||
"mom",
|
||
"夏夏,你不接电话我有点担心。妈妈知道你工作忙,你早点睡。"),
|
||
|
||
# ── 16 · 02:00 妈妈长语音(分 8 段)──
|
||
("lv11_0200_mom_voice_01_seg1",
|
||
"mom",
|
||
"夏夏。妈妈知道你今天电话不接。妈妈不催你,就是想跟你说一件事。"),
|
||
("lv11_0200_mom_voice_01_seg2",
|
||
"mom",
|
||
"你24了对吧。妈妈24那年——你应该没听我说过——"),
|
||
("lv11_0200_mom_voice_01_seg3",
|
||
"mom",
|
||
"那时候妈妈在广州,给一个香港老板做秘书,1995年。"
|
||
"那年我也被分手过。也被开除过。一模一样的春天。"),
|
||
("lv11_0200_mom_voice_01_seg4",
|
||
"mom",
|
||
"那年我哭得特别难看。后来呢?后来就没什么后来了。"
|
||
"我回了老家,认识了你爸,生了你。"),
|
||
("lv11_0200_mom_voice_01_seg5",
|
||
"mom",
|
||
"我跟你说这个不是想跟你说什么都会过去——"
|
||
"我想跟你说,那年夏天我没死,是因为我外婆每天晚上都给我打一个电话。"),
|
||
("lv11_0200_mom_voice_01_seg6",
|
||
"mom",
|
||
"她不说什么,就问我今天吃饭了没。"),
|
||
("lv11_0200_mom_voice_01_seg7",
|
||
"mom",
|
||
"我现在打给你,你不接也没事。妈妈给你发个语音,你什么时候想听就听。你不用回我。"),
|
||
("lv11_0200_mom_voice_01_seg8",
|
||
"mom",
|
||
"你今天接了好多电话吧。妈妈不打扰你了。晚安。"),
|
||
|
||
# ── self-3y 三版(台词待编剧定稿)──
|
||
# ("lv11_0030_self-3y_voice_01", "self_3y", "(明朗版台词)"),
|
||
# ("lv11_0030_self-3y_voice_02", "self_3y", "(迷茫版台词)"),
|
||
# ("lv11_0030_self-3y_voice_03", "self_3y", "(温柔版台词)"),
|
||
]
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────
|
||
# 工具函数
|
||
# ──────────────────────────────────────────────────────────────
|
||
|
||
def find_ref_audio(source_dir, role_key):
|
||
"""在 source_dir/{dir}/ 下找到第一个音频文件作为参考音色"""
|
||
role_dir = os.path.join(source_dir, ROLES[role_key]["dir"])
|
||
if not os.path.isdir(role_dir):
|
||
return None
|
||
exts = ("*.wav", "*.mp3", "*.flac", "*.ogg")
|
||
for ext in exts:
|
||
files = sorted(glob.glob(os.path.join(role_dir, ext)))
|
||
if files:
|
||
return files[0]
|
||
return None
|
||
|
||
|
||
def wav_to_base64(wav_path):
|
||
"""读取 WAV 文件并转 base64"""
|
||
with open(wav_path, "rb") as f:
|
||
return base64.b64encode(f.read()).decode("utf-8")
|
||
|
||
|
||
def convert_to_wav(src_path, dst_path):
|
||
"""用 ffmpeg 把任意音频转为 16kHz mono WAV"""
|
||
cmd = [
|
||
"ffmpeg", "-y", "-i", src_path,
|
||
"-ar", "16000", "-ac", "1",
|
||
"-acodec", "pcm_s16le",
|
||
dst_path,
|
||
]
|
||
subprocess.run(cmd, check=True, capture_output=True)
|
||
|
||
|
||
def apply_speed(wav_path, speed, out_path):
|
||
"""用 ffmpeg atempo 调整语速"""
|
||
if abs(speed - 1.0) < 0.01:
|
||
# speed ≈ 1.0,直接复制
|
||
if wav_path != out_path:
|
||
import shutil
|
||
shutil.copy2(wav_path, out_path)
|
||
return
|
||
cmd = [
|
||
"ffmpeg", "-y", "-i", wav_path,
|
||
"-filter:a", f"atempo={speed}",
|
||
"-acodec", "pcm_s16le",
|
||
out_path,
|
||
]
|
||
subprocess.run(cmd, check=True, capture_output=True)
|
||
|
||
|
||
def check_server():
|
||
"""检查 VoxCPM 服务是否就绪"""
|
||
try:
|
||
r = requests.get(f"{BASE_URL}/health", timeout=5)
|
||
return r.status_code == 200
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def register_voice(audio_path):
|
||
"""向 VoxCPM 注册参考音色,返回 voice_id"""
|
||
b64 = wav_to_base64(audio_path)
|
||
r = requests.post(
|
||
f"{BASE_URL}/v1/voices",
|
||
json={"wav_base64": b64},
|
||
timeout=60,
|
||
)
|
||
r.raise_for_status()
|
||
return r.json()["voice_id"]
|
||
|
||
|
||
def synthesize(voice_id, text, cfg_value=2.0, inference_timesteps=10):
|
||
"""调用 VoxCPM 合成语音,返回 WAV bytes"""
|
||
r = requests.post(
|
||
f"{BASE_URL}/v1/speech",
|
||
json={
|
||
"text": text,
|
||
"voice_id": voice_id,
|
||
"cfg_value": cfg_value,
|
||
"inference_timesteps": inference_timesteps,
|
||
},
|
||
timeout=120,
|
||
)
|
||
r.raise_for_status()
|
||
return r.content
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────
|
||
# 主流程
|
||
# ──────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="《林夏》TTS 批量生产 — VoxCPM2 版")
|
||
parser.add_argument("--source", choices=["voice", "voice_mp3"], default="voice",
|
||
help="音色来源目录 (默认: voice)")
|
||
parser.add_argument("--role", help="只合成指定角色")
|
||
parser.add_argument("--test", action="store_true", help="只合成第一条")
|
||
parser.add_argument("--dry-run", action="store_true", help="只打印任务列表")
|
||
parser.add_argument("--cfg", type=float, default=2.0, help="VoxCPM cfg_value (默认: 2.0)")
|
||
parser.add_argument("--steps", type=int, default=10, help="VoxCPM inference_timesteps (默认: 10)")
|
||
args = parser.parse_args()
|
||
|
||
source_dir = os.path.join(PROJECT_DIR, args.source)
|
||
source_tag = "wav" if args.source == "voice" else "mp3"
|
||
|
||
lines = LINES
|
||
if args.role:
|
||
lines = [(f, r, t) for f, r, t in lines if r == args.role or r.startswith(args.role)]
|
||
if args.test:
|
||
lines = lines[:1]
|
||
|
||
print(f"\n{'=' * 60}")
|
||
print(f" 《林夏》TTS 批量合成 — VoxCPM2")
|
||
print(f" 音色源: {args.source}/ ({source_tag})")
|
||
print(f" 共 {len(lines)} 条")
|
||
print(f"{'=' * 60}")
|
||
|
||
# dry-run: 只打印任务
|
||
if args.dry_run:
|
||
voice_cache = {}
|
||
for f, r, t in lines:
|
||
ref = find_ref_audio(source_dir, r)
|
||
if ref is None:
|
||
print(f" [✗缺参考] {f} [{r}] {t[:30]}...")
|
||
continue
|
||
if r not in voice_cache:
|
||
voice_cache[r] = ref
|
||
count = len(glob.glob(os.path.join(source_dir, ROLES[r]["dir"], "*")))
|
||
extra = f" (目录有 {count} 个文件)" if count > 1 else ""
|
||
print(f" [✓] {f} [{r}] ref={os.path.basename(ref)}{extra} speed={ROLES[r]['speed']}")
|
||
else:
|
||
print(f" [✓] {f} [{r}] {t[:30]}...")
|
||
return
|
||
|
||
# 检查服务
|
||
if not check_server():
|
||
print(f"\n[错误] VoxCPM 服务未就绪: {BASE_URL}/health")
|
||
print(f"请先启动服务:")
|
||
print(f" bash audio/check_tts_env.sh")
|
||
print(f" 或手动启动:")
|
||
print(f" bash /home/xsl/AutoVideo/start-voxcpm.sh")
|
||
sys.exit(1)
|
||
|
||
print(f" 服务就绪: {BASE_URL}/health\n")
|
||
|
||
# 预处理:转换 MP3 → WAV,注册音色
|
||
voice_ids = {} # role_key → voice_id
|
||
tmp_wav_dir = tempfile.mkdtemp(prefix="voxcpm_ref_")
|
||
|
||
for role_key in set(r for _, r, _ in lines):
|
||
ref_path = find_ref_audio(source_dir, role_key)
|
||
if ref_path is None:
|
||
print(f" [跳过] {role_key}: 未找到参考音频 ({source_dir}/{ROLES[role_key]['dir']}/)")
|
||
continue
|
||
|
||
# 如果是 MP3 或其他格式,先转为 WAV
|
||
if ref_path.lower().endswith(".wav"):
|
||
wav_path = ref_path
|
||
else:
|
||
wav_path = os.path.join(tmp_wav_dir, f"{role_key}_ref.wav")
|
||
print(f" 转换参考音频: {os.path.basename(ref_path)} → WAV ...", end=" ", flush=True)
|
||
convert_to_wav(ref_path, wav_path)
|
||
print("ok")
|
||
|
||
# 注册音色
|
||
print(f" 注册音色: {role_key} ({os.path.basename(ref_path)}) ...", end=" ", flush=True)
|
||
try:
|
||
vid = register_voice(wav_path)
|
||
voice_ids[role_key] = vid
|
||
print(f"ok voice_id={vid}")
|
||
except Exception as e:
|
||
print(f"失败: {e}")
|
||
|
||
print()
|
||
|
||
# 合成
|
||
ok_count, skip_count, fail_count = 0, 0, 0
|
||
os.makedirs(OUT_DIR, exist_ok=True)
|
||
|
||
for i, (filename, role_key, text) in enumerate(lines, 1):
|
||
out_path = os.path.join(OUT_DIR, f"{filename}_{source_tag}_v1.wav")
|
||
|
||
if os.path.exists(out_path):
|
||
print(f" [{i}/{len(lines)}] [跳过,已存在] {filename}")
|
||
ok_count += 1
|
||
continue
|
||
|
||
if role_key not in voice_ids:
|
||
print(f" [{i}/{len(lines)}] [跳过,无音色] {role_key}: {filename}")
|
||
skip_count += 1
|
||
continue
|
||
|
||
print(f" [{i}/{len(lines)}] {filename} [{role_key}] ...", end=" ", flush=True)
|
||
t0 = time.time()
|
||
|
||
try:
|
||
wav_bytes = synthesize(
|
||
voice_ids[role_key], text,
|
||
cfg_value=args.cfg,
|
||
inference_timesteps=args.steps,
|
||
)
|
||
|
||
# 先写原始合成结果到临时文件
|
||
tmp_out = out_path + ".raw.wav"
|
||
with open(tmp_out, "wb") as f:
|
||
f.write(wav_bytes)
|
||
|
||
# 语速调整
|
||
speed = ROLES[role_key]["speed"]
|
||
apply_speed(tmp_out, speed, out_path)
|
||
|
||
# 清理临时文件
|
||
if os.path.exists(tmp_out) and tmp_out != out_path:
|
||
os.remove(tmp_out)
|
||
|
||
elapsed = time.time() - t0
|
||
duration = len(wav_bytes) / 48000 / 2 # 粗估 48kHz 16bit
|
||
print(f"✓ {elapsed:.1f}s speed={speed}")
|
||
|
||
ok_count += 1
|
||
|
||
except Exception as e:
|
||
print(f"✗ {e}")
|
||
fail_count += 1
|
||
|
||
# 清理临时 WAV
|
||
import shutil
|
||
shutil.rmtree(tmp_wav_dir, ignore_errors=True)
|
||
|
||
print(f"\n{'=' * 60}")
|
||
print(f" 完成:{ok_count} 条 跳过:{skip_count} 条 失败:{fail_count} 条")
|
||
print(f" 输出目录:{OUT_DIR}")
|
||
print(f" 文件标记:*_{source_tag}_v1.wav")
|
||
print(f"{'=' * 60}\n")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|