Version 3.4.0: Edit tab (edit list, large subtitle, replacements with effects and presets), Sounds tab only browses, ackit fx (pedalboard + own reverb) and replace --fx, first Linux build, catalog without a mod package

This commit is contained in:
2026-10-28 13:54:12 +01:00
parent a706dae419
commit 6de470174e
36 changed files with 2872 additions and 278 deletions

View File

@@ -303,7 +303,8 @@ def index_claims(cat):
% sub["scripted_objects"])
claim("REPORT.md", "(%s speech lines x %d languages) and" % (n(sub["dialogue_lines"]), len(sub["lines"])))
claim("REPORT.md", "in %d scripted `RSubtitle` objects" % sub["scripted_objects"])
claim("docs/formats/banks.md", "(in %s of the 2,650 packages)" % n(b["files"] - 67)) # 67 loose .bnk with banks
claim("docs/formats/banks.md", "(in %s of the " % n(b["files"] - 67)) # 67 loose .bnk with banks; the
# total number of packages needs the game: scan_claims
# --- the game's own files ----------------------------------------------------------------------------
@@ -379,6 +380,7 @@ def scan_claims(game):
claim("docs/formats/banks.md", "| banks embedded in `.upk` packages (in %s of the %s packages) | %s |"
% (n(sum(1 for f in sc.files if f.lower().endswith(".upk"))), n(len(glob.glob(os.path.join(game.cooked, "*.upk")))),
n(len(blobs))))
claim("docs/formats/upk.md", "Arkham City's %s packages in" % n(len(glob.glob(os.path.join(game.cooked, "*.upk")))))
claim("docs/formats/banks.md", "Source kinds over all bank copies: %s in-memory sources of the bank's own media; %s "
"in-memory sources with bank id 0" % (n(kinds["memory own"]), n(kinds["memory other"])))
claim("docs/formats/banks.md", "(loaded by another bank); %s streamed sources; %s **prefetch** sources"
@@ -530,6 +532,24 @@ def originality_claims(game):
n(len(rels)), "no false alarm" if not bad else "%d false alarms" % len(bad)), "Workspace.unoriginal_files")
def fx_claims():
"""Effects (ackit/fx.py), the dependency pin and the Edit tab's tested window sizes."""
from ackit import fx
pa = fx.preset("echo", "PA announcement")["time"]
claim("docs/user-guide.md", "(the *PA announcement* preset: %d ms)" % pa, "fx.PRESETS")
looks = [name for name, _ in fx.LOOKS]
claim("docs/user-guide.md", "sets several effects at once: %s, %s." % (", ".join(looks[:-1]), looks[-1]),
"fx.LOOKS")
for b in fx.BLOCKS:
claim("docs/user-guide.md", "| **%s**" % b.label, "fx.BLOCKS (effects table)")
pin = re.search(r'"pedalboard==([\d.]+)"', open(os.path.join(ROOT, "pyproject.toml")).read()).group(1)
claim("docs/decisions.md", "Wheels for Python 3.14 on Windows and Linux (%s)" % pin, "pyproject.toml")
sizes = re.search(r"for size in \(\((\d+), (\d+)\), \((\d+), (\d+)\)\)",
open(os.path.join(ROOT, "tests", "test_app.py"), encoding="utf-8").read()).groups()
claim("docs/decisions.md", "No scrolling at %s x %s and %s x %s (tested)" % sizes, "tests/test_app.py")
claim("REPORT.md", "No scrolling at %s x %s or %s x %s." % sizes, "tests/test_app.py")
def test_claims():
import subprocess
out = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"],
@@ -551,6 +571,7 @@ def main():
cat = catalog_dir()
catalog_claims(cat)
index_claims(cat)
fx_claims()
test_claims()
gd = a.game or default_game_dir(settings.load())
game = Game(gd) if gd else None

View File

