#!/usr/bin/env python3 """ 生成六个旁白音色的样本音频(Voice Design 模式,无需参考音频) 输出目录: audio/narrator_samples/{game_id}/sample_{n}.wav 每个游戏生成 3 条有代表性的台词,覆盖不同场景类型。 """ import os import time import requests import subprocess HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1") PORT = os.environ.get("VOXCPM_PORT", "8000") BASE_URL = f"http://{HOST}:{PORT}" OUT_BASE = "/home/xsl/blind/audio/narrator_samples" # ────────────────────────────────────────────────────────────── # 六个旁白的 Voice Design 风格描述(中文,给 VoxCPM2) # ────────────────────────────────────────────────────────────── NARRATORS = { "linxia": { "style": "成熟女声,三十岁出头,克制平静,纪录片旁白语气,语速偏慢,普通话标准,胸腔共鸣,不带情感评判", "speed": 0.85, "samples": [ ("s1_narrate", "晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。"), ("s2_tap", "你接了。妈妈的声音从电话里传来,带着一点日常的关心,问你今天怎么样,吃了没有。你说还好。挂掉之后,房间里又安静了。"), ("s3_ending", "今晚大部分的消息,你都没有回应。你一个人度过了这个夜晚。这也是一种选择,你心里清楚。"), ], }, "yinian": { "style": "年轻女声,二十五岁左右,气声轻柔,内心独白风格,语速很慢,像说给自己听,声音贴近,偏头腔,有重量但不悲伤", "speed": 0.80, "samples": [ ("s1_narrate", "你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。"), ("s2_tap", "你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。"), ("s3_ending", "你今晚接住了很多,你没有选择只是轻装。也许你的决定,就是带着这些往前走。"), ], }, "shouye": { "style": "中年男声,四十五岁左右,略带沙哑,低沉疲惫,实务语气,像在做交班记录,不慌张,稳定", "speed": 0.90, "samples": [ ("s1_narrate", "凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。"), ("s2_tap", "你拨了地下室的内线。没人接。你下去看了一眼,是一根管子在漏水,用旧布堵住了。回到监控室,零点二十三分。"), ("s3_ending", "今晚你一个人上了屋顶,你看到了那只猫,你看到了天边开始变色。只有你看到了。这算不上什么,但只有你知道。"), ], }, "houzhen": { "style": "女声,完全中性,无情感,播音腔,字正腔圆,精确清晰,像医院叫号系统,句末无语调起伏", "speed": 0.95, "samples": [ ("s1_narrate", "叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。"), ("s2_tap", "你看了他一会儿。他感觉到了,朝你点了点头。你也点了点头。没有说话,但有什么东西发生了。"), ("s3_final", "九十四号。是你。你站起来,整理了一下衣服,走向那扇门。"), ], }, "yisheng": { "style": "年轻女声,二十五岁,清晰精确,略带机械感,手机系统语音风格,有礼但冷静,像精调过的语音助手", "speed": 0.92, "samples": [ ("s1_intro", "你收到了一部手机。手机的主人去世了,一个月前。他留下了很多录音,按时间排列。你不认识他。或者你以为你不认识他。"), ("s2_meta", "第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。"), ("s3_skip", "你跳过了这条。你永远不会知道这条说了什么了。"), ], }, "fu": { "style": "女声,大量气声,极轻,飘渺,语速极慢,声音散而不集中,像梦里的声音,若即若离", "speed": 0.75, "samples": [ ("s1_intro", "你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。"), ("s2_item", "第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。"), ("s3_tap", "它停在你手心里,有点暖。"), ], }, } def synthesize_styled(text: str, style: str, cfg: float = 3.0, steps: int = 20) -> bytes: r = requests.post( f"{BASE_URL}/v1/speech/styled", json={ "text": text, "style": style, "voice_id": None, "cfg_value": cfg, "inference_timesteps": steps, "denoise": True, "retry_badcase": True, "normalize": True, }, timeout=180, ) r.raise_for_status() return r.content def apply_speed(wav_in: str, speed: float, wav_out: str): if abs(speed - 1.0) < 0.01: import shutil; shutil.copy2(wav_in, wav_out) return subprocess.run([ "ffmpeg", "-y", "-i", wav_in, "-filter:a", f"atempo={speed}", "-acodec", "pcm_s16le", wav_out, ], check=True, capture_output=True) def main(): print(f"\n{'='*60}") print(f" 旁白样本生成 — Voice Design 模式(无需参考音频)") print(f" 输出: {OUT_BASE}/") print(f"{'='*60}\n") total_ok = 0 total_fail = 0 for game_id, cfg in NARRATORS.items(): game_dir = os.path.join(OUT_BASE, game_id) os.makedirs(game_dir, exist_ok=True) print(f"── {game_id} [{cfg['style'][:30]}...]") for tag, text in cfg["samples"]: out_path = os.path.join(game_dir, f"{tag}.wav") if os.path.exists(out_path): print(f" [跳过] {tag}.wav 已存在") total_ok += 1 continue print(f" [{tag}] {text[:40]}...", end=" ", flush=True) t0 = time.time() try: wav_bytes = synthesize_styled(text, cfg["style"]) raw_path = out_path + ".raw.wav" with open(raw_path, "wb") as f: f.write(wav_bytes) apply_speed(raw_path, cfg["speed"], out_path) if os.path.exists(raw_path) and raw_path != out_path: os.remove(raw_path) elapsed = time.time() - t0 size_kb = os.path.getsize(out_path) // 1024 print(f"✓ {elapsed:.1f}s {size_kb}KB") total_ok += 1 except Exception as e: print(f"✗ {e}") total_fail += 1 print() print(f"{'='*60}") print(f" 完成 {total_ok} 条 失败 {total_fail} 条") print(f" 输出目录: {OUT_BASE}/") print(f"{'='*60}\n") if __name__ == "__main__": main()