通过 Voice Design 试听选定各角色参考音色,Style Control 全量克隆并部署到 audio/mp3/tts;清理未引用音频与 audio_1 旧产物。
178 lines
5.7 KiB
Python
178 lines
5.7 KiB
Python
#!/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()
|