Files
SoundGame/tools/gen_voice_dual.py
T
SoundGame Dev 79dffac01e guide层v1: 全局向导交互层落地(状态签名earcon/turn cue/静默看门狗/纠错) + 6游戏接线 + 8句向导语音 + 测试全绿
- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白
- settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题
- 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名
- 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性
- 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
2026-08-30 22:46:05 +08:00

817 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""双性别语音资产管线(R1):为 5 个 web 游戏生成 male/female 两版旁白 voice。
约定:
- 台词表从各游戏已有 gen 脚本原样提取(storyline/escape 的原始表在 app/src/<flavor>/gen/,
本脚本只做只读引用,不改 app/)。
- storyline 只生成旁白 + 系统音(NARR* 角色)的双性别版本;角色台词(林晚/猎狗/小满等)
不在本脚本范围内,沿用 web/storyline/audio/voice/ 平铺原文件。
- 输出 staging wav: tools/gen_voice_dual_wav/<game>/<gender>/<id>.wav
--convert 后落地: web/<game>/audio/voice/<gender>/<id>.{ogg,m4a}
(原有平铺 voice/*.ogg 保留不动,等引擎切换后由后续轮次清理。)
质量控制:
- 每条 assert 时长 > 0.3s、峰值 > 0.05;统一峰值归一 0.89。
- 台词 > 40 字按句拆分生成再拼接(句间 0.12s 静音),避免长句吞字。
- 短句/数字类(文本 ≤ 2 字)同 instruct 一整批连续生成,减少 prosody 漂移。
- --check 对比同句两性时长,相差 3 倍以上判异常(删掉异常 wav 后重跑本脚本即可)。
用法(GPU 必须串行):
source env.d/tts.sh
flock /home/ubuntu/SoundGame/.gpu.lock .venv-tts/bin/python tools/gen_voice_dual.py --game simon --gender both
.venv-tts/bin/python tools/gen_voice_dual.py --game all --check # 无需 GPU
source env.d/android.sh && .venv-tts/bin/python tools/gen_voice_dual.py --game all --convert
"""
import argparse
import os
import re
import subprocess
import sys
os.environ.setdefault("HF_HOME", "/home/ubuntu/SoundGame/models/hf")
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
ROOT = "/home/ubuntu/SoundGame"
STAGING = os.path.join(ROOT, "tools", "gen_voice_dual_wav")
SPLIT_LIMIT = 40 # 超过 40 字拆句
GAP_SEC = 0.12 # 拼接句间静音
PEAK_NORM = 0.89
BATCH_SHORT = 16 # 数字/短句一批
BATCH_LONG = 6
# ---------------------------------------------------------------- 音色分配
# 游戏 × 性别 -> speaker。说明见 docs/audio/asset-manifest.md。
# Ryan(英语母语音色)经冒烟测试未采用:项目已有中文发音投诉,男声统一用
# 中文母语的 Uncle_Fu;测试样本在 tools/samples/ryan_zh_*.wav 供人工试听。
SPEAKERS = {
"storyline": {"male": "Uncle_Fu", "female": "Vivian"},
"escape": {"male": "Uncle_Fu", "female": "Serena"},
"simon": {"male": "Uncle_Fu", "female": "Serena"},
"rhythm": {"male": "Uncle_Fu", "female": "Vivian"},
"space": {"male": "Uncle_Fu", "female": "Serena"},
"rpg": {"male": "Uncle_Fu", "female": "Serena"},
}
# instruct 对:(male, female)。None 表示两性别共用同一 instruct。
def _pair(m, f=None):
return (m, f if f is not None else m)
# ---------------------------------------------------------------- storyline
_SL_NARR = _pair("低沉醇厚的中年男声旁白,冷静、缓慢,带着末世废墟的苍凉感",
"冷静低沉的女声旁白,语速缓慢,带着末世废墟的苍凉感")
_SL_WARM = _pair("低沉醇厚的中年男声旁白,由紧张转为温暖,结尾带希望",
"女声旁白,由紧张转为温暖,结尾带希望")
_SL_COLD = _pair("低沉醇厚的中年男声旁白,冷漠、空洞,越说越轻",
"女声旁白,冷漠、空洞,越说越轻")
_SL_EPIC = _pair("低沉醇厚的中年男声旁白,悲壮,尾音下沉",
"女声旁白,悲壮,尾音下沉")
STORYLINE_LINES = [
# ---- 系统语音 ----
("help", _SL_NARR, "操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。"),
("status1", _SL_NARR, "当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。"),
("status2", _SL_NARR, "当前进度:第二幕,酸雨笼罩的街区与断桥。你的目标:渡过江水,抵达广播塔。小心,猎狗的人,就在附近。"),
("status3", _SL_NARR, "当前进度:第三幕,广播塔下。你的目标:把警报发出去,找到林晨。这是最后一段路了。"),
("menu", _SL_NARR, "菜单。向左滑:重新开始整个故事。向右滑,或者单击:回到故事。向上滑:听操作说明。"),
# ---- 选择提示 ----
("p1", _SL_NARR, "请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。"),
("p2", _SL_NARR, "请选择。向左滑:钻进旁边的通风管道。向右滑:躺进路边的尸体堆里装死。"),
("p3", _SL_NARR, "请选择。向左滑:循着哭声过去看看。向右滑:不去管它,直接去北区。"),
("p4", _SL_NARR, "请选择。向左滑:带上小满一起走。向右滑:留下食物,独自上路。"),
("p5", _SL_NARR, "请选择。向左滑:走桥下的污水涵洞。向右滑:爬桥上悬着的钢索。"),
("p6", _SL_NARR, "请选择。向左滑:交出坐标,保命要紧。向右滑:拒绝他,想办法周旋。"),
("p7", _SL_NARR, "请选择。向左滑:追上去,警告避难所。向右滑:自己逃命,越远越好。"),
("p8", _SL_NARR, "请选择。向左滑:用公开频道广播,让全城都听见。向右滑:用加密频段,只发给避难所。"),
# ---- 第一幕 ----
("n00_intro", _SL_NARR, "大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:单击屏幕,继续剧情;双击,重听上一句;向上滑,听你的状态和帮助;向下滑,呼出菜单。当我说“请选择”的时候,向左滑,或者向右滑,做出你的决定。选择一旦做出,就无法回头。现在,单击屏幕,睁开眼睛。"),
("n01_wake", _SL_NARR, "后脑一阵钝痛,嘴里全是铁锈味。你在废弃的地铁站里醒来,四周黑得像沉在水底。背包还压在身下,手电筒却不知去向。远处滴水的声音,一下,一下,像某种倒计时。"),
("n04_exit", _SL_NARR, "你扶着潮湿的墙,一步一步往外摸。第五十步,脚尖踢到一个硬物——是一支手电,还剩半格电。光柱劈开黑暗的那一瞬,你看见墙上用红漆刷着一行大字:别去北区。"),
("n05_signal", _SL_NARR, "就在这时,背包侧袋里的收音机,沙沙声忽然变得清晰。一个平稳的女声,一遍又一遍地重复着:"),
("n06_whistle", _SL_NARR, "你刚走上街面,黑暗里忽然响起一声口哨。接着是第二声,第三声,从不同的方向,把你围在中间。有人的手电光,扫过你的脸。"),
("n08_vent", _SL_NARR, "你一矮身,钻进了通风管道。铁皮贴着脊背,窄得几乎无法呼吸。身后传来咒骂和铁棍敲击管道口的声音,越来越近,又渐渐远去。十分钟后,你从两条街外的井口爬出来,肺里像塞满了沙子。"),
("n09_dead", _SL_NARR, "你屏住呼吸,把自己埋进路边冰冷的尸体堆里。手电、罐头、半瓶水,被他们一样一样摸走。有人踢了你一脚,你纹丝不动。就在他们离开时,你听见一句不该听见的话——“北区那个陷阱,今晚就收网。”"),
# ---- 第二幕 ----
("n10_rain", _SL_NARR, "酸雨开始下了。街面上升起细密的嘶嘶声,像无数条小蛇在游。雨幕那头,隐约传来一阵哭声——细细的,是个孩子。"),
("n11_child", _SL_NARR, "哭声来自一间塌了一半的便利店。一个小男孩抱着膝盖坐在货架底下,身边躺着一个再也不会醒来的女人。他抬起头,脸上全是灰,小声说:“我叫小满。妈妈说,数到一千,她就会醒。我已经数到……数到好多个一千了。”"),
("n13_leave", _SL_NARR, "你把仅剩的一罐罐头放在他身边,转身走进雨里。身后,孩子重新开始数数:一……二……三……你告诉自己,别回头。千万别回头。"),
("n14_ignore", _SL_NARR, "哭声被你甩在身后,越来越远,最后混进雨声里,再也听不见了。你走得很快,快得像在逃跑。快一点,再快一点。你对自己说,只要到了北区,这一切就都值了。"),
("n15_bridge", _SL_NARR, "两个小时后,江水拦住了去路。跨江大桥断成两截,桥身斜插进黑沉沉的江面。要去广播塔,只剩两条路:桥下的污水涵洞,黑得看不见底;或者,桥上悬着的钢索,在风里荡来荡去。"),
("n16_culvert", _SL_NARR, "涵洞里,污水没到胸口,又冷又黏,带着腐烂的甜味。走到一半,水面下有什么东西,擦着你的腿滑了过去。你没有叫出声,只是走,一步,一步,直到看见洞口那点灰白的天光。"),
("n16b_culvert_c", _SL_NARR, "你让小满骑在肩上,自己蹚进没到胸口的污水里。又冷又黏,带着腐烂的甜味。走到一半,水面下有什么东西擦过你的腿,小满吓得一抖,你抬手按住他的膝盖:“数数。”他小声数起来,一,二,三……数到一百七,你们看见了洞口的天光。"),
("n17_cable", _SL_NARR, "钢索比想象中更滑。酸雨把它泡得发黏,你每挪一步,整根钢索就晃一下。江风从断桥的缺口灌进来,像有无数只手在水底下拽你的脚。你不往下看。你只知道,前面就是北岸。"),
("n17b_cable_c", _SL_NARR, "钢索比想象中更滑。小满趴在你背上,一声不吭,两条小胳膊勒得你生疼。走到一半,钢索猛地一沉,你听见身后钢丝崩断的脆响。“闭眼。”你说。然后你用尽全身力气,向前,向前,直到膝盖磕上北岸的石头。"),
# ---- 第三幕 ----
("n20_convoy", _SL_NARR, "猎狗的车队朝北区开走了,探照灯在雨幕里划出一道道口子。你蹲在原地,胃里一阵一阵地泛酸。晨晨在北区。而刚才,是你,亲手把狼,引到了她的门口。"),
("n22_fight", _SL_NARR, "你往后退了半步,忽然笑了:“坐标在我脑子里。杀了我,你什么都拿不到。”猎狗愣了半秒。就是这半秒,你抓起一把沙土扬在他脸上,转身就跑。子弹擦着肩膀打进墙里,火辣辣地疼。但你在跑,你在跑——活着的感觉,从来没有这么清楚过。"),
("n23_tower", _SL_NARR, "广播塔就立在头顶,红色的信号灯在雨里一闪一闪,像一颗还在跳动的心脏。塔底的中控台居然还能用。只要爬上去,你就能接入城市广播网,把警报发给北区的每一个人。问题是——用哪个频道。"),
# ---- 结局 ----
("e1_dawn", _SL_WARM, "加密频道那头沉默了三秒。然后,一个你等了三十天的声音响了起来:“姐?姐!是你吗?”天亮之前,避难所的救援队打开了大门。猎狗的人,在北区的高墙下溃散了。你抱着晨晨,听见广播塔里,重新响起了音乐。后来人们说,这座城市,是从一个女人的声音开始,重新活过来的。——好结局:黎明。感谢你,陪林晚走完了这条路。单击屏幕,可以回到故事开始的地方。"),
("e2_price", _SL_EPIC, "你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。单击屏幕,重新开始。"),
("e3_alone", _SL_COLD, "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。单击屏幕,重新开始。"),
("e4_static", _SL_EPIC, "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。单击屏幕,重新开始。"),
("skip_hint", _SL_NARR, "单击可跳过旁白。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_continue", _SL_NARR, "轻触屏幕,继续。"),
("gd_invalid", _SL_NARR, "这个操作在这里用不上。"),
]
# ---------------------------------------------------------------- escape
# R2 重设计(docs/design/01):全游戏同一 instruct 常量(00 文档 §3.2,不含语速词)。
_ESC = _pair("低沉平稳的男声,冷静、克制,带一点神秘感",
"沉稳平静的女声,冷静、克制,带一点神秘感")
ESCAPE_LINES = [
# ---- 开场引导 G0-G7 ----
("g0_intro", _ESC, "黑暗。你在废弃的地下设施里醒来,什么都想不起来。你必须找到出路。"),
("g1_move", _ESC, "先活动一下。朝任意方向滑一下,走一小步。"),
("g1_done", _ESC, "很好,你迈出了第一步。"),
("g2_wall", _ESC, "这个房间不大,四面都是墙。再滑两下,试试碰碰墙。"),
("g3_left", _ESC, "跟着我指的路走。向左滑。"),
("g4_down", _ESC, "对。再向下滑。"),
("g5_inspect", _ESC, "听到水声了吗?单击一下,仔细检查这个房间。"),
("g5_key", _ESC, "你摸到了一把钥匙!"),
("g6_doubletap", _ESC, "钥匙能开锁。双击,是把物品用出去的用法。现在,双击试试。"),
("g6_done", _ESC, "对,就是这样。接下来,靠你自己了。"),
("g7_goal", _ESC, "找到能用这把钥匙打开的东西。需要帮助时,在原地双击。"),
# ---- 引导纠错与跳过 ----
("guide_wrong_up", _ESC, "不是这个方向。向上滑。"),
("guide_wrong_down", _ESC, "不是这个方向。向下滑。"),
("guide_wrong_left", _ESC, "不是这个方向。向左滑。"),
("guide_wrong_right", _ESC, "不是这个方向。向右滑。"),
("guide_wrong_gesture", _ESC, "不是这个操作。照我说的做。"),
("guide_blunt_up", _ESC, "手指贴着屏幕,向上推一下。"),
("guide_blunt_down", _ESC, "手指贴着屏幕,向下推一下。"),
("guide_blunt_left", _ESC, "手指贴着屏幕,向左推一下。"),
("guide_blunt_right", _ESC, "手指贴着屏幕,向右推一下。"),
("guide_offpath", _ESC, "先跟我走。"),
("guide_skip", _ESC, "跳过引导,直接开始自由探索。"),
("reopen_prompt", _ESC, "上滑,重新听一遍引导。单击,直接开始。"),
# ---- 卡关三级提示 + 导航 ----
("stuck_ask", _ESC, "需要帮助吗?"),
("stuck_key_1", _ESC, "铁锈味最重的地方,值得仔细看看。"),
("stuck_key_2", _ESC, "去西南角的储物间,单击检查,找到钥匙。"),
("stuck_key_3", _ESC, "跟我说的方向走,去西南角的储物间。"),
("stuck_cabinet_1", _ESC, "钥匙就在你身上。它能打开某一把锁。"),
("stuck_cabinet_2", _ESC, "去东边的铁柜房,双击,用钥匙打开铁柜。"),
("stuck_cabinet_3", _ESC, "跟我说的方向走,去铁柜所在的房间。"),
("stuck_box_1", _ESC, "保险丝属于电箱。有电流声的地方,就有电箱。"),
("stuck_box_2", _ESC, "去西北角的电箱房,双击,把保险丝装进电箱。"),
("stuck_box_3", _ESC, "跟我说的方向走,去电箱所在的房间。"),
("stuck_gate_1", _ESC, "电已经通了。该去找那扇铁门了。"),
("stuck_gate_2", _ESC, "去东南角的大门,双击,打开它,离开这里。"),
("stuck_gate_3", _ESC, "跟我说的方向走,去大门所在的房间。"),
("nav_up", _ESC, "向上滑。"),
("nav_down", _ESC, "向下滑。"),
("nav_left", _ESC, "向左滑。"),
("nav_right", _ESC, "向右滑。"),
# ---- 房间描述 ----
("room_storage", _ESC, "一间储物间。货架上堆满杂物,角落里传来水滴声。"),
("room_cabinet", _ESC, "这个房间里,立着一个铁柜。"),
("room_power", _ESC, "这个房间的墙上,有一只电箱。"),
("room_gate", _ESC, "面前,是一扇沉重的铁门。"),
# ---- 用时播报 4 段整句 ----
("time_band1", _ESC, "你逃出来了,用时不到两分钟。了不起!"),
("time_band2", _ESC, "你逃出来了,用时不到五分钟。"),
("time_band3", _ESC, "你逃出来了,用时不到十分钟。"),
("time_band4", _ESC, "你逃出来了,用时超过十分钟。"),
# ---- 保留句(文本沿用,按新 instruct 重录) ----
("wall", _ESC, "那边是墙,换个方向。"),
("start_room", _ESC, "你站在设施中央的房间里。滑动移动,单击检查,双击使用物品。"),
("found_key", _ESC, "你蹲下身在储物架的角落里摸索,指尖碰到一个冰凉的金属物件——是一把钥匙!钥匙到手了。"),
("inspect_cabinet_locked", _ESC, "这个房间里立着一个铁柜,柜门上挂着一把锁。也许,有什么钥匙能打开它。"),
("unlock_cabinet", _ESC, "钥匙插进锁孔,缓缓转动。咔哒一声,柜门开了。柜子深处,躺着一枚保险丝。保险丝到手了。"),
("inspect_box_dead", _ESC, "墙上是一只电箱,箱盖敞开着,里面的保险丝座空空如也。没有电,这里的大门永远打不开。"),
("box_fixed", _ESC, "电箱运转正常,电流的嗡鸣声平稳而持续。"),
("fix_box", _ESC, "你把保险丝用力按进卡座。火花一闪,嗡鸣声响起——头顶的线路恢复供电了!"),
("inspect_gate", _ESC, "面前是一扇沉重的铁门。门边的电子锁一片漆黑,没有任何反应。它需要电力。"),
("inspect_gate_ready", _ESC, "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。双击,打开它,离开这里。"),
("escape", _ESC, "你扳动开关。电机轰鸣,沉重的铁门缓缓升起,刺眼的光线涌了进来。你眯起眼睛,向前迈出了第一步。自由了。你逃出来了。"),
("use_fail", _ESC, "这里没有用得上东西的地方。换个房间试试,或者先单击检查一下周围。"),
("inv_empty", _ESC, "你摸了摸身上,什么也没有。"),
("inv_key", _ESC, "你身上带着一把钥匙。"),
("inv_fuse", _ESC, "你身上带着一枚保险丝。"),
("inv_both", _ESC, "你身上带着一把钥匙和一枚保险丝。"),
("replay", _ESC, "黑暗再次合拢。你回到了中央的房间。这一次,你会更快。"),
("again", _ESC, "单击屏幕,再来一次。下滑,打开菜单。"),
("empty_1", _ESC, "房间里空空荡荡,只有你自己的回声。"),
("empty_2", _ESC, "你伸出手,摸到的只有冰冷粗糙的墙壁。"),
("empty_3", _ESC, "除了远处的水滴声,这里什么都没有。"),
# ---- 菜单与帮助 ----
("help_ops", _ESC, "滑动,在房间之间移动。单击,检查房间,或者重听房间的声音。双击,使用物品,或者查看背包。"),
("menu_open", _ESC, "菜单。"),
("menu_restart", _ESC, "重新开始。"),
("menu_home", _ESC, "回大厅。"),
("menu_back", _ESC, "返回游戏。"),
("skip_hint", _ESC, "单击可跳过旁白。"),
]
# ---------------------------------------------------------------- simon
# R2 重设计(docs/design/02):全游戏同一 instruct 常量,轮次/成绩全部整句化。
_SIMON = _pair("平静、清晰,像一个耐心的游戏主持人")
_SIMON_ROUNDS = ["第一", "第二", "第三", "第四", "第五", "第六", "第七", "第八", "第九", "第十",
"第十一", "第十二", "第十三", "第十四", "第十五", "第十六", "第十七", "第十八", "第十九", "第二十"]
SIMON_LINES = [
# ---- 交互式教学 T0-T7 ----
("t0_welcome", _SIMON, "欢迎来到音序回响。先认识六种声音。"),
("t0_goal", _SIMON, "我会演奏一串声音,请你记住顺序,原样复现。"),
("t1_up", _SIMON, "向上滑,是这个声音。"),
("t2_down", _SIMON, "向下滑,是这个声音。"),
("t3_left", _SIMON, "向左滑,是这个声音。"),
("t4_right", _SIMON, "向右滑,是这个声音。"),
("t5_tap", _SIMON, "单击,是这个声音。"),
("t6_double", _SIMON, "双击,是这个声音。"),
("t_try", _SIMON, "你试试。"),
("t_ok", _SIMON, "对了。"),
("t_wrong_up", _SIMON, "不是这个,向上滑试试。"),
("t_wrong_down", _SIMON, "不是这个,向下滑试试。"),
("t_wrong_left", _SIMON, "不是这个,向左滑试试。"),
("t_wrong_right", _SIMON, "不是这个,向右滑试试。"),
("t_wrong_tap", _SIMON, "不是这个,单击试试。"),
("t_wrong_double", _SIMON, "不是这个,双击试试。"),
("t_blunt_up", _SIMON, "手指贴着屏幕,向上推一下。"),
("t_blunt_down", _SIMON, "手指贴着屏幕,向下推一下。"),
("t_blunt_left", _SIMON, "手指贴着屏幕,向左推一下。"),
("t_blunt_right", _SIMON, "手指贴着屏幕,向右推一下。"),
("t_blunt_tap", _SIMON, "用指尖,轻轻点一下屏幕。"),
("t_blunt_double", _SIMON, "用指尖,快速点两下屏幕。"),
("t7_ready", _SIMON, "六种声音都记住了。后面的序列,会越来越长。"),
("t7_retry", _SIMON, "答错可以消耗机会重听。双击,可以随时重听。"),
("t_skip", _SIMON, "跳过教学,直接开始。"),
# ---- 轮次播报(整句,round_01..round_20 + 兜底) ----
*[(f"round_{i + 1:02d}", _SIMON, f"{w}轮,听好了。") for i, w in enumerate(_SIMON_ROUNDS)],
("round_more", _SIMON, "新的一轮,听好了。"),
# ---- 成绩播报 6 段整句 ----
("score_band1", _SIMON, "一轮都没坚持下来。别灰心。"),
("score_band2", _SIMON, "你坚持了三轮以内。"),
("score_band3", _SIMON, "你坚持了五轮左右。"),
("score_band4", _SIMON, "你坚持了将近十轮。"),
("score_band5", _SIMON, "你坚持了十轮以上,很厉害。"),
("score_band6", _SIMON, "你坚持了十五轮以上,大师级!"),
# ---- 流程句 ----
("your_turn", _SIMON, "轮到你了。"),
("chances_out", _SIMON, "重听机会用完了,小心。"),
("retry_use", _SIMON, "错了。用一次机会,再听一遍。"),
("relisten", _SIMON, "再听一遍。"),
("replay", _SIMON, "单击屏幕,再来一局。"),
("rules_prompt", _SIMON, "上滑,重听规则。下滑,打开菜单。"),
("rules", _SIMON, "上行音,向上滑。下行音,向下滑。左边的音,向左滑。右边的音,向右滑。一声轻响,单击。两声轻响,双击。"),
("gameover", _SIMON, "游戏结束。"),
# ---- 反馈短句 ----
("correct", _SIMON, "答对了!"),
("wrong", _SIMON, "错了。"),
("chance_plus", _SIMON, "机会加一。"),
("new_record", _SIMON, "新纪录!"),
# ---- 菜单 ----
("menu_open", _SIMON, "菜单。"),
("menu_restart", _SIMON, "重新开始。"),
("menu_home", _SIMON, "回大厅。"),
("menu_back", _SIMON, "返回游戏。"),
("skip_hint", _SIMON, "单击可跳过旁白。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_relisten", _SIMON, "需要的话,双击可以重新听一遍序列。"),
]
# ---------------------------------------------------------------- rhythm
# R3 重构(docs/design/03-rhythm-voice-ids.md):31 句,全表同一 instruct 常量。
_RHY = _pair("低沉醇厚的男声,像节奏游戏主持人,平稳有力",
"活力清晰的年轻女声,像节奏游戏主持人,情绪饱满但不夸张")
RHYTHM_LINES = [
# ---- W 组 · 开场与流程 ----
("welcome", _RHY, "欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。"),
("welcome_back", _RHY, "欢迎回来。我奏一句,你跟一句。"),
("tut_1", _RHY, "听,底鼓。在下一小节的同一个位置,单击。"),
("tut_2", _RHY, "这次是左和右。跟着打,左滑,右滑。"),
("tut_3", _RHY, "最后一句,三个音,中间是双击。"),
("tut_done", _RHY, "很好,演出开始。"),
("tut_skip", _RHY, "跳过教学,直接开始。"),
("mid_song", _RHY, "过半了,保持。"),
# ---- C 组 · 教学纠正 ----
("retry_early", _RHY, "早了,等鼓点再出手。"),
("retry_late", _RHY, "晚了,跟着鼓点走。"),
("retry_gesture", _RHY, "不是这个手势,再听一遍。"),
("retry_listen", _RHY, "再听一遍,跟着来。"),
("retry_skip", _RHY, "没关系,我们先走下一句。"),
# ---- S 组 · 选曲与解锁 ----
("select_hint", _RHY, "左右滑选曲目,单击开始,下滑回大厅。"),
("song1_name", _RHY, "第一首,启程,九十拍。"),
("song2_name", _RHY, "第二首,追风,一百零五拍。"),
("song3_name", _RHY, "第三首,星爆,一百二十拍。"),
("song_locked", _RHY, "这首还没解锁。上一首拿到 B 级以上就能解锁。"),
("unlock_song2", _RHY, "新曲目解锁,追风!"),
("unlock_song3", _RHY, "新曲目解锁,星爆!"),
# ---- G 组 · 结算 ----
("grade_s", _RHY, "S 级!准确率超过九成五,完美演出。"),
("grade_a", _RHY, "A 级!准确率超过八成五,非常出色。"),
("grade_b", _RHY, "B 级,准确率超过七成。不错,还能更好。"),
("grade_c", _RHY, "C 级,准确率不到七成。别灰心,再来一遍。"),
("new_best_combo", _RHY, "新连击纪录!"),
("restart", _RHY, "单击再来一遍,右滑下一首,下滑回大厅。"),
# ---- M 组 · 菜单与规则 ----
("rules", _RHY, "规则:我奏一句,你在下一小节原样跟打。音要对,拍点也要对。"),
("settings_hint", _RHY, "音色、语速、背景音乐,请回大厅修改。"),
("menu_main", _RHY, "菜单。单击切换选项,双击确认。"),
("menu_resume", _RHY, "继续演奏。"),
("menu_lobby", _RHY, "回大厅。"),
("skip_hint", _RHY, "单击可跳过旁白。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_answer", _RHY, "现在跟着我打。"),
("gd_miss", _RHY, "慢了半拍,下一小节跟上我就行。"),
]
# ---------------------------------------------------------------- space
# R3 备产(docs/design/04-space-voice-ids.md):52 句全量替换 R1 版,全表同一 instruct 常量。
_SPC = _pair("低沉醇厚的男声,像飞船领航员,沉稳有力",
"平静清晰的女声,像飞船领航员,沉稳可信")
SPACE_LINES = [
# ---- D 组 · 交互式演练 ----
("drill_welcome", _SPC, "欢迎登上盲航。你只有一双耳朵。"),
("drill_rule", _SPC, "陨石来了,会有声音告诉你方位。"),
("drill_left", _SPC, "听,左边有陨石。向左滑,把它拨开!"),
("drill_right", _SPC, "这次在右边。向右滑!"),
("drill_up", _SPC, "上面有东西,声音又尖又高。向上滑!"),
("drill_down", _SPC, "下面是低沉的轰隆。向下滑!"),
("drill_good", _SPC, "漂亮。"),
("drill_retry_left", _SPC, "那是左边来的,向左滑。"),
("drill_retry_right", _SPC, "那是右边来的,向右滑。"),
("drill_retry_up", _SPC, "声音又高又尖,是上面,向上滑。"),
("drill_retry_down", _SPC, "轰隆声在下面,向下滑。"),
("drill_moveon", _SPC, "没关系,跟着我,我们继续。"),
("drill_done", _SPC, "四个方向你都会了。引擎点火,出发!"),
("drill_skip", _SPC, "跳过演练,直接出发。"),
# ---- P 组 · 保护期 ----
("protect_left", _SPC, "训练护盾接住了。那颗在左边,听到左边的呼啸,向左滑。"),
("protect_right", _SPC, "训练护盾接住了。那颗在右边,听到右边的呼啸,向右滑。"),
("protect_up", _SPC, "训练护盾接住了。那颗在上面,听到高高的呼啸,向上滑。"),
("protect_down", _SPC, "训练护盾接住了。那颗在下面,听到低沉的轰隆,向下滑。"),
("protect_end", _SPC, "训练护盾关闭。接下来,靠你自己了。"),
# ---- F 组 · 单击/双击分批引入 ----
("feat_boost", _SPC, "试试单击,引擎会短促加速,直接冲过空隙。"),
("feat_boost_done", _SPC, "感觉到了吗?加速的时候,陨石撞不上你。"),
("feat_shield", _SPC, "双击展开护盾,能挡下一次撞击。"),
("feat_shield_done", _SPC, "护盾展开后会自动充能,好了我会告诉你。"),
("shield_ready", _SPC, "护盾充能完毕。"),
("shield_block", _SPC, "护盾挡下了撞击。"),
# ---- H 组 · 被撞归因 ----
("hit_left", _SPC, "左边撞上的。听到左边的呼啸,向左滑。"),
("hit_right", _SPC, "右边撞上的。听到右边的呼啸,向右滑。"),
("hit_up", _SPC, "上面撞上的。听到高高的呼啸,向上滑。"),
("hit_down", _SPC, "下面撞上的。听到低沉的轰隆,向下滑。"),
("hit_last", _SPC, "船体只剩最后一次了,小心!"),
# ---- M 组 · 里程碑 ----
("ms_10", _SPC, "十颗了,稳。"),
("ms_25", _SPC, "二十五颗,老练的飞行员。"),
("ms_50", _SPC, "五十颗,传奇。"),
("ms_100", _SPC, "一百颗!天空属于你。"),
# ---- R 组 · 结算 ----
("time_1", _SPC, "不到三十秒,再试一次。"),
("time_2", _SPC, "超过三十秒,有进步。"),
("time_3", _SPC, "超过一分钟,合格的飞行员。"),
("time_4", _SPC, "超过两分钟,王牌飞行员。"),
("dodge_1", _SPC, "只拨开几颗。"),
("dodge_2", _SPC, "拨开了十来颗。"),
("dodge_3", _SPC, "拨开了几十颗。"),
("dodge_4", _SPC, "拨开了一大群,漂亮。"),
("new_record", _SPC, "新纪录!"),
("restart_hint", _SPC, "单击再飞一次,下滑回大厅。"),
# ---- Y 组 · 菜单与系统 ----
("second_run_ask", _SPC, "上滑重新听演练,单击直接出发。"),
("menu_main", _SPC, "菜单。单击切换选项,双击确认。"),
("menu_resume", _SPC, "继续飞行。"),
("menu_lobby", _SPC, "回大厅。"),
("menu_help", _SPC, "重新听教学要点。"),
("help_recap", _SPC, "左边来向左滑,右边来向右滑,上面来向上滑,下面来向下滑。单击加速,双击护盾。"),
("settings_hint", _SPC, "音色、语速、背景音乐,请回大厅修改。"),
("paused", _SPC, "已暂停。"),
("skip_hint", _SPC, "单击可跳过旁白。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_hit_hint", _SPC, "被撞了。听方位,同向滑动就能拨开。"),
]
# ---------------------------------------------------------------- rpg
# R4 新游戏《剑与声》第一章(docs/design/05-rpg-ch1-voice-ids.md + 05-rpg-addendum.md §6.2)。
# 旁白全表同一 instruct 常量;NPC 单音色平铺见 RPG_NPC。
_RPG = _pair("低沉醇厚的男声,像说书人,平静有韵味",
"平静清晰的年轻女声,像在讲武侠故事")
RPG_LINES = [
# ---- A 组 · 序章 ----
("intro_1", _RPG, "你的眼睛看不见了。但你的耳朵,比谁都灵。"),
("intro_2", _RPG, "黑风寨掳走了你唯一的徒弟。追进迷雾林,把他们连根拔起。"),
("intro_3", _RPG, "先记住,怎么用耳朵走路。"),
# ---- S 组 · 系统/菜单/状态 ----
("sys_continue_ask", _RPG, "检测到你的旅程记录。单击继续,右滑开始新的旅程。"),
("sys_new_game", _RPG, "新的旅程,开始了。"),
("sys_saved", _RPG, "进度已保存。"),
("sys_rest", _RPG, "你在篝火边睡了一觉,状态全满。"),
("sys_drink", _RPG, "溪水清冽,你喝了个饱,伤势尽消。"),
("sys_menu", _RPG, "菜单。左右滑选择,单击确认。"),
("sys_menu_resume", _RPG, "继续旅程。"),
("sys_menu_save", _RPG, "保存并回大厅。"),
("sys_menu_help", _RPG, "重新听帮助。"),
("help_explore", _RPG, "左右滑切换选项,单击确认。双击重新听场景,上滑查看状态,下滑打开菜单。"),
("help_battle", _RPG, "战斗中,左右滑选招式,单击出手。听到敌人的前摇,双击格挡。"),
("settings_hint", _RPG, "音色、语速、背景音乐,请回大厅修改。"),
("st_hp_high", _RPG, "你状态很好。"),
("st_hp_mid", _RPG, "你受了伤。"),
("st_hp_low", _RPG, "你伤势很重,命悬一线。"),
("st_gold_poor", _RPG, "口袋空空。"),
("st_gold_some", _RPG, "有些积蓄。"),
("st_gold_rich", _RPG, "腰包鼓鼓。"),
("st_lv_low", _RPG, "你初出茅庐。"),
("st_lv_mid", _RPG, "你小有名气。"),
("st_lv_high", _RPG, "你已是高手。"),
("st_weapon_none", _RPG, "你赤手空拳。"),
("st_weapon_iron", _RPG, "你手持铁剑。"),
("st_weapon_steel", _RPG, "你手持青钢剑。"),
("st_weapon_wind", _RPG, "你手持听风剑。"),
("st_armor_none", _RPG, "你身无片甲。"),
("st_armor_leather", _RPG, "你身着皮甲。"),
("st_armor_iron", _RPG, "你身着铁鳞甲。"),
# ---- D 组 · 场景描述 ----
("desc_village", _RPG, "雾口村。篝火噼啪作响,村民低声议论着林子里的土匪。"),
("desc_village_re", _RPG, "你回到了雾口村。"),
("desc_path", _RPG, "林间小路。风穿过树叶,脚下的落叶沙沙作响。"),
("desc_path_re", _RPG, "你回到林间小路。"),
("desc_fork", _RPG, "岔路口。风声在左右两边,听起来不太一样。"),
("desc_fork_re", _RPG, "你回到岔路口。"),
("desc_chest", _RPG, "一个枯树洞。洞里似乎藏着什么。"),
("desc_chest_re", _RPG, "你回到枯树洞前。"),
("desc_stream", _RPG, "溪流。水声潺潺,是个歇脚的好地方。"),
("desc_stream_re", _RPG, "你回到溪流边。"),
("desc_hunter", _RPG, "猎人小屋。柴火正旺,还有磨刀的声音。"),
("desc_hunter_re", _RPG, "你回到猎人小屋。"),
("desc_deepwood", _RPG, "密林深处。虫鸣急促,远处有狼嚎,杀气很重。"),
("desc_deepwood_re", _RPG, "你回到密林深处。"),
("desc_gate", _RPG, "黑风寨门前。火把熊熊,人声嘈杂。"),
("desc_hall", _RPG, "聚义厅。火盆熊熊,大厅空旷,回声冰冷。"),
# ---- O 组 · 选项名 ----
("opt_go_path", _RPG, "前进,去林间小路。"),
("opt_go_fork", _RPG, "前进,去岔路口。"),
("opt_go_chest", _RPG, "往左,看看树洞。"),
("opt_go_stream", _RPG, "往右,去溪流。"),
("opt_go_hunter", _RPG, "前进,去猎人小屋。"),
("opt_go_deepwood", _RPG, "前进,进密林深处。"),
("opt_go_gate", _RPG, "前进,去寨门。"),
("opt_back", _RPG, "返回。"),
("opt_shop", _RPG, "去商店。"),
("opt_rest", _RPG, "休息一晚。"),
("opt_status", _RPG, "查看状态。"),
("opt_investigate", _RPG, "调查四周。"),
("opt_open_chest", _RPG, "打开树洞。"),
("opt_drink", _RPG, "饮水休息。"),
("opt_talk_hunter", _RPG, "和老猎人谈谈。"),
("opt_assault", _RPG, "强攻寨门。"),
("opt_sneak", _RPG, "绕行潜入。"),
("opt_leave", _RPG, "离开。"),
# ---- E 组 · 事件 ----
("evt_gold15", _RPG, "你在落叶下摸到十五枚金币。"),
("evt_nothing", _RPG, "这里什么都没有了。"),
("evt_chest", _RPG, "树洞里有两瓶金疮药,你收好了。"),
("evt_hunter_gift", _RPG, "老猎人塞给你一瓶金疮药,还说商店会给你算便宜些。"),
("evt_gate_open", _RPG, "寨门缓缓打开。"),
("evt_ch1_end", _RPG, "寨主倒了,但徒弟不在这里。一张字条,指向南方的腐沼。"),
# ---- B 组 · 战斗 ----
("btl_start_wolf", _RPG, "野狼拦路!"),
("btl_start_bandit", _RPG, "山贼围上来了!"),
("btl_start_spider", _RPG, "毒蛛从树上垂了下来!"),
("btl_start_elite", _RPG, "寨丁头目挡住了去路!"),
("btl_start_boss1", _RPG, "黑风寨主,就在你面前。"),
("cmd_attack", _RPG, "攻击。"),
("cmd_skill", _RPG, "破空斩。"),
("cmd_defend", _RPG, "防御。"),
("cmd_item", _RPG, "金疮药。"),
("btl_nomp", _RPG, "内劲不足。"),
("btl_noitem", _RPG, "没有金疮药了。"),
("btl_flee", _RPG, "你抽身退出了战斗。"),
("btl_noflee", _RPG, "避无可避!"),
("btl_half_bandit", _RPG, "山贼的攻势缓了下来。"),
("btl_enemy_die", _RPG, "敌人倒下了。"),
("btl_win", _RPG, "战斗胜利。"),
("btl_loot_small", _RPG, "小有收获。"),
("btl_loot_mid", _RPG, "收获不少。"),
("btl_loot_big", _RPG, "大获全胜,收获颇丰。"),
("btl_levelup", _RPG, "你的修为精进了。"),
("btl_lose", _RPG, "你倒下了,黑暗吞没了一切。"),
("btl_lose_hint", _RPG, "记住,听清前摇,双击格挡。"),
("tut_b1", _RPG, "它要扑了,听前摇!"),
("tut_b2", _RPG, "就是现在,双击格挡!"),
("tut_b3", _RPG, "挡住了!轮到你了。左右滑选攻击,单击出剑。"),
("tut_b4", _RPG, "没挡住也没关系,再来一次。"),
("tut_b5", _RPG, "好剑法!接下来,靠你自己了。"),
# ---- SH 组 · 商店 ----
("shop_open", _RPG, "商店。左右滑看看货,单击购买。"),
("shop_price_sword", _RPG, "铁剑,一百金。"),
("shop_price_armor", _RPG, "皮甲,八十金。"),
("shop_price_potion", _RPG, "金疮药,三十金。"),
("shop_owned", _RPG, "这件你已经有了。"),
("shop_poor", _RPG, "钱不够。"),
("shop_buy", _RPG, "买下了。"),
# ---- addendum §6.2 补录:敌方血量档位(旁白) ----
("foe_hp_high", _RPG, "敌人气焰正盛。"),
("foe_hp_mid", _RPG, "敌人露出疲态。"),
("foe_hp_low", _RPG, "敌人撑不住了。"),
("skip_hint", _RPG, "单击可跳过旁白。"),
# ---- R6 补录:首次战斗教学(用户反馈:首战必须讲清交互) ----
("tut_battle_1", _RPG, "第一场战斗来了。别慌,战斗是回合制:你先出手,敌人再反击。"),
("tut_battle_2", _RPG, "左右滑切换招式,攻击、破空斩、防御、金疮药。单击,出手。"),
("tut_battle_3", _RPG, "敌人出手前有一声前摇。听到前摇,马上双击,就能格挡。"),
("tut_battle_4", _RPG, "没把握就防御,伤害减半。金疮药可以回血。"),
("tut_battle_5", _RPG, "记住:听清前摇,双击格挡。开始吧!"),
# ---- R5 补录(11 文档新增台词,资产已先行落盘;staging 无 wav,convert 跳过)----
("tut_b0", _RPG, "战斗里,左右滑选招式,单击出手。听到这个声音,就是你的回合。先滑一滑,试试。"),
("tut_b6", _RPG, "来不及格挡,就选防御。左右滑,选防御,单击。"),
("btl_flee_fail", _RPG, "没甩掉!它追上来了。"),
("boss1_warn", _RPG, "里面就是黑风寨主。你现在回去练练再来,也来得及。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_parry_miss", _RPG, "前摇声起的时候双击,才能格挡。"),
("gd_stall", _RPG, "想好了吗?左右滑动选择,单击出手。"),
]
# NPC 单音色平铺(id, speaker, instruct, 台词)→ web/rpg/audio/voice/<id>.*
# 角色 speaker+instruct 恒定(村长与寨主同 speaker 不同 instruct,从不在同场景出现)。
RPG_NPC = [
("npc_headman_1", "Uncle_Fu", "慈祥和缓的老者", "外乡人,林子里不太平。这五十金,拿着防身。"),
("npc_headman_2", "Uncle_Fu", "慈祥和缓的老者", "商店就在村里,缺什么就去看看。"),
("npc_headman_3", "Uncle_Fu", "慈祥和缓的老者", "听说你是用耳朵走路的剑客?保重啊。"),
("npc_headman_4", "Uncle_Fu", "慈祥和缓的老者", "救救那些被掳走的孩子吧。"),
("npc_hunter_1", "Eric", "沙哑粗犷,豪爽中带着暖意", "站住。……哦,是个用耳朵的。"),
("npc_hunter_2", "Eric", "沙哑粗犷,豪爽中带着暖意", "寨子里有我旧识。带上这个,别死在路上。"),
("npc_hunter_3", "Eric", "沙哑粗犷,豪爽中带着暖意", "黑风寨主的双刀很快,听清他出刀前的动静。"),
("npc_hunter_4", "Eric", "沙哑粗犷,豪爽中带着暖意", "去吧。让我听听,你的传说。"),
("npc_elite_1", "Dylan", "凶狠粗鲁,大声喝骂", "哪儿来的瞎子,敢闯黑风寨!"),
("npc_elite_half", "Dylan", "凶狠粗鲁,大声喝骂", "你……你到底是什么人!"),
("npc_elite_die", "Dylan", "凶狠粗鲁,大声喝骂", "寨主……会替我报仇……"),
("npc_boss1_1", "Uncle_Fu", "低沉阴狠,慢条斯理", "盲剑客?正好,省得挖你的眼睛。"),
("npc_boss1_2", "Uncle_Fu", "低沉阴狠,慢条斯理", "有两下子。可惜,你听不懂死期的声音。"),
("npc_boss1_die", "Uncle_Fu", "低沉阴狠,慢条斯理", "白无瞳……会找到你……"),
]
NPCS = {"rpg": RPG_NPC}
GAMES = {
"storyline": STORYLINE_LINES,
"escape": ESCAPE_LINES,
"simon": SIMON_LINES,
"rhythm": RHYTHM_LINES,
"space": SPACE_LINES,
"rpg": RPG_LINES,
}
# ---------------------------------------------------------------- 工具函数
def split_text(text, limit=SPLIT_LIMIT):
"""按句末标点拆句并合并到 <=limit 字;超长单句再按逗号拆。"""
if len(text) <= limit:
return [text]
sents = [s for s in re.split(r"(?<=[。!?;…])", text) if s]
chunks, cur = [], ""
for s in sents:
if cur and len(cur) + len(s) > limit:
chunks.append(cur)
cur = s
else:
cur += s
if cur:
chunks.append(cur)
out = []
for c in chunks:
if len(c) <= limit:
out.append(c)
else: # 单句仍超限,按逗号硬拆
parts = [p for p in re.split(r"(?<=[,、:])", c) if p]
buf = ""
for p in parts:
if buf and len(buf) + len(p) > limit:
out.append(buf)
buf = p
else:
buf += p
if buf:
out.append(buf)
return [o for o in out if o.strip()]
def staging_path(game, gender, fid):
return os.path.join(STAGING, game, gender, fid + ".wav")
def valid_wav(path):
import numpy as np
import soundfile as sf
if not os.path.exists(path):
return False
try:
data, sr = sf.read(path)
except Exception:
return False
if len(data) == 0:
return False
dur = len(data) / sr
peak = float(np.max(np.abs(data)))
return dur > 0.3 and peak > 0.05
# ---------------------------------------------------------------- 生成
def generate(games, genders, only):
import numpy as np
import soundfile as sf
import torch
from qwen_tts import Qwen3TTSModel
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
device_map="cuda:0",
dtype=torch.bfloat16,
)
print("model loaded", flush=True)
for game in games:
lines = GAMES[game]
if only:
lines = [x for x in lines if x[0] in only]
for gender in genders:
speaker = SPEAKERS[game][gender]
ins_idx = 0 if gender == "male" else 1
outdir = os.path.join(STAGING, game, gender)
os.makedirs(outdir, exist_ok=True)
# 展开成 (fid, chunk_idx, n_chunks, text, instruct),跳过已完成
items = []
for fid, ins_pair, text in lines:
path = staging_path(game, gender, fid)
if valid_wav(path):
continue
chunks = split_text(text)
for ci, ctext in enumerate(chunks):
items.append((fid, ci, len(chunks), ctext, ins_pair[ins_idx]))
if not items:
print(f"[{game}/{gender}] nothing to do", flush=True)
continue
# 短句(数字类)优先、同 instruct 聚合成整批,减少 prosody 漂移
shorts = [it for it in items if len(it[3]) <= 2]
longs = [it for it in items if len(it[3]) > 2]
queue = ([("short", shorts[i:i + BATCH_SHORT]) for i in range(0, len(shorts), BATCH_SHORT)]
+ [("long", longs[i:i + BATCH_LONG]) for i in range(0, len(longs), BATCH_LONG)])
results = {} # fid -> {chunk_idx: wav}
for bi, (kind, batch) in enumerate(queue):
wavs, sr = model.generate_custom_voice(
text=[b[3] for b in batch],
language=["Chinese"] * len(batch),
speaker=[speaker] * len(batch),
instruct=[b[4] for b in batch],
max_new_tokens=2048,
)
for (fid, ci, _n, _t, _i), wav in zip(batch, wavs):
results.setdefault(fid, {})[ci] = np.asarray(wav, dtype=np.float32)
print(f"[{game}/{gender}] batch {bi + 1}/{len(queue)} ({kind}, n={len(batch)})", flush=True)
gap = np.zeros(int(GAP_SEC * sr), dtype=np.float32)
for fid, ins_pair, text in lines:
path = staging_path(game, gender, fid)
if valid_wav(path):
continue
got = results.get(fid, {})
n_chunks = len(split_text(text))
assert len(got) == n_chunks, f"{game}/{gender}/{fid}: 缺 chunk {len(got)}/{n_chunks}"
wav = got[0]
for ci in range(1, n_chunks):
wav = np.concatenate([wav, gap, got[ci]])
peak = float(np.max(np.abs(wav)))
assert peak > 0.05, f"{game}/{gender}/{fid}: peak {peak:.3f}"
wav = wav / peak * PEAK_NORM
dur = len(wav) / sr
assert dur > 0.3, f"{game}/{gender}/{fid}: dur {dur:.2f}s"
sf.write(path, wav, sr)
print(f"[ok] {game}/{gender}/{fid} {dur:.2f}s chunks={n_chunks}", flush=True)
# NPC 单音色平铺(staging <game>/npc/,convert 落 flat voice/)
npc_lines = NPCS.get(game)
if npc_lines:
outdir = os.path.join(STAGING, game, "npc")
os.makedirs(outdir, exist_ok=True)
todo = [x for x in npc_lines if not valid_wav(os.path.join(outdir, x[0] + ".wav"))]
for i in range(0, len(todo), BATCH_LONG):
batch = todo[i:i + BATCH_LONG]
wavs, sr = model.generate_custom_voice(
text=[b[3] for b in batch],
language=["Chinese"] * len(batch),
speaker=[b[1] for b in batch],
instruct=[b[2] for b in batch],
max_new_tokens=2048,
)
for (fid, _sp, _ins, _t), wav in zip(batch, wavs):
wav = np.asarray(wav, dtype=np.float32)
peak = float(np.max(np.abs(wav)))
assert peak > 0.05, f"{game}/npc/{fid}: peak {peak:.3f}"
wav = wav / peak * PEAK_NORM
dur = len(wav) / sr
assert dur > 0.3, f"{game}/npc/{fid}: dur {dur:.2f}s"
sf.write(os.path.join(outdir, fid + ".wav"), wav, sr)
print(f"[ok] {game}/npc/{fid} {dur:.2f}s", flush=True)
# ---------------------------------------------------------------- 检查
def check(games):
import numpy as np
import soundfile as sf
problems = []
for game in games:
print(f"=== {game} ===")
for fid, _ins, _text in GAMES[game]:
durs = {}
for gender in ("male", "female"):
path = staging_path(game, gender, fid)
if not os.path.exists(path):
durs[gender] = None
continue
data, sr = sf.read(path)
peak = float(np.max(np.abs(data)))
durs[gender] = len(data) / sr
if durs[gender] <= 0.3 or peak <= 0.05:
problems.append(f"{game}/{gender}/{fid}: 质量不达标 dur={durs[gender]:.2f} peak={peak:.3f}")
m, f = durs["male"], durs["female"]
if m is None or f is None:
problems.append(f"{game}/{fid}: 缺 {'male' if m is None else 'female'}")
continue
ratio = max(m, f) / min(m, f)
flag = " <-- OUTLIER" if ratio > 3 else ""
if ratio > 3:
problems.append(f"{game}/{fid}: 时长离群 m={m:.2f}s f={f:.2f}s ratio={ratio:.1f}")
print(f" {fid:24s} m={m:6.2f}s f={f:6.2f}s{flag}")
print()
if problems:
print("PROBLEMS:")
for p in problems:
print(" ", p)
return 1
print("ALL OK")
return 0
# ---------------------------------------------------------------- 转码
def convert(games):
# 只按台词表转换(不扫目录),staging 里残留的旧 id wav 不会落盘
total = 0
for game in games:
for gender in ("male", "female"):
dst_dir = os.path.join(ROOT, "web", game, "audio", "voice", gender)
os.makedirs(dst_dir, exist_ok=True)
for fid, _ins, _text in GAMES[game]:
src = staging_path(game, gender, fid)
if not os.path.exists(src):
print(f"[convert] WARN missing {game}/{gender}/{fid}.wav")
continue
base = os.path.join(dst_dir, fid)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
"-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
"-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True)
total += 1
# NPC 平铺(staging <game>/npc/ → audio/voice/<id>.*)
for fid, _sp, _ins, _t in NPCS.get(game, []):
src = os.path.join(STAGING, game, "npc", fid + ".wav")
if not os.path.exists(src):
print(f"[convert] WARN missing {game}/npc/{fid}.wav")
continue
base = os.path.join(ROOT, "web", game, "audio", "voice", fid)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
"-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
"-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True)
total += 1
print(f"[convert] {game} done")
print(f"converted {total} files (ogg+m4a each)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--game", required=True,
choices=list(GAMES.keys()) + ["all"])
ap.add_argument("--gender", default="both", choices=["male", "female", "both"])
ap.add_argument("--only", default="", help="逗号分隔的 id 列表,只生成这些")
ap.add_argument("--check", action="store_true")
ap.add_argument("--convert", action="store_true")
args = ap.parse_args()
games = list(GAMES.keys()) if args.game == "all" else [args.game]
genders = ["male", "female"] if args.gender == "both" else [args.gender]
only = set(x for x in args.only.split(",") if x)
if args.check:
sys.exit(check(games))
if args.convert:
convert(games)
return
generate(games, genders, only)
if __name__ == "__main__":
main()