#!/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", "然后呢?"), # ══════════════════════════════════════════════════════════ # 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()