Initial commit

This commit is contained in:
2026-10-09 02:48:24 +02:00
commit e05d973a8d
133 changed files with 101507 additions and 0 deletions

335
ackit/cli.py Normal file
View File

@@ -0,0 +1,335 @@
"""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 _workspace(a, archives=None):
from .workspace import Workspace
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)
game = Game(gd)
if archives is None and getattr(a, "lang", None):
archives = [a.lang, "SFX"]
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)
else:
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()
if not keys:
print("no sounds modified; game audio is 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))
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()
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 "")
print("wrote %s (%d sounds)" % (a.out, n))
def cmd_import_mod(a):
from .modpack import import_mod
ws = _workspace(a)
for im in import_mod(ws, a.file, force=a.force, progress=print):
print("%-8s %-46s %s" % (im.how, im.item.get("name"), im.note))
# --- 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):
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.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("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), or test in the real game with --ingame (Windows)")
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)
# 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"))