# -*- coding: utf-8 -*- """26 号重构台词清单合并器:tools/voice_lines_26/.md → tools/gen_voice_dual.py。 用法: .venv-tts/bin/python tools/merge_voice_lines_26.py ... [--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): """游戏分区(`# ---- ` 头到下一个分区头)——主表增删改必须限定在分区内, 防止跨游戏同名 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()