feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)
配对码与认领(B): - POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏 - 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化 - api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22 键盘语音双套资产(A,路线 a): - 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原) - 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条) - check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成) - voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引 - mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动 回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12) 同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
This commit is contained in:
@@ -5,7 +5,10 @@
|
||||
检查 8 个游戏 web/<game>/audio/:
|
||||
1. 每个 .ogg 都有同名 .m4a(反之亦然)
|
||||
2. voice/male 与 voice/female 的 id 集合一致(storyline/rpg 的平铺 NPC/角色句除外)
|
||||
3. 无 0 字节文件;ogg/m4a 均可被 ffprobe 解码且时长 > 0
|
||||
3. 键盘变体目录 voice/<gender>/key/(P1 路线 a):
|
||||
male/female 两性别 key 集合一致;key 集合 ⊆ 主(性别目录)集合——变体只
|
||||
覆盖含操作词的句子,其余 id 由引擎回退主资产
|
||||
4. 无 0 字节文件;ogg/m4a 均可被 ffprobe 解码且时长 > 0
|
||||
输出 Markdown 报告到 stdout(重定向进 docs/audio/final-asset-check.md)。
|
||||
"""
|
||||
import os
|
||||
@@ -27,8 +30,8 @@ def ids(d, ext):
|
||||
def main():
|
||||
problems = []
|
||||
print("# 全项目音频资产终检(R5 建立,批次 1 起含 radio/dungeon,2026-09-01)\n")
|
||||
print("| 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 |")
|
||||
print("|---|---|---|---|---|---|")
|
||||
print("| 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 | key male/female 一致且 ⊆ 主集 |")
|
||||
print("|---|---|---|---|---|---|---|")
|
||||
for g in GAMES:
|
||||
base = os.path.join(ROOT, g, "audio")
|
||||
oggs, m4as, zero, bad = [], [], [], []
|
||||
@@ -72,17 +75,30 @@ def main():
|
||||
problems.append(f"{g}: voice 缺 female/{i}")
|
||||
for i in sorted(vf - vm):
|
||||
problems.append(f"{g}: voice 缺 male/{i}")
|
||||
# 键盘变体目录(P1 路线 a):两性别 key 集合一致 + key ⊆ 主集合
|
||||
km = ids(os.path.join(base, "voice", "male", "key"), ".ogg")
|
||||
kf = ids(os.path.join(base, "voice", "female", "key"), ".ogg")
|
||||
if km == kf and km <= vm:
|
||||
kpair = f"✅ 各 {len(km)} ⊆ 主集" if km else "—(无变体目录)"
|
||||
else:
|
||||
kpair = f"❌ male={len(km)} female={len(kf)}"
|
||||
for i in sorted(km - kf):
|
||||
problems.append(f"{g}: key 变体缺 female/key/{i}")
|
||||
for i in sorted(kf - km):
|
||||
problems.append(f"{g}: key 变体缺 male/key/{i}")
|
||||
for i in sorted((km | kf) - (vm | vf)):
|
||||
problems.append(f"{g}: key 变体 {i} 不在主集合(key 必须 ⊆ 主集)")
|
||||
flat = ids(os.path.join(base, "voice"), ".ogg")
|
||||
flat_note = f"(平铺 {len(flat)} 个角色/NPC 句,豁免)" if g in FLAT_OK and flat else \
|
||||
(f"(⚠️ 非豁免游戏存在平铺 {len(flat)} 个)" if flat and g not in FLAT_OK else "")
|
||||
print(f"| {g} | {len(oggs)} | {len(m4as)} | {len(zero)} | {len(bad)} | {pair}{flat_note} |")
|
||||
print(f"| {g} | {len(oggs)} | {len(m4as)} | {len(zero)} | {len(bad)} | {pair}{flat_note} | {kpair} |")
|
||||
print()
|
||||
if problems:
|
||||
print(f"## 问题清单({len(problems)} 条)\n")
|
||||
for p in problems:
|
||||
print(f"- {p}")
|
||||
return 1
|
||||
print("## 结论\n\n全部通过:双格式配对完整、无 0 字节、全部可解码、voice 性别目录 id 集合一致(storyline/rpg/radio 平铺角色句按约定豁免)。")
|
||||
print("## 结论\n\n全部通过:双格式配对完整、无 0 字节、全部可解码、voice 性别目录 id 集合一致(storyline/rpg/radio 平铺角色句按约定豁免)、键盘变体目录两性别一致且 ⊆ 主集合。")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@ for game, lines in gvd.GAMES.items():
|
||||
table[fid] = text
|
||||
out[game] = table
|
||||
|
||||
# 键盘变体(P1 路线 a):独立顶层表 "__key__",不与主表混放——现有消费者
|
||||
# (mock.mjs / transcribe.mjs / live/control.mjs 的 VOICE_TEXT[game][id] 查找)
|
||||
# 行为零改动;键盘措辞会话(input=key)按 VOICE_TEXT.__key__[game][id] 取期望文本。
|
||||
out["__key__"] = {
|
||||
game: {fid: text for fid, text in gvd.KEY_LINES[game]}
|
||||
for game in gvd.GAMES
|
||||
}
|
||||
|
||||
|
||||
def parse_legacy(py_path: Path) -> dict:
|
||||
"""AST 解析旧版 gen_voice.py:提取 ('id', ("Speaker", "instruct"), "text") 元组。
|
||||
@@ -57,5 +65,6 @@ for game, legacy in [("storyline", ROOT / "app/src/storyline/gen/gen_voice.py"),
|
||||
dest = ROOT / "web" / "harness" / "voice_text.json"
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
total = sum(len(v) for v in out.values())
|
||||
print(f"OK {dest.relative_to(ROOT)} games={len(out)} lines={total}")
|
||||
total = sum(len(v) for k, v in out.items() if k != "__key__")
|
||||
key_total = sum(len(v) for v in out["__key__"].values())
|
||||
print(f"OK {dest.relative_to(ROOT)} games={len(out) - 1} lines={total} key_variants={key_total}")
|
||||
|
||||
+340
-74
@@ -9,6 +9,12 @@
|
||||
- 输出 staging wav: tools/gen_voice_dual_wav/<game>/<gender>/<id>.wav
|
||||
--convert 后落地: web/<game>/audio/voice/<gender>/<id>.{ogg,m4a}
|
||||
(原有平铺 voice/*.ogg 保留不动,等引擎切换后由后续轮次清理。)
|
||||
- 键盘变体(P1 路线 a,research/platform/02-input-parity.md §4.3):KEY_LINES 只登记
|
||||
含操作词的句子(key 集合 ⊆ 主集合),复用主表同 id 的 instruct/speaker;
|
||||
staging wav: tools/gen_voice_dual_wav/<game>/<gender>/key/<id>.wav
|
||||
--convert 后落地: web/<game>/audio/voice/<gender>/key/<id>.{ogg,m4a}
|
||||
(引擎 audio.js 在生效输入模式=键盘时前置该目录,触屏主资产零改动。)
|
||||
生成建议:--only-variants 只跑变体(--check/--convert 对变体始终生效)。
|
||||
|
||||
质量控制:
|
||||
- 每条 assert 时长 > 0.3s、峰值 > 0.05;统一峰值归一 0.89。
|
||||
@@ -897,6 +903,218 @@ DUNGEON_LINES = [
|
||||
|
||||
NPCS = {"rpg": RPG_NPC, "radio": RADIO_NPC}
|
||||
|
||||
# ---------------------------------------------------------------- 键盘变体(P1 路线 a)
|
||||
# 「生效输入模式=键盘」的玩家听按键措辞:向左滑→按左方向键(四向同理,WASD 不进
|
||||
# 台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格。只改操作词,
|
||||
# 其余内容一字不动(复合短语「左右滑」→「左右方向键」等按语义等价展开;确需
|
||||
# 补一两个连接字才成句处已在 docs/design/*-voice-ids.md「键盘变体」小节逐句登记)。
|
||||
# 主表为叙事误用(非操作指令)的句子不做变体:storyline n16/n16b(「滑了过去」
|
||||
# 「按住他的膝盖」)、n17/n17b(「更滑」)。
|
||||
# 双表一致铁律:本表与 docs/design/*-voice-ids.md「键盘变体」小节必须逐字一致。
|
||||
KEY_LINES = {
|
||||
"storyline": [
|
||||
("help", "操作说明。按空格:继续剧情。按回车:重听刚才的台词。按上方向键:听这段说明和当前进度。按下方向键:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,按左方向键或者按右方向键,做出你的决定。选择无法撤销。"),
|
||||
("menu", "菜单。按左方向键:重新开始整个故事。按右方向键,或者按空格:回到故事。按上方向键:听操作说明。"),
|
||||
("p1", "请选择。按左方向键:先检查背包里的收音机。按右方向键:先摸黑寻找出口。"),
|
||||
("p2", "请选择。按左方向键:钻进旁边的通风管道。按右方向键:躺进路边的尸体堆里装死。"),
|
||||
("p3", "请选择。按左方向键:循着哭声过去看看。按右方向键:不去管它,直接去北区。"),
|
||||
("p4", "请选择。按左方向键:带上小满一起走。按右方向键:留下食物,独自上路。"),
|
||||
("p5", "请选择。按左方向键:走桥下的污水涵洞。按右方向键:爬桥上悬着的钢索。"),
|
||||
("p6", "请选择。按左方向键:交出坐标,保命要紧。按右方向键:拒绝他,想办法周旋。"),
|
||||
("p7", "请选择。按左方向键:追上去,警告避难所。按右方向键:自己逃命,越远越好。"),
|
||||
("p8", "请选择。按左方向键:用公开频道广播,让全城都听见。按右方向键:用加密频段,只发给避难所。"),
|
||||
("n00_intro", "大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:按空格,继续剧情;按回车,重听上一句;按上方向键,听你的状态和帮助;按下方向键,呼出菜单。当我说“请选择”的时候,按左方向键,或者按右方向键,做出你的决定。选择一旦做出,就无法回头。现在,按空格,睁开眼睛。"),
|
||||
("e1_dawn", "加密频道那头沉默了三秒。然后,一个你等了三十天的声音响了起来:“姐?姐!是你吗?”天亮之前,避难所的救援队打开了大门。猎狗的人,在北区的高墙下溃散了。你抱着晨晨,听见广播塔里,重新响起了音乐。后来人们说,这座城市,是从一个女人的声音开始,重新活过来的。——好结局:黎明。感谢你,陪林晚走完了这条路。按空格,可以回到故事开始的地方。"),
|
||||
("e2_price", "你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。按空格,重新开始。"),
|
||||
("e3_alone", "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。按空格,重新开始。"),
|
||||
("e4_static", "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。按空格,重新开始。"),
|
||||
("skip_hint", "按空格可跳过旁白。"),
|
||||
("gd_continue", "按空格,继续。"),
|
||||
("gd_brief", "按空格继续剧情。请选择的时候,按左右方向键。"),
|
||||
("gd_again", "还在吗?按空格继续。"),
|
||||
],
|
||||
"escape": [
|
||||
("g1_move", "先活动一下。按任意方向键,走一小步。"),
|
||||
("g2_wall", "这个房间不大,四面都是墙。再按两下方向键,试试碰碰墙。"),
|
||||
("g3_left", "跟着我指的路走。按左方向键。"),
|
||||
("g4_down", "对。再按下方向键。"),
|
||||
("g5_inspect", "听到水声了吗?按一下空格,仔细检查这个房间。"),
|
||||
("g6_doubletap", "钥匙能开锁。按回车,是把物品用出去的用法。现在,按回车试试。"),
|
||||
("g7_goal", "找到能用这把钥匙打开的东西。需要帮助时,在原地按回车。"),
|
||||
("guide_wrong_up", "不是这个方向。按上方向键。"),
|
||||
("guide_wrong_down", "不是这个方向。按下方向键。"),
|
||||
("guide_wrong_left", "不是这个方向。按左方向键。"),
|
||||
("guide_wrong_right", "不是这个方向。按右方向键。"),
|
||||
("guide_blunt_up", "按一下上方向键。"),
|
||||
("guide_blunt_down", "按一下下方向键。"),
|
||||
("guide_blunt_left", "按一下左方向键。"),
|
||||
("guide_blunt_right", "按一下右方向键。"),
|
||||
("reopen_prompt", "按上方向键,重新听一遍引导。按空格,直接开始。"),
|
||||
("stuck_key_2", "去西南角的储物间,按空格检查,找到钥匙。"),
|
||||
("stuck_cabinet_2", "去东边的铁柜房,按回车,用钥匙打开铁柜。"),
|
||||
("stuck_box_2", "去西北角的电箱房,按回车,把保险丝装进电箱。"),
|
||||
("stuck_gate_2", "去东南角的大门,按回车,打开它,离开这里。"),
|
||||
("nav_up", "按上方向键。"),
|
||||
("nav_down", "按下方向键。"),
|
||||
("nav_left", "按左方向键。"),
|
||||
("nav_right", "按右方向键。"),
|
||||
("start_room", "你站在设施中央的房间里。方向键移动,按空格检查,按回车使用物品。"),
|
||||
("inspect_gate_ready", "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。按回车,打开它,离开这里。"),
|
||||
("use_fail", "这里没有用得上东西的地方。换个房间试试,或者先按空格检查一下周围。"),
|
||||
("again", "按空格,再来一次。按下方向键,打开菜单。"),
|
||||
("help_ops", "方向键,在房间之间移动。按空格,检查房间,或者重听房间的声音。按回车,使用物品,或者查看背包。"),
|
||||
("skip_hint", "按空格可跳过旁白。"),
|
||||
],
|
||||
"simon": [
|
||||
("t1_up", "按上方向键,是这个声音。"),
|
||||
("t2_down", "按下方向键,是这个声音。"),
|
||||
("t3_left", "按左方向键,是这个声音。"),
|
||||
("t4_right", "按右方向键,是这个声音。"),
|
||||
("t5_tap", "按空格,是这个声音。"),
|
||||
("t6_double", "按回车,是这个声音。"),
|
||||
("t_wrong_up", "不是这个,按上方向键试试。"),
|
||||
("t_wrong_down", "不是这个,按下方向键试试。"),
|
||||
("t_wrong_left", "不是这个,按左方向键试试。"),
|
||||
("t_wrong_right", "不是这个,按右方向键试试。"),
|
||||
("t_wrong_tap", "不是这个,按空格试试。"),
|
||||
("t_wrong_double", "不是这个,按回车试试。"),
|
||||
("t_blunt_up", "按一下上方向键。"),
|
||||
("t_blunt_down", "按一下下方向键。"),
|
||||
("t_blunt_left", "按一下左方向键。"),
|
||||
("t_blunt_right", "按一下右方向键。"),
|
||||
("t_blunt_tap", "轻轻按一下空格。"),
|
||||
("t_blunt_double", "按一下回车。"),
|
||||
("t7_retry", "答错可以消耗机会重听。按上方向键,可以随时重听。"),
|
||||
("replay", "按空格,再来一局。"),
|
||||
("rules_prompt", "按上方向键,重听规则。按下方向键,打开菜单。"),
|
||||
("rules", "上行音,按上方向键。下行音,按下方向键。左边的音,按左方向键。右边的音,按右方向键。一声轻响,按空格。两声轻响,按回车。"),
|
||||
("skip_hint", "按空格可跳过旁白。"),
|
||||
("gd_relisten", "需要的话,按上方向键可以重新听一遍序列。"),
|
||||
("gd_economy", "记不住就按上方向键,重新听一遍。免费机会有两次,用完要扣一次机会。"),
|
||||
("gd_occ_up", "这一轮,按上方向键是作答。"),
|
||||
("gd_occ_dt", "这一轮,按回车是作答。"),
|
||||
("gd_occ_both", "这一轮,按上方向键和回车都是作答。"),
|
||||
],
|
||||
"rhythm": [
|
||||
("tut_1", "听,底鼓。在下一小节的同一个位置,按空格。"),
|
||||
("tut_2", "这次是左和右。跟着打,按左方向键,按右方向键。"),
|
||||
("tut_3", "最后一句,三个音,中间是按回车。"),
|
||||
("select_hint", "左右方向键选曲目,按空格开始,按下方向键回大厅。"),
|
||||
("restart", "按空格再来一遍,按右方向键下一首,按下方向键回大厅。"),
|
||||
("menu_main", "菜单。按空格切换选项,按回车确认。"),
|
||||
("skip_hint", "按空格可跳过旁白。"),
|
||||
("gd_miss_early", "抢拍了。等声音落下再按。"),
|
||||
("gd_miss_wrong", "方向不对。跟着音色按方向键。"),
|
||||
],
|
||||
"space": [
|
||||
("drill_left", "听,左边有陨石。按左方向键,把它拨开!"),
|
||||
("drill_right", "这次在右边。按右方向键!"),
|
||||
("drill_up", "上面有东西,声音又尖又高。按上方向键!"),
|
||||
("drill_down", "下面是低沉的轰隆。按下方向键!"),
|
||||
("drill_retry_left", "那是左边来的,按左方向键。"),
|
||||
("drill_retry_right", "那是右边来的,按右方向键。"),
|
||||
("drill_retry_up", "声音又高又尖,是上面,按上方向键。"),
|
||||
("drill_retry_down", "轰隆声在下面,按下方向键。"),
|
||||
("protect_left", "训练护盾接住了。那颗在左边,听到左边的呼啸,按左方向键。"),
|
||||
("protect_right", "训练护盾接住了。那颗在右边,听到右边的呼啸,按右方向键。"),
|
||||
("protect_up", "训练护盾接住了。那颗在上面,听到高高的呼啸,按上方向键。"),
|
||||
("protect_down", "训练护盾接住了。那颗在下面,听到低沉的轰隆,按下方向键。"),
|
||||
("feat_boost", "试试按空格,引擎会短促加速,直接冲过空隙。"),
|
||||
("feat_shield", "按回车展开护盾,能挡下一次撞击。"),
|
||||
("hit_left", "左边撞上的。听到左边的呼啸,按左方向键。"),
|
||||
("hit_right", "右边撞上的。听到右边的呼啸,按右方向键。"),
|
||||
("hit_up", "上面撞上的。听到高高的呼啸,按上方向键。"),
|
||||
("hit_down", "下面撞上的。听到低沉的轰隆,按下方向键。"),
|
||||
("restart_hint", "按空格再飞一次,按下方向键回大厅。"),
|
||||
("second_run_ask", "按上方向键重新听演练,按空格直接出发。"),
|
||||
("menu_main", "菜单。按空格切换选项,按回车确认。"),
|
||||
("help_recap", "左边来按左方向键,右边来按右方向键,上面来按上方向键,下面来按下方向键。按空格加速,按回车护盾。"),
|
||||
("skip_hint", "按空格可跳过旁白。"),
|
||||
("gd_hit_hint", "被撞了。听方位,按对应方向键就能拨开。"),
|
||||
],
|
||||
"rpg": [
|
||||
("sys_continue_ask", "检测到你的旅程记录。按空格继续,按右方向键开始新的旅程。"),
|
||||
("sys_menu", "菜单。左右方向键选择,按空格确认。"),
|
||||
("help_explore", "左右方向键切换选项,按空格确认。按回车重新听场景,按上方向键查看状态,按下方向键打开菜单。"),
|
||||
("help_battle", "战斗中,左右方向键选招式,按空格出手。听到敌人的前摇,按回车格挡。"),
|
||||
("btl_lose_hint", "记住,听清前摇,按回车格挡。"),
|
||||
("tut_b2", "就是现在,按回车格挡!"),
|
||||
("tut_b3", "挡住了!轮到你了。左右方向键选攻击,按空格出剑。"),
|
||||
("shop_open", "商店。左右方向键看看货,按空格购买。"),
|
||||
("skip_hint", "按空格可跳过旁白。"),
|
||||
("tut_battle_2", "左右方向键切换招式,攻击、破空斩、防御、金疮药。按空格,出手。"),
|
||||
("tut_battle_3", "敌人出手前有一声前摇。听到前摇,马上按回车,就能格挡。"),
|
||||
("tut_battle_5", "记住:听清前摇,按回车格挡。开始吧!"),
|
||||
("tut_b0", "战斗里,左右方向键选招式,按空格出手。听到这个声音,就是你的回合。先按一按方向键,试试。"),
|
||||
("tut_b6", "来不及格挡,就选防御。左右方向键,选防御,按空格。"),
|
||||
("gd_parry_miss", "前摇声起的时候按回车,才能格挡。"),
|
||||
("gd_stall", "想好了吗?左右方向键选择,按空格出手。"),
|
||||
],
|
||||
"radio": [
|
||||
("gd_brief", "按空格继续节目。请选择的时候,按左或右方向键。"),
|
||||
("gd_continue", "按空格,继续。"),
|
||||
("gd_again", "还在吗?按空格继续。"),
|
||||
("gd_ring1", "热线在闪。按空格,接听。"),
|
||||
("gd_ring2", "灯还亮着,听众在等。按空格,接听。"),
|
||||
("sys_help", "按空格继续,按回车重新听。按上方向键听状态,按下方向键开菜单。请选择时,左右方向键。"),
|
||||
("sys_menu", "菜单。按空格切换选项,按回车确认。"),
|
||||
("sys_skip", "想跳过这句,按空格就行。"),
|
||||
("sys_continue_ask", "检测到你的夜班记录。按空格继续上次,按右方向键开始新的夜晚。"),
|
||||
("p1", "老人在发抖。按左方向键,陪他哼那首老歌。按右方向键,冷静问他路口的路牌。"),
|
||||
("p2", "雨声更密了。按左方向键,留在原地陪他等巡逻队。按右方向键,转接派出所。"),
|
||||
("p3", "按左方向键,告诉她这趟车值得上。按右方向键,把选择还给她。"),
|
||||
("p4", "按左方向键,唱一段摇篮曲哄她睡。按右方向键,轻轻问她怎么了。"),
|
||||
("p5", "孩子的声音在抖。按左方向键,告诉她这不是她的错。按右方向键,答应她明天都会好。"),
|
||||
("p6", "摔门声你听见了。按左方向键,替孩子给她带句话。按右方向键,劝她回家把话说开。"),
|
||||
("p7", "她的呼吸平了。按左方向键,留在直播里,让全城等这个门铃。按右方向键,换垫乐,私下陪她说完。"),
|
||||
("p8", "方向盘比话筒危险。按左方向键,陪他聊聊闺女。按右方向键,让他立刻进服务区。"),
|
||||
("p9", "留言机红灯在闪,是老主播三年前没播完的那句晚安。按左方向键,替他播出去。按右方向键,照常收播。"),
|
||||
],
|
||||
"dungeon": [
|
||||
("sys_continue_ask", "检测到探窟记录。按空格继续,按右方向键重新开始。"),
|
||||
("tut_d0", "先学会用耳朵看。按空格,发出声波脉冲。"),
|
||||
("tut_walk", "按住空格不放,你就往前走。"),
|
||||
("tut_turn", "按左右方向键,可以转身。"),
|
||||
("tut_replay", "按回车,再发一次脉冲,不要钱。"),
|
||||
("tut_status", "按上方向键,随时听自己的状态。"),
|
||||
("tut_menu", "按下方向键,打开菜单。"),
|
||||
("tut_parry", "听到它的动静,按回车格挡。"),
|
||||
("tut_attack", "现在按空格,挥拳打它。"),
|
||||
("tut_flee_hold", "想逃跑,按住空格别松手。"),
|
||||
("tut_stairs_on", "楼梯就在脚下。按空格,下潜。"),
|
||||
("tut_skip", "已跳过教学。按空格发脉冲,先听听四周。"),
|
||||
("sys_menu", "菜单。按空格切换,按回车确认。"),
|
||||
("help_explore", "按空格发脉冲,按住空格前进,左右方向键转身。按回车再听,上方向键查状态,下方向键开菜单。"),
|
||||
("help_battle", "敌人前摇声响时按回车格挡,按空格挥击。打不过就按住空格逃跑。"),
|
||||
("die_adv_1", "下次,前摇声响时就按回车。"),
|
||||
("die_adv_2", "打不过,就按住空格逃跑。"),
|
||||
("gd_pulse", "按空格发脉冲,听听四周。"),
|
||||
("gd_turn", "轮到你了。按空格挥击。"),
|
||||
("settle_again", "按空格,再探一次。"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def key_variants(game):
|
||||
"""key 变体表 [(fid, instruct_pair, text), ...]。
|
||||
|
||||
约束(启动即校验,防两表漂移):
|
||||
- key id 必须存在于该游戏主台词表(key 集合 ⊆ 主集合),复用其 instruct
|
||||
与 VOICE_SPK speaker(radio HOST/PROD 配对自动生效);
|
||||
- key 表内 id 不得重复。
|
||||
"""
|
||||
ins_by_id = {fid: ins for fid, ins, _t in GAMES[game]}
|
||||
seen = set()
|
||||
out = []
|
||||
for fid, text in KEY_LINES[game]:
|
||||
if fid not in ins_by_id:
|
||||
raise SystemExit(f"key 变体 {game}/{fid} 不在主台词表中(key 集合必须 ⊆ 主集合)")
|
||||
if fid in seen:
|
||||
raise SystemExit(f"key 变体重复 id:{game}/{fid}")
|
||||
seen.add(fid)
|
||||
out.append((fid, ins_by_id[fid], text))
|
||||
return out
|
||||
|
||||
# radio 双性别行的逐句 speaker 覆盖(HOST=主播 / PROD=导播,同性别两个音色,
|
||||
# 见 15-radio-voice-ids.md 录制说明:同场景高频对话的两者永不同 speaker)。
|
||||
VOICE_SPK = {"radio": {}}
|
||||
@@ -970,8 +1188,12 @@ def split_text(text, limit=SPLIT_LIMIT):
|
||||
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 staging_path(game, gender, fid, variant=""):
|
||||
"""staging wav 路径;variant='key' 时落 <gender>/key/ 子目录。"""
|
||||
parts = [STAGING, game, gender]
|
||||
if variant:
|
||||
parts.append(variant)
|
||||
return os.path.join(*parts, fid + ".wav")
|
||||
|
||||
|
||||
def valid_wav(path):
|
||||
@@ -991,7 +1213,71 @@ def valid_wav(path):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 生成
|
||||
def generate(games, genders, only):
|
||||
def _generate_lines(model, game, gender, lines, variant=""):
|
||||
"""一组双性别台词(主表或 key 变体)生成到 staging <game>/<gender>[/<variant>]/。"""
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
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, variant) if variant else 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, variant)
|
||||
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}/{variant or '-'}] nothing to do", flush=True)
|
||||
return
|
||||
|
||||
# 短句(数字类)优先、同 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}/{variant or '-'}] 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, variant)
|
||||
if valid_wav(path):
|
||||
continue
|
||||
got = results.get(fid, {})
|
||||
n_chunks = len(split_text(text))
|
||||
assert len(got) == n_chunks, f"{game}/{gender}/{variant}/{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}/{variant}/{fid}: peak {peak:.3f}"
|
||||
wav = wav / peak * PEAK_NORM
|
||||
dur = len(wav) / sr
|
||||
assert dur > 0.3, f"{game}/{gender}/{variant}/{fid}: dur {dur:.2f}s"
|
||||
sf.write(path, wav, sr)
|
||||
print(f"[ok] {game}/{gender}/{variant}/{fid} {dur:.2f}s chunks={n_chunks}", flush=True)
|
||||
|
||||
|
||||
def generate(games, genders, only, variants_only=False):
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
@@ -1005,70 +1291,24 @@ def generate(games, genders, only):
|
||||
print("model loaded", flush=True)
|
||||
|
||||
for game in games:
|
||||
lines = GAMES[game]
|
||||
if not variants_only:
|
||||
lines = GAMES[game]
|
||||
if only:
|
||||
lines = [x for x in lines if x[0] in only]
|
||||
for gender in genders:
|
||||
_generate_lines(model, game, gender, lines)
|
||||
# 键盘变体(key 集合 ⊆ 主集合,复用主表 instruct/speaker)
|
||||
kvars = key_variants(game)
|
||||
if only:
|
||||
lines = [x for x in lines if x[0] in only]
|
||||
kvars = [x for x in kvars 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)
|
||||
_generate_lines(model, game, gender, kvars, variant="key")
|
||||
|
||||
# 展开成 (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)
|
||||
# NPC 单音色平铺(staging <game>/npc/,convert 落 flat voice/)。
|
||||
# --only-variants 时跳过:R5 起部分 NPC 资产已先行落盘而 staging 无 wav
|
||||
# (见 RPG_LINES R5 注释),变体跑批不应顺手重录已验收 NPC 台词——
|
||||
# 否则 --convert 会用新 take 覆盖 web/ 下已验收资产。
|
||||
npc_lines = None if variants_only else NPCS.get(game)
|
||||
if npc_lines:
|
||||
outdir = os.path.join(STAGING, game, "npc")
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
@@ -1098,13 +1338,13 @@ 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]:
|
||||
def check_set(game, lines, variant=""):
|
||||
problems = []
|
||||
prefix = f"key/" if variant else ""
|
||||
for fid, _ins, _text in lines:
|
||||
durs = {}
|
||||
for gender in ("male", "female"):
|
||||
path = staging_path(game, gender, fid)
|
||||
path = staging_path(game, gender, fid, variant)
|
||||
if not os.path.exists(path):
|
||||
durs[gender] = None
|
||||
continue
|
||||
@@ -1112,16 +1352,26 @@ def check(games):
|
||||
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}")
|
||||
problems.append(f"{game}/{gender}/{prefix}{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'}")
|
||||
problems.append(f"{game}/{prefix}{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}")
|
||||
problems.append(f"{game}/{prefix}{fid}: 时长离群 m={m:.2f}s f={f:.2f}s ratio={ratio:.1f}")
|
||||
print(f" {prefix}{fid:24s} m={m:6.2f}s f={f:6.2f}s{flag}")
|
||||
return problems
|
||||
|
||||
problems = []
|
||||
for game in games:
|
||||
print(f"=== {game} ===")
|
||||
problems += check_set(game, GAMES[game])
|
||||
kvars = key_variants(game)
|
||||
if kvars:
|
||||
print(f" --- key 变体 {len(kvars)} 句 ---")
|
||||
problems += check_set(game, kvars, variant="key")
|
||||
print()
|
||||
if problems:
|
||||
print("PROBLEMS:")
|
||||
@@ -1151,6 +1401,20 @@ def convert(games):
|
||||
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
|
||||
"-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True)
|
||||
total += 1
|
||||
# 键盘变体:落 voice/<gender>/key/ 子目录(引擎 key 模式候选链前置)
|
||||
kdst = os.path.join(dst_dir, "key")
|
||||
for fid, _ins, _text in key_variants(game):
|
||||
src = staging_path(game, gender, fid, "key")
|
||||
if not os.path.exists(src):
|
||||
print(f"[convert] WARN missing {game}/{gender}/key/{fid}.wav")
|
||||
continue
|
||||
os.makedirs(kdst, exist_ok=True)
|
||||
base = os.path.join(kdst, 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")
|
||||
@@ -1175,6 +1439,8 @@ def main():
|
||||
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("--only-variants", action="store_true",
|
||||
help="只生成键盘变体(key)台词,跳过主表(--check/--convert 对变体始终生效)")
|
||||
ap.add_argument("--check", action="store_true")
|
||||
ap.add_argument("--convert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
@@ -1188,7 +1454,7 @@ def main():
|
||||
if args.convert:
|
||||
convert(games)
|
||||
return
|
||||
generate(games, genders, only)
|
||||
generate(games, genders, only, variants_only=args.only_variants)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user