Files
SoundGame/tools/merge_voice_lines_26.py
T
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

322 lines
14 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 -*-
"""26 号重构台词清单合并器:tools/voice_lines_26/<game>.md → tools/gen_voice_dual.py。
用法:
.venv-tts/bin/python tools/merge_voice_lines_26.py <game>... [--apply]
不带 --apply = 干跑,只打印计划与校验结果。
清单四节(新增/修改/删除/键盘变体)的落地规则:
新增 按 GAME_ROUTING 插入对应表(radio/abyss/cipher/keeper 的系统句插
PROD/DECK/WIND/SHORE 子表、在 `XX_LINES = A + B` 拼接行之前——
这样 GAMES 与 VOICE_SPK 都能拿到);简单游戏在 LINES 字面量闭合后
追加 `XX_LINES = XX_LINES + [...]` 块。若 id 已在本游戏表中
(清单误归类),自动降级为「修改」并警告。
修改 原地替换该 id 元组的 instruct 变量与文案(id 不变)。
删除 从主表摘除元组行;KEY_LINES 中该 id 的变体一并摘除(key_variants()
启动校验要求 key ⊆ 主集合);删除 web 双性别/key 资产与 staging wav。
键盘变体 ("id", "键改文案") 二元组:id 已有变体 = 原地改文案(重录);
没有则追加进 KEY_LINES[game]。主 id 被删的变体在「删除」里顺带摘除。
校验:合并后 ast 语法 + 模块导入(含 key_variants key⊆主集合校验)。
"""
import argparse
import ast
import importlib.util
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GVD = os.path.join(ROOT, "tools", "gen_voice_dual.py")
MANIFEST_DIR = os.path.join(ROOT, "tools", "voice_lines_26")
GAME_ROUTING = {
"rpg": {"mode": "simple", "list": "RPG_LINES"},
"rhythm": {"mode": "simple", "list": "RHYTHM_LINES"},
"space": {"mode": "simple", "list": "SPACE_LINES"},
"dungeon": {"mode": "simple", "list": "DUNGEON_LINES"},
"detective": {"mode": "simple", "list": "DETECTIVE_LINES"},
"teahouse": {"mode": "simple", "list": "TEAHOUSE_LINES"},
"hunt": {"mode": "simple", "list": "HUNT_LINES"},
"bridge": {"mode": "simple", "list": "BRIDGE_LINES"},
"radio": {"mode": "concat", "sub": "RADIO_PROD_LINES",
"anchor": "RADIO_LINES = RADIO_HOST_LINES + RADIO_PROD_LINES"},
"abyss": {"mode": "concat", "sub": "ABYSS_DECK_LINES",
"anchor": "ABYSS_LINES = ABYSS_DIVER_LINES + ABYSS_DECK_LINES"},
"cipher": {"mode": "concat", "sub": "CIPHER_WIND_LINES",
"anchor": "CIPHER_LINES = CIPHER_OP_LINES + CIPHER_WIND_LINES"},
"keeper": {"mode": "concat", "sub": "KEEPER_S_LINES",
"anchor": "KEEPER_LINES = KEEPER_K_LINES + KEEPER_S_LINES"},
}
# 音色变量名可含下划线(_RADIO_PROD / _ABY_DECK / _CIPHER_WIND / _SHORE …)
TUPLE3 = re.compile(r'\(\s*["\']([A-Za-z0-9_]+)["\']\s*,\s*(_[A-Za-z0-9_]+)\s*,\s*["\']([^"\']*)["\']\s*\)')
TUPLE2 = re.compile(r'\(\s*["\']([A-Za-z0-9_]+)["\']\s*,\s*["\']([^"\']*)["\']\s*\)')
DEL_ID = re.compile(r'^\s*\(\s*["\']([A-Za-z0-9_]+)["\']\s*[,,]')
SECTION = re.compile(r'^#{1,3}\s*.*?(新增|修改|删除|键盘变体|停用)')
SECTION_MAP = {"新增": "new", "修改": "mod", "删除": "del", "停用": "del", "键盘变体": "key"}
def parse_manifest(game):
path = os.path.join(MANIFEST_DIR, f"{game}.md")
if not os.path.exists(path):
raise SystemExit(f"缺清单:{path}")
out = {"new": [], "mod": [], "del": [], "key": []}
cur = None
for ln in open(path, encoding="utf-8").read().splitlines():
if ln.strip().startswith("```"):
continue
m = SECTION.match(ln)
if m:
cur = SECTION_MAP[m.group(1)]
continue
if cur is None:
continue
stripped = ln.lstrip()
# 划线勘误行(~~`id`~~(保留),行首可能带 |)与表头分隔行跳过
if "~~" in ln or "(保留)" in ln or stripped.startswith("|-") or stripped.startswith("| 行"):
continue
if cur in ("new", "mod"):
# 支持裸元组行与 markdown 表格行(`("id", _VAR, "text")` 反引号包裹)
for fid, var, text in TUPLE3.findall(ln):
out[cur].append((fid, var, text))
elif cur == "del":
dm = DEL_ID.match(stripped)
if dm:
out["del"].append(dm.group(1))
continue
# 表格行:| `id` | 原文 | 理由 |
for fid in re.findall(r'`([A-Za-z0-9_]+)`', ln):
out["del"].append(fid)
break # 每行只取第一个反引号 id
elif cur == "key":
for fid, text in TUPLE2.findall(ln):
out["key"].append((fid, text))
for k in ("new", "mod", "key"):
seen, uniq = set(), []
for e in out[k]:
if e[0] not in seen:
seen.add(e[0])
uniq.append(e)
out[k] = uniq
out["del"] = list(dict.fromkeys(out["del"]))
return out
def load_gvd():
spec = importlib.util.spec_from_file_location("gvd", GVD)
gvd = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gvd)
return gvd
def list_close(src, open_idx):
"""从 `[` 下标找配对 `]` 下标。"""
depth = 0
for j in range(open_idx, len(src)):
if src[j] == "[":
depth += 1
elif src[j] == "]":
depth -= 1
if depth == 0:
return j
raise SystemExit("列表闭合未找到(文件损坏?)")
def key_lines_span(src, game):
m = re.search(r'^\s*"' + game + r'":\s*\[', src, re.M)
if not m:
return None
i = src.index("[", m.start())
return (i, list_close(src, i))
def game_section_span(src, game):
"""游戏分区(`# ---- <game>` 头到下一个分区头)——主表增删改必须限定在分区内,
防止跨游戏同名 id 撞车(sys_continue_ask / gd_brief 等多游戏共有)。"""
hdr = re.search(r'^# -{10,}\s*' + re.escape(game) + r'\s*$', src, re.M)
if not hdr:
raise SystemExit(f"gen_voice_dual.py 缺分区头:{game}")
nxt = re.search(r'^# -{10,}\s*\S+\s*$', src[hdr.end():], re.M)
end = hdr.end() + nxt.start() if nxt else len(src)
return (hdr.start(), end)
def replace_main_tuple(src, fid, var, text):
pat = re.compile(
r'^(\s*\(\s*["\'])' + re.escape(fid) +
r'(["\']\s*,\s*)_[A-Za-z0-9_]+(\s*,\s*["\'])[^"\']*(["\']\s*\)[,]?)', re.M)
def sub(m):
return f'{m.group(1)}{fid}{m.group(2)}{var}{m.group(3)}{text}{m.group(4)}'
out, n = pat.subn(sub, src, count=1)
return out, n == 1
def remove_line_with_id(src, fid, where=None):
"""摘除含 ("fid", …) 的整行(限定 where=(start,end) 范围时只在该范围)。"""
seg_src, off = (src[where[0]:where[1] + 1], where[0]) if where else (src, 0)
pat = re.compile(r'^\s*\(\s*["\']' + re.escape(fid) + r'["\'][^\n]*\n', re.M)
seg2, n = pat.subn('', seg_src, count=1)
if n:
src = src[:off] + seg2 + src[where[1] + 1:] if where else seg2
return src, n == 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument("games", nargs="+")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
gvd = load_gvd()
src = open(GVD, encoding="utf-8").read()
plans, problems = [], []
for game in args.games:
route = GAME_ROUTING.get(game)
if not route:
problems.append(f"{game}: 无路由")
continue
man = parse_manifest(game)
existing = {fid for fid, _i, _t in gvd.GAMES.get(game, [])}
key_existing = {fid for fid, _t in gvd.KEY_LINES.get(game, [])}
# 新增撞已有 → 自动降级为修改(清单归类笔误)
real_new, demoted = [], []
for e in man["new"]:
(demoted if e[0] in existing else real_new).append(e)
man["new"] = real_new
man["mod"] = man["mod"] + demoted
if demoted:
print(f" ! {game}: {len(demoted)} 句从「新增」降级为「修改」(id 已在表中): {[e[0] for e in demoted]}")
# 键盘变体:已有变体 = 改文案;引用的主 id 必须存在或在本轮新增
key_add, key_mod, key_bad = [], [], []
for fid, text in man["key"]:
if fid in key_existing:
key_mod.append((fid, text))
elif fid in existing or fid in {e[0] for e in man["new"]}:
key_add.append((fid, text))
else:
key_bad.append(fid)
man["key"] = key_add
man["_keymod"] = key_mod
if key_bad:
problems.append(f"{game}: 键盘变体引用不存在的主 id {key_bad}")
miss = [f for f, _, _ in man["mod"] if f not in existing]
if miss:
problems.append(f"{game}: 修改 id 不在表中 {miss}")
missd = [f for f in man["del"] if f not in existing]
if missd:
problems.append(f"{game}: 删除 id 不在表中 {missd}")
dup = [f for f, _, _ in man["new"] if f in existing]
if dup:
problems.append(f"{game}: 新增 id 已存在 {dup}")
plans.append((game, route, man))
print(f"== {game}: 新增 {len(man['new'])} / 修改 {len(man['mod'])}"
f"(含降级 {len(demoted)})/ 删除 {len(man['del'])}"
f" / 键盘变体 +{len(key_add)} 改{len(key_mod)}")
if problems:
print("\n计划校验未过,未做任何修改:")
for p in problems:
print(" ✗", p)
sys.exit(1)
if not args.apply:
print("\n干跑通过(未修改文件)。加 --apply 执行合并。")
return
cleanup = []
for game, route, man in plans:
# 主表操作限定在本游戏分区(防跨游戏同名 id 撞车);
# 每次操作前重算分区(前一次删除会移动边界)
# 1) 删除:主表 + KEY_LINES 变体 + 资产
for fid in man["del"]:
src, ok = remove_line_with_id(src, fid, game_section_span(src, game))
if not ok:
problems.append(f"{game}: 删除未命中 {fid}")
ks = key_lines_span(src, game)
src, _ = remove_line_with_id(src, fid, ks)
for g_ in ("male", "female"):
for e in ("ogg", "m4a"):
cleanup.append(os.path.join(ROOT, "web", game, "audio", "voice", g_, f"{fid}.{e}"))
cleanup.append(os.path.join(ROOT, "web", game, "audio", "voice", g_, "key", f"{fid}.{e}"))
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, f"{fid}.wav"))
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, "key", f"{fid}.wav"))
# 2) 修改:主表原地替换(分区内);staging 作废(重录)
for fid, var, text in man["mod"]:
seg_span = game_section_span(src, game)
seg = src[seg_span[0]:seg_span[1]]
seg2, ok = replace_main_tuple(seg, fid, var, text)
if not ok:
problems.append(f"{game}: 修改替换未命中 {fid}")
src = src[:seg_span[0]] + seg2 + src[seg_span[1]:]
for g_ in ("male", "female"):
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, f"{fid}.wav"))
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, "key", f"{fid}.wav"))
# 3) 新增:插入块
block = ",\n".join(f' ("{fid}", {var}, "{text}")' for fid, var, text in man["new"])
if man["new"]:
if route["mode"] == "concat":
idx = src.index(route["anchor"])
insert = (f"# ---- 26 号重构新增(docs/design/26-apply-{game}.md;清单 tools/voice_lines_26/{game}.md)----\n"
f"{route['sub']} = {route['sub']} + [\n{block},\n]\n\n")
src = src[:idx] + insert + src[idx:]
else:
lst = route["list"]
m = re.search(r'^' + lst + r'\s*=\s*\[', src, re.M)
j = list_close(src, src.index("[", m.start()))
eol = src.index("\n", j) + 1
insert = (f"\n# ---- 26 号重构新增(docs/design/26-apply-{game}.md;清单 tools/voice_lines_26/{game}.md)----\n"
f"{lst} = {lst} + [\n{block},\n]\n")
src = src[:eol] + insert + src[eol:]
# 4) 键盘变体:改文案(重录)+ 追加
for fid, text in man.get("_keymod", []):
ks = key_lines_span(src, game)
seg = src[ks[0]:ks[1] + 1]
pat = re.compile(r'^(\s*\(\s*["\'])' + re.escape(fid) + r'(["\']\s*,\s*["\'])[^"\']*(["\']\s*\)[,]?)', re.M)
seg2, n = pat.subn(lambda m: f'{m.group(1)}{fid}{m.group(2)}{text}{m.group(3)}', seg, count=1)
if n == 0:
problems.append(f"{game}: 键盘变体改文案未命中 {fid}")
src = src[:ks[0]] + seg2 + src[ks[1] + 1:]
for g_ in ("male", "female"):
cleanup.append(os.path.join(ROOT, "tools", "gen_voice_dual_wav", game, g_, "key", f"{fid}.wav"))
if man["key"]:
ks = key_lines_span(src, game)
if ks is None:
problems.append(f"{game}: KEY_LINES 无该游戏条目,变体未插入")
else:
insert = "".join(f' ("{fid}", "{text}"),\n' for fid, text in man["key"])
src = src[:ks[1]] + "\n" + insert.rstrip("\n") + src[ks[1]:]
if problems:
print("应用中出现未命中项(其余已写入,请人工核查):")
for p in problems:
print(" ✗", p)
ast.parse(src)
open(GVD, "w", encoding="utf-8").write(src)
print("gen_voice_dual.py 已更新(ast 语法通过)")
gvd2 = load_gvd()
for g in args.games:
gvd2.key_variants(g)
print(f"模块导入自检通过(key ⊆ 主集合 ×{len(args.games)})")
removed = 0
for p in cleanup:
if p and os.path.exists(p):
os.remove(p)
removed += 1
print(f"清理待重录/废止文件 {removed} 个")
if __name__ == "__main__":
main()