"""ackit - Batman: Arkham City audio toolkit (command line). Everyday use, by sound name (see `ackit find`): ackit find joker tape search names, categories, characters ackit info Speech_TAPE_JOKER_1_4 details, size limit, modified state ackit extract Speech_TAPE_JOKER_1_4 orig.wav save the original (or --current) ackit replace Speech_TAPE_JOKER_1_4 line.wav encode + install a replacement ackit revert Speech_TAPE_JOKER_1_4 put the original back (--all for everything) ackit status what you have changed ackit export-mod my.acmod / import-mod my.acmod share your changes The game folder is detected from Steam (Windows, Linux, Proton); override with --game DIR. """ import argparse import os import sys from . import audio, settings from .game import Game, GameError, find_installs, default_game_dir def _game(a): st = settings.load() gd = a.game or default_game_dir(st) if not gd: raise GameError("Arkham City was not found. Pass the install folder with --game DIR " "(the folder that contains BmGame and Binaries).") if a.game: st["game_dir"] = os.path.abspath(a.game) settings.save(st) return Game(gd) def _workspace(a, archives=None): from .workspace import Workspace st = settings.load() game = _game(a) if archives is None and getattr(a, "lang", None): archives = [a.lang, "SFX", "BANK"] return Workspace(game, a.store or st.get("store_root"), archives, full_backup=not getattr(a, "no_full_backup", False) and st.get("full_backup", True), progress=lambda n, t: None) def _row(s, ws=None): mod = " *" if ws and s.key in ws.store.entries and ws.store.entries[s.key].get("applied") else "" who = s.character or (("~" + s.characters_inferred) if s.characters_inferred else "") return "%-46s %-4s %7.2fs %-20s %s%s" % (s.name, s.archive, s.seconds, s.category[:20], who[:28], mod) # --- everyday --------------------------------------------------------------------------------- def cmd_find(a): ws = _workspace(a) res = ws.catalog.search(" ".join(a.words)) if not res: near = ws.catalog.suggest("_".join(a.words)) print("nothing found." + (" Did you mean: " + ", ".join(near) if near else "")) return for s in (res if a.all else res[:40]): print(_row(s, ws)) if not a.all and len(res) > 40: print("... %d more (--all)" % (len(res) - 40)) def cmd_list(a): ws = _workspace(a) rows = ws.catalog.sounds if a.group: rows = [s for s in rows if s.group.lower() == a.group.lower()] if a.category: rows = [s for s in rows if a.category.lower() in s.category.lower()] if a.character: c = a.character.lower() rows = [s for s in rows if c in s.character.lower() or (a.inferred and c in s.characters_inferred.lower())] if a.modified: rows = [s for s in rows if s.key in ws.modified_keys()] if a.csv: from .catalog import write_csv write_csv(a.csv, rows) print("wrote %s (%d sounds)" % (a.csv, len(rows))) return for s in rows: print(_row(s, ws)) print("%d sound(s)" % len(rows)) def cmd_info(a): if a.name.lower().endswith(".wem") and os.path.exists(a.name): from .wem import Wem m = Wem.parse(open(a.name, "rb").read()) print("rate %d, channels %d, mask 0x%x" % (m.rate, m.channels, m.channel_mask)) for k, v in m.vorb.items(): print(" %-20s %s" % (k, hex(v) if k == "codebook_hash" else v)) return ws = _workspace(a) s = ws.sound(a.name) for k in ("name", "key", "group", "category", "character", "characters_inferred", "seconds", "rate", "channels"): print("%-20s %s" % (k, getattr(s, k))) print("%-20s %s" % ("aliases", ", ".join(s.aliases))) lim = ws.limit(s) print("%-20s %s" % ("max size", "any" if lim is None else "%d bytes (the original's size)" % lim)) print("%-20s %s" % ("state", ws.state(s))) def cmd_extract(a): ws = _workspace(a) s = ws.sound(a.name) data = ws.current_bytes(s) if a.current else ws.original_bytes(s) if a.out.lower().endswith(".wem"): open(a.out, "wb").write(data) else: x, sr = audio.decode_wem(data) audio.save_wav(a.out, x, sr) print("wrote %s (%s, %.2f s)" % (a.out, s.name, s.seconds)) def _process(a, x, sr, ws, s): if a.trim: start, _, end = a.trim.partition(":") x = audio.trim(x, sr, float(start or 0), float(end) if end else None) if a.trim_silence: x = audio.trim_silence(x, rate=sr) if a.match_loudness: ref, _ = ws.original_audio(s) x = audio.match_loudness(x, ref) if a.normalize: x = audio.normalize(x) if a.gain: x = audio.gain(x, a.gain) return x def cmd_replace(a): ws = _workspace(a) s = ws.sound(a.name) x, sr = audio.load(a.audio) x = _process(a, x, sr, ws, s) secs = audio.duration(x, sr) if not s.is_pack_media and secs > s.seconds * 1.15 + 0.25: print("warning: your audio (%.2f s) is longer than the original (%.2f s); it will be squeezed into the " "original's size and the game may cut it off" % (secs, s.seconds)) wem, info = ws.prepare(s, x, sr, a.quality, progress=print) ws.apply([(s, wem, {"source": os.path.basename(a.audio)})], force=a.force, progress=print) print("replaced %s (%d Hz, q%.1f, %d bytes). Undo: ackit revert %s" % (s.name, info["rate"], info["quality"], len(wem), s.name)) def cmd_apply(a): ws = _workspace(a) changes, errors = [], [] for f in sorted(os.listdir(a.folder)): p = os.path.join(a.folder, f) if not os.path.isfile(p) or f.startswith("."): continue try: s = ws.sound(f) x, sr = audio.load(p) wem, _ = ws.prepare(s, x, sr, a.quality) changes.append((s, wem, {"source": f})) print("ok %-40s -> %s" % (f, s.name)) except (LookupError, ValueError, audio.AudioError) as e: errors.append(f) print("skip %-40s %s" % (f, e)) if changes: ws.apply(changes, force=a.force) print("%d applied, %d skipped" % (len(changes), len(errors))) def cmd_revert(a): ws = _workspace(a) if a.all: done = ws.revert_all(progress=print) texts = ws.texts.revert_all(progress=print) print("reverted %d sound(s) and %d text(s)" % (len(done), len(texts))) return done = ws.revert([ws.sound(n) for n in a.names], progress=print) print("reverted %d sound(s)" % len(done)) def cmd_status(a): ws = _workspace(a) keys = ws.modified_keys() texts = ws.texts.modified() if not keys and not texts: print("no sounds or texts modified; game files are original") return for k in keys: s = ws.catalog.by_key.get(k) st = ws.state(s) if s else "(other language)" print("%-50s %s" % (s.name if s else k, st)) for k in texts: print("%-50s %s" % (k, ws.texts.state(k))) def cmd_export_mod(a): from .modpack import export_mod ws = _workspace(a) keys = [ws.sound(n).key for n in a.names] if a.names else ws.modified_keys() texts = [] if a.names else ws.texts.modified() n = export_mod(ws, a.out, keys, a.title or os.path.splitext(os.path.basename(a.out))[0], a.author or "", a.description or "", texts=texts) print("wrote %s (%d sounds, %d texts)" % (a.out, len(keys), n - len(keys))) def cmd_import_mod(a): from .modpack import import_mod ws = _workspace(a) items, texts = import_mod(ws, a.file, force=a.force, progress=print) for im in items: print("%-8s %-46s %s" % (im.how, im.item.get("name"), im.note)) for k, _, problem in texts: print("%-8s %-46s %s" % ("text", k, problem or "applied")) def cmd_text(a): """Subtitles and menu text: find, show, set, revert.""" ws = _workspace(a) tx = ws.texts lang = (a.lang or ws.game.language()).upper() if a.action == "find": words = [w.lower() for w in a.args] rows = [] if a.kind in ("all", "subs"): rows += [(subtitles_key(s), s.text) for s in tx.subtitles(lang)] if a.kind in ("all", "menu"): rows += [("MENU:" + e.key, e.value) for e in tx.menu(lang)] hits = [(k, v) for k, v in rows if all(w in (k + " " + v).lower() for w in words)] for k, v in hits[:a.limit]: print("%s\n %s" % (k, v)) print("%d match(es)%s" % (len(hits), "" if len(hits) <= a.limit else " (first %d shown)" % a.limit)) elif a.action == "show": for k in a.args: print("%s\n now: %s\n original: %s\n state: %s%s" % ( k, tx.current(k), tx.original(k), tx.state(k), "" if tx.limit(k) is None else "\n room: %d characters" % tx.limit(k))) elif a.action == "set": if len(a.args) != 2: raise GameError("usage: ackit text set KEY \"new text\"") tx.apply({a.args[0]: a.args[1]}, force=a.force, progress=print) print("set %s" % a.args[0]) elif a.action == "revert": done = tx.revert_all(progress=print) if a.all else tx.revert(a.args, progress=print) print("reverted %d text(s)" % len(done)) def subtitles_key(s): from .subtitles import key return key(s.lang, s.speech, s.line) # --- advanced --------------------------------------------------------------------------------- def cmd_encode(a): from .encoder import encode if a.like: ws = _workspace(a) s = ws.sound(a.like) rate, ch = s.rate, s.channels else: rate, ch = a.rate, a.channels x, sr = audio.load(a.audio) wem, info = encode(x, sr, rate, ch, a.quality) open(a.out, "wb").write(wem) print("wrote %s (%d Hz, %d ch, q%.1f, %d bytes)" % (a.out, info["rate"], ch, info["quality"], len(wem))) def cmd_inject(a): ws = _workspace(a) s = ws.sound(a.name) ws.apply([(s, open(a.wem, "rb").read(), {"source": os.path.basename(a.wem)})], force=a.force) print("injected %s" % s.name) def cmd_decode(a): x, sr = audio.decode_wem(open(a.wem, "rb").read()) audio.save_wav(a.out, x, sr) print("wrote %s (%.2f s, %d Hz, %d ch)" % (a.out, len(x) / sr, sr, x.shape[1])) def cmd_game(a): st = settings.load() print("installs found:", ", ".join(find_installs()) or "none") gd = a.game or default_game_dir(st) if gd: g = Game(gd) from .workspace import default_store_root print("game folder: ", g.root) print("archives: ", ", ".join("%s=%s" % (k, g.rel(v)) for k, v in g.archives.items())) print("speech language: ", g.language()) print("originals store: ", a.store or st.get("store_root") or default_store_root(g)) print("settings file: ", settings.path()) def cmd_verify(a): if a.catalog: from .verify import catalogcheck rep = catalogcheck.run(_game(a), [x.upper() for x in a.archive] or None, quick=a.quick) for (arch, kind), n in sorted(rep.counts().items()): print("%-14s %-10s %d" % (arch, kind, n)) for arch, kind, key, detail in rep.issues[:a.show]: print(" %s %s %s %s" % (arch, kind, key, detail)) print("checked: %s" % ", ".join("%s %d" % kv for kv in sorted(rep.checked.items()))) for f in rep.facts: print("fact: " + f) print("catalog OK" if rep.ok else "%d issue(s)" % len(rep.issues)) sys.exit(0 if rep.ok else 1) from .verify import selfcheck ws = _workspace(a) if not a.ingame: ok = selfcheck.run(ws, log=print) sys.exit(0 if ok else 1) from .verify import ingame if a.banks: rep = ingame.run_banks(ws, a.sound or None) elif a.texts: rep = ingame.run_texts(ws) elif a.baseline: rep = ingame.run(ws, a.target, mode="baseline", seconds=a.seconds) elif a.installed: rep = ingame.run(ws, a.target, mode="installed", seconds=a.seconds) else: s = ws.sound(a.target) x, sr = audio.load(a.audio) if a.audio else ingame.probe_signal() wem, _ = ws.prepare(s, x, sr) rep = ingame.run(ws, a.target, wem=wem, mode="modded", seconds=a.seconds) for k, v in rep.items(): print("%-24s %s" % (k, v)) sys.exit(0 if rep["result"] == "PASS" else 1) def build_parser(): common = argparse.ArgumentParser(add_help=False) common.add_argument("--game", metavar="DIR", help="Arkham City install folder (remembered)") common.add_argument("--lang", type=str.upper, help="speech language archive, e.g. ITA, INT (default: game setting)") common.add_argument("--store", metavar="DIR", help="originals store folder (default: user data folder)") enc = argparse.ArgumentParser(add_help=False) enc.add_argument("--quality", default="auto", help="auto (default) or libvorbis quality 0..10") enc.add_argument("--force", action="store_true", help="overwrite sounds changed outside this tool") enc.add_argument("--no-full-backup", action="store_true", help="skip the one-time full WAD backup") proc = argparse.ArgumentParser(add_help=False) proc.add_argument("--trim", metavar="START:END", help="keep only this part (seconds), e.g. 0.5:4.2") proc.add_argument("--trim-silence", action="store_true", help="cut leading/trailing silence") proc.add_argument("--gain", type=float, metavar="DB", help="volume change in dB") proc.add_argument("--normalize", action="store_true", help="peak-normalize to -1 dBFS") proc.add_argument("--match-loudness", action="store_true", help="match the original's loudness") p = argparse.ArgumentParser(prog="ackit", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sp = p.add_subparsers(dest="cmd", required=True, metavar="COMMAND") def add(name, f, help, parents=(), **kw): s = sp.add_parser(name, parents=[common] + list(parents), help=help, description=help, **kw) s.set_defaults(f=f) return s s = add("find", cmd_find, "search sounds by name, category or character") s.add_argument("words", nargs="+"); s.add_argument("--all", action="store_true") s = add("list", cmd_list, "list sounds with filters (or export them as CSV)") s.add_argument("--group"); s.add_argument("--category"); s.add_argument("--character") s.add_argument("--inferred", action="store_true", help="also match characters inferred from names") s.add_argument("--modified", action="store_true"); s.add_argument("--csv", metavar="FILE") s = add("info", cmd_info, "show details of a sound (or of a .wem file)") s.add_argument("name") s = add("extract", cmd_extract, "save a sound as .wav (decoded) or .wem") s.add_argument("name"); s.add_argument("out"); s.add_argument("--current", action="store_true", help="the installed version instead of the original") s = add("replace", cmd_replace, "replace a sound with an audio file (WAV/FLAC/OGG/MP3/...)", (enc, proc)) s.add_argument("name"); s.add_argument("audio") s = add("apply", cmd_apply, "replace every sound named by the files in a folder", (enc,)) s.add_argument("folder") s = add("revert", cmd_revert, "put original sounds back", aliases=["restore"]) s.add_argument("names", nargs="*"); s.add_argument("--all", action="store_true") add("status", cmd_status, "list the sounds you modified") s = add("text", cmd_text, "subtitles and menu text: find WORDS | show KEY | set KEY TEXT | revert KEY/--all") s.add_argument("action", choices=["find", "show", "set", "revert"]) s.add_argument("args", nargs="*") s.add_argument("--kind", choices=["all", "subs", "menu"], default="all", help="with find: what to search") s.add_argument("--limit", type=int, default=40); s.add_argument("--all", action="store_true") s.add_argument("--force", action="store_true", help="overwrite texts changed outside this tool") s = add("export-mod", cmd_export_mod, "save your changes as a shareable .acmod package") s.add_argument("out"); s.add_argument("names", nargs="*", help="default: everything you modified") s.add_argument("--title"); s.add_argument("--author"); s.add_argument("--description") s = add("import-mod", cmd_import_mod, "install a .acmod package", (enc,)) s.add_argument("file") add("game", cmd_game, "show the detected game folder, archives, language and store") s = add("verify", cmd_verify, "self-check (offline), catalog cross-check (--catalog), or test in the real " "game with --ingame (Windows)") s.add_argument("--catalog", action="store_true", help="cross-check the catalog against the game files") s.add_argument("--archive", action="append", default=[], help="with --catalog: only this archive (repeatable)") s.add_argument("--quick", action="store_true", help="with --catalog: loose banks + a sample of packages") s.add_argument("--show", type=int, default=30, help="with --catalog: issues to list (default 30)") s.add_argument("--ingame", action="store_true"); s.add_argument("--target", default="SFX:EDF036D6/393BC53A") s.add_argument("--audio"); s.add_argument("--baseline", action="store_true") s.add_argument("--installed", action="store_true"); s.add_argument("--seconds", type=float, default=40) s.add_argument("--banks", action="store_true", help="with --ingame: test in-memory sounds (default: the front-end banks, or --sound NAME ...)") s.add_argument("--sound", action="append", default=[], help="with --banks: a sound to test (repeatable)") s.add_argument("--texts", action="store_true", help="with --ingame: test menu text and subtitle edits") # advanced s = add("encode", cmd_encode, "[advanced] audio -> .wem file", (enc,)) s.add_argument("audio"); s.add_argument("out"); s.add_argument("--like", metavar="NAME") s.add_argument("--rate", type=int, default=28000); s.add_argument("--channels", type=int, default=1) s = add("inject", cmd_inject, "[advanced] install a prepared .wem file", (enc,)) s.add_argument("name"); s.add_argument("wem") s = add("decode", cmd_decode, "[advanced] .wem file -> .wav") s.add_argument("wem"); s.add_argument("out") return p def main(argv=None): a = build_parser().parse_args(argv) try: a.f(a) except (GameError, LookupError, ValueError, audio.AudioError) as e: sys.exit("error: %s" % e) except PermissionError as e: sys.exit("error: cannot write %s - close the game and try again" % getattr(e, "filename", "a game file"))