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:
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user