从github迁移过来

This commit is contained in:
xsl
2026-05-18 18:39:49 +08:00
commit 9c5d9e586c
89 changed files with 9035 additions and 0 deletions
+327
View File
@@ -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": { # 复用 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()
+463
View File
@@ -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()
+179
View File
@@ -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 "=== 检查完成:可开始生产 ==="
+109
View File
@@ -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。"
+251
View File
@@ -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()
+351
View File
@@ -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()
+158
View File
@@ -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()
+252
View File
@@ -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')"
+16
View File
@@ -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
+233
View File
@@ -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())