Files
SoundGame/tools/gen_voice_dual.py
T
xsl f7509ad5d8 feat(new8/R3): 双游戏收口修复轮 + 验证档案(radio G1-G6 / dungeon E0-1..E1-2)
- radio:帮助链去重(同等待点二次=beat震+earcon,全量口令38→20)/rep_low警告震/空点轻震/状态链重排(状态在前)/「首播必完整、复播可即掐」显式规则登记/G3核查为设计使然转断言;221→246断言
- dungeon:看门狗双轨制(无操作轨+无进展轨,30/60/90s三级升级到导航接管)/跳教学90s自动滚出(2403s→95s内)/踩楼梯到达即报贴身铁器/band1新词「带回来一点,不多」全管线重录/逃跑长按900ms+提示句;1610→1642断言
- R3 验证(进行中已入库部分):全量回归 9/9 全绿;R3 转写×4;P2/P3/P4 终审×6份(radio 满分档首评/三审,dungeon 4/4.5/3-4 全票愿意玩,无P0);两处独立定址同一残余=漫游型迷路刷不响无进展轨(R4 第一条)
- 8套件+guide全绿;Mureka 累计 $0.87/$2.00
2026-09-02 23:08:35 +08:00

1196 lines
81 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/xsl/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/xsl/SoundGame/models/hf")
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
ROOT = "/home/xsl/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"},
# radio 默认 speaker 仅作兜底;双性别行按 VOICE_SPK["radio"] 逐句覆盖(HOST/PROD 双配对)
"radio": {"male": "Uncle_Fu", "female": "Serena"},
"dungeon": {"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, "这个操作在这里用不上。"),
# ---- 向导层 round2(docs/design/14-guide-layer.md「round2 v1.1」,synthesis §3 定稿全文)----
("gd_brief", _SL_NARR, "单击继续剧情。请选择的时候,左右滑动。"),
("gd_again", _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, "单击可跳过旁白。"),
# ---- 向导层 round3(docs/design/14-guide-layer.md「round3 v1.2」,synthesis §3 定稿全文)----
("gd_next_fuse", _ESC, "保险丝到手。接下来,去找电箱。"),
# ---- 向导层 round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis §3 定稿全文)----
# H1:目标句后按房间图接播目标房间方位句(移动坐标系:up/down/left/right 滑动方向即方位)。
("gd_room_l", _ESC, "左面的房间。"),
("gd_room_r", _ESC, "右面的房间。"),
("gd_room_u", _ESC, "上面的房间。"),
("gd_room_d", _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, "需要的话,向上滑可以重新听一遍序列。"),
# ---- 向导层 round2(docs/design/14-guide-layer.md「round2 v1.1」,synthesis §3 定稿全文)----
# round3 G5 重录(14 号文档「round3 v1.2」):双击改回纯音色、重听改绑上滑,
# 机会经济句按裁定书新文案一字不改重录;教学链 t7_retry 同步手势口径。
("gd_economy", _SIMON, "记不住就向上滑,重新听一遍。免费机会有两次,用完要扣一次机会。"),
# ---- 向导层 round3(docs/design/14-guide-layer.md「round3 v1.2」,synthesis §3 定稿全文)----
("gd_clear", _SIMON, "漂亮,过了。"),
("gd_record", _SIMON, "破纪录了。"),
# ---- 向导层 round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis §3 定稿全文)----
# H2:轮次开场占用预告(序列播放后、轮到你提示前;重听键与答案音共用词表的根治形态)。
("gd_occ_up", _SIMON, "这一轮,向上滑是作答。"),
("gd_occ_dt", _SIMON, "这一轮,双击是作答。"),
("gd_occ_both", _SIMON, "这一轮,向上滑和双击都是作答。"),
# H3:使用时如实报余量(替换主动重听路径的旧「用完了」绝对句)。
("gd_chances_left1", _SIMON, "免费重听还剩一次。"),
("gd_chances_left0", _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, "慢了半拍,下一小节跟上我就行。"),
# ---- 向导层 round2(docs/design/14-guide-layer.md「round2 v1.1」,synthesis §3 定稿全文)----
("gd_miss_early", _RHY, "抢拍了。等声音落下再滑。"),
("gd_miss_late", _RHY, "慢了半拍。跟着节拍走。"),
("gd_miss_wrong", _RHY, "方向不对。跟着音色滑。"),
# ---- 向导层 round3(docs/design/14-guide-layer.md「round3 v1.2」,synthesis §3 定稿全文)----
("gd_slow", _RHY, "我放慢一些,跟着试试。"),
("gd_count", _RHY, "四拍之后,继续。"),
# ---- 向导层 round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis §3 定稿全文)----
# H5:归因真话律——应答窗口无任何尝试播此句(早/晚/错分档只留给真出手的情况)。
("gd_miss_none", _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, "被撞了。听方位,同向滑动就能拨开。"),
# ---- 向导层 round2(docs/design/14-guide-layer.md「round2 v1.1」,synthesis §3 定稿全文)----
("gd_last_life", _SPC, "这是最后一次机会,撑住。"),
# ---- 向导层 round3(docs/design/14-guide-layer.md「round3 v1.2」,synthesis §3 定稿全文)----
# dir_* 为角色音风格超短呼喊,speaker 用本游戏旁白 pair(synthesis §3 表注)。
("dir_left", _SPC, "左!"),
("dir_right", _SPC, "右!"),
("dir_up", _SPC, "上!"),
("dir_down", _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, "想好了吗?左右滑动选择,单击出手。"),
# ---- round2 新增(docs/design/14-guide-layer.md「round2 v1.1」,synthesis §3 定稿全文)----
("tut_b2b", _RPG, "挡得漂亮。"),
("gd_wrap_first", _RPG, "回到第一个。"),
("gd_wrap_last", _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}
# ---------------------------------------------------------------- radio
# 《不眠频率》批次 1(docs/design/15-radio-voice-ids.md,132 句:双性别 103 + 平铺 NPC 29)。
# 台词全文由提取脚本逐字照抄 voice-ids 文档;双性别组分 HOST(主播)/ PROD(导播/系统)
# 两个 speaker 配对(同性别双音色,VOICE_SPK 覆盖);NPC 单音色平铺(RADIO_NPC)+ 通话带通(NPC_BANDPASS)。
_RADIO_HOST = _pair("低沉温和的中年男声,深夜电台主播,沉稳亲切,有磁性",
"温暖清晰的年轻女声,深夜电台主播,沉稳亲切,不甜腻")
_RADIO_PROD = _pair("贴近耳机的低语男声,年轻电台导播,冷静克制",
"贴近耳机的低语女声,年轻电台导播,冷静克制")
RADIO_HOST_LINES = [
("b_open", _RADIO_HOST, "午夜零点,红灯亮起。欢迎回到《今夜不眠》。"),
("b_rain", _RADIO_HOST, "雨下了一整晚。热线灯,马上就要亮了。"),
("z1_song", _RADIO_HOST, "大爷别急,这首歌我也会。咱们一句一句来。"),
("z1_ask", _RADIO_HOST, "大爷,路口有什么招牌?念一个给我听听。"),
("z1_stay", _RADIO_HOST, "哪儿也不去。我陪你,这首歌还长着呢。"),
("z1_pol", _RADIO_HOST, "大爷,我把您转给派出所,他们马上就到。"),
("l1_go", _RADIO_HOST, "你妈等的不是奖杯,是你进门喊的那声妈。"),
("l1_free", _RADIO_HOST, "退不退,都是你的自由。夜还长,慢慢想。"),
("k1_lull", _RADIO_HOST, "闭上眼睛。风把云朵盖在被子上,月亮替你值夜班。"),
("k1_ask", _RADIO_HOST, "睡不着的时候,心里是不是装着事?说给我听听。"),
("k1_truth", _RADIO_HOST, "糖糖,记住,大人的事,不是小孩的错。一个字都不是。"),
("k1_lie", _RADIO_HOST, "睡吧。明天一睁眼,什么都会好起来的。"),
("w1_say", _RADIO_HOST, "四十分钟前,有个孩子问我们:爸妈摔门,是不是她的错。"),
("w1_say2", _RADIO_HOST, "今天有位小朋友,还在等爸妈回家切蛋糕。"),
("w1_air", _RADIO_HOST, "陈姐,别挂。让他接电话——全城都在听。"),
("w1_priv", _RADIO_HOST, "这里只有我们俩了。想说就说,想哭就哭。"),
("w1_home", _RADIO_HOST, "吵架隔着电话吵不赢。回家吧,蛋糕别放到坏了。"),
("h1_talk", _RADIO_HOST, "说说她。开学第一天,她跟你说的头一句话,是什么?"),
("h1_stop", _RADIO_HOST, "听我的,前面三公里有服务区。学费急,命更急。"),
("d9_face", _RADIO_HOST, "师父,三年了。这句晚安,我替您播。各位,晚安。"),
("d9_avoid", _RADIO_HOST, "各位,今晚到这里。明晚零点,今夜不眠,不见不散。"),
("e1_a", _RADIO_HOST, "天亮了,热线灯还亮着。你们的晚安,是这座城市的心跳。"),
("e1_b", _RADIO_HOST, "这把话筒,我总算接得住了。明晚零点,接着不眠。"),
("e2_a", _RADIO_HOST, "今晚的热线烧得发烫。全城的失眠者,都被接住了。"),
("e2_b", _RADIO_HOST, "只是路过留言机的时候,你还是没敢看它。红灯灭得很慢。"),
("e3_a", _RADIO_HOST, "口碑掉了一夜。可那句迟到三年的晚安,今晚说出去了。"),
("e3_b", _RADIO_HOST, "一个人的频道,也是频道。师父,收到了吗?"),
("e4_a", _RADIO_HOST, "今夜不眠,静音收播。有的夜班,谁也接不住谁。"),
("e4_b", _RADIO_HOST, "留言机还在闪。你没看它,它也没怪你。明晚再说吧。"),
]
RADIO_PROD_LINES = [
("gd_brief", _RADIO_PROD, "单击继续节目。请选择的时候,向左或向右滑。"),
("gd_continue", _RADIO_PROD, "单击屏幕,继续。"),
("gd_again", _RADIO_PROD, "还在吗?单击继续。"),
("gd_invalid", _RADIO_PROD, "这个手势,直播间里用不上。"),
("gd_ring1", _RADIO_PROD, "热线在闪。单击,接听。"),
("gd_ring2", _RADIO_PROD, "灯还亮着,听众在等。单击,接听。"),
("sys_help", _RADIO_PROD, "单击继续,双击重新听。上滑听状态,下滑开菜单。请选择时,左右滑。"),
("sys_menu", _RADIO_PROD, "菜单。单击切换选项,双击确认。"),
("sys_menu_resume", _RADIO_PROD, "回到直播。"),
("sys_menu_restart", _RADIO_PROD, "重新开始今晚。"),
("sys_menu_lobby", _RADIO_PROD, "保存并回大厅。"),
("sys_settings", _RADIO_PROD, "音色、语速、背景音乐,请回大厅修改。"),
("sys_skip", _RADIO_PROD, "想跳过这句,单击就行。"),
("sys_continue_ask", _RADIO_PROD, "检测到你的夜班记录。单击继续上次,右滑开始新的夜晚。"),
("sys_new_night", _RADIO_PROD, "新的夜晚,开始了。"),
("sys_saved", _RADIO_PROD, "进度已保存。"),
("prog_c1", _RADIO_PROD, "现在是今晚第一通电话。"),
("prog_c2", _RADIO_PROD, "现在是今晚第二通电话。"),
("prog_c3", _RADIO_PROD, "现在是今晚第三通电话。"),
("prog_c4", _RADIO_PROD, "现在是今晚第四通电话。"),
("prog_c5", _RADIO_PROD, "现在是今晚第五通电话。"),
("rep_high", _RADIO_PROD, "今晚的口碑,在听众里立住了。"),
("rep_mid", _RADIO_PROD, "今晚的口碑,不好不坏。"),
("rep_low", _RADIO_PROD, "今晚的口碑,还没立起来。"),
("b_rule", _RADIO_PROD, "台里的规矩就一条:先接住人,再接电话。"),
("b_goal", _RADIO_PROD, "今晚五通电话。说什么话,由你决定。"),
("z1_ring_in", _RADIO_PROD, "一线来电。七十多岁,雨夜,走丢了。"),
("p1", _RADIO_PROD, "老人在发抖。向左滑,陪他哼那首老歌。向右滑,冷静问他路口的路牌。"),
("z1_sdone", _RADIO_PROD, "他稳住了。老歌,比路牌管用。"),
("z1_adone", _RADIO_PROD, "他慌了。先接住情绪,再走流程。"),
("p2", _RADIO_PROD, "雨声更密了。向左滑,留在原地陪他等巡逻队。向右滑,转接派出所。"),
("z1_staye", _RADIO_PROD, "十五分钟后,巡逻灯停在路口。全城听见老人笑了。"),
("z1_pole", _RADIO_PROD, "转接完成,地址报得清。干净利落。"),
("z1_bade", _RADIO_PROD, "地址没报清就转接,雨又大。这通砸了。先稳人,再办事。"),
("z1_ok1", _RADIO_PROD, "就这么接。记住这个手感。"),
("l1_in", _RADIO_PROD, "二线来电。姑娘,声音很轻,像怕吵到谁。"),
("p3", _RADIO_PROD, "向左滑,告诉她这趟车值得上。向右滑,把选择还给她。"),
("l1_goe", _RADIO_PROD, "二线收工。这句话,值一个头条。"),
("l1_freee", _RADIO_PROD, "她走了。没上车,也没退票。年轻人的夜,长了点。"),
("k1_in", _RADIO_PROD, "三线来电。听呼吸,是个小孩。"),
("p4", _RADIO_PROD, "向左滑,唱一段摇篮曲哄她睡。向右滑,轻轻问她怎么了。"),
("k1_lulle", _RADIO_PROD, "睡着了。可你没听见,她在怕什么。"),
("p5", _RADIO_PROD, "孩子的声音在抖。向左滑,告诉她这不是她的错。向右滑,答应她明天都会好。"),
("k1_truthe", _RADIO_PROD, "蛋糕。记下来——四线的来电,可能跟这个家有关。"),
("k1_liee", _RADIO_PROD, "你许了个兑不了的现。孩子睡了,谎留下了。"),
("w1_in", _RADIO_PROD, "四线来电。户外风声大,是位大姐。"),
("p6", _RADIO_PROD, "摔门声你听见了。向左滑,替孩子给她带句话。向右滑,劝她回家把话说开。"),
("w1_saye", _RADIO_PROD, "她攥着电话,很久没出声。风都停了。"),
("w1_say2e", _RADIO_PROD, "话递到了。就差最后一脚。"),
("p7", _RADIO_PROD, "她的呼吸平了。向左滑,留在直播里,让全城等这个门铃。向右滑,换垫乐,私下陪她说完。"),
("w1_aire", _RADIO_PROD, "电话那头,传来跑步声。今晚的高光时刻。"),
("w1_prive", _RADIO_PROD, "灯牌是你亲手关的,故事还是完整的。稳。"),
("w1_homee", _RADIO_PROD, "她往回走了。成不成,看他们自己的了。"),
("h1_in", _RADIO_PROD, "五线来电。引擎声没停过——他在开车!"),
("p8", _RADIO_PROD, "方向盘比话筒危险。向左滑,陪他聊聊闺女。向右滑,让他立刻进服务区。"),
("h1_danger", _RADIO_PROD, "车道偏了——听!"),
("h1_talke", _RADIO_PROD, "吓醒的,也算醒。这一趟,聊到家。"),
("h1_stope", _RADIO_PROD, "他下了高速。单子黄了,人齐了。"),
("h1_echo", _RADIO_PROD, "对了。头通电话那位大爷,就是这片被巡逻队找到的。这城市的夜,是接着的。"),
("g9_in", _RADIO_PROD, "五线收工。垫乐起——零点档,还剩最后一段。"),
("p9", _RADIO_PROD, "留言机红灯在闪,是老主播三年前没播完的那句晚安。向左滑,替他播出去。向右滑,照常收播。"),
("d9_facee", _RADIO_PROD, "带子里其实只有电流声。但空着的那一秒,你说满了。"),
("d9_avoide", _RADIO_PROD, "留言机的红灯,又多亮了一夜。"),
("ep_lead", _RADIO_PROD, "收播了。导播间替你记了一笔账——今晚的人都怎样了。"),
("ep_zhou_ok", _RADIO_PROD, "老周:孙子替他买了老花镜。回信说,谢谢那首歌。"),
("ep_zhou_no", _RADIO_PROD, "老周在便利店屋檐下坐到天亮。没出事,也没人听见他的歌。"),
("ep_deer_ok", _RADIO_PROD, "小鹿上车了。她妈后来打电话说,那声妈,比奖杯值钱。"),
("ep_deer_no", _RADIO_PROD, "小鹿退了票。检票口的灯,记得那趟没上成的车。"),
("ep_kid_ok", _RADIO_PROD, "糖糖家的门,这个月没再摔过。她说这是主播魔法。"),
("ep_kid_no", _RADIO_PROD, "糖糖后来又打来过一次。还是那句:楼下在吵。"),
("ep_wife_ok", _RADIO_PROD, "陈姐家切了蛋糕。那句「回来吧」,是我这晚听过最好的声音。"),
("ep_wife_no", _RADIO_PROD, "陈姐在公车站坐到了末班车。那通电话,成了没寄出的信。"),
("ep_han_ok", _RADIO_PROD, "老韩到家了。后视镜上,多了朵小红花。"),
("ep_han_no", _RADIO_PROD, "老韩在服务区睡了一觉。学费齐了,那首歌没放成。"),
]
RADIO_LINES = RADIO_HOST_LINES + RADIO_PROD_LINES
# NPC 单音色平铺(id, speaker, instruct, 台词)→ web/radio/audio/voice/<id>.*
#(转码时按 15-radio.md §6.2 逐来电者带通,见 NPC_BANDPASS;staging 保留未滤波原声)。
RADIO_NPC = [
("z1_a", "Uncle_Fu", "苍老沙哑的男声,七十五岁,疲惫害怕,带着雨夜的颤抖", "主播……我,我找不到回家的路了。"),
("z1_b", "Uncle_Fu", "苍老沙哑的男声,七十五岁,疲惫害怕,带着雨夜的颤抖", "雨太大了。我本来,只想买副老花镜。"),
("z1_songo", "Uncle_Fu", "苍老沙哑的男声,七十五岁,疲惫害怕,带着雨夜的颤抖", "对,对!路口修车铺,天天放这首!"),
("z1_asko", "Uncle_Fu", "苍老沙哑的男声,七十五岁,疲惫害怕,带着雨夜的颤抖", "招牌……雨糊住了,我眼花,看不清啊……"),
("z1_stayo", "Uncle_Fu", "苍老沙哑的男声,七十五岁,疲惫害怕,带着雨夜的颤抖", "好,好。有人说话,我就不怕了。"),
("z1_polo", "Uncle_Fu", "苍老沙哑的男声,七十五岁,疲惫害怕,带着雨夜的颤抖", "好,好。谢谢你啊,谢谢。"),
("z1_bad", "Uncle_Fu", "苍老沙哑的男声,七十五岁,疲惫害怕,带着雨夜的颤抖", "说不清……我真是,说不清了……"),
("l1_a", "Serena", "年轻女声,倔强,带着哭腔,疲惫", "主播,退票窗口十一点半就关了。我站这儿半小时了。"),
("l1_b", "Serena", "年轻女声,倔强,带着哭腔,疲惫", "票是回去看我妈的。可我试镜又黄了,两手空空……"),
("l1_goo", "Serena", "年轻女声,倔强,带着哭腔,疲惫", "呜……嗯。不退了,我现在就去检票。谢谢您。"),
("l1_freeo", "Serena", "年轻女声,倔强,带着哭腔,疲惫", "嗯……我再想想。窗口要关了,我先过去了。"),
("k1_a", "Vivian", "稚嫩童声,八岁小女孩,困倦,胆怯", "喂……有人吗?我睡不着。"),
("k1_b", "Vivian", "稚嫩童声,八岁小女孩,困倦,胆怯", "家里灯关了。可是楼下,一直有声音。"),
("k1_lullo", "Vivian", "稚嫩童声,八岁小女孩,困倦,胆怯", "月亮也睡了……那我也睡。"),
("k1_asko", "Vivian", "稚嫩童声,八岁小女孩,困倦,胆怯", "爸爸妈妈摔门了。是不是……我做错了什么呀。"),
("k1_trutho", "Vivian", "稚嫩童声,八岁小女孩,困倦,胆怯", "嗯……那我睡了。他们明天,会一起给我切蛋糕吗?"),
("k1_lieo", "Vivian", "稚嫩童声,八岁小女孩,困倦,胆怯", "真的吗?拉钩。晚安……"),
("w1_a", "Vivian", "疲惫沙哑的中年女声,四十五岁,强撑着平静", "主播,我就想找个人说说。我家那口子,今晚要出远门的活儿。"),
("w1_b", "Vivian", "疲惫沙哑的中年女声,四十五岁,强撑着平静", "明天孩子生日!蛋糕都订好了,他人要今晚走!"),
("w1_sayo", "Vivian", "疲惫沙哑的中年女声,四十五岁,强撑着平静", "什么?她还没睡?这么晚了,她还醒着?"),
("w1_say2o", "Vivian", "疲惫沙哑的中年女声,四十五岁,强撑着平静", "蛋糕……是他订的。他嘴上不说,心里都记着。"),
("w1_airo", "Vivian", "疲惫沙哑的中年女声,四十五岁,强撑着平静", "喂!回来吧!蛋糕要化了,孩子等着呢!"),
("w1_privq", "Vivian", "疲惫沙哑的中年女声,四十五岁,强撑着平静", "呜……其实他订完蛋糕就跟我道歉了。是我,把门摔得太响。"),
("w1_homeo", "Vivian", "疲惫沙哑的中年女声,四十五岁,强撑着平静", "嗯……我这就往回走。谢谢你,听我瞎嚷嚷。"),
("h1_a", "Eric", "沙哑粗犷的中年男声,货车司机,困倦强撑", "主播,别挂。陪我说话,我的眼皮,沉得很。"),
("h1_b", "Eric", "沙哑粗犷的中年男声,货车司机,困倦强撑", "跑完这趟,闺女的学费就齐了。雨刷都快刮不动了。"),
("h1_talko", "Eric", "沙哑粗犷的中年男声,货车司机,困倦强撑", "她说,爸,我在学校给你赚了朵小红花。哈哈,哈哈……"),
("h1_dango", "Eric", "沙哑粗犷的中年男声,货车司机,困倦强撑", "嘶——好险!前车急刹,我擦着护栏过去了。彻底醒了!"),
("h1_stopo", "Eric", "沙哑粗犷的中年男声,货车司机,困倦强撑", "行,听你的。进了服务区,我给你们放首歌听。"),
]
# ---------------------------------------------------------------- dungeon
# 《回声地窟》批次 1(docs/design/16-dungeon-voice-ids.md,125 句 ×2 性别,无 NPC);
# R3 起 +2(E1-2 gold_some / 打磨 tut_flee_hold)= 127 句,st_best_2 措辞重录。
_DUN = _pair("低沉醇厚的男声,像探窟向导,沉稳有力",
"平静清晰的女声,像探窟向导,沉稳可信")
DUNGEON_LINES = [
("intro_1", _DUN, "你在黑暗中醒来。这里是回声地窟。"),
("intro_2", _DUN, "伸手不见五指,声音就是你的眼睛。"),
("sys_continue_ask", _DUN, "检测到探窟记录。单击继续,右滑重新开始。"),
("sys_new_run", _DUN, "新的探窟,开始了。"),
("tut_d0", _DUN, "先学会用耳朵看。单击,发出声波脉冲。"),
("tut_echo_come", _DUN, "听,回声回来了。"),
("tut_front_rule", _DUN, "回声回来越快,墙就越近。"),
("tut_side_rule", _DUN, "两侧的回声,发闷是墙,空旷是通路。"),
("tut_walk", _DUN, "按住屏幕不放,你就往前走。"),
("tut_turn", _DUN, "左滑右滑,可以转身。"),
("tut_replay", _DUN, "双击,再发一次脉冲,不要钱。"),
("tut_status", _DUN, "上滑,随时听自己的状态。"),
("tut_menu", _DUN, "下滑,打开菜单。"),
("tut_mob", _DUN, "前面有东西!放心,它伤不到你。"),
("tut_parry", _DUN, "听到它的动静,双击格挡。"),
("tut_parry_ok", _DUN, "挡住了!"),
("tut_parry_miss", _DUN, "没挡住也别怕,它伤不到你。"),
("tut_attack", _DUN, "现在单击,挥拳打它。"),
("tut_kill", _DUN, "打中了,就是这样。"),
("tut_flee_hold", _DUN, "想逃跑,按住别松手。"), # R3 打磨:战斗短长按取消逃跑时的区分提示(每场 1 次)
("tut_stairs", _DUN, "铁器的回声,就是下潜的楼梯。"),
("tut_stairs_on", _DUN, "楼梯就在脚下。单击,下潜。"),
("tut_done", _DUN, "教学完了。地窟很深,小心走。"),
("tut_skip", _DUN, "已跳过教学。单击脉冲,先听听四周。"),
("sys_saved", _DUN, "进度已保存。"),
("sys_menu", _DUN, "菜单。单击切换,双击确认。"),
("sys_menu_resume", _DUN, "返回探索。"),
("sys_menu_save", _DUN, "保存并回大厅。"),
("sys_menu_help", _DUN, "重新听帮助。"),
("sys_menu_restart", _DUN, "重新开始本周目。"),
("help_explore", _DUN, "单击脉冲,按住前进,左右滑转身。双击再听,上滑状态,下滑菜单。"),
("help_battle", _DUN, "敌人前摇声响时双击格挡,单击挥击。打不过就按住屏幕逃跑。"),
("settings_hint", _DUN, "音色、语速、背景音乐,请回大厅修改。"),
("st_floor_1", _DUN, "这是第一层。"),
("st_floor_2", _DUN, "这是第二层。"),
("st_floor_3", _DUN, "这是第三层。"),
("st_floor_4", _DUN, "这是第四层。"),
("st_floor_5", _DUN, "这是第五层。"),
("st_floor_6", _DUN, "这是第六层。"),
("st_floor_7", _DUN, "这是第七层。"),
("st_hp_1", _DUN, "你毫发无损。"),
("st_hp_2", _DUN, "你受了点伤。"),
("st_hp_3", _DUN, "你伤势不轻。"),
("st_hp_4", _DUN, "你命悬一线。"),
("st_gold_1", _DUN, "口袋空空。"),
("st_gold_2", _DUN, "有些积蓄。"),
("st_gold_3", _DUN, "收获可观。"),
("st_gold_4", _DUN, "腰包鼓鼓。"),
# R3 E1-2:金币档 1(0<金<50)新真话句——修「捡到金币→口袋空空/这趟没带回什么」
# 的档位谎报(P2/P4 共识);=0 仍播 st_gold_1/meta_gold_1 空手措辞。
# 上滑状态链与 SETTLE/TITLE(元进度)三处共用这一条(管线单条)。
("gold_some", _DUN, "带回来一点,不多。"),
("st_face_e", _DUN, "你面朝东。"),
("st_face_s", _DUN, "你面朝南。"),
("st_face_w", _DUN, "你面朝西。"),
("st_face_n", _DUN, "你面朝北。"),
("st_threat_none", _DUN, "附近很安静。"),
("st_threat_left", _DUN, "左边有脚步声。"),
("st_threat_right", _DUN, "右边有脚步声。"),
("st_threat_both", _DUN, "两边都有动静。"),
("st_best_1", _DUN, "你还没到过第三层。"),
("st_best_2", _DUN, "你最远到过中层。"), # R3 打磨措辞:加「最远」(live P3:防误读为"本局到过")
("st_best_3", _DUN, "你到过最深层。"),
("evt_coin_1", _DUN, "捡到几枚金币。"),
("evt_coin_2", _DUN, "捡到一小把金币。"),
("evt_coin_3", _DUN, "捡到一大袋金币。"),
("evt_potion", _DUN, "一瓶药水,收进腰带。"),
("evt_potion_full", _DUN, "腰带满了,药水留在原地。"),
("evt_weapon", _DUN, "捡到更快的短刃,出手更狠了。"),
("evt_armor", _DUN, "换上更硬的护甲,更抗打了。"),
("evt_stairs_lock", _DUN, "楼梯被大东西堵住了,先赢它。"),
("evt_descend", _DUN, "你顺着楼梯,往下潜。"),
("btl_bat", _DUN, "盲眼蝠扑了过来!"),
("btl_rat", _DUN, "窟鼠窜了出来!"),
("btl_mush", _DUN, "蘑菇怪拦住了你!"),
("btl_crab", _DUN, "窟主石甲蟹,就在你面前。"),
("btl_crawler", _DUN, "绕行者贴上来了,小心背后!"),
("btl_spider", _DUN, "洞蛛落地了!"),
("btl_leech", _DUN, "窟主腐沼蟒,挡住了楼梯。"),
("btl_guard", _DUN, "石像卫醒了!"),
("btl_king", _DUN, "窟中之王,就在深处。"),
("btl_parried", _DUN, "挡下了大半!"),
("btl_behind", _DUN, "它在你背后,转身再挡。"),
("btl_swish", _DUN, "挥空了,它不在你面前。"),
("btl_no_turn", _DUN, "它贴着你不放,不用转身。"),
("btl_kill", _DUN, "它倒下了。"),
("btl_loot_1", _DUN, "战利不多。"),
("btl_loot_2", _DUN, "小有收获。"),
("btl_loot_3", _DUN, "大有收获。"),
("btl_lvup", _DUN, "你的拳脚更利落了。"),
("btl_flee", _DUN, "你甩掉了它,被抓了一下。"),
("btl_flee_boss", _DUN, "退路塌了,跑不掉!"),
("btl_half_crab", _DUN, "石甲蟹的甲裂了!"),
("btl_half_leech", _DUN, "腐沼蟒暴怒了!"),
("btl_half_king", _DUN, "王怒了,脚步更重了!"),
("die_intro", _DUN, "你倒下了。"),
("die_cause_1", _DUN, "你被小怪拖垮了。"),
("die_cause_2", _DUN, "你没挡住那记重击。"),
("die_cause_3", _DUN, "你背后的偷袭,要了命。"),
("die_adv_1", _DUN, "下次,前摇声响时就双击。"),
("die_adv_2", _DUN, "打不过,就按住屏幕逃跑。"),
("die_adv_3", _DUN, "身后有动静,先转身。"),
("gd_pulse", _DUN, "单击脉冲,听听四周。"),
("gd_turn", _DUN, "轮到你了。单击挥击。"),
("gd_invalid", _DUN, "这个操作在这里用不上。"),
("stair_hint_a", _DUN, "找找铁器的回声,那是楼梯。"),
("stair_hint_b", _DUN, "楼梯有大东西守着,先赢它。"),
("stair_dir_e", _DUN, "楼梯在东边。"),
("stair_dir_s", _DUN, "楼梯在南边。"),
("stair_dir_w", _DUN, "楼梯在西边。"),
("stair_dir_n", _DUN, "楼梯在北边。"),
("nav_e", _DUN, "往东。"),
("nav_s", _DUN, "往南。"),
("nav_w", _DUN, "往西。"),
("nav_n", _DUN, "往北。"),
("nav_done", _DUN, "到了,就是这里。"),
("meta_gold_1", _DUN, "这趟没带回什么。"),
("meta_gold_2", _DUN, "这趟带回一些金币。"),
("meta_gold_3", _DUN, "这趟满载而归。"),
("settle_stronger", _DUN, "下个周目,你会更强。"),
("settle_win_1", _DUN, "你捧起了地窟之心,通关了!"),
("settle_win_2", _DUN, "黑暗的尽头,是光。"),
("settle_again", _DUN, "单击,再探一次。"),
("unlock_lead", _DUN, "攒够了新的遗产。"),
("unlock_potion", _DUN, "解锁行囊药,开局自带一瓶。"),
("unlock_vigor", _DUN, "解锁体魄,起始更强壮。"),
("unlock_veil", _DUN, "解锁护符,每层头一下减半。"),
("unlock_echo", _DUN, "解锁回声强化,侧向听得更远。"),
("unlock_blade", _DUN, "解锁武器,开局带着短刃。"),
("unlock_none", _DUN, "金币还不够,继续攒。"),
]
NPCS = {"rpg": RPG_NPC, "radio": RADIO_NPC}
# radio 双性别行的逐句 speaker 覆盖(HOST=主播 / PROD=导播,同性别两个音色,
# 见 15-radio-voice-ids.md 录制说明:同场景高频对话的两者永不同 speaker)。
VOICE_SPK = {"radio": {}}
VOICE_SPK["radio"].update({fid: ("Uncle_Fu", "Serena") for fid, _i, _t in RADIO_HOST_LINES})
VOICE_SPK["radio"].update({fid: ("Dylan", "Vivian") for fid, _i, _t in RADIO_PROD_LINES})
# radio 来电者电话滤波(15-radio.md §6.2,全部 + 轻压缩):按 NPC id 前缀分档,
# convert 转码时应用;staging wav 保留未滤波原声以便重滤/回退(宁窄勿脏)。
NPC_BANDPASS = {
"radio": [
("z1_", "highpass=f=500,lowpass=f=2800"), # 老周:老旧手机,窄而颤抖
("l1_", "highpass=f=400,lowpass=f=3400"), # 小鹿:车站手机,干净偏冷
("k1_", "highpass=f=450,lowpass=f=3000"), # 糖糖:家用座机,小而近
("w1_", "highpass=f=420,lowpass=f=3100"), # 陈姐:户外手机,风噪
("h1_", "highpass=f=300,lowpass=f=3400"), # 老韩:车载蓝牙,宽而轰
]
}
_NPC_ACOMP = "acompressor=threshold=-20dB:ratio=3:attack=10:release=150" # 轻压缩(全档)
def npc_af(game, fid):
"""NPC id -> ffmpeg -af 字符串(无滤波档返回 None)。"""
for prefix, af in NPC_BANDPASS.get(game, []):
if fid.startswith(prefix):
return af + "," + _NPC_ACOMP
return None
GAMES = {
"storyline": STORYLINE_LINES,
"escape": ESCAPE_LINES,
"simon": SIMON_LINES,
"rhythm": RHYTHM_LINES,
"space": SPACE_LINES,
"rpg": RPG_LINES,
"radio": RADIO_LINES,
"dungeon": DUNGEON_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
spk_map = VOICE_SPK.get(game, {})
outdir = os.path.join(STAGING, game, gender)
os.makedirs(outdir, exist_ok=True)
# 展开成 (fid, chunk_idx, n_chunks, text, instruct, speaker),跳过已完成
items = []
for fid, ins_pair, text in lines:
path = staging_path(game, gender, fid)
if valid_wav(path):
continue
spk = spk_map.get(fid, (speaker, speaker))[ins_idx]
chunks = split_text(text)
for ci, ctext in enumerate(chunks):
items.append((fid, ci, len(chunks), ctext, ins_pair[ins_idx], spk))
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=[b[5] for b in batch],
instruct=[b[4] for b in batch],
max_new_tokens=2048,
)
for (fid, ci, _n, _t, _i, _s), 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>.*;radio 按 NPC_BANDPASS 带通)
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)
af = npc_af(game, fid)
af_args = ["-af", af] if af else []
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, *af_args,
"-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, *af_args,
"-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()