VoxCPM2 v5 音色克隆:脚本、参考音频、游戏对白与状态文档

通过 Voice Design 试听选定各角色参考音色,Style Control 全量克隆并部署到 audio/mp3/tts;清理未引用音频与 audio_1 旧产物。
This commit is contained in:
xsl
2026-05-24 00:34:50 +08:00
parent bb79a69bd2
commit 12eb1eab44
1085 changed files with 1779 additions and 955 deletions
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
《林夏》TTS v2 — VoxCPM2 Style Control 批量生成
输出:audio_2/tts/v2_styled/
用法:
VOXCPM_PORT=8002 bash /home/xsl/tts-server/start-voxcpm.sh
VOXCPM_PORT=8002 python audio_2/scripts/01_gen_tts_styled.py
python audio_2/scripts/01_gen_tts_styled.py --dry-run
python audio_2/scripts/01_gen_tts_styled.py --role linxia --test
"""
import argparse
import base64
import glob
import os
import subprocess
import sys
import tempfile
import time
import requests
PROJECT_DIR = "/home/xsl/blind"
VOICE_DIR = f"{PROJECT_DIR}/voice"
OUT_DIR = f"{PROJECT_DIR}/audio_2/tts/v2_styled"
HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1")
PORT = os.environ.get("VOXCPM_PORT", "8002")
BASE_URL = f"http://{HOST}:{PORT}"
sys.path.insert(0, f"{PROJECT_DIR}/audio")
sys.path.insert(0, f"{PROJECT_DIR}/audio_2")
from batch_tts_voxcpm import ROLES, LINES # noqa: E402
from voice_style_prompts import get_style # noqa: E402
os.makedirs(OUT_DIR, exist_ok=True)
def find_ref_audio(role_key):
role_dir = os.path.join(VOICE_DIR, ROLES[role_key]["dir"])
for ext in ("*.wav", "*.mp3", "*.flac"):
files = sorted(glob.glob(os.path.join(role_dir, ext)))
if files:
return files[0]
return None
def convert_to_wav(src, dst):
subprocess.run(
["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", "-acodec", "pcm_s16le", dst],
check=True,
capture_output=True,
)
def wav_to_base64(path):
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode()
def apply_speed(src, speed, dst):
if abs(speed - 1.0) < 0.01:
import shutil
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 wav_to_mp3(src, dst):
subprocess.run(
[
"ffmpeg", "-y", "-i", src,
"-codec:a", "libmp3lame", "-b:a", "128k", "-ar", "24000", "-ac", "1",
dst,
],
check=True,
capture_output=True,
)
def check_server():
try:
r = requests.get(f"{BASE_URL}/health", timeout=5)
if r.status_code != 200:
return False
data = r.json()
return data.get("status") == "ok"
except Exception:
return False
def register_voice(wav_path):
r = requests.post(
f"{BASE_URL}/v1/voices",
json={"wav_base64": wav_to_base64(wav_path)},
timeout=60,
)
r.raise_for_status()
return r.json()["voice_id"]
def synthesize_styled(voice_id, text, style, cfg=2.0, steps=10):
r = requests.post(
f"{BASE_URL}/v1/speech/styled",
json={
"text": text,
"style": style,
"voice_id": voice_id,
"cfg_value": cfg,
"inference_timesteps": steps,
},
timeout=180,
)
r.raise_for_status()
return r.content
def main():
parser = argparse.ArgumentParser(description="《林夏》Style Control 批量 TTS → audio_2")
parser.add_argument("--role", help="只合成指定角色")
parser.add_argument("--test", action="store_true", help="只合成第一条")
parser.add_argument("--dry-run", action="store_true", help="只打印 style 分配")
parser.add_argument("--force", action="store_true", help="覆盖已存在文件")
parser.add_argument("--cfg", type=float, default=2.0)
parser.add_argument("--steps", type=int, default=10)
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(" 《林夏》TTS v2 — Style Control")
print(f" 服务: {BASE_URL}")
print(f" 输出: {OUT_DIR}")
print(f"{len(lines)}")
print(f"{'=' * 60}\n")
if args.dry_run:
for fname, role, text in lines:
style = get_style(fname, role, text)
out = os.path.join(OUT_DIR, f"{fname}_wav_v2.mp3")
mark = "" if os.path.exists(out) else "·"
print(f" [{mark}] {fname} [{role}]")
print(f" style: {style}")
print(f" text: {text[:50]}{'...' if len(text) > 50 else ''}\n")
return
if not check_server():
print(f"[错误] VoxCPM2 服务未就绪: {BASE_URL}/health")
print("请先启动:")
print(f" VOXCPM_PORT={PORT} bash /home/xsl/tts-server/start-voxcpm.sh")
sys.exit(1)
voice_ids = {}
tmp = tempfile.mkdtemp(prefix="voxcpm2_")
for role_key in sorted(set(r for _, r, _ in lines)):
ref = find_ref_audio(role_key)
if not ref:
print(f" [跳过] {role_key}: 未找到参考音频")
continue
wav = ref if ref.endswith(".wav") else os.path.join(tmp, f"{role_key}.wav")
if not ref.endswith(".wav"):
convert_to_wav(ref, wav)
print(f" 注册音色: {role_key} ... ", end="", flush=True)
voice_ids[role_key] = register_voice(wav)
print(f"ok ({voice_ids[role_key][:10]}...)")
ok = skip = fail = 0
manifest_path = os.path.join(OUT_DIR, "style_manifest.tsv")
manifest_lines = ["filename\trole\tstyle\ttext\n"]
for i, (fname, role, text) in enumerate(lines, 1):
out_mp3 = os.path.join(OUT_DIR, f"{fname}_wav_v2.mp3")
style = get_style(fname, role, text)
manifest_lines.append(
f"{fname}\t{role}\t{style.replace(chr(9), ' ')}\t{text.replace(chr(9), ' ')}\n"
)
if os.path.exists(out_mp3) and not args.force:
print(f" [{i:3d}/{len(lines)}] SKIP {fname}")
skip += 1
continue
if role not in voice_ids:
print(f" [{i:3d}/{len(lines)}] MISS {fname} (无音色)")
fail += 1
continue
print(f" [{i:3d}/{len(lines)}] {fname} ... ", end="", flush=True)
t0 = time.time()
try:
wav_bytes = synthesize_styled(
voice_ids[role], text, style, cfg=args.cfg, steps=args.steps
)
raw_wav = os.path.join(tmp, f"{fname}_raw.wav")
spd_wav = os.path.join(tmp, f"{fname}_spd.wav")
with open(raw_wav, "wb") as f:
f.write(wav_bytes)
apply_speed(raw_wav, ROLES[role]["speed"], spd_wav)
wav_to_mp3(spd_wav, out_mp3)
kb = os.path.getsize(out_mp3) // 1024
print(f"ok ({time.time() - t0:.1f}s, {kb}KB)")
ok += 1
except Exception as e:
print(f"FAIL: {e}")
fail += 1
with open(manifest_path, "w", encoding="utf-8") as f:
f.writelines(manifest_lines)
print(f"\n完成: {ok} ok, {skip} skip, {fail} fail")
print(f"输出: {OUT_DIR}")
print(f"清单: {manifest_path}")
if __name__ == "__main__":
main()
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
《林夏》TTS v3 — VoxCPM2 Voice Design 批量生成
纯提示词生成音色,不使用参考音频。
输出:audio_2/tts/v3_voice_design/
用法:
VOXCPM_PORT=8002 bash /home/xsl/tts-server/start-voxcpm.sh
VOXCPM_PORT=8002 python audio_2/scripts/02_gen_tts_voice_design.py
python audio_2/scripts/02_gen_tts_voice_design.py --dry-run
python audio_2/scripts/02_gen_tts_voice_design.py --role delivery --test
"""
import argparse
import os
import subprocess
import sys
import tempfile
import time
import requests
PROJECT_DIR = "/home/xsl/blind"
OUT_DIR = f"{PROJECT_DIR}/audio_2/tts/v3_voice_design"
HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1")
PORT = os.environ.get("VOXCPM_PORT", "8002")
BASE_URL = f"http://{HOST}:{PORT}"
sys.path.insert(0, f"{PROJECT_DIR}/audio")
sys.path.insert(0, f"{PROJECT_DIR}/audio_2")
from batch_tts_voxcpm import ROLES, LINES # noqa: E402
from voice_style_prompts import get_voice_design # noqa: E402
os.makedirs(OUT_DIR, exist_ok=True)
def apply_speed(src, speed, dst):
if abs(speed - 1.0) < 0.01:
import shutil
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 wav_to_mp3(src, dst):
subprocess.run(
[
"ffmpeg", "-y", "-i", src,
"-codec:a", "libmp3lame", "-b:a", "128k", "-ar", "24000", "-ac", "1",
dst,
],
check=True,
capture_output=True,
)
def check_server():
try:
r = requests.get(f"{BASE_URL}/health", timeout=5)
return r.status_code == 200 and r.json().get("status") == "ok"
except Exception:
return False
def synthesize_design(text, style, cfg=2.0, steps=10):
"""Voice Design: 不传 voice_id。"""
r = requests.post(
f"{BASE_URL}/v1/speech/styled",
json={
"text": text,
"style": style,
"voice_id": None,
"cfg_value": cfg,
"inference_timesteps": steps,
},
timeout=180,
)
r.raise_for_status()
return r.content
def main():
parser = argparse.ArgumentParser(description="《林夏》Voice Design 批量 TTS → audio_2")
parser.add_argument("--role", help="只合成指定角色")
parser.add_argument("--test", action="store_true", help="只合成第一条")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true")
parser.add_argument("--cfg", type=float, default=2.0)
parser.add_argument("--steps", type=int, default=10)
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(" 《林夏》TTS v3 — Voice Design(无参考音频)")
print(f" 服务: {BASE_URL}")
print(f" 输出: {OUT_DIR}")
print(f"{len(lines)}")
print(f"{'=' * 60}\n")
if args.dry_run:
for fname, role, text in lines:
style = get_voice_design(fname, role, text)
out = os.path.join(OUT_DIR, f"{fname}_wav_v3.mp3")
mark = "" if os.path.exists(out) else "·"
print(f" [{mark}] {fname} [{role}]")
print(f" design: {style}")
print(f" text: {text[:50]}{'...' if len(text) > 50 else ''}\n")
return
if not check_server():
print(f"[错误] VoxCPM2 服务未就绪: {BASE_URL}/health")
print(f" VOXCPM_PORT={PORT} bash /home/xsl/tts-server/start-voxcpm.sh")
sys.exit(1)
tmp = tempfile.mkdtemp(prefix="voxcpm3_")
ok = skip = fail = 0
manifest_path = os.path.join(OUT_DIR, "voice_design_manifest.tsv")
manifest_lines = ["filename\trole\tdesign_prompt\ttext\n"]
for i, (fname, role, text) in enumerate(lines, 1):
out_mp3 = os.path.join(OUT_DIR, f"{fname}_wav_v3.mp3")
style = get_voice_design(fname, role, text)
manifest_lines.append(
f"{fname}\t{role}\t{style.replace(chr(9), ' ')}\t{text.replace(chr(9), ' ')}\n"
)
if os.path.exists(out_mp3) and not args.force:
print(f" [{i:3d}/{len(lines)}] SKIP {fname}")
skip += 1
continue
print(f" [{i:3d}/{len(lines)}] {fname} [{role}] ... ", end="", flush=True)
t0 = time.time()
try:
wav_bytes = synthesize_design(text, style, cfg=args.cfg, steps=args.steps)
raw_wav = os.path.join(tmp, f"{fname}_raw.wav")
spd_wav = os.path.join(tmp, f"{fname}_spd.wav")
with open(raw_wav, "wb") as f:
f.write(wav_bytes)
apply_speed(raw_wav, ROLES[role]["speed"], spd_wav)
wav_to_mp3(spd_wav, out_mp3)
print(f"ok ({time.time() - t0:.1f}s, {os.path.getsize(out_mp3) // 1024}KB)")
ok += 1
except Exception as e:
print(f"FAIL: {e}")
fail += 1
with open(manifest_path, "w", encoding="utf-8") as f:
f.writelines(manifest_lines)
print(f"\n完成: {ok} ok, {skip} skip, {fail} fail")
print(f"输出: {OUT_DIR}")
print(f"清单: {manifest_path}")
if __name__ == "__main__":
main()
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
《林夏》Voice Design 试听样本 — 每角色 5 条约 10 秒音频
输出:audio_2/tts/v4_voice_samples/{role}/{role}_v01.mp3 … v05.mp3
用法:
VOXCPM_PORT=8002 python audio_2/scripts/03_gen_voice_samples.py
python audio_2/scripts/03_gen_voice_samples.py --role delivery
python audio_2/scripts/03_gen_voice_samples.py --dry-run
"""
import argparse
import os
import subprocess
import sys
import tempfile
import time
import requests
PROJECT_DIR = "/home/xsl/blind"
OUT_ROOT = f"{PROJECT_DIR}/audio_2/tts/v4_voice_samples"
os.makedirs(OUT_ROOT, exist_ok=True)
HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1")
PORT = os.environ.get("VOXCPM_PORT", "8002")
BASE_URL = f"http://{HOST}:{PORT}"
sys.path.insert(0, f"{PROJECT_DIR}/audio_2")
from voice_sample_prompts import AUDITION_TEXT, ROLE_VOICE_VARIANTS # noqa: E402
def wav_to_mp3(src, dst):
subprocess.run(
[
"ffmpeg", "-y", "-i", src,
"-codec:a", "libmp3lame", "-b:a", "128k", "-ar", "24000", "-ac", "1",
dst,
],
check=True,
capture_output=True,
)
def wav_duration(path):
r = subprocess.run(
[
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path,
],
capture_output=True,
text=True,
check=True,
)
return float(r.stdout.strip())
def check_server():
try:
r = requests.get(f"{BASE_URL}/health", timeout=5)
return r.status_code == 200 and r.json().get("status") == "ok"
except Exception:
return False
def synthesize_design(text, style, cfg=2.0, steps=10):
r = requests.post(
f"{BASE_URL}/v1/speech/styled",
json={
"text": text,
"style": style,
"voice_id": None,
"cfg_value": cfg,
"inference_timesteps": steps,
},
timeout=180,
)
r.raise_for_status()
return r.content
def main():
parser = argparse.ArgumentParser(description="生成每角色 5 条 Voice Design 试听样本")
parser.add_argument("--role", help="只生成指定角色")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true")
parser.add_argument("--cfg", type=float, default=2.0)
parser.add_argument("--steps", type=int, default=10)
args = parser.parse_args()
roles = sorted(ROLE_VOICE_VARIANTS.keys())
if args.role:
roles = [r for r in roles if r == args.role or r.startswith(args.role)]
if not roles:
print(f"[错误] 未知角色: {args.role}")
sys.exit(1)
total = sum(len(ROLE_VOICE_VARIANTS[r]) for r in roles)
print(f"\n{'=' * 60}")
print(" 《林夏》Voice Design 试听样本(每角色 5 条)")
print(f" 服务: {BASE_URL}")
print(f" 输出: {OUT_ROOT}")
print(f" 角色: {len(roles)} 样本: {total}")
print(f"{'=' * 60}\n")
if args.dry_run:
for role in roles:
text = AUDITION_TEXT[role]
print(f"[{role}] ({len(text)} 字) {text[:40]}...")
for i, prompt in enumerate(ROLE_VOICE_VARIANTS[role], 1):
print(f" v{i:02d}: {prompt}")
print()
return
if not check_server():
print(f"[错误] VoxCPM2 未就绪: {BASE_URL}/health")
sys.exit(1)
os.makedirs(OUT_ROOT, exist_ok=True)
tmp = tempfile.mkdtemp(prefix="voxcpm_sample_")
manifest_path = os.path.join(OUT_ROOT, "samples_manifest.tsv")
manifest = ["role\tvariant\tseconds\tprompt\ttext\tfile\n"]
ok = skip = fail = 0
n = 0
for role in roles:
role_dir = os.path.join(OUT_ROOT, role)
os.makedirs(role_dir, exist_ok=True)
text = AUDITION_TEXT[role]
variants = ROLE_VOICE_VARIANTS[role]
for i, prompt in enumerate(variants, 1):
n += 1
tag = f"v{i:02d}"
out_mp3 = os.path.join(role_dir, f"{role}_{tag}.mp3")
out_wav = os.path.join(role_dir, f"{role}_{tag}.wav")
if os.path.exists(out_mp3) and not args.force:
print(f" [{n}/{total}] SKIP {role}_{tag}")
skip += 1
continue
print(f" [{n}/{total}] {role}_{tag} ... ", end="", flush=True)
t0 = time.time()
try:
wav_bytes = synthesize_design(text, prompt, cfg=args.cfg, steps=args.steps)
raw_wav = os.path.join(tmp, f"{role}_{tag}.wav")
with open(raw_wav, "wb") as f:
f.write(wav_bytes)
subprocess.run(
["ffmpeg", "-y", "-i", raw_wav, "-acodec", "pcm_s16le", "-ar", "24000", "-ac", "1", out_wav],
check=True,
capture_output=True,
)
wav_to_mp3(out_wav, out_mp3)
dur = wav_duration(out_mp3)
manifest.append(
f"{role}\t{tag}\t{dur:.1f}\t{prompt.replace(chr(9), ' ')}\t"
f"{text.replace(chr(9), ' ')}\t{role}/{role}_{tag}.mp3\n"
)
print(f"ok ({time.time() - t0:.1f}s, {dur:.1f}s)")
ok += 1
except Exception as e:
print(f"FAIL: {e}")
fail += 1
with open(manifest_path, "w", encoding="utf-8") as f:
f.writelines(manifest)
print(f"\n完成: {ok} ok, {skip} skip, {fail} fail")
print(f"试听目录: {OUT_ROOT}")
print(f"清单: {manifest_path}")
print("\n选定样本后告诉我,例如: delivery v03, linxia v02")
print("我会把对应 wav 注册为参考音频,再跑全量克隆。")
if __name__ == "__main__":
main()
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""
《林夏》TTS v5 — 选定试听样本克隆 + Style Control 表演
参考音频:audio_2/tts/v4_voice_samples/{role}/*.wav(每角色保留 1 条)
输出:audio_2/tts/v5_cloned/*_wav_v5.mp3
部署:audio/mp3/tts/*_wav_v1.mp3(游戏直接可用)
用法:
VOXCPM_PORT=8002 python audio_2/scripts/04_gen_tts_clone.py --force
python audio_2/scripts/04_gen_tts_clone.py --role delivery --test
python audio_2/scripts/04_gen_tts_clone.py --no-deploy
"""
import argparse
import base64
import glob
import os
import shutil
import subprocess
import sys
import tempfile
import time
import requests
PROJECT_DIR = "/home/xsl/blind"
SAMPLE_DIR = f"{PROJECT_DIR}/audio_2/tts/v4_voice_samples"
OUT_DIR = f"{PROJECT_DIR}/audio_2/tts/v5_cloned"
DEPLOY_DIR = f"{PROJECT_DIR}/audio/mp3/tts"
HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1")
PORT = os.environ.get("VOXCPM_PORT", "8002")
BASE_URL = f"http://{HOST}:{PORT}"
sys.path.insert(0, f"{PROJECT_DIR}/audio")
sys.path.insert(0, f"{PROJECT_DIR}/audio_2")
from batch_tts_voxcpm import ROLES, LINES # noqa: E402
from voice_style_prompts import get_clone_style # noqa: E402
# story.js 引用但不在 LINES 中的台词
EXTRA_LINES = [
(
"lv11_0030_self3y_voice_01",
"self_3y",
"哦对,今天是我来北京的第一百天。妈,我没跟你说,我自己数的。"
"这个城市好大。我走了很多路,见了很多人。我觉得我还没准备好,"
"但我又觉得好像准备了很久很久了。你们不用担心我。我很好。"
"以后的我,如果你听到这条,记得要好好的。",
),
(
"lv11_0030_self3y_voice_02",
"self_3y",
"我今天又没睡好。不是失眠,就是睡不踏实。"
"北京这个地方,它会让你觉得你随时都要更好,随时都要更努力。"
"我在努力。但我不知道在努力什么。"
"我就想留下一条记录。今天,今天是……算了,就今天吧。",
),
(
"lv11_0030_self3y_voice_03",
"self_3y",
"是我。23岁的我。我设了三年后打开。"
"我不知道那时候的你怎么样了。如果你很好,就当我多虑了。"
"如果你不好——你看,你熬过来了对不对。"
"你23岁的时候,也觉得很难。但你还是过来了。"
"所以……撑住啊。",
),
("lv11_sys_memo_01", "linxia", "三年前今天,一条未听的录音"),
]
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(DEPLOY_DIR, exist_ok=True)
def find_selected_ref(role_key):
role_dir = os.path.join(SAMPLE_DIR, role_key)
wavs = sorted(glob.glob(os.path.join(role_dir, "*.wav")))
if not wavs:
return None
if len(wavs) > 1:
print(f" [警告] {role_key} 有多条参考音频,使用: {os.path.basename(wavs[0])}")
return wavs[0]
def convert_to_wav(src, dst):
subprocess.run(
["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", "-acodec", "pcm_s16le", dst],
check=True,
capture_output=True,
)
def wav_to_base64(path):
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode()
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 wav_to_mp3(src, dst):
subprocess.run(
[
"ffmpeg", "-y", "-i", src,
"-codec:a", "libmp3lame", "-b:a", "128k", "-ar", "24000", "-ac", "1",
dst,
],
check=True,
capture_output=True,
)
def check_server():
try:
r = requests.get(f"{BASE_URL}/health", timeout=5)
return r.status_code == 200 and r.json().get("status") == "ok"
except Exception:
return False
def register_voice(wav_path):
r = requests.post(
f"{BASE_URL}/v1/voices",
json={"wav_base64": wav_to_base64(wav_path)},
timeout=60,
)
r.raise_for_status()
return r.json()["voice_id"]
def synthesize_styled(voice_id, text, style, cfg=2.0, steps=10):
payload = {
"text": text,
"style": style,
"voice_id": voice_id,
"cfg_value": cfg,
"inference_timesteps": steps,
}
r = requests.post(f"{BASE_URL}/v1/speech/styled", json=payload, timeout=180)
r.raise_for_status()
return r.content
def main():
parser = argparse.ArgumentParser(description="选定样本克隆 → v5 + 部署游戏目录")
parser.add_argument("--role", help="只合成指定角色")
parser.add_argument("--test", action="store_true", help="只合成第一条")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true")
parser.add_argument("--no-deploy", action="store_true", help="不复制到 audio/mp3/tts")
parser.add_argument("--cfg", type=float, default=2.0)
parser.add_argument("--steps", type=int, default=10)
args = parser.parse_args()
lines = LINES + EXTRA_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]
roles_needed = sorted(set(r for _, r, _ in lines))
print(f"\n{'=' * 60}")
print(" 《林夏》TTS v5 — 试听样本克隆")
print(f" 参考: {SAMPLE_DIR}")
print(f" 输出: {OUT_DIR}")
if not args.no_deploy:
print(f" 部署: {DEPLOY_DIR} (*_wav_v1.mp3)")
print(f" 台词: {len(lines)} 条 角色: {len(roles_needed)}")
print(f"{'=' * 60}\n")
refs = {}
for role in roles_needed:
ref = find_selected_ref(role)
if ref:
refs[role] = ref
print(f" [{role}] ← {os.path.basename(ref)}")
else:
print(f" [{role}] ✗ 未找到参考音频")
if args.dry_run:
print()
for fname, role, text in lines:
style = get_clone_style(fname, role, text) or "(无表演修饰)"
print(f" {fname} [{role}] style={style}")
print(f" ref={'yes' if role in refs else 'NO'} text={text[:40]}...")
return
missing = [r for r in roles_needed if r not in refs]
if missing:
print(f"\n[错误] 缺少参考音频: {', '.join(missing)}")
sys.exit(1)
if not check_server():
print(f"[错误] VoxCPM2 未就绪: {BASE_URL}/health")
sys.exit(1)
tmp = tempfile.mkdtemp(prefix="voxcpm5_")
voice_ids = {}
for role, ref_path in sorted(refs.items()):
wav = os.path.join(tmp, f"{role}_ref.wav")
convert_to_wav(ref_path, wav)
print(f" 注册音色: {role} ... ", end="", flush=True)
voice_ids[role] = register_voice(wav)
print(f"ok ({voice_ids[role][:10]}...)")
ok = skip = fail = 0
manifest_path = os.path.join(OUT_DIR, "clone_manifest.tsv")
manifest_lines = ["filename\trole\tref\tstyle\ttext\n"]
for i, (fname, role, text) in enumerate(lines, 1):
out_v5 = os.path.join(OUT_DIR, f"{fname}_wav_v5.mp3")
out_game = os.path.join(DEPLOY_DIR, f"{fname}_wav_v1.mp3")
style = get_clone_style(fname, role, text)
ref_name = os.path.basename(refs[role])
manifest_lines.append(
f"{fname}\t{role}\t{ref_name}\t{(style or '自然说话').replace(chr(9), ' ')}\t"
f"{text.replace(chr(9), ' ')}\n"
)
if os.path.exists(out_v5) and not args.force:
print(f" [{i:3d}/{len(lines)}] SKIP {fname}")
skip += 1
continue
if role not in voice_ids:
print(f" [{i:3d}/{len(lines)}] MISS {fname}")
fail += 1
continue
print(f" [{i:3d}/{len(lines)}] {fname} [{role}] ... ", end="", flush=True)
t0 = time.time()
try:
wav_bytes = synthesize_styled(
voice_ids[role], text, style, cfg=args.cfg, steps=args.steps
)
raw_wav = os.path.join(tmp, f"{fname}_raw.wav")
spd_wav = os.path.join(tmp, f"{fname}_spd.wav")
with open(raw_wav, "wb") as f:
f.write(wav_bytes)
speed = ROLES.get(role, {}).get("speed", 1.0)
apply_speed(raw_wav, speed, spd_wav)
wav_to_mp3(spd_wav, out_v5)
if not args.no_deploy:
shutil.copy2(out_v5, out_game)
kb = os.path.getsize(out_v5) // 1024
print(f"ok ({time.time() - t0:.1f}s, {kb}KB)")
ok += 1
except Exception as e:
print(f"FAIL: {e}")
fail += 1
with open(manifest_path, "w", encoding="utf-8") as f:
f.writelines(manifest_lines)
print(f"\n完成: {ok} ok, {skip} skip, {fail} fail")
print(f"输出: {OUT_DIR}")
print(f"清单: {manifest_path}")
if not args.no_deploy:
print(f"游戏目录: {DEPLOY_DIR}")
if __name__ == "__main__":
main()