DazedTL/util/speaker_detector.py

284 lines
11 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.

"""
Speaker Format Detector for RPGMaker MV/MZ
Mirrors the detection priority of rpgmakermvmz.py searchCodes() exactly:
Pass 1 — scan 401/405 codes in order:
1. \\n<Name> / \\k<Name> inline nametag codes (always active, no flag needed)
2. 【Name】 alone on a 401 line (always active, no flag needed)
3. 【Name】dialogue on same 401 line (always active, no flag needed)
4. Name「dialogue」 inline quote -> INLINE401SPEAKERS
5. Short 401 (<40 chars) followed by 401 whose
text starts with 「 " ( * [ -> FIRSTLINESPEAKERS
Pass 2 — only if Pass 1 produced no reliable hits:
6. 101 code param[0] is a non-empty name string -> FACENAME101
Returns the best mode and confidence scores.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
# ── Regexes matching rpgmakermvmz.py exactly ────────────────────────────────
# \\n<Name> / \\k<Name> nametag codes (always active in module)
_NAMETAG_RE = re.compile(
r"[\\]+[kKnN][wWcCrRrEe]?[\[<](?:[\\]*\w\[\d+\])?(.*?)(?:[\\]*\w\[\d+\])?[>]"
)
# 【Name】 alone on the line (with optional trailing control codes)
_BRACKET_ALONE_RE = re.compile(
r"^\s*【[^】]+】(?:\s*|(?:[\\]+[A-Za-z]+(?:\[(?:[^\[\]]|\[[^\]]*\])*\])+\s*)*)$"
)
# 【Name】dialogue on the same line
_BRACKET_INLINE_RE = re.compile(r"^\s*【([^】]+)】(.+)", re.DOTALL)
# Inline quote: Name「dialogue…
_INLINE_QUOTE_RE = re.compile(r"^([^\s「」。、\\\n]{1,20})「")
# Dialogue starters that follow a FIRSTLINESPEAKERS name line
_DIALOGUE_STARTERS = ("", '"', "(", "", "*", "[")
# Minimum hits to trust a result
_MIN_HITS = 3
def detect_speaker_format(
files_dir: str | Path = "files",
sample_size: int = 20,
) -> dict:
"""Scan up to *sample_size* map files and determine the speaker format.
Returns:
{
"best_mode": "INLINE401SPEAKERS" | "FIRSTLINESPEAKERS" | "FACENAME101"
| "ALWAYS_ON" | "NONE",
"scores": {
"nametag_codes": int, # \\n<Name> hits — always handled, no flag
"bracket_401": int, # 【Name】 hits — always handled, no flag
"INLINE401SPEAKERS": int,
"FIRSTLINESPEAKERS": int,
"FACENAME101": int,
},
"total_401_groups": int,
"files_scanned": int,
"recommended_config": {
"INLINE401SPEAKERS": bool,
"FIRSTLINESPEAKERS": bool,
"FACENAME101": bool,
},
"confidence": "high" | "medium" | "low",
"note": str,
}
"""
files_dir = Path(files_dir)
scores = {
"nametag_codes": 0,
"bracket_401": 0,
"INLINE401SPEAKERS": 0,
"FIRSTLINESPEAKERS": 0,
"FACENAME101": 0,
}
total_groups = 0
files_scanned = 0
# Collect map files (Maps only, not MapInfos), largest first for better signal
map_files = sorted(
[p for p in files_dir.glob("Map[0-9]*.json") if p.is_file()],
key=lambda p: p.stat().st_size,
reverse=True,
)[:sample_size]
for fp in map_files:
try:
with open(fp, "r", encoding="utf-8-sig") as fh:
data = json.load(fh)
except Exception:
continue
files_scanned += 1
events = data.get("events") or []
for evt in events:
if not evt:
continue
for page in evt.get("pages") or []:
cmd_list = (page or {}).get("list") or []
_score_command_list(cmd_list, scores)
# Count 401 groups
i = 0
while i < len(cmd_list):
if cmd_list[i].get("code") in (401, 405):
total_groups += 1
while i < len(cmd_list) and cmd_list[i].get("code") in (401, 405, -1):
i += 1
continue
i += 1
# Also scan CommonEvents
ce_path = files_dir / "CommonEvents.json"
if ce_path.is_file():
try:
with open(ce_path, "r", encoding="utf-8-sig") as fh:
ce_data = json.load(fh)
files_scanned += 1
for entry in ce_data or []:
if not entry:
continue
_score_command_list(entry.get("list") or [], scores)
except Exception:
pass
# ── Decision: 401-based patterns first, FACENAME101 only as fallback ────
always_on_hits = scores["nametag_codes"] + scores["bracket_401"]
inline_hits = scores["INLINE401SPEAKERS"]
first_hits = scores["FIRSTLINESPEAKERS"]
face_hits = scores["FACENAME101"]
total_401_hits = always_on_hits + inline_hits + first_hits
recommended = {
"INLINE401SPEAKERS": False,
"FIRSTLINESPEAKERS": False,
"FACENAME101": False,
}
if total_401_hits >= _MIN_HITS:
if always_on_hits >= _MIN_HITS and always_on_hits >= inline_hits and always_on_hits >= first_hits:
best_mode = "ALWAYS_ON"
confidence = "high" if always_on_hits > (inline_hits + first_hits) else "medium"
note = (
f"Speakers detected via \\\\n<Name> codes or 【Name】 brackets "
f"({always_on_hits} hits) — no extra flags needed. "
f"INLINE={inline_hits}, FIRSTLINE={first_hits}. "
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
)
elif inline_hits >= first_hits:
best_mode = "INLINE401SPEAKERS"
recommended["INLINE401SPEAKERS"] = True
confidence = "high" if inline_hits > first_hits * 2 else "medium"
note = (
f"INLINE401SPEAKERS: {inline_hits} hits "
f"(FIRSTLINESPEAKERS: {first_hits}, always-on: {always_on_hits}). "
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
)
else:
best_mode = "FIRSTLINESPEAKERS"
recommended["FIRSTLINESPEAKERS"] = True
confidence = "high" if first_hits > inline_hits * 2 else "medium"
note = (
f"FIRSTLINESPEAKERS: {first_hits} hits "
f"(INLINE401SPEAKERS: {inline_hits}, always-on: {always_on_hits}). "
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
)
elif face_hits >= _MIN_HITS:
best_mode = "FACENAME101"
recommended["FACENAME101"] = True
confidence = "high" if face_hits > _MIN_HITS * 2 else "medium"
note = (
f"No 401-based speaker pattern found (401 hits: {total_401_hits}). "
f"FACENAME101 recommended based on {face_hits} 101-code name hits. "
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
)
else:
best_mode = "NONE"
confidence = "low"
note = (
f"No reliable speaker pattern detected "
f"(401 hits: {total_401_hits}, FACENAME101 hits: {face_hits}). "
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s). "
"Check speaker settings manually."
)
return {
"best_mode": best_mode,
"scores": scores,
"total_401_groups": total_groups,
"files_scanned": files_scanned,
"recommended_config": recommended,
"confidence": confidence,
"note": note,
}
# ── Internal helpers ─────────────────────────────────────────────────────────
def _score_command_list(cmd_list: list, scores: dict) -> None:
"""Walk a command list and score speaker patterns in module priority order."""
if not cmd_list:
return
i = 0
while i < len(cmd_list):
cmd = cmd_list[i]
code = cmd.get("code")
params = cmd.get("parameters") or []
# ── 101: Show Text / face name ────────────────────────────────────────
if code == 101:
# FACENAME101: param[0] is a non-empty face-name string
face = params[0] if params else ""
if isinstance(face, str) and face.strip():
scores["FACENAME101"] += 1
i += 1
continue
# ── 401 / 405: dialogue line ──────────────────────────────────────────
if code in (401, 405):
text = (params[0] if params else "") or ""
# 1. \\n<Name> nametag codes (always active — no flag needed)
if _NAMETAG_RE.search(text):
scores["nametag_codes"] += 1
i += 1
continue # module strips the nametag before further processing
# 2+3. 【Name】 alone on line, or 【Name】dialogue inline (always active)
if _BRACKET_ALONE_RE.match(text) or _BRACKET_INLINE_RE.match(text):
scores["bracket_401"] += 1
i += 1
continue
# 4. INLINE401SPEAKERS: Name「dialogue
if _INLINE_QUOTE_RE.match(text):
scores["INLINE401SPEAKERS"] += 1
i += 1
continue
# 5. FIRSTLINESPEAKERS: short line followed by 401 starting with
# a dialogue-starter character
if (
len(text) < 40
and _has_japanese(text)
and not any(text.lstrip().startswith(s) for s in _DIALOGUE_STARTERS)
):
j = i + 1
while j < len(cmd_list) and cmd_list[j].get("code") == -1:
j += 1
if j < len(cmd_list) and cmd_list[j].get("code") in (401, 405):
next_params = cmd_list[j].get("parameters") or []
next_text = (next_params[0] if next_params else "") or ""
# Strip leading RPGMaker control codes before checking starter
next_stripped = re.sub(
r"^(?:[\\]+[^cCnNiIkKvVSs{}]+?\[[\d\w\W]+?\]?\])+",
"", next_text,
).lstrip()
if next_stripped and next_stripped[0] in _DIALOGUE_STARTERS:
scores["FIRSTLINESPEAKERS"] += 1
i += 1
continue
i += 1
_JP_RE = re.compile(
r"[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF\uFF61-\uFF9F]"
)
def _has_japanese(text: str) -> bool:
return bool(_JP_RE.search(text))