Files
blind/audio/batch_tts_voxcpm.py
T
xsl 12eb1eab44 VoxCPM2 v5 音色克隆:脚本、参考音频、游戏对白与状态文档
通过 Voice Design 试听选定各角色参考音色,Style Control 全量克隆并部署到 audio/mp3/tts;清理未引用音频与 audio_1 旧产物。
2026-05-24 00:34:50 +08:00

690 lines
30 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
"""
《林夏》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 = {
"linxia": {
"dir": "linxia",
"speed": 1.00,
},
"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.00,
},
"self_3y": {
"dir": "linxia",
"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", "(温柔版台词)"),
# ══════════════════════════════════════════════════════════
# v2: 林夏回应 + NPC 反应(Round 2 & 3
# ══════════════════════════════════════════════════════════
# ── 01 · 林夏自动回应 ──
("v2_msg01_linxia_auto", "linxia", "嗯……好。"),
# ── 02 · 小美 ──
# 林夏回应
("v2_msg02_linxia_warm", "linxia", "好啊,明天不见不散。"),
("v2_msg02_linxia_cold", "linxia", "再说吧。"),
("v2_msg02_linxia_curious", "linxia", "你怎么知道的啊?"),
("v2_msg02_linxia_tap", "linxia", "嗯。"),
# 小美反应
("v2_msg02_react_warm", "xiaomei", "行!那我叫上安安!老地方见!别放鸽子啊!"),
("v2_msg02_react_cold", "xiaomei", "……好吧。你要是改主意了随时说。"),
("v2_msg02_react_curious", "xiaomei",
"还不是阿哲自己发朋友圈!你不知道吗?他发了又秒删……"),
("v2_msg02_react_silent", "xiaomei", "??你还活着吗哈哈。好了不烦你了,有事找我。"),
("v2_msg02_react_tap", "xiaomei", "嗯是什么意思啊哈哈!行吧你休息。"),
# Round 3 (追问路径)
("v2_msg02_r3_linxia_curious", "linxia", "他……发了什么?"),
("v2_msg02_r3_react", "xiaomei",
"就发了一张你们以前的照片,配了一句'值得'。两分钟就删了。"),
# ── 03 · 外卖 · 林夏自动回应 ──
("v2_msg03_linxia_auto", "linxia", "好,我下来拿。"),
# ── 04 · 阿哲 ──
# 林夏回应
("v2_msg04_linxia_warm", "linxia", "你那边……还好吗。"),
("v2_msg04_linxia_cold", "linxia", "知道了。明天寄。"),
("v2_msg04_linxia_curious", "linxia", "你怎么不自己来拿。"),
("v2_msg04_linxia_tap", "linxia", "好。"),
# 阿哲反应
("v2_msg04_react_warm", "azhe", "我……挺好的。你呢?"),
("v2_msg04_react_cold", "azhe", "嗯……谢了。"),
("v2_msg04_react_curious", "azhe", "我……不太方便过去。"),
("v2_msg04_react_silent", "azhe", "那……我晚点再说吧。"),
("v2_msg04_react_tap", "azhe", "谢谢。"),
# Round 3 温暖路径 (阿哲问"你呢?"后)
("v2_msg04_r3w_linxia_warm", "linxia", "不太好。"),
("v2_msg04_r3w_linxia_cold", "linxia", "挺好的。"),
("v2_msg04_r3w_react_warm", "azhe", "……对不起。"),
("v2_msg04_r3w_react_cold", "azhe", "那就好。"),
("v2_msg04_r3w_react_silent", "azhe", "……那,晚安。"),
# Round 3 追问路径 (阿哲说"不太方便"后)
("v2_msg04_r3c_linxia_curious", "linxia", "为什么。"),
("v2_msg04_r3c_linxia_warm", "linxia", "算了,不问了。"),
("v2_msg04_r3c_react_curious", "azhe", "我……没什么。你寄吧。"),
# ── 05 · HR 王姐 ──
# 林夏回应
("v2_msg05_linxia_warm", "linxia", "谢谢王姐。"),
("v2_msg05_linxia_cold", "linxia", "好的,知道了。"),
("v2_msg05_linxia_curious", "linxia", "王姐……有什么建议吗?"),
("v2_msg05_linxia_tap", "linxia", "好。"),
# 王姐反应
("v2_msg05_react_warm", "hr", "别客气。你还年轻,出去看看也好。这行业你知道的。"),
("v2_msg05_react_curious", "hr", "你想听真话?别在这行耗了。你有本事,去试试别的。"),
# ── 06 · 安安 ──
# 林夏回应
("v2_msg06_linxia_warm", "linxia", "在哪儿啊?我看看吧。"),
("v2_msg06_linxia_cold", "linxia", "不了,你们玩好。"),
("v2_msg06_linxia_curious", "linxia", "你喝了多少了?"),
("v2_msg06_linxia_tap", "linxia", "嗯。"),
# 安安反应
("v2_msg06_react_warm", "anan", "对面那个!我给你留了位置!赶紧赶紧!"),
("v2_msg06_react_cold", "anan", "好吧!那我给你寄一杯!"),
("v2_msg06_react_curious", "anan", "才两杯!我清醒得很!……好吧三杯。快来!"),
("v2_msg06_react_silent", "anan", "喂?信号不好吗?好吧以后再约!"),
("v2_msg06_react_tap", "anan", "嗯是来还是不来啊!算了回头聊!"),
# ── 07 · 寝室群 ──
# 林夏回应
("v2_msg07_linxia_warm", "linxia", "恭喜恭喜!太厉害了!"),
("v2_msg07_linxia_cold", "linxia", "恭喜。"),
("v2_msg07_linxia_curious", "linxia", "怎么升的啊?讲讲!"),
# 群反应
("v2_msg07_react_warm", "dorm_a", "哈哈谢谢!我请客!你最近怎么样啊?"),
("v2_msg07_react_curious", "dorm_a", "说来话长!我们老板走了我就顶上了哈哈"),
("v2_msg07_react_tap", "dorm_b", "林夏发表情了哈哈"),
# Round 3 温暖路径 (室友A问"你最近怎么样"后)
("v2_msg07_r3_linxia_warm", "linxia", "挺好的~你请客我去!"),
("v2_msg07_r3_linxia_cold", "linxia", "还行。"),
("v2_msg07_r3_react_cold", "dorm_b", "你是不是有事啊?没事可以跟我们说"),
# ── 09 · 小美(醉)──
# 林夏回应
("v2_msg09_linxia_warm", "linxia", "小美……你没事吧。"),
("v2_msg09_linxia_cold", "linxia", "我知道了。"),
("v2_msg09_linxia_curious", "linxia", "你……为什么现在才说。"),
("v2_msg09_linxia_tap", "linxia", "嗯。"),
# 小美反应
("v2_msg09_react_warm", "xiaomei_drunk",
"我没事……我就是喝多了想告诉你……你别怪我。"),
("v2_msg09_react_curious", "xiaomei_drunk",
"因为你今天……我怕你以后恨我……"),
("v2_msg09_react_tap", "xiaomei_drunk",
"你不生气吗?算了……当我没说。"),
# Round 3 追问路径 (小美说"怕你恨我"后)
("v2_msg09_r3_linxia_warm", "linxia", "我不恨你。"),
("v2_msg09_r3_linxia_cold", "linxia", "我得想想。"),
("v2_msg09_r3_react_warm", "xiaomei_drunk", "谢谢你……谢谢你林夏。"),
("v2_msg09_r3_react_cold", "xiaomei_drunk", "好……对不起。"),
("v2_msg09_r3_react_silent_q", "xiaomei_drunk", "你还在吗?"),
# ── 10 · 妈妈文字 ──
# 林夏回应
("v2_msg10_linxia_warm", "linxia", "没有,怎么了?"),
("v2_msg10_linxia_cold", "linxia", "嗯,要睡了。"),
("v2_msg10_linxia_curious", "linxia", "妈……你能打电话给我吗?"),
("v2_msg10_linxia_tap", "linxia", "嗯。"),
# 妈妈反应
("v2_msg10_react_warm", "mom", "没事,就是想起来问一下。今天怎么样?"),
("v2_msg10_react_cold", "mom", "好,早点睡。"),
("v2_msg10_react_silent", "mom", "看到了回一下。"),
("v2_msg10_react_tap", "mom", "嗯就是睡了?晚安。"),
# 追问路径:妈妈打来电话
("v2_msg10_call_mom", "mom", "怎么了夏夏?你声音不对。"),
("v2_msg10_call_linxia_warm", "linxia", "没什么……就是有点累。"),
("v2_msg10_call_linxia_curious", "linxia", "妈,我辞职了。"),
("v2_msg10_call_react_warm", "mom", "累了就回家歇几天。妈煮了排骨。"),
("v2_msg10_call_react_curious", "mom", "辞了就辞了。回来再说。"),
("v2_msg10_call_react_silent", "mom", "不想说就不说。妈在这呢。"),
# 温暖路径 Round 3 (妈妈问"今天怎么样"后)
("v2_msg10_rw_linxia_warm", "linxia", "还好。"),
("v2_msg10_rw_linxia_curious", "linxia", "不太好。"),
("v2_msg10_rw_react_warm", "mom", "那就好。别太晚了。"),
("v2_msg10_rw_react_curious", "mom", "怎么了?"),
# ── 11 · 阿哲电话 ──
# 林夏回应
("v2_msg11_linxia_warm", "linxia", "可以。但只有5分钟。"),
("v2_msg11_linxia_cold", "linxia", "不方便。我明天寄给你。"),
("v2_msg11_linxia_curious", "linxia", "为什么非要今天来?"),
("v2_msg11_linxia_tap", "linxia", "不了。"),
# 阿哲反应
("v2_msg11_react_warm", "azhe", "好,我马上过来。谢谢。"),
("v2_msg11_react_cold", "azhe", "好吧……那谢了。"),
("v2_msg11_react_curious", "azhe", "我……明天要出差。就5分钟。"),
("v2_msg11_react_tap", "azhe", "好。"),
# ── 12 · 周南 ──
# 林夏回应
("v2_msg12_linxia_warm", "linxia", "周南!你还记得我啊!"),
("v2_msg12_linxia_cold", "linxia", "嗯,你好。"),
("v2_msg12_linxia_curious", "linxia", "你怎么有我微信的?"),
("v2_msg12_linxia_tap", "linxia", "嗯……好久不见。"),
# 周南反应
("v2_msg12_react_warm", "zhounan", "当然记得。你是我们班最安静的那个。"),
("v2_msg12_react_cold", "zhounan", "嗯……好久不见。你现在在哪呢?"),
("v2_msg12_react_curious", "zhounan",
"你忘了吗?毕业那天你加的我。不过你可能早忘了哈哈。"),
("v2_msg12_react_silent", "zhounan",
"你是不是不记得我了?没关系的。就是突然想说声嗨。"),
("v2_msg12_react_tap", "zhounan", "是啊,好久不见。"),
# ── 13 · 周南长语音 ──
# 林夏回应
("v2_msg13_linxia_warm", "linxia", "一个人在北京,还好吗?"),
("v2_msg13_linxia_cold", "linxia", "你讲了好多。"),
("v2_msg13_linxia_curious", "linxia", "……生日快乐。"),
("v2_msg13_linxia_tap", "linxia", "嗯,我都听到了。"),
# 周南反应
("v2_msg13_react_warm", "zhounan", "还行吧。习惯了。你呢?你在北京过得好吗?"),
("v2_msg13_react_cold", "zhounan", "是吗?不好意思,话太多了。"),
("v2_msg13_react_curious", "zhounan", "谢谢你。你是今天第一个跟我说的。"),
("v2_msg13_react_silent", "zhounan", "不好意思突然说这么多……那,晚安。"),
("v2_msg13_react_tap", "zhounan", "嗯。谢谢你听我说。"),
# Round 3 温暖路径 (周南问"你在北京过得好吗"后)
("v2_msg13_r3_linxia_warm", "linxia", "不太好。今天也是我的坏日子。"),
("v2_msg13_r3_linxia_cold", "linxia", "还行吧。"),
("v2_msg13_r3_linxia_curious", "linxia", "你为什么突然联系我?"),
("v2_msg13_r3_react_warm", "zhounan", "那我们算是难兄难妹了。"),
("v2_msg13_r3_react_cold", "zhounan", "那就好。"),
("v2_msg13_r3_react_curious", "zhounan",
"因为……今天生日嘛,想起以前的事。想起你。"),
# ── 14 · 妈妈第二次电话 ──
("v2_msg14_linxia_auto", "linxia", "嗯,我没事。"),
("v2_msg14_mom_ask", "mom", "真的没事?"),
("v2_msg14_react_linked", "mom", "好。那早点睡。妈爱你。"),
("v2_msg14_react_unlinked", "mom", "你今天怎么都不理妈妈?出什么事了吗?"),
# ── 结局 E · 周南秒回 ──
("v2_ending_e_zhounan", "zhounan", "那我下次出差来北京,请你吃饭好不好。"),
# ── 通用林夏短句(备用) ──
("v2_linxia_generic_hmm", "linxia", "嗯。"),
("v2_linxia_generic_ok", "linxia", "好。"),
("v2_linxia_generic_warm_ok", "linxia", "嗯~好啊。"),
("v2_linxia_generic_cold_know", "linxia", "知道了。"),
("v2_linxia_generic_cold_fine", "linxia", "行吧。"),
("v2_linxia_generic_curious", "linxia", "嗯?"),
("v2_linxia_generic_then", "linxia", "然后呢?"),
# ══════════════════════════════════════════════════════════
# v2 收尾:结局旁白 + 系统提示 + 分支补全
# ══════════════════════════════════════════════════════════
# ── 结局旁白(林夏轻声自语) ──
("v2_ending_a_linxia", "linxia", "明天再说。"),
("v2_ending_b_linxia", "linxia", "寄出去了。"),
("v2_ending_c_linxia", "linxia", "今晚就这样。"),
("v2_ending_d_linxia", "linxia", "早上好。"),
("v2_ending_e_linxia", "linxia", "好。"),
# ── 系统语音提示(林夏声线) ──
("v2_sys_wait_reply", "linxia", "等待回复。"),
("v2_sys_paused", "linxia", "游戏已暂停。单击继续,三指点击退出。"),
("v2_sys_content_warning", "linxia",
"本游戏涉及孤独、家庭关系等情感话题,请在舒适的状态下体验。"),
("v2_sys_hotline", "linxia",
"如需倾诉,全国心理援助热线,400-161-9995。"),
# ── 分支补全:MSG-10/11 沉默路径 NPC 反应 ──
("v2_msg10_rw_react_silent", "mom", "那早点睡啊。"),
("v2_msg10_call_react_down", "mom", "没事就好。早点睡。"),
("v2_msg11_r3_react_silent", "azhe", "行吧,那我不来了。"),
]
# ──────────────────────────────────────────────────────────────
# 工具函数
# ──────────────────────────────────────────────────────────────
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()