从github迁移过来
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
#!/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": { # 复用 xiaomei,speed +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()
|
||||
@@ -0,0 +1,463 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# VoxCPM TTS chain health check for blind/audio workflow.
|
||||
# Usage:
|
||||
# bash audio/check_tts_env.sh # quick check
|
||||
# bash audio/check_tts_env.sh --full # quick check + test synthesis
|
||||
|
||||
HOST="${VOXCPM_HOST:-127.0.0.1}"
|
||||
PORT="${VOXCPM_PORT:-8000}"
|
||||
HEALTH_URL="http://${HOST}:${PORT}/health"
|
||||
START_SCRIPT="${VOXCPM_START_SCRIPT:-/home/xsl/AutoVideo/start-voxcpm.sh}"
|
||||
MODEL_DIR="${VOXCPM_MODEL_DIR:-/home/xsl/models/VoxCPM2}"
|
||||
VENV_PYTHON="${VOXCPM_PYTHON:-/home/xsl/tts-server/.venv/bin/python}"
|
||||
LOG_FILE="${VOXCPM_LOG_FILE:-/tmp/voxcpm-server.log}"
|
||||
FULL_CHECK=0
|
||||
|
||||
if [[ "${1:-}" == "--full" ]]; then
|
||||
FULL_CHECK=1
|
||||
fi
|
||||
|
||||
ok() { echo "[OK] $*"; }
|
||||
warn() { echo "[WARN] $*"; }
|
||||
err() { echo "[ERROR] $*"; exit 1; }
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || err "缺少命令: $cmd"
|
||||
}
|
||||
|
||||
wait_health() {
|
||||
local max_retry="${1:-30}"
|
||||
local i=1
|
||||
while [[ "$i" -le "$max_retry" ]]; do
|
||||
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
((i++))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
print_startup_hint() {
|
||||
[[ -f "$LOG_FILE" ]] || return 0
|
||||
if grep -q "speech_zipenhancer_ans_multiloss_16k_base" "$LOG_FILE"; then
|
||||
warn "检测到降噪模型下载失败(ModelScope),常见原因是 DNS/网络不可达。"
|
||||
warn "建议:先确保可访问 modelscope,或在服务端改为可关闭 denoiser。"
|
||||
elif grep -q "ConnectionError" "$LOG_FILE"; then
|
||||
warn "检测到网络连接错误,请检查当前网络和 DNS。"
|
||||
fi
|
||||
}
|
||||
|
||||
start_server() {
|
||||
local denoiser="${1:-1}"
|
||||
VOXCPM_ENABLE_DENOISER="$denoiser" nohup "$START_SCRIPT" >"$LOG_FILE" 2>&1 &
|
||||
local pid=$!
|
||||
ok "已发起启动,PID=$pid,日志: $LOG_FILE (denoiser=$denoiser)"
|
||||
if wait_health 90; then
|
||||
ok "服务启动成功: $HEALTH_URL"
|
||||
return 0
|
||||
fi
|
||||
if kill -0 "$pid" >/dev/null 2>&1; then
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
is_port_busy() {
|
||||
ss -lnt | awk '{print $4}' | grep -qE "(^|:)${PORT}$"
|
||||
}
|
||||
|
||||
quick_checks() {
|
||||
echo "=== VoxCPM 快速环境检查 ==="
|
||||
require_cmd curl
|
||||
require_cmd ffmpeg
|
||||
require_cmd python3
|
||||
require_cmd ss
|
||||
|
||||
[[ -f "$VENV_PYTHON" ]] || err "Python 虚拟环境不存在: $VENV_PYTHON"
|
||||
[[ -x "$START_SCRIPT" ]] || warn "启动脚本不可执行,建议: chmod +x $START_SCRIPT"
|
||||
[[ -f "$START_SCRIPT" ]] || err "启动脚本不存在: $START_SCRIPT"
|
||||
[[ -d "$MODEL_DIR" ]] || err "模型目录不存在: $MODEL_DIR"
|
||||
|
||||
ffmpeg -version >/dev/null 2>&1 || err "FFmpeg 不可用"
|
||||
ok "基础依赖可用(curl / ffmpeg / python3 / ss)"
|
||||
ok "模型目录存在: $MODEL_DIR"
|
||||
ok "虚拟环境存在: $VENV_PYTHON"
|
||||
}
|
||||
|
||||
ensure_server() {
|
||||
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
||||
ok "TTS 服务已运行: $HEALTH_URL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "服务未就绪,尝试启动: $START_SCRIPT"
|
||||
if is_port_busy; then
|
||||
warn "端口 $PORT 已被占用,但 /health 不可达,请检查占用进程"
|
||||
fi
|
||||
|
||||
if start_server 1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_startup_hint
|
||||
if grep -q "speech_zipenhancer_ans_multiloss_16k_base" "$LOG_FILE"; then
|
||||
warn "检测到 denoiser 远端依赖失败,自动回退到 denoiser=0 重试。"
|
||||
if start_server 0; then
|
||||
warn "当前服务以 denoiser=0 运行(更稳,降噪关闭)。"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_startup_hint
|
||||
err "服务启动超时,查看日志: $LOG_FILE"
|
||||
}
|
||||
|
||||
full_check() {
|
||||
echo "=== VoxCPM 全链路测试(注册音色 + 合成) ==="
|
||||
local ref_wav="/tmp/voxcpm_check_ref.wav"
|
||||
local out_wav="/tmp/voxcpm_check_tts.wav"
|
||||
|
||||
ffmpeg -f lavfi -i "sine=frequency=220:duration=3" -ar 16000 -ac 1 -y "$ref_wav" >/dev/null 2>&1
|
||||
ok "生成测试参考音频: $ref_wav"
|
||||
|
||||
local payload
|
||||
payload="$(python3 - <<'PY'
|
||||
import base64, json
|
||||
with open("/tmp/voxcpm_check_ref.wav", "rb") as f:
|
||||
print(json.dumps({"wav_base64": base64.b64encode(f.read()).decode("utf-8")}))
|
||||
PY
|
||||
)"
|
||||
|
||||
local voice_resp
|
||||
voice_resp="$(curl -fsS -X POST "http://${HOST}:${PORT}/v1/voices" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload")"
|
||||
|
||||
local voice_id
|
||||
voice_id="$(VOICE_RESP="$voice_resp" python3 - <<'PY'
|
||||
import json, os
|
||||
resp = json.loads(os.environ["VOICE_RESP"])
|
||||
print(resp.get("voice_id", ""))
|
||||
PY
|
||||
)"
|
||||
[[ -n "$voice_id" ]] || err "注册音色失败: $voice_resp"
|
||||
ok "注册测试音色成功: $voice_id"
|
||||
|
||||
local speech_payload
|
||||
speech_payload="$(VOICE_ID="$voice_id" python3 - <<'PY'
|
||||
import json, os
|
||||
print(json.dumps({
|
||||
"text": "这是 VoxCPM 环境自检音频。",
|
||||
"voice_id": os.environ["VOICE_ID"],
|
||||
"cfg_value": 2.0,
|
||||
"inference_timesteps": 10
|
||||
}))
|
||||
PY
|
||||
)"
|
||||
|
||||
local status_code
|
||||
status_code="$(curl -sS -o "$out_wav" -w "%{http_code}" \
|
||||
-X POST "http://${HOST}:${PORT}/v1/speech" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$speech_payload")"
|
||||
|
||||
[[ "$status_code" == "200" ]] || err "语音合成失败,HTTP $status_code"
|
||||
[[ -s "$out_wav" ]] || err "语音合成输出为空: $out_wav"
|
||||
ok "语音合成成功: $out_wav"
|
||||
}
|
||||
|
||||
quick_checks
|
||||
ensure_server
|
||||
if [[ "$FULL_CHECK" -eq 1 ]]; then
|
||||
full_check
|
||||
fi
|
||||
|
||||
echo "=== 检查完成:可开始生产 ==="
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Check reference voice library completeness for batch TTS.
|
||||
# Usage:
|
||||
# bash audio/check_voice_library.sh
|
||||
|
||||
BASE_DIR="/home/xsl/blind/audio/00_raw/tts/prompt_ref"
|
||||
MANIFEST="${BASE_DIR}/ref_manifest.txt"
|
||||
|
||||
REQUIRED_FILES=(
|
||||
"mom_ref.wav"
|
||||
"azhe_ref.wav"
|
||||
"xiaomei_ref.wav"
|
||||
"zhounan_ref.wav"
|
||||
"hr_ref.wav"
|
||||
"anan_ref.wav"
|
||||
"dorm_a_ref.wav"
|
||||
"dorm_b_ref.wav"
|
||||
"dorm_c_ref.wav"
|
||||
"delivery_ref.wav"
|
||||
)
|
||||
|
||||
ok() { echo "[OK] $*"; }
|
||||
warn() { echo "[WARN] $*"; }
|
||||
err() { echo "[ERROR] $*"; }
|
||||
|
||||
missing_count=0
|
||||
warn_count=0
|
||||
|
||||
command -v ffprobe >/dev/null 2>&1 || {
|
||||
err "缺少 ffprobe(请安装 ffmpeg)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "=== 角色音色库检查 ==="
|
||||
echo "目录: $BASE_DIR"
|
||||
echo ""
|
||||
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
err "缺少清单文件: $MANIFEST"
|
||||
((missing_count+=1))
|
||||
fi
|
||||
|
||||
for f in "${REQUIRED_FILES[@]}"; do
|
||||
path="${BASE_DIR}/${f}"
|
||||
if [[ ! -f "$path" ]]; then
|
||||
err "缺少参考音频: $f"
|
||||
((missing_count+=1))
|
||||
continue
|
||||
fi
|
||||
|
||||
sample_rate="$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=noprint_wrappers=1:nokey=1 "$path" || true)"
|
||||
channels="$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=noprint_wrappers=1:nokey=1 "$path" || true)"
|
||||
duration="$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$path" || true)"
|
||||
duration_int="${duration%.*}"
|
||||
|
||||
if [[ -z "$sample_rate" || -z "$channels" || -z "$duration" ]]; then
|
||||
warn "无法读取音频信息: $f"
|
||||
((warn_count+=1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$sample_rate" -lt 16000 ]]; then
|
||||
warn "$f 采样率 ${sample_rate}Hz,低于 16kHz"
|
||||
((warn_count+=1))
|
||||
fi
|
||||
if [[ "$channels" -ne 1 ]]; then
|
||||
warn "$f 声道数 ${channels},建议为单声道"
|
||||
((warn_count+=1))
|
||||
fi
|
||||
if [[ "$duration_int" -lt 5 || "$duration_int" -gt 30 ]]; then
|
||||
warn "$f 时长 ${duration}s,建议 5-30s"
|
||||
((warn_count+=1))
|
||||
fi
|
||||
|
||||
ok "$f 通过基础检测 (${sample_rate}Hz/${channels}ch/${duration}s)"
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [[ -f "$MANIFEST" ]]; then
|
||||
for f in "${REQUIRED_FILES[@]}"; do
|
||||
line="$(awk -F'|' -v file="$f" '$1 ~ file {print $0}' "$MANIFEST" | head -n 1)"
|
||||
if [[ -z "$line" ]]; then
|
||||
err "清单缺少条目: $f"
|
||||
((missing_count+=1))
|
||||
continue
|
||||
fi
|
||||
|
||||
text="$(echo "$line" | awk -F'|' '{sub(/^[ \t]+/, "", $2); sub(/[ \t]+$/, "", $2); print $2}')"
|
||||
if [[ -z "$text" || "$text" == TODO:* ]]; then
|
||||
warn "清单文本未完成: $f"
|
||||
((warn_count+=1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 结果 ==="
|
||||
echo "缺失项: $missing_count"
|
||||
echo "警告项: $warn_count"
|
||||
|
||||
if [[ "$missing_count" -gt 0 ]]; then
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$warn_count" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
ok "音色库完整,可进入批量 TTS。"
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
《林夏》BGM + 角色铃声 批量生成脚本
|
||||
使用 ACE-Step 1.5 本地模型
|
||||
|
||||
用法:
|
||||
conda activate acestep
|
||||
python audio/gen_music_acestep.py [--category bgm|ringtone|all] [--dry-run]
|
||||
"""
|
||||
import os, sys, time, argparse
|
||||
|
||||
# ── ACE-Step 修复(RTX 5090 cuBLAS)──────────────────────────
|
||||
import torch
|
||||
torch.backends.cuda.preferred_blas_library("cublaslt")
|
||||
|
||||
REPO_DIR = "/home/xsl/tools/ACE-Step-1.5"
|
||||
OUT_DIR = "/home/xsl/blind/audio/00_raw/music"
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
sys.path.insert(0, REPO_DIR)
|
||||
|
||||
# ── BGM 四段 ─────────────────────────────────────────────────
|
||||
BGM_TRACKS = [
|
||||
{
|
||||
"filename": "lv11_bgm_m1_main_loop",
|
||||
"duration": 90,
|
||||
"caption": (
|
||||
"lo-fi midnight bedroom, soft piano, light vinyl crackle, "
|
||||
"muted piano, subtle synth pad, occasional distant rain, "
|
||||
"melancholy but warm, alone but not lonely, "
|
||||
"ambient loop, no drums, no clear melody peak, 70 bpm"
|
||||
),
|
||||
"lyrics": "",
|
||||
"steps": 20,
|
||||
"seed": 2103,
|
||||
"note": "M1 · 21:00 主题 Loop",
|
||||
},
|
||||
{
|
||||
"filename": "lv11_bgm_m2_suspense",
|
||||
"duration": 30,
|
||||
"caption": (
|
||||
"subtle ambient suspense, cinematic tension, "
|
||||
"low drone, single hanging piano note high register, sparse strings, "
|
||||
"unsettling but not horror, like a question mark, "
|
||||
"builds slowly, ends on unresolved chord, 60 bpm"
|
||||
),
|
||||
"lyrics": "",
|
||||
"steps": 20,
|
||||
"seed": 2230,
|
||||
"note": "M2 · 22:30 悬疑过门",
|
||||
},
|
||||
{
|
||||
"filename": "lv11_bgm_m3_zhounan",
|
||||
"duration": 120,
|
||||
"caption": (
|
||||
"gentle indie folk, intimate acoustic, "
|
||||
"fingerpicked nylon guitar, wordless soft humming no lyrics just mmm, "
|
||||
"light shaker, hopeful nostalgic like reading an old letter, "
|
||||
"gradual build soft climax at 80 seconds gentle outro, 75 bpm"
|
||||
),
|
||||
"lyrics": "",
|
||||
"steps": 25,
|
||||
"seed": 2345,
|
||||
"note": "M3 · 23:45 周南段落",
|
||||
},
|
||||
{
|
||||
"filename": "lv11_bgm_m4_ending",
|
||||
"duration": 90,
|
||||
"caption": (
|
||||
"warm cinematic piano, dawn moment, solo piano, "
|
||||
"soft string pad entering at 30 seconds, "
|
||||
"like the moment you realize you survived a hard night, "
|
||||
"single melody line, no drums, fade out, 50 bpm"
|
||||
),
|
||||
"lyrics": "",
|
||||
"steps": 20,
|
||||
"seed": 200,
|
||||
"note": "M4 · 02:00 结尾",
|
||||
},
|
||||
]
|
||||
|
||||
# ── 角色铃声 9 个 ──────────────────────────────────────────────
|
||||
RINGTONES = [
|
||||
{
|
||||
"filename": "lv11_ring_mom",
|
||||
"duration": 6,
|
||||
"caption": "warm marimba ringtone, simple 4-note motif, old Nokia style, 70 bpm, loop",
|
||||
"note": "妈妈铃声",
|
||||
"seed": 1001,
|
||||
},
|
||||
{
|
||||
"filename": "lv11_ring_azhe",
|
||||
"duration": 5,
|
||||
"caption": "cold minimal electronic ringtone, 2 notes only, distant, 80 bpm, loop",
|
||||
"note": "阿哲铃声",
|
||||
"seed": 1002,
|
||||
},
|
||||
{
|
||||
"filename": "lv11_ring_xiaomei",
|
||||
"duration": 5,
|
||||
"caption": "cheerful pop ringtone, ukulele plucks, 4-bar phrase, energetic, 100 bpm, loop",
|
||||
"note": "小美铃声",
|
||||
"seed": 1003,
|
||||
},
|
||||
{
|
||||
"filename": "lv11_ring_zhounan",
|
||||
"duration": 5,
|
||||
"caption": "nostalgic 90s chime, soft marimba, gentle, 70 bpm, loop",
|
||||
"note": "周南铃声",
|
||||
"seed": 1004,
|
||||
},
|
||||
{
|
||||
"filename": "lv11_ring_hr",
|
||||
"duration": 5,
|
||||
"caption": "corporate professional ringtone, neutral marimba, clean, 90 bpm, loop",
|
||||
"note": "HR王姐铃声",
|
||||
"seed": 1005,
|
||||
},
|
||||
{
|
||||
"filename": "lv11_ring_anan",
|
||||
"duration": 4,
|
||||
"caption": "bouncy J-pop intro ringtone, bright, energetic, 110 bpm, loop",
|
||||
"note": "安安铃声",
|
||||
"seed": 1006,
|
||||
},
|
||||
{
|
||||
"filename": "lv11_ring_dorm_group",
|
||||
"duration": 5,
|
||||
"caption": "soft group notification chime, friendly, warm bells, 80 bpm, loop",
|
||||
"note": "寝室群铃声",
|
||||
"seed": 1007,
|
||||
},
|
||||
{
|
||||
"filename": "lv11_ring_unknown",
|
||||
"duration": 6,
|
||||
"caption": "eerie low drone ringtone, no melody, unsettling, 40 bpm, dark ambient, loop",
|
||||
"note": "未知号码铃声",
|
||||
"seed": 1008,
|
||||
},
|
||||
{
|
||||
"filename": "lv11_ring_delivery",
|
||||
"duration": 3,
|
||||
"caption": "short delivery notification beep, simple two-tone, neutral, 90 bpm",
|
||||
"note": "外卖铃声",
|
||||
"seed": 1009,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def load_handler():
|
||||
"""初始化 ACE-Step handler(带 cublaslt 修复)"""
|
||||
from acestep.handler import AceStepHandler
|
||||
print("正在加载 ACE-Step 模型...", end=" ", flush=True)
|
||||
t0 = time.time()
|
||||
handler = AceStepHandler()
|
||||
handler.initialize_service(
|
||||
project_root=REPO_DIR,
|
||||
config_path="acestep-v15-turbo",
|
||||
device="cuda",
|
||||
)
|
||||
print(f"就绪 ({time.time()-t0:.1f}s) dtype={handler.dtype}")
|
||||
return handler
|
||||
|
||||
|
||||
def generate_one(handler, track, is_ringtone=False):
|
||||
"""生成单条音频,返回输出路径或 None"""
|
||||
from acestep.inference import GenerationParams, GenerationConfig, generate_music
|
||||
|
||||
filename = track["filename"]
|
||||
out_path = os.path.join(OUT_DIR, f"{filename}_v1.wav")
|
||||
|
||||
if os.path.exists(out_path):
|
||||
print(f" [跳过] {filename}(已存在)")
|
||||
return out_path
|
||||
|
||||
note = track.get("note", "")
|
||||
print(f" 生成: {filename} [{note}] {track['duration']}s ...", end=" ", flush=True)
|
||||
|
||||
params = GenerationParams(
|
||||
caption=track["caption"],
|
||||
lyrics=track.get("lyrics", ""),
|
||||
duration=track["duration"],
|
||||
instrumental=True,
|
||||
inference_steps=track.get("steps", 20),
|
||||
seed=track.get("seed", 42),
|
||||
)
|
||||
from acestep.inference import GenerationConfig
|
||||
config = GenerationConfig(batch_size=1, audio_format="wav")
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
result = generate_music(handler, None, params, config, save_dir=OUT_DIR)
|
||||
elapsed = time.time() - t0
|
||||
if result.success:
|
||||
gen_path = result.audios[0]["path"]
|
||||
# 重命名为规范名称
|
||||
if gen_path != out_path:
|
||||
os.rename(gen_path, out_path)
|
||||
print(f"✓ {elapsed:.1f}s → {os.path.basename(out_path)}")
|
||||
return out_path
|
||||
else:
|
||||
print(f"✗ {result.error}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"✗ {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--category", choices=["bgm", "ringtone", "all"], default="all")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
all_tracks = []
|
||||
if args.category in ("bgm", "all"):
|
||||
all_tracks += [("bgm", t) for t in BGM_TRACKS]
|
||||
if args.category in ("ringtone", "all"):
|
||||
all_tracks += [("ringtone", t) for t in RINGTONES]
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"《林夏》音乐生成 共 {len(all_tracks)} 条")
|
||||
print(f"输出目录: {OUT_DIR}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if args.dry_run:
|
||||
for cat, t in all_tracks:
|
||||
exists = "✓" if os.path.exists(os.path.join(OUT_DIR, f"{t['filename']}_v1.wav")) else "○"
|
||||
print(f" [{exists}][{cat}] {t['filename']} {t['duration']}s {t.get('note','')}")
|
||||
return
|
||||
|
||||
handler = load_handler()
|
||||
|
||||
ok, skip, fail = 0, 0, 0
|
||||
for cat, track in all_tracks:
|
||||
result = generate_one(handler, track, is_ringtone=(cat == "ringtone"))
|
||||
if result is None:
|
||||
fail += 1
|
||||
elif "跳过" in str(result):
|
||||
skip += 1
|
||||
else:
|
||||
ok += 1
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"完成 {ok} 跳过 {skip} 失败 {fail}")
|
||||
print(f"输出: {OUT_DIR}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
《林夏》SFX 批量生成脚本(ACE-Step 1.5)
|
||||
生成环境氛围类、过渡类、结局类音效
|
||||
|
||||
非 ACE-Step 的短促 UI 音效(叮、咚、震动)需另行处理,
|
||||
本脚本专注可由 ACE-Step 生成的 ≥5s 氛围类音效。
|
||||
|
||||
用法:
|
||||
conda activate acestep
|
||||
python audio/gen_sfx_acestep.py [--dry-run]
|
||||
"""
|
||||
import os, sys, time, argparse
|
||||
import torch
|
||||
torch.backends.cuda.preferred_blas_library("cublaslt")
|
||||
|
||||
REPO_DIR = "/home/xsl/tools/ACE-Step-1.5"
|
||||
OUT_DIR = "/home/xsl/blind/audio/00_raw/sfx"
|
||||
AMB_DIR = "/home/xsl/blind/audio/00_raw/ambience"
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
os.makedirs(AMB_DIR, exist_ok=True)
|
||||
|
||||
sys.path.insert(0, REPO_DIR)
|
||||
|
||||
# ── 氛围类音效(ACE-Step 可生成)─────────────────────────────
|
||||
SFX_TRACKS = [
|
||||
# 过渡 / 转场
|
||||
{
|
||||
"filename": "sfx_transition_chapter",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 8,
|
||||
"caption": "cinematic transition swell, soft orchestral, gentle rise and fall, cinematic fade",
|
||||
"note": "章节过渡轻 swell",
|
||||
"seed": 2001,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_heartbeat_fast",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 8,
|
||||
"caption": "human heartbeat accelerating, tension building, thumping close mic, anxiety",
|
||||
"note": "心跳加速",
|
||||
"seed": 2002,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_deep_breath",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 6,
|
||||
"caption": "single deep breath, calm, close mic, soft exhale, meditative",
|
||||
"note": "深呼吸",
|
||||
"seed": 2003,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_rain_heavier",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 8,
|
||||
"caption": "rain suddenly intensifying, window glass, urban rain, dramatic shift",
|
||||
"note": "雨势忽大",
|
||||
"seed": 2004,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_rain_lighter",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 8,
|
||||
"caption": "rain gradually softening, distant window, fading urban rain, peaceful",
|
||||
"note": "雨势忽小",
|
||||
"seed": 2005,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_glass_shatter_short",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 5,
|
||||
"caption": "glass breaking, single crash, sharp impact, quiet aftermath",
|
||||
"note": "玻璃碎一秒",
|
||||
"seed": 2006,
|
||||
},
|
||||
|
||||
# 结局专用
|
||||
{
|
||||
"filename": "sfx_ending_chime",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 5,
|
||||
"caption": "soft chime, title card reveal, delicate bells, warm resonance, fade out",
|
||||
"note": "字幕浮现 chime",
|
||||
"seed": 2007,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_night_bus",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 10,
|
||||
"caption": "late night bus passing on empty street, distant rumble, urban quiet",
|
||||
"note": "夜班车驶过",
|
||||
"seed": 2008,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_cat_yowl",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 5,
|
||||
"caption": "single cat yowl distant alley, late night, brief, natural",
|
||||
"note": "野猫叫一声",
|
||||
"seed": 2009,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_breathing_unknown",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 8,
|
||||
"caption": "quiet human breathing through phone, slightly muffled, anonymous, eerie stillness",
|
||||
"note": "未知号码呼吸",
|
||||
"seed": 2010,
|
||||
},
|
||||
|
||||
# 拟音
|
||||
{
|
||||
"filename": "sfx_kettle_whistle",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 6,
|
||||
"caption": "kettle whistling, close mic, small kitchen, vintage, building steam",
|
||||
"note": "烧水壶哨响",
|
||||
"seed": 2011,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_water_boiling",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 8,
|
||||
"caption": "water boiling in kettle, bubbling, close mic, kitchen ambience",
|
||||
"note": "水沸腾",
|
||||
"seed": 2012,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_keyboard_slow",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 6,
|
||||
"caption": "slow thoughtful keyboard typing, soft mechanical, contemplative rhythm",
|
||||
"note": "键盘慢打",
|
||||
"seed": 2013,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_keyboard_fast",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 5,
|
||||
"caption": "fast keyboard typing, mechanical, busy, rapid rhythm, office",
|
||||
"note": "键盘快打",
|
||||
"seed": 2014,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_door_knock_gentle",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 5,
|
||||
"caption": "gentle three knocks on apartment door, polite delivery, hallway echo",
|
||||
"note": "外卖敲门(轻)",
|
||||
"seed": 2015,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_door_knock_firm",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 5,
|
||||
"caption": "firm knocking on apartment door, impatient, louder, hallway",
|
||||
"note": "外卖敲门(响)",
|
||||
"seed": 2016,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_phone_vibrate",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 5,
|
||||
"caption": "phone vibrating on wooden bedside table, buzzing, close mic, 2 bursts",
|
||||
"note": "手机震动桌面",
|
||||
"seed": 2017,
|
||||
},
|
||||
{
|
||||
"filename": "sfx_paper_turning",
|
||||
"dir": OUT_DIR,
|
||||
"duration": 5,
|
||||
"caption": "paper pages turning, quiet desk, gentle rustling, close mic",
|
||||
"note": "纸张翻动",
|
||||
"seed": 2018,
|
||||
},
|
||||
]
|
||||
|
||||
# ── 环境底噪(单独输出到 ambience/)────────────────────────────
|
||||
AMBIENCE_TRACKS = [
|
||||
{
|
||||
"filename": "amb_apartment_night_01",
|
||||
"dir": AMB_DIR,
|
||||
"duration": 120,
|
||||
"caption": (
|
||||
"late night apartment ambience, very quiet, distant traffic, "
|
||||
"refrigerator hum, occasional water pipe, urban residential, "
|
||||
"stereo, immersive, Beijing night"
|
||||
),
|
||||
"note": "合租屋夜晚底噪 (2min loop段)",
|
||||
"seed": 3001,
|
||||
},
|
||||
{
|
||||
"filename": "amb_apartment_night_02",
|
||||
"dir": AMB_DIR,
|
||||
"duration": 120,
|
||||
"caption": (
|
||||
"quiet Beijing apartment at night, minimal sounds, "
|
||||
"faint city noise, soft hum, occasional distant car, "
|
||||
"late 2020s urban residential night ambience"
|
||||
),
|
||||
"note": "合租屋夜晚底噪备用",
|
||||
"seed": 3002,
|
||||
},
|
||||
{
|
||||
"filename": "amb_bar_loud_01",
|
||||
"dir": AMB_DIR,
|
||||
"duration": 60,
|
||||
"caption": (
|
||||
"crowded bar ambience, people talking laughing, "
|
||||
"background electronic music, glasses clinking, "
|
||||
"busy nightclub atmosphere, lively"
|
||||
),
|
||||
"note": "酒吧嘈杂环境",
|
||||
"seed": 3003,
|
||||
},
|
||||
{
|
||||
"filename": "amb_rain_window_01",
|
||||
"dir": AMB_DIR,
|
||||
"duration": 120,
|
||||
"caption": (
|
||||
"gentle rain on window, soft steady patter, "
|
||||
"cozy indoor feeling, night rain, Beijing apartment, "
|
||||
"stereo, immersive"
|
||||
),
|
||||
"note": "窗外细雨(BGM M1 衬底)",
|
||||
"seed": 3004,
|
||||
},
|
||||
{
|
||||
"filename": "amb_kitchen_morning",
|
||||
"dir": AMB_DIR,
|
||||
"duration": 60,
|
||||
"caption": (
|
||||
"morning kitchen sounds, water dripping tap, "
|
||||
"refrigerator hum, quiet domestic morning, "
|
||||
"natural, realistic"
|
||||
),
|
||||
"note": "厨房晨间(结局A)",
|
||||
"seed": 3005,
|
||||
},
|
||||
{
|
||||
"filename": "amb_dawn_birds",
|
||||
"dir": AMB_DIR,
|
||||
"duration": 30,
|
||||
"caption": (
|
||||
"dawn birds chirping, urban morning, early light, "
|
||||
"sparse birdsong, peaceful city dawn"
|
||||
),
|
||||
"note": "清晨鸟叫(结局D)",
|
||||
"seed": 3006,
|
||||
},
|
||||
]
|
||||
|
||||
ALL_TRACKS = SFX_TRACKS + AMBIENCE_TRACKS
|
||||
|
||||
|
||||
def load_handler():
|
||||
from acestep.handler import AceStepHandler
|
||||
print("正在加载 ACE-Step 模型...", end=" ", flush=True)
|
||||
t0 = time.time()
|
||||
handler = AceStepHandler()
|
||||
handler.initialize_service(
|
||||
project_root=REPO_DIR,
|
||||
config_path="acestep-v15-turbo",
|
||||
device="cuda",
|
||||
)
|
||||
print(f"就绪 ({time.time()-t0:.1f}s) dtype={handler.dtype}")
|
||||
return handler
|
||||
|
||||
|
||||
def generate_one(handler, track):
|
||||
from acestep.inference import GenerationParams, GenerationConfig, generate_music
|
||||
|
||||
out_dir = track["dir"]
|
||||
filename = track["filename"]
|
||||
out_path = os.path.join(out_dir, f"{filename}_v1.wav")
|
||||
|
||||
if os.path.exists(out_path):
|
||||
print(f" [跳过] {filename}")
|
||||
return "skip"
|
||||
|
||||
note = track.get("note", "")
|
||||
print(f" {filename} [{note}] {track['duration']}s ...", end=" ", flush=True)
|
||||
|
||||
params = GenerationParams(
|
||||
caption=track["caption"],
|
||||
lyrics="",
|
||||
duration=track["duration"],
|
||||
instrumental=True,
|
||||
inference_steps=20,
|
||||
seed=track.get("seed", 42),
|
||||
)
|
||||
config = GenerationConfig(batch_size=1, audio_format="wav")
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
result = generate_music(handler, None, params, config, save_dir=out_dir)
|
||||
if result.success:
|
||||
gen_path = result.audios[0]["path"]
|
||||
if gen_path != out_path:
|
||||
os.rename(gen_path, out_path)
|
||||
print(f"✓ {time.time()-t0:.1f}s")
|
||||
return "ok"
|
||||
else:
|
||||
print(f"✗ {result.error}")
|
||||
return "fail"
|
||||
except Exception as e:
|
||||
print(f"✗ {e}")
|
||||
return "fail"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--category", choices=["sfx", "ambience", "all"], default="all")
|
||||
args = parser.parse_args()
|
||||
|
||||
tracks = []
|
||||
if args.category in ("sfx", "all"):
|
||||
tracks += SFX_TRACKS
|
||||
if args.category in ("ambience", "all"):
|
||||
tracks += AMBIENCE_TRACKS
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"《林夏》SFX + 环境底噪生成 共 {len(tracks)} 条")
|
||||
print(f"SFX 输出: {OUT_DIR}")
|
||||
print(f"环境 输出: {AMB_DIR}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if args.dry_run:
|
||||
for t in tracks:
|
||||
out = os.path.join(t["dir"], f"{t['filename']}_v1.wav")
|
||||
flag = "✓" if os.path.exists(out) else "○"
|
||||
print(f" [{flag}] {t['filename']} {t['duration']}s {t.get('note','')}")
|
||||
return
|
||||
|
||||
handler = load_handler()
|
||||
|
||||
ok = skip = fail = 0
|
||||
for track in tracks:
|
||||
r = generate_one(handler, track)
|
||||
if r == "ok": ok += 1
|
||||
elif r == "skip": skip += 1
|
||||
else: fail += 1
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"完成 {ok} 跳过 {skip} 失败 {fail}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
生成缺失的 TTS 资产:
|
||||
- self-3y 三个版本(明朗/迷茫/温柔)
|
||||
- system 系统提示音 TTS
|
||||
使用 VoxCPM2 API (http://127.0.0.1:8000)
|
||||
"""
|
||||
import os, sys, time, glob, base64, subprocess, tempfile, shutil
|
||||
import requests
|
||||
|
||||
PROJECT_DIR = "/home/xsl/blind"
|
||||
OUT_DIR = f"{PROJECT_DIR}/audio/00_raw/tts"
|
||||
VOICE_DIR = f"{PROJECT_DIR}/voice"
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
# ── 台词定义 ──────────────────────────────────────────────────
|
||||
|
||||
MISSING_LINES = [
|
||||
# self-3y 明朗版(23岁刚来北京,对一切新鲜)
|
||||
("lv11_0030_self3y_voice_01", # filename(不含后缀)
|
||||
"xiaomei", # 音色来源目录(同xiaomei基底+调整)
|
||||
0.95, # speed(比正常略快,体现23岁轻盈)
|
||||
"哦对,今天是我来北京的第一百天。妈,我没跟你说,我自己数的。"
|
||||
"这个城市好大。我走了很多路,见了很多人。我觉得我还没准备好,"
|
||||
"但我又觉得好像准备了很久很久了。你们不用担心我。我很好。"
|
||||
"以后的我,如果你听到这条,记得要好好的。"),
|
||||
|
||||
# self-3y 迷茫版(其实已经很累了,只是没说)
|
||||
("lv11_0030_self3y_voice_02",
|
||||
"xiaomei",
|
||||
1.00,
|
||||
"我今天又没睡好。不是失眠,就是睡不踏实。"
|
||||
"北京这个地方,它会让你觉得你随时都要更好,随时都要更努力。"
|
||||
"我在努力。但我不知道在努力什么。"
|
||||
"我就想留下一条记录。今天,今天是……算了,就今天吧。"),
|
||||
|
||||
# self-3y 温柔版(对未来的自己说撑住)
|
||||
("lv11_0030_self3y_voice_03",
|
||||
"xiaomei",
|
||||
1.00,
|
||||
"是我。23岁的我。我设了三年后打开。"
|
||||
"我不知道那时候的你怎么样了。如果你很好,就当我多虑了。"
|
||||
"如果你不好——你看,你熬过来了对不对。"
|
||||
"你23岁的时候,也觉得很难。但你还是过来了。"
|
||||
"所以……撑住啊。"),
|
||||
|
||||
# system TTS — 游戏系统提示
|
||||
("lv11_sys_battery_01", "xiaomei", 1.00, "电量百分之一"),
|
||||
("lv11_sys_battery_02", "xiaomei", 1.00, "电量不足,请及时充电"),
|
||||
("lv11_sys_unread_01", "xiaomei", 1.00, "您有一条未读消息"),
|
||||
("lv11_sys_unread_multi", "xiaomei", 1.00, "您有多条未读消息"),
|
||||
("lv11_sys_memo_01", "xiaomei", 1.00, "三年前今天,一条未听的录音"),
|
||||
("lv11_sys_goodnight", "xiaomei", 1.00, "晚安"),
|
||||
]
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────
|
||||
|
||||
def find_ref(voice_dir_name):
|
||||
d = os.path.join(VOICE_DIR, voice_dir_name)
|
||||
for ext in ("*.wav", "*.mp3", "*.flac"):
|
||||
files = sorted(glob.glob(os.path.join(d, ext)))
|
||||
if files:
|
||||
return files[0]
|
||||
return None
|
||||
|
||||
def to_wav(src, dst):
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", "-acodec", "pcm_s16le", dst],
|
||||
check=True, capture_output=True
|
||||
)
|
||||
|
||||
def apply_speed(src, speed, dst):
|
||||
if abs(speed - 1.0) < 0.01:
|
||||
shutil.copy2(src, dst)
|
||||
return
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-i", src, "-filter:a", f"atempo={speed}", "-acodec", "pcm_s16le", dst],
|
||||
check=True, capture_output=True
|
||||
)
|
||||
|
||||
def register_voice(wav_path):
|
||||
with open(wav_path, "rb") as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
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):
|
||||
r = requests.post(f"{BASE_URL}/v1/speech",
|
||||
json={"text": text, "voice_id": voice_id, "cfg_value": 2.0, "inference_timesteps": 10},
|
||||
timeout=180)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
# ── 主流程 ────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
# 健康检查
|
||||
try:
|
||||
requests.get(f"{BASE_URL}/health", timeout=5).raise_for_status()
|
||||
print(f"VoxCPM 服务就绪: {BASE_URL}")
|
||||
except Exception as e:
|
||||
print(f"[错误] VoxCPM 服务不可用: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix="tts_missing_")
|
||||
voice_cache = {} # dir_name → voice_id
|
||||
|
||||
print(f"\n共 {len(MISSING_LINES)} 条待合成\n{'='*50}")
|
||||
|
||||
ok, skip, fail = 0, 0, 0
|
||||
for filename, voice_dir, speed, text in MISSING_LINES:
|
||||
out_path = os.path.join(OUT_DIR, f"{filename}_wav_v1.wav")
|
||||
if os.path.exists(out_path):
|
||||
print(f" [跳过已存在] {filename}")
|
||||
skip += 1
|
||||
continue
|
||||
|
||||
# 注册音色(缓存)
|
||||
if voice_dir not in voice_cache:
|
||||
ref = find_ref(voice_dir)
|
||||
if not ref:
|
||||
print(f" [✗无参考] {voice_dir}: {filename}")
|
||||
fail += 1
|
||||
continue
|
||||
wav_ref = os.path.join(tmp_dir, f"{voice_dir}_ref.wav")
|
||||
if not ref.endswith(".wav"):
|
||||
to_wav(ref, wav_ref)
|
||||
else:
|
||||
shutil.copy2(ref, wav_ref)
|
||||
print(f" 注册音色 [{voice_dir}] ...", end=" ", flush=True)
|
||||
vid = register_voice(wav_ref)
|
||||
voice_cache[voice_dir] = vid
|
||||
print(f"ok ({vid[:8]}…)")
|
||||
|
||||
print(f" 合成: {filename} ...", end=" ", flush=True)
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw_bytes = synthesize(voice_cache[voice_dir], text)
|
||||
tmp_raw = os.path.join(tmp_dir, f"{filename}.raw.wav")
|
||||
with open(tmp_raw, "wb") as f:
|
||||
f.write(raw_bytes)
|
||||
apply_speed(tmp_raw, speed, out_path)
|
||||
print(f"✓ {time.time()-t0:.1f}s → {os.path.basename(out_path)}")
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
print(f"✗ {e}")
|
||||
fail += 1
|
||||
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
print(f"\n{'='*50}")
|
||||
print(f"完成 {ok} 跳过 {skip} 失败 {fail}")
|
||||
print(f"输出: {OUT_DIR}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/bin/bash
|
||||
# 《林夏》音频后期批量处理脚本
|
||||
# 用法: bash audio/process_audio.sh [tts|music|sfx|ambience|all]
|
||||
# 执行前需要先跑 batch_tts.py 生产原始音频
|
||||
|
||||
set -e
|
||||
cd /home/xsl/blind
|
||||
|
||||
TYPE="${1:-all}"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# TTS 后期处理: 00_raw/tts → 01_processed/tts
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
process_tts() {
|
||||
echo "=== TTS 后期处理 ==="
|
||||
local count=0
|
||||
for f in audio/00_raw/tts/lv11_*.wav; do
|
||||
[ -f "$f" ] || continue
|
||||
local base=$(basename "$f")
|
||||
local out="audio/01_processed/tts/$base"
|
||||
[ -f "$out" ] && echo " 跳过: $base" && continue
|
||||
|
||||
# 判断是否为电话/语音形态(需要加听筒效果)
|
||||
local apply_phone=0
|
||||
[[ "$base" == *_call_* ]] && apply_phone=1
|
||||
[[ "$base" == *_voice_* ]] && apply_phone=1
|
||||
# 例外: mom 第 16 条长语音不加听筒效果
|
||||
[[ "$base" == *lv11_0200_mom_voice* ]] && apply_phone=0
|
||||
|
||||
if [ "$apply_phone" -eq 1 ]; then
|
||||
# 加听筒效果
|
||||
ffmpeg -i "$f" \
|
||||
-af "highpass=f=80,\
|
||||
acompressor=threshold=-18dB:ratio=4:attack=20:release=250,\
|
||||
highpass=f=300:poles=2,lowpass=f=3400:poles=2,\
|
||||
equalizer=f=1500:t=q:w=1:g=1,\
|
||||
loudnorm=I=-16:TP=-1:LRA=11" \
|
||||
-ar 48000 -sample_fmt s16 -ac 1 \
|
||||
-y "$out" 2>/dev/null
|
||||
else
|
||||
# 普通处理(文字/系统/长语音)
|
||||
ffmpeg -i "$f" \
|
||||
-af "highpass=f=80,\
|
||||
acompressor=threshold=-18dB:ratio=4:attack=20:release=250,\
|
||||
loudnorm=I=-16:TP=-1:LRA=11" \
|
||||
-ar 48000 -sample_fmt s16 -ac 1 \
|
||||
-y "$out" 2>/dev/null
|
||||
fi
|
||||
|
||||
echo " ✓ $base"
|
||||
((count++))
|
||||
done
|
||||
echo " 处理完成 $count 条"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 拼接分段长戏
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
concat_segments() {
|
||||
echo "=== 拼接分段长戏 ==="
|
||||
|
||||
# 妈妈 4'17" (8 段)
|
||||
local mom_segs=()
|
||||
for i in $(seq 1 8); do
|
||||
local seg="audio/01_processed/tts/lv11_0200_mom_voice_01_seg${i}_v1.wav"
|
||||
[ -f "$seg" ] && mom_segs+=("$seg")
|
||||
done
|
||||
if [ ${#mom_segs[@]} -eq 8 ]; then
|
||||
printf "file '%s'\n" "${mom_segs[@]}" > /tmp/mom_concat.txt
|
||||
ffmpeg -f concat -safe 0 -i /tmp/mom_concat.txt \
|
||||
-c copy -y audio/01_processed/tts/lv11_0200_mom_voice_01_full.wav 2>/dev/null
|
||||
echo " ✓ 妈妈长语音拼接完成"
|
||||
else
|
||||
echo " 跳过妈妈拼接(只有 ${#mom_segs[@]}/8 段)"
|
||||
fi
|
||||
|
||||
# 小美醉语音 (4 段)
|
||||
local xiaomei_segs=()
|
||||
for i in $(seq 1 4); do
|
||||
local seg="audio/01_processed/tts/lv11_2245_xiaomei_voice_01_seg${i}_v1.wav"
|
||||
[ -f "$seg" ] && xiaomei_segs+=("$seg")
|
||||
done
|
||||
if [ ${#xiaomei_segs[@]} -eq 4 ]; then
|
||||
printf "file '%s'\n" "${xiaomei_segs[@]}" > /tmp/xiaomei_concat.txt
|
||||
ffmpeg -f concat -safe 0 -i /tmp/xiaomei_concat.txt \
|
||||
-c copy -y audio/01_processed/tts/lv11_2245_xiaomei_voice_01_full.wav 2>/dev/null
|
||||
echo " ✓ 小美醉语音拼接完成"
|
||||
else
|
||||
echo " 跳过小美拼接(只有 ${#xiaomei_segs[@]}/4 段)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 音乐后期: 00_raw/music → 01_processed/music
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
process_music() {
|
||||
echo "=== 音乐后期处理 ==="
|
||||
local count=0
|
||||
for f in audio/00_raw/music/*.wav audio/00_raw/music/*.mp3; do
|
||||
[ -f "$f" ] || continue
|
||||
local base=$(basename "${f%.*}.wav")
|
||||
local out="audio/01_processed/music/$base"
|
||||
[ -f "$out" ] && echo " 跳过: $base" && continue
|
||||
|
||||
ffmpeg -i "$f" \
|
||||
-af "equalizer=f=350:t=q:w=1.5:g=-2,\
|
||||
loudnorm=I=-18:TP=-1:LRA=11" \
|
||||
-ar 48000 -ac 2 \
|
||||
-y "$out" 2>/dev/null
|
||||
echo " ✓ $base"
|
||||
((count++))
|
||||
done
|
||||
echo " 处理完成 $count 条"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 音效后期: 00_raw/sfx → 01_processed/sfx
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
process_sfx() {
|
||||
echo "=== 音效后期处理 ==="
|
||||
local count=0
|
||||
for f in audio/00_raw/sfx/*.wav audio/00_raw/sfx/*.mp3; do
|
||||
[ -f "$f" ] || continue
|
||||
local base=$(basename "${f%.*}.wav")
|
||||
local out="audio/01_processed/sfx/$base"
|
||||
[ -f "$out" ] && continue
|
||||
|
||||
ffmpeg -i "$f" \
|
||||
-af "loudnorm=I=-14:TP=-1:LRA=11" \
|
||||
-ar 48000 \
|
||||
-y "$out" 2>/dev/null
|
||||
echo " ✓ $base"
|
||||
((count++))
|
||||
done
|
||||
echo " 处理完成 $count 条"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 环境底噪后期: 00_raw/ambience → 01_processed/ambience
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
process_ambience() {
|
||||
echo "=== 环境底噪后期处理 ==="
|
||||
local count=0
|
||||
for f in audio/00_raw/ambience/*.wav; do
|
||||
[ -f "$f" ] || continue
|
||||
local base=$(basename "$f")
|
||||
local out="audio/01_processed/ambience/$base"
|
||||
[ -f "$out" ] && continue
|
||||
|
||||
local channels=$(ffprobe -v quiet -select_streams a:0 \
|
||||
-show_entries stream=channels -of csv=p=0 "$f" 2>/dev/null)
|
||||
|
||||
if [ "$channels" -eq 1 ] 2>/dev/null; then
|
||||
# 单声道→假性立体声
|
||||
ffmpeg -i "$f" \
|
||||
-filter_complex "[0:a]asplit=2[L][Rc];[Rc]adelay=12|12[R];[L][R]amerge=inputs=2[out]" \
|
||||
-map "[out]" -ac 2 \
|
||||
-af "highpass=f=60,lowpass=f=12000,loudnorm=I=-22:TP=-3:LRA=11" \
|
||||
-ar 48000 -y "$out" 2>/dev/null
|
||||
else
|
||||
ffmpeg -i "$f" \
|
||||
-af "highpass=f=60,lowpass=f=12000,loudnorm=I=-22:TP=-3:LRA=11" \
|
||||
-ar 48000 -ac 2 -y "$out" 2>/dev/null
|
||||
fi
|
||||
echo " ✓ $base"
|
||||
((count++))
|
||||
done
|
||||
echo " 处理完成 $count 条"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 最终打包: 01_processed → 02_final (.ogg)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
pack_final() {
|
||||
echo "=== 最终打包 wav → ogg ==="
|
||||
for subdir in tts music sfx ambience; do
|
||||
local count=0
|
||||
for f in audio/01_processed/$subdir/*.wav; do
|
||||
[ -f "$f" ] || continue
|
||||
local base=$(basename "${f%.wav}.ogg")
|
||||
local out="audio/02_final/$subdir/$base"
|
||||
[ -f "$out" ] && continue
|
||||
ffmpeg -i "$f" -c:a libvorbis -q:a 5 -y "$out" 2>/dev/null
|
||||
((count++))
|
||||
done
|
||||
[ $count -gt 0 ] && echo " ✓ $subdir: $count 条"
|
||||
done
|
||||
echo " 打包完成"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 响度验收报告
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
qa_loudness() {
|
||||
echo "=== 响度验收 ==="
|
||||
local pass=0 fail=0
|
||||
|
||||
check_dir() {
|
||||
local dir="$1" target="$2" label="$3"
|
||||
for f in audio/01_processed/$dir/*.wav; do
|
||||
[ -f "$f" ] || continue
|
||||
local lufs=$(ffmpeg -i "$f" -af loudnorm=print_format=json -f null - 2>&1 | \
|
||||
python3 -c "
|
||||
import sys, json, re
|
||||
out = sys.stdin.read()
|
||||
m = re.search(r'\"input_i\"\s*:\s*\"(-?\d+\.?\d*)\"', out)
|
||||
print(m.group(1) if m else 'err')
|
||||
")
|
||||
local diff=$(echo "$lufs $target" | awk '{d=$1-$2; if(d<0)d=-d; print d}')
|
||||
local ok=$(echo "$diff" | awk '{print ($1 <= 1.5) ? "pass" : "FAIL"}')
|
||||
printf " %-50s %s LUFS [%s]\n" "$(basename $f)" "$lufs" "$ok"
|
||||
[ "$ok" = "pass" ] && ((pass++)) || ((fail++))
|
||||
done
|
||||
}
|
||||
|
||||
check_dir tts -16 "角色语音"
|
||||
check_dir music -18 "音乐"
|
||||
check_dir ambience -22 "环境底噪"
|
||||
|
||||
echo ""
|
||||
echo " 通过: $pass 不通过: $fail"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 主流程
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
echo "《林夏》音频后期处理 $(date '+%Y-%m-%d %H:%M')"
|
||||
echo "执行类型: $TYPE"
|
||||
echo ""
|
||||
|
||||
case "$TYPE" in
|
||||
tts) process_tts; concat_segments ;;
|
||||
music) process_music ;;
|
||||
sfx) process_sfx ;;
|
||||
ambience) process_ambience ;;
|
||||
pack) pack_final ;;
|
||||
qa) qa_loudness ;;
|
||||
all)
|
||||
process_tts
|
||||
concat_segments
|
||||
process_music
|
||||
process_sfx
|
||||
process_ambience
|
||||
pack_final
|
||||
;;
|
||||
*)
|
||||
echo "用法: bash audio/process_audio.sh [tts|music|sfx|ambience|pack|qa|all]"
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "完成 $(date '+%H:%M:%S')"
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# 启动 CosyVoice WebUI
|
||||
# 用法: bash audio/start_webui.sh
|
||||
|
||||
echo "启动 CosyVoice2 WebUI..."
|
||||
echo "启动后访问: http://localhost:8000"
|
||||
echo "Ctrl+C 退出"
|
||||
echo ""
|
||||
|
||||
source /home/xsl/miniconda3/etc/profile.d/conda.sh
|
||||
conda activate cosyvoice
|
||||
cd /home/xsl/tools/CosyVoice
|
||||
|
||||
python webui.py \
|
||||
--port 8000 \
|
||||
--model_dir pretrained_models/CosyVoice2-0.5B
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ACE-Step 1.5 Test Script
|
||||
Tests basic music generation functionality.
|
||||
|
||||
Prerequisites:
|
||||
- ACE-Step installed in conda env 'acestep' or via uv
|
||||
- Models downloaded to /home/xsl/tools/ACE-Step-1.5/checkpoints/
|
||||
|
||||
Usage:
|
||||
conda activate acestep
|
||||
python test_acestep.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Configuration
|
||||
REPO_DIR = "/home/xsl/tools/ACE-Step-1.5"
|
||||
CHECKPOINTS_DIR = os.path.join(REPO_DIR, "checkpoints")
|
||||
OUTPUT_DIR = "/home/xsl/blind/audio/test_output"
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def test_imports():
|
||||
"""Test that all required packages are importable."""
|
||||
print("=" * 60)
|
||||
print("Step 1: Testing imports...")
|
||||
print("=" * 60)
|
||||
|
||||
# Add repo to path
|
||||
if REPO_DIR not in sys.path:
|
||||
sys.path.insert(0, REPO_DIR)
|
||||
|
||||
results = {}
|
||||
|
||||
# Test torch
|
||||
try:
|
||||
import torch
|
||||
results["torch"] = f"OK (v{torch.__version__}, CUDA={torch.cuda.is_available()})"
|
||||
if torch.cuda.is_available():
|
||||
results["torch"] += f" ({torch.cuda.get_device_name(0)})"
|
||||
except ImportError as e:
|
||||
results["torch"] = f"FAIL: {e}"
|
||||
|
||||
# Test transformers
|
||||
try:
|
||||
import transformers
|
||||
results["transformers"] = f"OK (v{transformers.__version__})"
|
||||
except ImportError as e:
|
||||
results["transformers"] = f"FAIL: {e}"
|
||||
|
||||
# Test diffusers
|
||||
try:
|
||||
import diffusers
|
||||
results["diffusers"] = f"OK (v{diffusers.__version__})"
|
||||
except ImportError as e:
|
||||
results["diffusers"] = f"FAIL: {e}"
|
||||
|
||||
# Test soundfile
|
||||
try:
|
||||
import soundfile
|
||||
results["soundfile"] = f"OK (v{soundfile.__version__})"
|
||||
except ImportError as e:
|
||||
results["soundfile"] = f"FAIL: {e}"
|
||||
|
||||
# Test acestep
|
||||
try:
|
||||
import acestep
|
||||
results["acestep"] = f"OK (from {acestep.__file__})"
|
||||
except ImportError as e:
|
||||
results["acestep"] = f"FAIL: {e}"
|
||||
|
||||
for k, v in results.items():
|
||||
print(f" {k}: {v}")
|
||||
|
||||
all_ok = all("OK" in v for v in results.values())
|
||||
print(f"\nImports: {'PASS' if all_ok else 'FAIL'}")
|
||||
return all_ok
|
||||
|
||||
|
||||
def test_models_exist():
|
||||
"""Test that model checkpoints are downloaded."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 2: Checking model checkpoints...")
|
||||
print("=" * 60)
|
||||
|
||||
required_components = {
|
||||
"acestep-v15-turbo": "DiT model (turbo)",
|
||||
"vae": "VAE encoder/decoder",
|
||||
"Qwen3-Embedding-0.6B": "Text encoder",
|
||||
"acestep-5Hz-lm-1.7B": "LM model (1.7B)",
|
||||
}
|
||||
|
||||
results = {}
|
||||
all_ok = True
|
||||
for component, description in required_components.items():
|
||||
path = os.path.join(CHECKPOINTS_DIR, component)
|
||||
exists = os.path.isdir(path)
|
||||
# Check for weight files
|
||||
has_weights = False
|
||||
if exists:
|
||||
weight_names = [
|
||||
"model.safetensors",
|
||||
"model.safetensors.index.json",
|
||||
"diffusion_pytorch_model.safetensors",
|
||||
"diffusion_pytorch_model.safetensors.index.json",
|
||||
]
|
||||
has_weights = any(
|
||||
os.path.isfile(os.path.join(path, w)) for w in weight_names
|
||||
)
|
||||
|
||||
status = "OK" if has_weights else ("MISSING_WEIGHTS" if exists else "MISSING")
|
||||
if not has_weights:
|
||||
all_ok = False
|
||||
results[component] = status
|
||||
print(f" {component} ({description}): {status}")
|
||||
|
||||
print(f"\nModels: {'PASS' if all_ok else 'FAIL'}")
|
||||
return all_ok
|
||||
|
||||
|
||||
def test_generation():
|
||||
"""Test basic music generation."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 3: Testing music generation...")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# Add repo to path
|
||||
if REPO_DIR not in sys.path:
|
||||
sys.path.insert(0, REPO_DIR)
|
||||
|
||||
from acestep.handler import AceStepHandler
|
||||
from acestep.inference import GenerationParams, GenerationConfig, generate_music
|
||||
|
||||
# Initialize handler (DiT only, no LM for faster test)
|
||||
print(" Initializing DiT handler...")
|
||||
dit_handler = AceStepHandler()
|
||||
dit_handler.initialize_service(
|
||||
project_root=REPO_DIR,
|
||||
config_path="acestep-v15-turbo",
|
||||
device="cuda",
|
||||
)
|
||||
print(" DiT handler initialized.")
|
||||
|
||||
# Simple generation without LM (faster)
|
||||
print(" Generating test audio (30s, no LM)...")
|
||||
params = GenerationParams(
|
||||
caption="A gentle piano melody with soft strings, ambient background music",
|
||||
duration=30,
|
||||
instrumental=True,
|
||||
inference_steps=8,
|
||||
seed=42,
|
||||
)
|
||||
|
||||
config = GenerationConfig(
|
||||
batch_size=1,
|
||||
audio_format="wav",
|
||||
)
|
||||
|
||||
start = time.time()
|
||||
result = generate_music(
|
||||
dit_handler,
|
||||
None, # No LM handler
|
||||
params,
|
||||
config,
|
||||
save_dir=OUTPUT_DIR,
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
|
||||
if result.success:
|
||||
for audio in result.audios:
|
||||
print(f" Generated: {audio['path']}")
|
||||
print(f" Duration: {elapsed:.1f}s")
|
||||
print(f"\nGeneration: PASS")
|
||||
return True
|
||||
else:
|
||||
print(f" Error: {result.error}")
|
||||
print(f"\nGeneration: FAIL")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" Exception: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"\nGeneration: FAIL")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("ACE-Step 1.5 Test Script")
|
||||
print(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print()
|
||||
|
||||
results = {}
|
||||
|
||||
# Step 1: Test imports
|
||||
results["imports"] = test_imports()
|
||||
|
||||
# Step 2: Test model checkpoints
|
||||
results["models"] = test_models_exist()
|
||||
|
||||
# Step 3: Test generation (only if imports and models pass)
|
||||
if results["imports"] and results["models"]:
|
||||
results["generation"] = test_generation()
|
||||
else:
|
||||
results["generation"] = False
|
||||
print("\nSkipping generation test (prerequisites not met)")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
for k, v in results.items():
|
||||
print(f" {k}: {'PASS' if v else 'FAIL'}")
|
||||
|
||||
all_pass = all(results.values())
|
||||
print(f"\nOverall: {'PASS' if all_pass else 'FAIL'}")
|
||||
|
||||
if not all_pass:
|
||||
print("\nTroubleshooting:")
|
||||
if not results["imports"]:
|
||||
print(" - Install dependencies: bash /home/xsl/tools/ACE-Step-1.5/install_acestep.sh")
|
||||
if not results["models"]:
|
||||
print(" - Download models: huggingface-cli download ACE-Step/Ace-Step1.5 --local-dir /home/xsl/tools/ACE-Step-1.5/checkpoints")
|
||||
|
||||
return 0 if all_pass else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user