- 用VoxCPM2生成128条v2语音(林夏回应+NPC反应+Round3对话) - story.js全部切换到VoxCPM2音频路径 - engine.js: BGM音量减半(0.2→0.1)、MSG-14前导语支持、结局E用周南新语音 - 林夏.md完整重写:6手势交互、主角有声音、VoxCPM2本地TTS方案 - batch_tts_voxcpm.py: 新增linxia角色、修复self_3y音色、添加全部v2台词 - 新增voice/linxia/参考音色 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
666 lines
28 KiB
Python
666 lines
28 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 = {
|
||
"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.20,
|
||
},
|
||
"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", "然后呢?"),
|
||
]
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────
|
||
# 工具函数
|
||
# ──────────────────────────────────────────────────────────────
|
||
|
||
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()
|