Files
UnheardCity/ackit/classify.py

118 lines
5.8 KiB
Python

"""Classify sounds into group / category and infer characters from Wwise names.
Scheme (documented in docs/categories.md):
group Speech | Music | Ambience | SFX | Unknown
category finer bucket derived from the name prefix (e.g. "Interview tapes", "Combat barks")
Characters: the authoritative speaker comes from the curated catalog (data/catalog/characters.csv,
built from the user's per-character lists). For other lines `infer_characters` returns the
characters/roles that the *name* mentions -- a hint, not a speaker label (conversations alternate
speakers between variations).
"""
import re
# (regex on the upper-case name, group, category) -- first match wins
_RULES = [
(r"^(CHR_RADIOSCANNER|PSO_CARRADIO|SPEECH_RADIO|SPEECH_TV|SPEECH_CAMERAS|DIA_)", "Speech", "Radio & TV"),
(r"^SPEECH_TAPE_", "Speech", "Interview tapes"),
(r"^SPEECH_INTERROGATION_", "Speech", "Interrogations"),
(r"^SPEECH_(CINEMATIC|NEW)_", "Speech", "Cinematics"),
(r"^SPEECH_BOSS_", "Speech", "Boss fights"),
(r"^SPEECH_RIDDLE(R|S)_", "Speech", "Riddler"),
(r"^SPEECH_SIDE_", "Speech", "Side missions"),
(r"^SPEECH_DLC_", "Speech", "DLC"),
(r"^SPEECH_PRED_", "Speech", "Predator chatter"),
(r"^SPEECH_(COMBAT|BULLY)_", "Speech", "Combat barks"),
(r"^SPEECH_EMOTE_", "Speech", "Efforts & emotes"),
(r"^SPEECH_REACT_", "Speech", "Reactions"),
(r"^SPEECH_(STREETS|ZONES|OUTCAST|VERTICAL)_", "Speech", "Open-world chatter"),
(r"^SPEECH_CHAR_", "Speech", "Character lines"),
(r"^SPEECH_(CH|SEQUENCE|STEEL|MUSEUM|CHURCH|COURT|UNDER|GCPD|ZP1)_", "Speech", "Story missions"),
(r"^SPEECH_(GAME|MAP|AUGMENTED|FORENSICS|TEMP)_", "Speech", "Gameplay & hints"),
(r"^NPC_.*CROWD", "Ambience", "Crowds"),
(r"^SPEECH_", "Speech", "Other speech"),
(r"^MUSIC_PDLC", "Music", "Music (DLC)"),
(r"^(MUSIC|MUS_)", "Music", "Music"),
(r"^(AMB|PSA)_", "Ambience", "Ambience"),
(r"^PSO_", "Ambience", "Ambient emitters"),
(r"^WND_", "Ambience", "Wind"),
(r"^WTR_", "Ambience", "Weather"),
(r"^P?VOL_", "Ambience", "Room tone"),
(r"FOOTSTEP", "SFX", "Footsteps"),
(r"^(CHR|NPC)_.*(COMBAT|BONEBREAK|BODYFALL)", "SFX", "Combat"),
(r"^(SEQ|MTN)_", "SFX", "Scripted sequences"),
(r"^OBJ_", "SFX", "Objects"),
(r"^(PX|PHY)_", "SFX", "Physics"),
(r"^WPN_", "SFX", "Weapons"),
(r"^DVC_", "SFX", "Gadgets"),
(r"^(GUI|SBJ)_", "SFX", "Interface"),
(r"^VFX_", "SFX", "Visual effects"),
(r"^NPC_", "SFX", "Enemies & NPCs"),
(r"^(CHR|GEN)_", "SFX", "Character foley"),
(r"^GLOBAL", "SFX", "Global"),
]
_RULES = [(re.compile(p), g, c) for p, g, c in _RULES]
GROUPS = ("Speech", "Music", "Ambience", "SFX", "Unknown")
def classify(name: str, pack: str = None, bank: str = None):
"""-> (group, category). `pack` = name of the stream pack a media belongs to, if any; `bank` = name of
the soundbank holding an in-memory sound (its prefix is more reliable than event names)."""
n = (bank or name).upper()
if pack:
p = pack.upper()
if p.startswith("MUSIC"):
return "Music", "Music (DLC)" if "PDLC" in p else "Music"
for rx, g, c in _RULES:
if rx.search(n):
return g, c
return "Unknown", "Unknown"
# --- characters -------------------------------------------------------------------------------
# display names for the labels used in the user's catalog files (data/catalog/source/ita/*.txt)
USER_LABELS = {
"Alfred": "Alfred", "Azrael": "Azrael", "Bane": "Bane", "Batman": "Batman",
"Calendar": "Calendar Man", "Catwoman": "Catwoman", "Croc": "Killer Croc", "Deadshot": "Deadshot",
"Freeze": "Mr. Freeze", "Gordon": "Gordon", "Ivy": "Poison Ivy", "Joker": "Joker",
"Madhatter": "Mad Hatter", "Oracle": "Oracle", "Penguin": "Penguin", "Quinn": "Harley Quinn",
"Ras": "Ra's al Ghul", "Riddler": "Riddler", "Robin": "Robin", "Strange": "Hugo Strange",
"Talia": "Talia al Ghul", "Twoface": "Two-Face", "Unknown": "Unknown", "Vicky": "Vicki Vale",
"Zsazz": "Victor Zsasz",
}
# name tokens -> character / role (matched on whole "_"-separated tokens, digits ignored)
_TOKENS = {
"Batman": ("BATMAN", "BM"), "Catwoman": ("CATWOMAN", "CAT", "CW"), "Joker": ("JOKER",),
"Penguin": ("PENGUIN", "PENG"), "Two-Face": ("TWOFACE", "TF"), "Riddler": ("RIDDLER",),
"Mr. Freeze": ("FREEZE", "MRFREEZE", "FREEZESUIT"), "Poison Ivy": ("IVY",),
"Hugo Strange": ("STRANGE", "HUGO"), "Robin": ("ROBIN",), "Oracle": ("ORACLE",),
"Alfred": ("ALFRED", "ALF"), "Harley Quinn": ("QUINN", "HARLEY"), "Ra's al Ghul": ("RAS",),
"Talia al Ghul": ("TALIA",), "Victor Zsasz": ("ZSASZ", "ZSAZZ"), "Bane": ("BANE",),
"Deadshot": ("DEADSHOT",), "Mad Hatter": ("MADHATTER", "HATTER"), "Calendar Man": ("CALENDAR", "CM"),
"Hush": ("HUSH",), "Clayface": ("CLAYFACE", "CLAY"), "Solomon Grundy": ("GRUNDY",),
"Azrael": ("AZRAEL",), "Vicki Vale": ("VALE", "VICKI", "VICKY", "VIC"), "Gordon": ("GORDON",),
"Nightwing": ("NIGHTWING", "NW"), "Killer Croc": ("CROC",),
# roles
"Thug": ("THUG", "THUGS", "GENTHUG", "JTHUG", "JTHUGS", "PTHUG", "PTHUGS", "BULLY"),
"TYGER guard": ("TYGER",), "Ninja": ("NINJA", "ASSASSIN"), "Doctor": ("DOC", "DOCTOR"),
"Cop": ("COP", "COPS", "GCPD"), "Political prisoner": ("HOBO", "OUTCAST", "OC"),
"Hostage": ("HOST", "HOSTAGE"), "Guard": ("GUARD", "GUARDS"),
}
_TOK2CHAR = {t: c for c, toks in _TOKENS.items() for t in toks}
_STRIP_DIGITS = re.compile(r"\d+$")
def infer_characters(name: str):
"""Characters/roles mentioned by a sound name (ordered, unique). Empty if none."""
n = name.upper()
out = []
if n.startswith("SPEECH_TAPE_"):
out.append("Hugo Strange") # Strange's interview tapes
for tok in re.split(r"[_\s]+", n):
t = _STRIP_DIGITS.sub("", tok)
c = _TOK2CHAR.get(t) or _TOK2CHAR.get(tok)
if c and c not in out:
out.append(c)
return out