@@ -73,6 +73,9 @@ def components():
dist_file("soxr", "LICENSE-PFFFT.txt")),
("NumPy (incl. bundled OpenBLAS / runtime libraries)", ver("numpy"), "BSD-3-Clause and others (see text)",
"https://github.com/numpy/numpy", dist_file("numpy", "LICENSE.txt")),
("Pedalboard (incl. JUCE and the Rubber Band library, statically linked)", ver("pedalboard"), "GPL-3.0",
"https://github.com/spotify/pedalboard", dist_file("pedalboard", "LICENSE") + "\n\n" +
dist_file("pedalboard", "NOTICE")),
("cffi", ver("cffi"), "MIT-0", "https://github.com/python-cffi/cffi", dist_file("cffi", "LICENSE")),
("pycparser", ver("pycparser"), "BSD-3-Clause", "https://github.com/eliben/pycparser",
dist_file("pycparser", "LICENSE")),

View File

@@ -1,17 +1,19 @@
"""Real end-to-end test of the EDITOR code path against the installed game (Windows, Steam running, WinDbg).
1. open the editor (offscreen) on the real game, select the intro music (Music_393BC53A);
2. load a 30 s probe file as replacement and press Apply (EditorPanel.apply);
1. open the editor (offscreen) on the real game, select the intro music (Music_393BC53A) and press Edit;
2. load a 30 s probe file as replacement and press Apply (EditorPanel.apply in the Edit tab);
3. start the game under cdb + speaker loopback: the probe must be heard, no crash, setup parsed;
4. press Revert (EditorPanel.revert) and check the WAD + pack index are byte-identical to before;
5. also apply/revert a speech line in place and check byte identity.
Uses the user's settings and originals store (an interrupted run shows up in `ackit status`); the settings file
is never written.
is never written, and the Edit tab's list and replacements go to a temporary folder (the user's edit list is
untouched).
python tools/e2e_editor.py
"""
import hashlib
import os
import shutil
import sys
import tempfile
@@ -40,6 +42,9 @@ def main():
# stores its settings on load and on close, which would overwrite the user's file with this test's ones
w = MainWindow(settings=dict(settings_mod.load(), full_backup=True), runner=Runner(sync=True))
w.save_settings = lambda: None
from app.edit import EditStore
edits = tempfile.mkdtemp() # not the user's edit list and replacements
w.edit.store = w.edit.model.store = EditStore(edits)
w.confirm = lambda *a: True
w.editor.confirm = lambda *a: True
errors = []
@@ -55,6 +60,7 @@ def main():
ws.texts.revert_all()
print("E2E EDITOR %s" % ("PASS" if ok else "FAIL"))
w.close()
shutil.rmtree(edits, ignore_errors=True)
return 0 if ok else 1
@@ -72,6 +78,7 @@ def run(w, ws, errors):
w.search.setText("Music_393BC53A")
w._filter_text()
assert w.select_name("Music_393BC53A")
w.edit_selected() # the Edit tab, on this sound
x, sr = ingame.probe_signal(30.0)
probe = os.path.join(td, "probe.wav")
audio.save_wav(probe, x, sr)
@@ -94,6 +101,7 @@ def run(w, ws, errors):
tb = sha(twad)
w.clear_filters()
assert w.select_name(t.name)
w.edit_selected()
y, ysr = audio.decode_wem(ws.original_bytes(t))
line = os.path.join(td, "line.wav")
audio.save_wav(line, y[::-1], ysr) # reversed speech, same length

View File

