文档整理: - 重写 game/操作说明.md 为项目主文档(6游戏/6手势/旁白/模型生成音频/TTS两步流程) - 归档过时文档至 other_docs/(林夏原始设计、旧执行文档) - 新增 docs/音频制作执行手册.md(从零制作游戏音频的完整流程) - docs/ 下保留旁白音色提示词、音频脚本 代码清理: - 删除旧架构死代码: story.js / gestures.js / profile.js - 删除耦合旧架构的过时测试目录 game/tests/ 脚本配置修正: - check_tts_env.sh: 启动脚本路径 AutoVideo → tts-server(实际位置) - gen_narrator_samples.py: 硬编码地址改为环境变量,默认对齐 tts-server 端口 8000
163 lines
7.4 KiB
Python
163 lines
7.4 KiB
Python
#!/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()
|