Files
2026-05-18 18:39:49 +08:00

328 lines
12 KiB
Python
Raw Permalink 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 批量生产脚本
用法:
conda activate cosyvoice
cd /home/xsl/blind
python audio/batch_tts.py [--role mom] [--test]
参数:
--role ROLE 只生产指定角色(不指定则全部)
--test 只合成第一条,用于快速验证环境
--dry-run 只打印任务列表,不实际合成
"""
import os, sys, time, argparse, warnings
import torch
import soundfile as sf # torchaudio nightly requires torchcodec; use soundfile instead
warnings.filterwarnings("ignore")
COSYVOICE_DIR = "/home/xsl/tools/CosyVoice"
PROJECT_DIR = "/home/xsl/blind"
REF_DIR = f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref"
OUT_DIR = f"{PROJECT_DIR}/audio/00_raw/tts"
sys.path.insert(0, COSYVOICE_DIR)
sys.path.insert(0, f"{COSYVOICE_DIR}/third_party/Matcha-TTS")
os.chdir(COSYVOICE_DIR)
# ──────────────────────────────────────────────────────────────
# 角色配置(对应 SOP §4.2
# ref_wav / ref_text 在参考音频备齐前先留空
# ──────────────────────────────────────────────────────────────
ROLES = {
"mom": {
"speed": 0.92,
"ref_wav": f"{REF_DIR}/mom_ref.wav",
"ref_text": "", # ← 参考音频对应文字,备齐后填入
},
"azhe": {
"speed": 1.05,
"ref_wav": f"{REF_DIR}/azhe_ref.wav",
"ref_text": "",
},
"xiaomei": {
"speed": 1.10,
"ref_wav": f"{REF_DIR}/xiaomei_ref.wav",
"ref_text": "",
},
"xiaomei_drunk": { # 醉语音用同参考,speed 调慢
"speed": 0.95,
"ref_wav": f"{REF_DIR}/xiaomei_ref.wav",
"ref_text": "",
},
"zhounan": {
"speed": 0.95,
"ref_wav": f"{REF_DIR}/zhounan_ref.wav",
"ref_text": "",
},
"hr": {
"speed": 1.00,
"ref_wav": f"{REF_DIR}/hr_ref.wav",
"ref_text": "",
},
"anan": {
"speed": 1.15,
"ref_wav": f"{REF_DIR}/anan_ref.wav",
"ref_text": "",
},
"dorm_a": {
"speed": 1.10,
"ref_wav": f"{REF_DIR}/dorm_a_ref.wav",
"ref_text": "",
},
"dorm_b": {
"speed": 1.00,
"ref_wav": f"{REF_DIR}/dorm_b_ref.wav",
"ref_text": "",
},
"dorm_c": {
"speed": 0.95,
"ref_wav": f"{REF_DIR}/dorm_c_ref.wav",
"ref_text": "",
},
"delivery": {
"speed": 1.20,
"ref_wav": f"{REF_DIR}/delivery_ref.wav",
"ref_text": "",
},
"self_3y": { # 复用 xiaomeispeed +0.05
"speed": 1.15,
"ref_wav": f"{REF_DIR}/xiaomei_ref.wav",
"ref_text": "",
},
# system 用预训练音色,不在这里
}
# ──────────────────────────────────────────────────────────────
# 台词表(对应林夏.md §3 + §5)
# 格式: (输出文件前缀, 角色key, 台词)
# 长戏(≥30s)已按 SOP §5.3 拆分为分段
# ──────────────────────────────────────────────────────────────
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 load_model():
from cosyvoice.cli.cosyvoice import CosyVoice2
print("加载 CosyVoice2 模型...")
model = CosyVoice2("pretrained_models/CosyVoice2-0.5B")
print(f"模型就绪,采样率 {model.sample_rate}Hz")
return model
def load_ref(role_cfg):
from cosyvoice.utils.file_utils import load_wav
wav_path = role_cfg["ref_wav"]
if not os.path.exists(wav_path):
return None, None
wav = load_wav(wav_path, 16000)
# 裁到 30s 以内
max_samples = 16000 * 30
if wav.shape[1] > max_samples:
wav = wav[:, :max_samples]
return wav, role_cfg["ref_text"]
def synthesize(model, filename, role_key, text):
role_cfg = ROLES[role_key]
ref_wav, ref_text = load_ref(role_cfg)
out_path = f"{OUT_DIR}/{filename}_v1.wav"
if os.path.exists(out_path):
print(f" [跳过,已存在] {filename}")
return True
if ref_wav is None:
print(f" [跳过,缺参考音频] {role_key}: {role_cfg['ref_wav']}")
return False
if not ref_text.strip():
print(f" [跳过,ref_text 未填] {role_key}")
return False
t0 = time.time()
chunks = []
for chunk in model.inference_zero_shot(
tts_text=text,
prompt_text=ref_text,
prompt_speech_16k=ref_wav,
stream=False,
speed=role_cfg["speed"],
):
chunks.append(chunk["tts_speech"])
if not chunks:
print(f" [失败,无输出] {filename}")
return False
audio = torch.concat(chunks, dim=1)
duration = audio.shape[1] / model.sample_rate
sf.write(out_path, audio.squeeze().cpu().numpy(), model.sample_rate)
elapsed = time.time() - t0
rtf = elapsed / duration if duration > 0 else 0
print(f" ✓ {filename} {duration:.1f}s 音频 {elapsed:.1f}s 耗时 RTF={rtf:.3f}")
return True
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--role", help="只合成指定角色")
parser.add_argument("--test", action="store_true", help="只合成第一条")
parser.add_argument("--dry-run", action="store_true", help="只打印任务")
args = parser.parse_args()
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 批量合成 共 {len(lines)} 条")
print(f"{'='*60}")
if args.dry_run:
for f, r, t in lines:
ref_exists = "✓" if os.path.exists(ROLES.get(r, {}).get("ref_wav", "")) else "✗缺参考"
print(f" [{ref_exists}] {f} [{r}] {t[:30]}...")
return
model = load_model()
ok, skip, fail = 0, 0, 0
for i, (filename, role_key, text) in enumerate(lines, 1):
print(f"\n[{i}/{len(lines)}] {filename}")
result = synthesize(model, filename, role_key, text)
if result is True: ok += 1
elif result is None: skip += 1
else: fail += 1
print(f"\n{'='*60}")
print(f" 完成:{ok} 条 跳过:{skip} 条 失败:{fail} 条")
print(f" 输出目录:{OUT_DIR}")
print(f"{'='*60}\n")
if __name__ == "__main__":
main()