@@ -16,6 +16,8 @@ of the editor and the CLI), read back from the game files, reverted, and the fil
7. mod package made with the language's voices and imported into another voice language (INT; FRA for INT):
matched by the game's line identity, so the same line (not the same WAD position) is replaced; a text key
names its language, so the edited subtitle stays that language's subtitle (the other language's is untouched)
8. 5-channel speech line replaced with effects (the Edit tab's chain: EQ, echo, PA channel layout): the
decoded game file has the line's channels in the game's order, the surrounds carry the delayed voice
Uses temporary originals stores (the user's store and settings are not touched), deleted at the end when every
change was reverted (kept, with a warning, otherwise). Whatever happens (a failed check, an exception), every
change is reverted before the tool exits; a sound that is not in its original state is never written.
@@ -32,7 +34,7 @@ sys.path.insert(0, ROOT)
import numpy as np # noqa: E402
from ackit import banks, settings, subtitles # noqa: E402
from ackit import audio, banks, fx, settings, subtitles # noqa: E402
from ackit.game import Game, default_game_dir # noqa: E402
from ackit.modpack import export_mod, import_mod, match_mod # noqa: E402
from ackit.wem import fill # noqa: E402
@@ -93,6 +95,39 @@ def sound_round_trip(ws, s, label):
check(label, ok, "(%s, %d files, %d copies/heads)" % (s.name, len(files), len(ws.locations(s))))
def effects_round_trip(ws, voices, lang):
"""8. A replacement through the effects into a 5-channel line (L R C SL SR), read back and decoded."""
label = "8 %s 5-channel line replaced with effects" % lang
s = next((x for x in ws.catalog.sounds if x.archive == voices and x.group == "Speech" and x.channels == 5
and 1.5 < x.seconds < 4 and ws.state(x) == ORIGINAL), None)
if s is None:
check(label, False, "no original 5-channel speech line found")
return
new, sr = tone(1.0, 400)
new[int(0.6 * sr):] = 0 # a pause, so the echo can be measured after the voice stops
st = fx.defaults()
st["eq"].update(on=True, low_cut=300, high_cut=3400)
st["echo"].update(on=True, time=120, feedback=0.2, mix=0.3)
st["channels"].update(on=True, front=-12, surround=-6, surround_delay=160)
x = fx.render(new, sr, st, s.channels)
wem, _ = ws.prepare(s, x, sr)
files = ws.files(s)
before = snapshot(files)
ws.apply([(s, wem, {"source": "e2e", "effects": fx.summary(st)})])
y, ysr = audio.decode_wem(ws.current_bytes(s))
ok = ws.state(s) == MODIFIED and y.shape[1] == 5
lag = -1.0
if ok:
def onset(c): # first sample above half of the channel's peak
a = np.abs(y[:, c])
return int(np.argmax(a > 0.5 * a.max())) / ysr
lag = onset(3) - onset(2) # SL vs C: the PA echo delay
ok &= abs(lag - 0.16) < 0.02 and np.abs(y[:, 2]).max() > 2 * np.abs(y[:, 0]).max()
ws.revert([s])
ok &= ws.state(s) == ORIGINAL and snapshot(files) == before and not ws.store.entries
check(label, ok, "(%s, surround delay %.3f s)" % (s.name, lag))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--game")
@@ -202,6 +237,8 @@ def run_checks(game, lang, voices, td, ws, first):
else:
check("6 %s sound + subtitle in one package" % lang, False, "no such package found")
effects_round_trip(ws, voices, lang)
# 7. cross-language mod: the same game line, not the same WAD position
from ackit.catalog import read_csv
from ackit.paths import catalog_dir

View File

@@ -1,6 +1,6 @@
"""Render the user-guide screenshots headless (docs/img/*.png). Read-only: never applies anything.
python tools/screenshots.py [--game DIR] [--lang INT]
python tools/screenshots.py [--game DIR] [--lang INT] [--only main,browse,settings,edit]
The app starts in the game's language as always; the screenshots then switch to --lang (default INT, the
game's English) the way a user does, through the Language list.
@@ -22,7 +22,10 @@ def main():
ap = argparse.ArgumentParser()
ap.add_argument("--game")
ap.add_argument("--lang", default="INT", help="language shown in the screenshots (default: INT)")
ap.add_argument("--only", default="main,browse,settings,edit",
help="which screenshots (comma-separated): main, browse, settings, edit (editor.png)")
a = ap.parse_args()
only = set(a.only.split(","))
from PySide6.QtWidgets import QApplication
from ackit import audio
from app.dialogs import SettingsDialog
@@ -49,31 +52,19 @@ def main():
app.processEvents()
# status bar: show the store where users have it, not this temporary folder (no local user names)
w.lbl_game.setText(w.lbl_game.text().replace(os.path.join(td, "data"), r"%APPDATA%\UnheardCity"))
# 1: browser filtered by character
# 1: browser filtered by character, a line selected (preview below the list)
from PySide6.QtTest import QTest
w.cmb_char.setCurrentText("Joker")
w._filters_changed()
w.search.setText("")
w._filter_text()
w.fit_editor()
rows = [w.proxy.sound_at(w.proxy.index(r, 1)) for r in range(w.proxy.rowCount())]
with_sub = [s for s in rows if w.ws.texts.for_sound(s, w.language)] # show the subtitle column too
with_sub = [s for s in rows if w.ws.texts.for_sound(s, w.language)] # show the subtitle too
target = max(with_sub or rows, key=lambda s: min(s.seconds, 6.0))
w.select_name(target.name)
app.processEvents()
# a replacement loaded for display only (not applied)
x, sr = w.editor.orig
t = np.arange(len(x)) / sr
rep = (0.5 * x[:, :1] * (0.6 + 0.4 * np.sin(2 * np.pi * 0.7 * t))[:, None]).astype(np.float32)
path = os.path.join(td, "my_line.wav")
audio.save_wav(path, rep, sr)
w.editor.load_file(path)
w.editor.cb_match.setChecked(True)
w.editor._update_new()
w.editor.sp_a.setValue(0.2)
app.processEvents()
w.grab().save(os.path.join(out, "main.png"))
from PySide6.QtCore import QPoint, QRect # the editor area, cut from the window (full styling)
w.grab(QRect(w.scroll.mapTo(w, QPoint(0, 0)), w.scroll.size())).save(os.path.join(out, "editor.png"))
if "main" in only:
w.grab().save(os.path.join(out, "main.png"))
# 2: category browsing
w.clear_filters()
w.cmb_group.setCurrentText("Speech")
@@ -83,14 +74,35 @@ def main():
w.search.setText("joker")
w._filter_text()
app.processEvents()
w.grab().save(os.path.join(out, "browse.png"))
if "browse" in only:
w.grab().save(os.path.join(out, "browse.png"))
# 3: settings
d = SettingsDialog(w.settings, w)
d.bg_on.setChecked(True) # show the user's default (on), only the screenshots run without it
d.show()
app.processEvents()
d.grab().save(os.path.join(out, "settings.png"))
d.close()
if "settings" in only:
d = SettingsDialog(w.settings, w)
d.bg_on.setChecked(True) # show the user's default (on), only the screenshots run without it
d.show()
app.processEvents()
d.grab().save(os.path.join(out, "settings.png"))
d.close()
# 4: the Edit tab: Mr. Freeze's lines on the edit list, one with a replacement (the original, reshaped,
# for display only) and the Mr. Freeze preset
if "edit" in only:
w.clear_filters()
w.cmb_char.setCurrentText("Mr. Freeze")
w._filters_changed()
freeze = [w.proxy.sound_at(w.proxy.index(r, 1)) for r in range(min(w.proxy.rowCount(), 12))]
target = max(freeze, key=lambda s: (bool(w.ws.texts.for_sound(s, w.language)), min(s.seconds, 4.0)))
w.edit_sounds([target] + [s for s in freeze if s is not target])
app.processEvents()
x, sr = w.editor.orig
t = np.arange(len(x)) / sr
new = (0.5 * x.mean(axis=1, keepdims=True) * (0.6 + 0.4 * np.sin(2 * np.pi * 0.7 * t))[:, None])
path = os.path.join(td, "new_line.wav")
audio.save_wav(path, new.astype(np.float32), sr)
w.editor.load_file(path)
w.edit.rack.apply_look("Mr. Freeze")
QTest.qWait(1500) # the effects render in the background, then the settings are saved
w.grab().save(os.path.join(out, "editor.png"))
w.close()
print("screenshots in", out)