415 lines
18 KiB
Python
415 lines
18 KiB
Python
"""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())))
|
|
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"))
|