# -*- coding: utf-8 -*- """双性别语音资产管线(R1):为 5 个 web 游戏生成 male/female 两版旁白 voice。 约定: - 台词表从各游戏已有 gen 脚本原样提取(storyline/escape 的原始表在 app/src//gen/, 本脚本只做只读引用,不改 app/)。 - storyline 只生成旁白 + 系统音(NARR* 角色)的双性别版本;角色台词(林晚/猎狗/小满等) 不在本脚本范围内,沿用 web/storyline/audio/voice/ 平铺原文件。 - 输出 staging wav: tools/gen_voice_dual_wav///.wav --convert 后落地: web//audio/voice//.{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/.* # 角色 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/.* #(转码时按 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 /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 /npc/ → audio/voice/.*;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()