Compare commits

..

8 Commits

Author SHA1 Message Date
6de470174e 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 2026-10-28 13:54:12 +01:00
a706dae419 Version 3.3.0: restore-backup command, Revert all includes texts, editor opens in text-only game languages, mod import asks before overwriting
dist/UnheardCity-3.3.0-windows-amd64.zip (86 MB): smoke test PASS with PATH reduced to the Windows folders (21,015 sounds, 9,377 subtitles, 7,020 menu texts), GUI start/quit PASS. REPORT and NOTES updated.
2026-10-13 21:37:05 +02:00
1ccaa8b398 Full correctness check against the docs: safer writes, reverts and texts
Safety
- Writes are serialized (Workspace.lock): two background jobs rebuilding one
  package (a sound and its subtitle applied side by side) lost an edit.
- A store copies a bank file or package as "original" only if it matches the
  shipped catalog (Workspace.unoriginal_files: in-memory sounds, bank copies of
  voice lines, subtitles); the editor refuses to switch stores while changes
  are applied. A second store could otherwise resurrect a reverted sound.
- Mod import in the editor asks before overwriting files changed outside the
  tool (it forced silently).
- Revert all in the editor reverts texts too; the uninstaller counts text
  edits before offering to delete the store.
- ackit restore-backup: the full-backup restore decisions.md described; it
  keeps the sounds of WADs without a full copy recorded.
- build_catalog also stops when a subtitle differs from the catalog.

Languages and texts
- Game.language() returns languages without voices (JPN, RUS, ...) and
  Game.voice_archive() their INT voices; ackit text --lang JPN works.
- Menu lookups parse one cached file (find & replace: minutes -> ms).
- Subtitles decode cp1252 as Windows does: the UTF-8-in-cp1252 line
  Speech_TAPE_J_Answer_1_00 (KOR/POL/POR/RUS) is written back byte-for-byte;
  SUB_KOR/SUB_RUS rebuilt from the original game (only those rows changed).
- Mods exported while another language is loaded keep name and line.

Other fixes
- Down-mix folds extra channels in at -3 dB (a 5.1 centre voice was dropped).
- e2e_editor no longer overwrites the user's settings.json; kill_game waits
  for the game to exit before reverts.
- Recorder handles Int32/UInt8 input and split frames; package block cache
  locked; conflict messages, CLI help examples, stale docstrings and doc
  references fixed; tests no longer read this PC's game language.

Docs (decisions, user guide, README, subtitles.md, banks.md, catalog README,
REPORT, NOTES, CLAUDE.md) updated; check_docs: 102 claims match. 148 tests;
e2e_features 9/9, e2e_editor and verify PASS; game original afterwards.
2026-10-13 21:34:13 +02:00
a6ef3b9458 README: preview screenshot after the introduction 2026-10-11 02:00:55 +02:00
f5d1dd9664 README: preview screenshot under the title 2026-10-11 02:00:17 +02:00
d9e17c2e88 README: align the comments in the code blocks 2026-10-11 01:55:21 +02:00
8e01e0e36d Remove Checkpoint.txt from the source lists 2026-10-11 01:53:35 +02:00
783b937c05 License: GPL-3.0 (LICENSE, shipped with the build) 2026-10-11 01:27:08 +02:00
67 changed files with 4393 additions and 459 deletions

7
.claudio Normal file
View File

@@ -0,0 +1,7 @@
MOUNTS=(
"$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Batman Arkham City GOTY"
)
GUI=1
AUDIO=1
ROOT=1
PERSIST=1

View File

@@ -5,7 +5,8 @@ Batman: Arkham City (PC) audio toolkit + editor. Read `NOTES.md` first (status,
- Layout: `ackit/` library + CLI (`python -m ackit -h`), `app/` PySide6 editor, `tests/` pytest, `data/catalog/` - Layout: `ackit/` library + CLI (`python -m ackit -h`), `app/` PySide6 editor, `tests/` pytest, `data/catalog/`
CSVs (+ user's original lists in `data/catalog/source/`, never edit them), `docs/`, `tools/` build scripts. CSVs (+ user's original lists in `data/catalog/source/`, never edit them), `docs/`, `tools/` build scripts.
- Dev env: `.venv` (Python 3.14, `pip install -e .[app,dev,verify]`). Run tests with `.venv/Scripts/python -m pytest -q`. - Dev env: `.venv` (Python 3.14, `pip install -e .[app,dev,verify]`). Run tests with `.venv/Scripts/python -m pytest -q`.
- Writing game files goes through `ackit.workspace.Workspace` only (originals store, atomic writes, conflict checks). - Writing game files goes through `ackit.workspace.Workspace` only (originals store, atomic writes, conflict checks,
one write at a time: `Workspace.lock`, held by every apply/revert/rebuild incl. `Texts`).
Speech/ambience: in place, never larger than the original WEM; music packs: any size (`docs/formats/wad.md`). Speech/ambience: in place, never larger than the original WEM; music packs: any size (`docs/formats/wad.md`).
In-memory sounds (archive BANK, loose `.bnk` + `.upk` packages): in place into every copy; a bank file is always In-memory sounds (archive BANK, loose `.bnk` + `.upk` packages): in place into every copy; a bank file is always
rebuilt from the store's original copy + the applied replacements (`docs/formats/banks.md`, `upk.md`). rebuilt from the store's original copy + the applied replacements (`docs/formats/banks.md`, `upk.md`).
@@ -15,7 +16,7 @@ Batman: Arkham City (PC) audio toolkit + editor. Read `NOTES.md` first (status,
`ackit verify --ingame [--banks | --texts]` (Windows; needs Steam running and logged in, WinDbg; ~1.5 min; reverts and `ackit verify --ingame [--banks | --texts]` (Windows; needs Steam running and logged in, WinDbg; ~1.5 min; reverts and
checks byte-identity itself). checks byte-identity itself).
- Rebuild catalogs only from original game files: `python tools/build_catalog.py` (~5 min, scans all packages; - Rebuild catalogs only from original game files: `python tools/build_catalog.py` (~5 min, scans all packages;
stops if a sound differs from the current catalog). Voice lines are identified by the game's Wwise Sound stops if a sound or subtitle differs from the current catalog). Voice lines are identified by the game's Wwise Sound
objects (`ackit/lines.py`, column `line`; one recording per language), never by WAD position or name: objects (`ackit/lines.py`, column `line`; one recording per language), never by WAD position or name:
positions differ per language (`docs/decisions.md` 5, `docs/formats/banks.md` *Lines*). positions differ per language (`docs/decisions.md` 5, `docs/formats/banks.md` *Lines*).
- Figures in the docs are checked, not copied: after changing data, code or docs run `python tools/check_docs.py` - Figures in the docs are checked, not copied: after changing data, code or docs run `python tools/check_docs.py`

674
LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

128
NOTES.md
View File

@@ -126,9 +126,125 @@
action bar level with the others; A/B and Loop are icons next to the volume. Fits a 1000-px window action bar level with the others; A/B and Loop are icons next to the volume. Fits a 1000-px window
without horizontal scrolling (minimum 984 px). Docs: recordings the game shares between languages (user saw without horizontal scrolling (minimum 984 px). Docs: recordings the game shares between languages (user saw
English lines "leaking" into other languages: the game's own data, banks.md *Lines*). English lines "leaking" into other languages: the game's own data, banks.md *Lines*).
- 2026-10-13 (user: "full check of the source code for correctness, also against the documentation"): figures
all matched (`check_docs --scan`), the formats code matched the docs; fixed: editor **Revert all** reverted
sounds only (docs and uninstaller promised everything; the uninstaller also counted only sound records and
could offer to delete the store while texts were modified) -> sounds + texts, uninstaller counts texts;
concurrent background writes to one package lost an edit (reproduced) -> `Workspace.lock` serializes every
write; editor mod import forced over conflicts silently -> asks; a second originals store (Settings / `--store`)
copied a package modified by the first as its "original" (reproduced: a replaced sound survived both reverts)
-> `Workspace.unoriginal_files` (catalog checksums, heads, subtitles; 1,447 files, no false alarm, ~15 s for
all) makes it a conflict and the editor refuses a store change while changes exist; `ackit text --lang JPN`
(text-only languages) failed -> `Game.voice_archive`; menu lookups reloaded all ~37 files per call (find &
replace of "the" in INT: ~4 min frozen) -> one cached file per lookup (0.04 s for 1,193 keys);
build_catalog's safety check ignored subtitles -> also compares them; `ackit restore-backup` added (decisions
named a restore that nothing exposed; it also forgot WAD sounds without a full copy); recorder handles
Int32/UInt8 input and split frames; stale docstrings, `ackit -h` examples, conflict message, case-insensitive
Localization path in `verify --catalog`. 146 tests. During the checks an e2e_editor game launch failed
("code never decrypted") and its revert found the WAD still locked by the dying game (left Music_393BC53A
modified; reverted by hand, SFX catalog OK): `debugger.kill_game` now waits until the game has exited.
- 2026-10-13 second full check (user: "do this again to make sure"): codec (wwvorbis, codebooks, allocsize, ogg),
classify vs categories.md, verify harness, tools and widgets re-read; fixed: `tools/e2e_editor.py` saved its own
settings over the user's settings.json (lost store/device/background settings; it now uses the user's
settings and never saves); `Game.language()` ignored languages without voices (a JPN game opened the editor
and `ackit text` in INT; now the game's language, `Game.voice_archive` for its speech archive) and the tests
read this PC's UserEngine.ini (conftest isolates it); a mod exported while another language was loaded lost
name + line of that language's changes (`Workspace.sound_for_key`); down-mix dropped the extra channels (a
5.1 centre-only voice became silent stereo; now folded in at -3 dB, peak-limited); `Texts.for_sound` default
for shared in-memory lines; package block cache locked (concurrent readers); `catalog._whole_recordings` ->
`whole_recordings` in docs/comments; the user guide no longer sends app users to the CLI-only restore.
148 tests.
- 2026-10-13 third check (property checks on the real game data): every menu file rewrites byte-identically,
no ambiguous menu keys, every subtitle copy has the catalog's room; 2 of 103,658 subtitle strings could not be
written back as stored: one line (`Speech_TAPE_J_Answer_1_00`) is UTF-8 bytes in a cp1252 string in KOR, POL,
POR, RUS (the game's data; KOR's damaged), and the RUS/KOR ones contain the 5 bytes Python's cp1252 leaves
undefined (decoded as U+FFFD, lossy) -> cp1252 as Windows maps it (lossless; all 103,658 reproduce), catalog
rebuilt from the original game (`--accept-changes`: exactly those 2 rows changed), subtitles.md documents it
(claim derived by check_docs). User guide: Space plays the replacement once one is loaded (not always the
original); tooltip too. App smoke test on the real game PASS.
- 2026-10-13 version 3.3.0 (minor: `ackit restore-backup`; Revert all includes texts; the editor opens in a
text-only game language; mod import asks; store folder locked while changes exist):
dist/UnheardCity-3.3.0-windows-amd64.zip (86 MB) smoke test PASS with PATH = C:\Windows only (21,015 sounds,
9,377 subtitles, 7,020 menu texts, ITA), GUI start/quit PASS.
- DONE by the user in game: replaced voice lines, edited subtitles and replaced footsteps (after the fill fix). - DONE by the user in game: replaced voice lines, edited subtitles and replaced footsteps (after the fill fix).
- OPEN: channel order for >2 channels unverified; 19 speaker labels to check by listening; the Linux build was - Mission 4 (2026-10-27, plan: `docs/history/mission4_dubbing_plan.md`): dubbing every character's lines with
never built or run (no Linux here). the game's voice effects, inside Unheard City (Dubbing tab), engine in ackit, effects from pedalboard.
Stage 0 (evidence, read-only) IN PROGRESS on the Linux container: HIRC effect survey (effect slots of sounds,
containers, buses; effect objects) + signal features of every speech recording, then listening per group.
2026-10-27 first results (scratch scripts, INT; not yet in docs/check_docs): v56 layouts from wwiser (node
base params: u8 override, u8 count, [u8 bypass, count x {u8 index, u32 fx id, u8 shareset, u8 rendered}],
u32 bus, u32 parent; Sound: after the source + u8 source bits; bus: u8 isEnv at +33, ducks 17 B, then fx).
Effect objects (type 0x12/0x13): RoomVerb 84 + Matrix Reverb 6 + Delay/Meter/ML1/EQ all in Init (env
reverbs = run time); McDSP FutzBox share-set 4272216A in 1,125 speech banks, on 464 actor-mixer slots, run
time with RTPC curves -> 2,858 speech rows (2,358 WAD + 500 in-memory) are clean in the files (wide band;
record dry). Rendered (baked) slots: 33 speech rows (car radio 25, radio scanner 3, museum thug-to-Penguin 4,
Steel HQ 1); their fx id is 0 (type lost). Every speech row goes through the ML1 limiter on the top bus.
Most baked effects are studio-made (no metadata): signal features vs each speaker's widest-band lines AND vs
all speakers (a speaker whose every line is processed has no dry baseline: Mr. Freeze was missed by the
speaker-relative test alone) + a "dark" flag (< 3 kHz, Clayface) give 77 groups of >= 6 lines (169 in all,
3,273 recordings; filters: Freeze, Strange/Mayor PA, Joker PRED, Two-Face court, radio scanner, Penguin,
Zsasz, copter, Oracle, tapes; echo: Penguin exhibit, Riddler cameo, Strange TV; multichannel layouts).
Not measured yet: pitch/formant shift, distortion, modulation (tremolo/flanger), noise beds; every flagged
line is listed in `flagged_lines.csv` so small groups are not lost.
Stage 0 DONE (2026-10-27): the user listened to the 77 groups (results + scope change in the plan, sections
9-10): manual controls for every effect heard, no automatic profiles/FX.csv.
- Mission 4 stages 1-2 DONE (2026-10-27, Linux container): `ackit/fx.py` (pedalboard 0.9.26 + numpy; 10 blocks:
pitch/formant/drift, octave layers, robot, glitch, voice band EQ (Butterworth via FFT: pedalboard's cuts are
first order and cost 3.7 dB in the pass band when stacked), distortion/GSM, noise, echo, reverb + reverse
swells, channel layout in WAVE mask order; seeded; `BLOCKS` drives the UI), CLI `ackit fx-template` +
`replace --fx`; Dubbing tab (`app/dubbing.py`, replaced the same day by the Edit tab, below: speech list with character/category/dub-state filters, a second
EditorPanel with the effects rack, takes + settings per sound key in user data `dubs/`, "use these effects for
the selected lines"); EditorPanel renders effects in the background (cached) between trim and loudness, apply
meta records the effects; group-box indicators styled like checkboxes. 170 tests (19 fx, 2 dubbing GUI, 1 CLI);
`tools/e2e_features.py --lang INT` 10/10 incl. new check 8 (5-ch dubbed line: decoded from the game file, C dry,
SL echo 0.160 s later; reverted byte-identical); game original after, `verify --catalog --quick` OK;
check_docs all match; smoke test renders effects; first Linux build (`dist/UnheardCity-3.3.0-linux-x86_64.tar.gz`,
124 MB) smoke + GUI start PASS with PATH=/usr/bin:/bin. User guide: Dubbing section + docs/img/dubbing.png
(`tools/screenshots.py --only dubbing`; the other images were not re-rendered on Linux).
NEXT: user tries the tab and tunes the effect defaults by ear; Windows build + in-game listening of a dubbed
line (needs the Windows PC); version bump (3.4.0) when released. Not done: hint per line that the game adds an
effect at run time (FutzBox lines; data known from stage 0, would need the HIRC survey in the catalog build).
- 2026-10-27 UI revision (user: subtitle box too small, Sounds and Dubbing redundant, effects panel "raw",
reverb quality): browsing and editing split. Sounds = browser + compact preview (app/preview.py: details,
subtitle text, original/installed waveforms, Edit / Add to edit list; Enter = Edit); Dubbing renamed Edit and
made universal (app/edit.py: edit list of the sounds sent from the browser, Previous/Next, Alt+Left/Right,
takes + settings in user data `edits/`, old `dubs/` moved there once); editor relaid out for the Edit tab
(SCRIPT = ScriptBox, large 3-line text + original + English; LISTEN rows with play buttons; YOUR TAKE; action
bar pinned below the scroll area); effects panel app/effects.py (looks, per-block preset buttons, sliders with
readable values, log for Hz/ms/s, double-click reset, wheel only when focused); fx: own reverb (generated room
response, 4 bands, early reflections; RT60 measured back within 15 %) instead of Freeverb, PRESETS + LOOKS.
`MainWindow.editor` = the Edit tab's editor. 175 tests (with the real game); check_docs match; smoke + Linux
package PASS; docs/img main/browse/editor re-rendered on Linux (dubbing.png removed); e2e_editor uses a
temporary edit store.
- 2026-10-27 second UI round (user: preview player buttons scattered; no scrolling in the Edit tab; generic
terms, not dubbing ones): preview = details | player (Play original, Play installed, Stop together above the
waveforms) | Edit, Add to edit list. Effects panel = preset list + effect chain (QListWidget: tick = on, click
= settings) + one settings page (QStackedWidget); moving a slider turns its effect on. Editor compacted
(2-line subtitle, room counter in the button row, 44 px waveforms that grow with the window, tighter
spacing, edit list 250 px): no scroll bars at 1400 x 900 and 1280 x 800 (GUI test). Wording: replacement
(not take), effects (not voice effects), Subtitle (not script), Band-pass EQ; code too (EditStore
has/load/save_replacement, item field `source`, folder `edits/replacements`; earlier `take`/`takes` data
moved on load, tested; SubtitlePanel/SubtitleEdit). Theme: list check boxes styled like the others.
- 2026-10-27 version 3.4.0 (minor: Edit tab, effects, `ackit fx-template` / `replace --fx`): every Markdown
document checked (links, images, named files; REPORT.md rewritten for mission 4; check_docs gained effect
claims: presets, effect names, the pedalboard pin, the no-scroll window sizes); generated files removed
(listening WAVs, 3.3.0 builds, scratch survey data). `dist/UnheardCity-3.4.0-linux-x86_64.tar.gz` (124 MB)
smoke + GUI start PASS with PATH=/usr/bin:/bin; tested by the user.
Catalog: `FEChar_Batman_AKV8_SF.upk` was a mod the user once installed, not a game file (absent from the clean
Linux install); rebuilt from that install (`tools/build_catalog.py`, no sound or subtitle differs): its 124 bank
copies left BANK_locations.csv.gz (every sound in it is also in original packages: same 15,355 sounds, 93,702
copies in 1,498 files), docs figures updated.
Reverb (user: "no mix slider"): the level was "Amount" and never fully wet (dry 1 - wet/2) -> "Mix" (key `mix`,
2nd slider), equal-power dry/wet (0 = dry only, 1 = reverb only); `wet` of older settings is read as `mix`
(fx.RENAMED); presets scaled x0.73 to sound as before. Effect chain: the selected row is grey, so its red tick
shows. Final check: 177 tests (with the game), `ackit verify` PASS, `verify --catalog` OK, e2e_features INT
10/10, check_docs all match, 22 Markdown files without broken links, package smoke + GUI start PASS. Channel order
of the 3-5 ch WEMs = fmt channel mask order (0x37 = FL FR C SL SR, 0x33 quad): the centre holds the dry voice,
PA surrounds echo it 160-173 ms later (Harley/HQ 121 ms, Freeze rears 73 ms); Riddler/Ivy/Ra's 5 ch are
decorrelated per channel. Baked reverb is rare (blind RT60 < 0.2 s for 90 %). Listening samples:
`external/fx_samples/INDEX.md` (git-ignored; the WAVs were deleted with version 3.4.0, the user's notes kept). NEXT: user listens and names the groups; then the survey goes
into ackit/bnk.py + a tool, docs/formats/effects.md with check_docs claims, and the other 4 voice languages.
- OPEN: 19 speaker labels to check by listening.
## Environment facts ## Environment facts
- Game: Steam AppID 200260 "Batman: Arkham City GOTY", `C:\Program Files (x86)\Steam\steamapps\common\Batman Arkham - Game: Steam AppID 200260 "Batman: Arkham City GOTY", `C:\Program Files (x86)\Steam\steamapps\common\Batman Arkham
@@ -147,6 +263,14 @@
their third-party downloads were moved from `tools/`, `repositories/` to `external/` (git-ignored). their third-party downloads were moved from `tools/`, `repositories/` to `external/` (git-ignored).
- Ghidra project BAC: `.text` Ghidra address = runtime + 0xC00 (docs use runtime addresses). - Ghidra project BAC: `.text` Ghidra address = runtime + 0xC00 (docs use runtime addresses).
- Linux dev machine (2026-10-27): podman container (Debian 13, user `claude`, HOME=/home/claude, sudo) on an
Arch host. The game is the host's Steam Flatpak install, mounted read-write at
`/home/gora/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Batman Arkham City GOTY`
(only that folder: no libraryfolders.vdf, no Proton prefix). Steam discovery looks under HOME, so pass
`--game DIR` (CLI) / `ACKIT_TEST_GAME=DIR` (tests) and `--lang` (no UserEngine.ini; `Game.language()` -> INT).
Python 3.14.8 built from source into /usr (`/usr/bin/python3.14`, the host's path too); `.venv` regenerated
with it (`.venv/bin/python -m pytest -q`; GUI tests need `QT_QPA_PLATFORM=offscreen`). No in-game checks here.
## Where to look ## Where to look
- Findings, failed hypotheses, in-game experiment log E0-E14: `docs/reverse-engineering/findings.md`. - Findings, failed hypotheses, in-game experiment log E0-E14: `docs/reverse-engineering/findings.md`.
- Formats: `docs/formats/`. Decoder addresses: `docs/reverse-engineering/decoder.md`. - Formats: `docs/formats/`. Decoder addresses: `docs/reverse-engineering/decoder.md`.

View File

@@ -4,11 +4,14 @@ Replace any voice line, music track, ambience or sound effect in **Batman: Arkha
audio (by name, from a file or a recording) and edit subtitles and menu text, with one-click revert. No Wwise, no audio (by name, from a file or a recording) and edit subtitles and menu text, with one-click revert. No Wwise, no
game patch, no external programs. game patch, no external programs.
<img src="docs/img/main.png" alt="Unheard City, the editor" width="720">
- **Unheard City** (`app/`, [user guide](docs/user-guide.md)): the desktop editor for Windows and Linux. Browse and - **Unheard City** (`app/`, [user guide](docs/user-guide.md)): the desktop editor for Windows and Linux. Browse and
search all ~21,000 sounds of one language (its voice lines, music, ambience, and the effects stored inside search all ~21,000 sounds of one language (its voice lines, music, ambience, and the effects stored inside
soundbanks: footsteps, foley, combat, weapons, UI), filter by category and character, listen, replace or soundbanks: footsteps, foley, combat, weapons, UI), filter by category and character, listen; replace or
record, apply and revert. Edit subtitles (linked to their speech lines) and the menu text in their own record in the Edit tab (an edit list, the subtitle shown large, effects for radios, phones, speakers,
sections. One Language setting (default: the game's) applies to sounds, subtitles and menu text. announcements...), apply and revert. Edit subtitles (linked to their speech lines) and the menu text in their
own sections. One Language setting (default: the game's) applies to sounds, subtitles and menu text.
- **`ackit`** (`ackit/`): the Python library and command-line tool behind it. - **`ackit`** (`ackit/`): the Python library and command-line tool behind it.
How it works in one paragraph: the game's Wwise 2011.1 decoder only knows the standard Vorbis codebooks, so ackit How it works in one paragraph: the game's Wwise 2011.1 decoder only knows the standard Vorbis codebooks, so ackit
@@ -26,7 +29,7 @@ localization files. Details in `docs/`.
- Nothing else is needed (no Python, no Wwise, no ffmpeg). To build it yourself: [docs/building.md](docs/building.md). - Nothing else is needed (no Python, no Wwise, no ffmpeg). To build it yourself: [docs/building.md](docs/building.md).
- **Toolkit (developers):** Python 3.10+: - **Toolkit (developers):** Python 3.10+:
``` ```
python -m venv .venv && .venv/Scripts/activate # Linux: source .venv/bin/activate python -m venv .venv && .venv/Scripts/activate # Linux: source .venv/bin/activate
pip install -e .[app,dev,verify] # app = editor, dev = tests/packaging, verify = in-game test pip install -e .[app,dev,verify] # app = editor, dev = tests/packaging, verify = in-game test
python -m app.main # run the editor from source python -m app.main # run the editor from source
``` ```
@@ -36,13 +39,16 @@ localization files. Details in `docs/`.
ackit find joker tape # search by name, category or character ackit find joker tape # search by name, category or character
ackit extract Speech_TAPE_JOKER_1_04 orig.wav # listen to the original (a line has one name in every language) ackit extract Speech_TAPE_JOKER_1_04 orig.wav # listen to the original (a line has one name in every language)
ackit replace Speech_TAPE_JOKER_1_04 my_line.wav --match-loudness ackit replace Speech_TAPE_JOKER_1_04 my_line.wav --match-loudness
ackit revert Speech_TAPE_JOKER_1_04 # or: ackit revert --all ackit fx-template fx.json # effects, all off: set "on" for band-pass EQ, echo, reverb...
ackit text find --lang INT protocol # subtitles and menu texts (keys SUB:... / MENU:...) ackit replace Speech_TAPE_JOKER_1_04 my_line.wav --fx fx.json
ackit revert Speech_TAPE_JOKER_1_04 # or: ackit revert --all (every sound and text)
ackit text find --lang INT protocol # subtitles and menu texts (keys SUB:... / MENU:...; any of the
# 11 languages, e.g. --lang JPN)
ackit text set "MENU:INT:FRONTEND.int:Buttonpress:DoubleTap" "Tap twice" ackit text set "MENU:INT:FRONTEND.int:Buttonpress:DoubleTap" "Tap twice"
``` ```
More: `ackit status`, `ackit list --character Joker`, `ackit export-mod my.acmod` / `ackit import-mod`, More: `ackit status`, `ackit list --character Joker`, `ackit export-mod my.acmod` / `ackit import-mod`,
`ackit verify` (offline self-check), `ackit verify --catalog` (catalog vs game files), `ackit game` (detected `ackit verify` (offline self-check), `ackit verify --catalog` (catalog vs game files), `ackit game` (detected
install, language, store). `ackit -h` lists all commands. The game folder is found through Steam (Windows, Linux, Flatpak, Proton); otherwise pass `--game DIR`. install, language, store), `ackit restore-backup` (last resort if the originals store is damaged). `ackit -h` lists all commands. The game folder is found through Steam (Windows, Linux, Flatpak, Proton); otherwise pass `--game DIR`.
Rules worth knowing: speech/effects replacements must fit in the original's size (the tool squeezes or refuses Rules worth knowing: speech/effects replacements must fit in the original's size (the tool squeezes or refuses
with the maximum length); music can be any length. Subtitles must fit the original text's length (menu text: with the maximum length); music can be any length. Subtitles must fit the original text's length (menu text:

View File

@@ -1,15 +1,16 @@
# Report — full-game audio, subtitles and menu text (mission 3) # Report — full-game audio, subtitles and menu text (mission 3), Edit tab and effects (mission 4)
Brief: `docs/history/mission3_audio_text_prompt.md`. Earlier missions: the WAD/WEM toolkit (mission 1) and the Briefs: `docs/history/mission3_audio_text_prompt.md`, `docs/history/mission4_dubbing_plan.md` (plan, the effects
editor Unheard City (mission 2). found in the game, the user's decisions). Earlier missions: the WAD/WEM toolkit (mission 1) and the editor Unheard
City (mission 2).
## What works (verified) ## What works (verified)
- **Every sound of the game is cataloged and editable**: the speech WADs (5 languages x 10,816), the SFX WAD (651) - **Every sound of the game is cataloged and editable**: the speech WADs (5 languages x 10,816), the SFX WAD (651)
and, new, the **15,355 in-memory sounds** stored inside soundbanks (archive `BANK`: footsteps, foley, combat, and, new, the **15,355 in-memory sounds** stored inside soundbanks (archive `BANK`: footsteps, foley, combat,
weapons, UI, efforts; 8,092 shared by all languages, ~1,450 localized speech lines per voice language, 6 weapons, UI, efforts; 8,092 shared by all languages, ~1,450 localized speech lines per voice language, 6
shared by several) in 65 loose `.bnk` files and 975 `.upk` packages. ackit reads and patches the shared by several) in 65 loose `.bnk` files and 974 `.upk` packages. ackit reads and patches the
LZO-compressed UE3 packages itself (`ackit/upk.py`, `lzo.py`; no external tools). A replacement goes into every LZO-compressed UE3 packages itself (`ackit/upk.py`, `lzo.py`; no external tools). A replacement goes into every
copy (93,826 copies). 445 WAD voice recordings (89 lines) also have bank copies (396 prefetch heads, 49 copy (93,702 copies). 445 WAD voice recordings (89 lines) also have bank copies (396 prefetch heads, 49
whole copies; 2,185 copies), which are rewritten with them. whole copies; 2,185 copies), which are rewritten with them.
Names: 13,501 BANK sounds are named after the Wwise event that plays them (`Play_...`, `Speech_...`), the Names: 13,501 BANK sounds are named after the Wwise event that plays them (`Play_...`, `Speech_...`), the
other 1,854 after their bank. other 1,854 after their bank.
@@ -34,18 +35,37 @@ editor Unheard City (mission 2).
- **Menu text**: the localization files (`BmGame\Localization\<LANG>\*.int`, ~7,000 strings per language), - **Menu text**: the localization files (`BmGame\Localization\<LANG>\*.int`, ~7,000 strings per language),
edited value by value; everything else in the files stays byte-identical. edited value by value; everything else in the files stays byte-identical.
- **Editor**: one *Language* setting (default: the game's language) for voices, subtitles and menu text - **Editor**: one *Language* setting (default: the game's language) for voices, subtitles and menu text
(languages without voices use the INT ones, like the game); tabs *Sounds* / *Subtitles* / *Menu text*. The sound (languages without voices use the INT ones, like the game); tabs *Sounds* / *Edit* / *Subtitles* / *Menu text*.
editor shows Listen / Replace / Subtitle side by side (resizable columns, no scrolling at usual window sizes); *Sounds* only browses (filters, list, a player with Play original / Play installed / Stop, Edit / Add to edit
the subtitle list links back to the sound. Search, filters (character, list). *Edit* is where every kind of sound is replaced: an edit list (Previous / Next) whose sounds keep their
category, file, modified), room counter, apply/revert/revert all, find & replace on the filtered rows, mod replacement audio, trim/level values and effects in the user data folder until applied; the subtitle shown
export/import with texts, changed-outside banner for sounds and texts. large with the original (and English) line; original / installed / replacement waveforms; record or load;
the effects. No scrolling at 1400 x 900 or 1280 x 800. The subtitle list links back to the sound. Search,
filters (character, category, file, modified), room counter, apply/revert/revert all, find & replace on the
filtered rows, mod export/import with texts, changed-outside banner for sounds and texts.
- **Effects** (mission 4, `ackit/fx.py`, pedalboard + numpy): the effects found in the game's sounds (metadata
survey of every bank + signal analysis of every INT speech recording + the user listening to sample groups):
pitch / formant / pitch drift, octave layers, ring modulator and comb, glitch repeats, band-pass EQ,
distortion and phone codec, noise, echo, reverb (a generated room response) with reverse swells, and the
channel layout of 2-5 channel sounds (WAVE mask order; the PA lines' dry centre and delayed surrounds).
Presets per effect and whole presets (Radio, Phone, PA announcement, Mr. Freeze, ...); set by hand in the
Edit tab, or `ackit replace --fx` on the command line. Effects the game adds while it plays (the McDSP
FutzBox radio sound of many thug and street lines, room reverbs) are not in the files and must not be added
again.
- **Safety**: every changed file (WAD, bank, package, localization file) is rebuilt from a verified original copy - **Safety**: every changed file (WAD, bank, package, localization file) is rebuilt from a verified original copy
in the store + the edits applied now; reverting the last edit restores it byte-for-byte; files changed by in the store + the edits applied now; reverting the last edit restores it byte-for-byte; files changed by
another tool or Steam are detected (whole-file SHA-1) and not overwritten without confirmation. another tool or Steam are detected (whole-file SHA-1) and not overwritten without confirmation (also when
- **Tests**: 135 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a installing a mod in the editor). The original copy is taken only from a file that matches the catalog
(another tool's or another originals store's change is a conflict; checked on all 1,446 files with sounds or
subtitles: no false alarm), and the editor does not switch stores while changes are applied. Writes are
serialized, so a sound and its subtitle applied at the same time both reach the package. **Revert all**
(editor and `ackit revert --all`) reverts sounds and texts; `ackit restore-backup` is the last resort for a
damaged store.
- **Tests**: 177 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a
two-language game for the lines (incl. a recording also stored whole in a bank), the shipped catalog's cross-references, real-game read-only (every two-language game for the lines (incl. a recording also stored whole in a bank), the shipped catalog's cross-references, real-game read-only (every
name of every language's catalog resolves to its own sound), offscreen GUI incl. list order, selection and name of every language's catalog resolves to its own sound), offscreen GUI incl. list order, selection,
loading overlays) pass. `ackit verify` PASS (incl. loading overlays and the Edit tab (edit list, saved replacements, effects panel, no scrolling), every effect
measured back on synthetic signals) pass. `ackit verify` PASS (incl.
300/300 in-memory WEMs rebuilt byte-identically). 300/300 in-memory WEMs rebuilt byte-identically).
- **Real game** (2026-10-10), all reverting and checking byte-identity themselves: - **Real game** (2026-10-10), all reverting and checking byte-identity themselves:
`tools/e2e_features.py --lang all` 89/89 PASS in all 11 languages (every copy of an in-memory sound, each `tools/e2e_features.py --lang all` 89/89 PASS in all 11 languages (every copy of an in-memory sound, each
@@ -54,13 +74,19 @@ editor Unheard City (mission 2).
position); `ackit verify --ingame` PASS (music heard, ncc 1.0); position); `ackit verify --ingame` PASS (music heard, ncc 1.0);
`--banks` PASS (77 in-memory sounds of the front-end packages replaced in 54 files: the game loads them, 71 `--banks` PASS (77 in-memory sounds of the front-end packages replaced in 54 files: the game loads them, 71
probes found in its memory, no crash); `--texts` PASS (12/12 menu texts and 23/23 subtitles of Startup.upk probes found in its memory, no crash); `--texts` PASS (12/12 menu texts and 23/23 subtitles of Startup.upk
found in the game's memory, no crash); `tools/e2e_editor.py` PASS. Game files original at the end found in the game's memory, no crash); `tools/e2e_editor.py` PASS. 2026-10-27 (Linux, the game's Flatpak
(`ackit status`). The catalog builder refuses to run on a modified game, so the catalog always describes the install): `tools/e2e_features.py --lang INT` 10/10 PASS, incl. a 5-channel line replaced with effects (EQ,
echo, PA layout), decoded back from the game file (dry centre, surround echo 0.160 s later) and reverted
byte-identically. Game files original at the end (`ackit status`). The catalog builder refuses to run on a modified game, so the catalog always describes the
original files. original files.
- **Package**: `dist/UnheardCity-3.2.3-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to - **Package**: `dist/UnheardCity-3.3.0-windows-amd64.zip` (86 MB, catalogs included; built 2026-10-13). With `PATH` reduced to
the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC: the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC:
21,015 sounds loaded, encode + inject, 9,377 subtitles and 7,020 menu texts loaded, an in-memory sound decoded from a package) and the GUI starts and 21,015 sounds loaded, encode + inject, 9,377 subtitles and 7,020 menu texts loaded, an in-memory sound decoded from a package) and the GUI starts and
quits cleanly. The installer is unchanged since mission 2 (install/upgrade/uninstall verified then). quits cleanly. The installer is unchanged since mission 2 (install/upgrade/uninstall verified then); the
uninstaller now also counts text edits before offering to delete the user data (2026-10-13; that count was
tested, the full uninstall cycle was not run again). Version 3.4.0: the Linux package
(`dist/UnheardCity-3.4.0-linux-x86_64.tar.gz`, built 2026-10-27, the first Linux build) passes the same smoke
test (incl. the effects) with `PATH=/usr/bin:/bin`, and the GUI starts.
## Unresolved / limitations ## Unresolved / limitations
- **Checked by a person in game** (2026-10-10), not automatically (nothing of this plays or shows on the title - **Checked by a person in game** (2026-10-10), not automatically (nothing of this plays or shows on the title
@@ -86,14 +112,16 @@ editor Unheard City (mission 2).
`<Bank>_<n>`. `<Bank>_<n>`.
- 25 SFX originals (music seek tables and sample counts, looping ambience) are not reproduced byte-for-byte by - 25 SFX originals (music seek tables and sample counts, looping ambience) are not reproduced byte-for-byte by
the WEM rules (`docs/formats/wem.md`); writing is not affected (replaced music plays in game). the WEM rules (`docs/formats/wem.md`); writing is not affected (replaced music plays in game).
- Two copies of media `1C898731` differ; its checksum is left empty. Channel order for 4-6 channel sounds is - Two copies of media `1C898731` differ; its checksum is left empty. Channel order of 3-6 channel sounds:
unverified. M4A/AAC/WMA input needs an ffmpeg on the system. the WEMs carry WAVE channel masks and ackit keeps that order (mission 4). M4A/AAC/WMA input needs an ffmpeg
on the system.
- **Effects are approximations** set by ear; the presets are starting points. The Edit tab does not mark the
lines the game itself adds an effect to (known from the bank survey, not in the catalog).
- Another game build is scanned at first start (WADs: seconds; banks, lines and subtitles: a few minutes, - Another game build is scanned at first start (WADs: seconds; banks, lines and subtitles: a few minutes,
cached); it gets the same line identities, but no original checksums. cached); it gets the same line identities, but no original checksums.
## Next steps ## Next steps
1. Optional: lift the subtitle length limit by relocating dialogue objects (checked in game by a person). 1. Optional: lift the subtitle length limit by relocating dialogue objects (checked in game by a person).
2. Build and smoke-test the Linux package once on a Linux machine. 2. Listen to the 19 lines whose speaker label differs from the game's dialogue speaker field
3. Listen to the 19 lines whose speaker label differs from the game's dialogue speaker field
(`docs/categories.md`) and fix the curated lists if needed; extend them (`data/catalog/source/<lang>/*.txt`) (`docs/categories.md`) and fix the curated lists if needed; extend them (`data/catalog/source/<lang>/*.txt`)
and rebuild the catalog. and rebuild the catalog.

View File

@@ -3,9 +3,9 @@
Library layers (low -> high): Library layers (low -> high):
formats wad, pack, bnk (soundbanks), upk + lzo (UE3 packages), wem, ogg, bits, codebooks, wwvorbis, formats wad, pack, bnk (soundbanks), upk + lzo (UE3 packages), wem, ogg, bits, codebooks, wwvorbis,
allocsize, builder, convert, known_setups allocsize, builder, convert, known_setups
audio audio (load/convert/decode), encoder (PCM -> game WEM) audio audio (load/convert/decode), encoder (PCM -> game WEM), fx (effects for replacements)
game paths, game (install discovery), catalog + classify (names/categories/characters), game paths, game (install discovery), catalog + classify (names/categories/characters),
inject (WAD writing), originals (store), workspace (apply/revert), modpack (share) inject (WAD writing), originals (store), workspace (apply/revert), modpack (share)
tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows) tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows)
""" """
__version__ = "3.2.3" __version__ = "3.4.0"

View File

@@ -53,21 +53,29 @@ def resample(x, rate, new_rate):
def to_channels(x, channels): def to_channels(x, channels):
"""Up/down-mix. Mono is copied to every channel; extra channels get the average.""" """Up/down-mix. Up: mono is copied to every channel, other sources keep their channels and the new ones get
the average. Down: nothing is dropped - every channel goes into the mono sum, or the channels beyond the
kept ones (e.g. the centre and surrounds of 5.1) are added to every kept one at -3 dB (the standard
fold-down); a result louder than the source's peak is scaled back to it, so it does not clip."""
x = _as2d(x) x = _as2d(x)
cur = x.shape[1] cur = x.shape[1]
if cur == channels: if cur == channels:
return x return x
if channels == 1:
return x.mean(axis=1, keepdims=True)
if cur == 1: if cur == 1:
return np.repeat(x, channels, axis=1) return np.repeat(x, channels, axis=1)
out = np.empty((len(x), channels), dtype=np.float32) if channels > cur:
keep = min(cur, channels) out = np.empty((len(x), channels), dtype=np.float32)
out[:, :keep] = x[:, :keep] out[:, :cur] = x
if channels > keep: out[:, cur:] = x.mean(axis=1, keepdims=True)
out[:, keep:] = x.mean(axis=1, keepdims=True) return out
return out if channels == 1:
out = x.sum(axis=1, keepdims=True)
else:
out = x[:, :channels] + 0.7071 * x[:, channels:].sum(axis=1, keepdims=True)
peak_in, peak_out = float(np.max(np.abs(x), initial=0.0)), float(np.max(np.abs(out), initial=0.0))
if peak_out > peak_in > 0:
out = out * (peak_in / peak_out)
return out.astype(np.float32)
def trim(x, rate, start=None, end=None): def trim(x, rate, start=None, end=None):

View File

@@ -4,10 +4,12 @@ in the .upk packages (docs/formats/banks.md, upk.md). Catalog archive `BANK`, ta
A media ID can be stored many times (the same bank copied into several packages, or several banks A media ID can be stored many times (the same bank copied into several packages, or several banks
holding the same media). The location index (data/catalog/BANK_locations.csv.gz) lists every copy: holding the same media). The location index (data/catalog/BANK_locations.csv.gz) lists every copy:
media, file (relative to CookedPCConsole, '/'-separated), offset (in the *uncompressed* file), size, kind media, file (relative to CookedPCConsole, '/'-separated), offset (in the *uncompressed* file), size, kind
kind 'm' = an in-memory sound, 'p' = the in-memory head of a streamed WAD sound (prefetch); for 'p' rows kind 'm' = an in-memory sound, 'p' = a bank copy of a streamed WAD recording (the in-memory head of a prefetched
`sound` is the WAD sound's key (e.g. ITA:2B14E472:3): speech lines have per-language media IDs and heads. sound, or a whole copy with the same media ID); for 'p' rows `sound` is the WAD sound's key (e.g. ITA:2B14E472:3):
Replacements are written in place into every copy (<= original size, zero-padded), so bank and package speech lines have per-language media IDs and heads.
layouts never change. Replacements are written in place into every copy, no larger than the smallest; a whole in-memory WEM fills its
slot exactly (wem.fill: the game plays a zero-padded one as silence), a head gets the start of the new
recording. So bank and package layouts never change.
""" """
import csv import csv
import gzip import gzip
@@ -157,7 +159,7 @@ class Scan:
self.actions = {} # id -> target id self.actions = {} # id -> target id
self.sound_media = defaultdict(set) # sound object id -> media ids self.sound_media = defaultdict(set) # sound object id -> media ids
self.prefetch = set() # media ids whose bank copies belong to a WAD recording (prefetch self.prefetch = set() # media ids whose bank copies belong to a WAD recording (prefetch
# heads; whole recordings: catalog._whole_recordings) # heads; whole recordings: catalog.whole_recordings)
self.lang_media = defaultdict(set) # language -> media ids named by Sound objects of that language's banks self.lang_media = defaultdict(set) # language -> media ids named by Sound objects of that language's banks
# BKPK tables: WAD entry id -> {language: {media id: (offset in entry, size)}}. A table belongs to the # BKPK tables: WAD entry id -> {language: {media id: (offset in entry, size)}}. A table belongs to the
# language its localized AkBank export declares ('' = outside such an export). The same media ID can be # language its localized AkBank export declares ('' = outside such an export). The same media ID can be

View File

@@ -1,10 +1,10 @@
"""Index of every sound in the game's WAD archives, with names, categories and characters """Index of every sound of the game, with names, categories and characters: the WAD archives (one per voice
(sounds stored inside soundbanks are not indexed yet: docs/formats/banks.md). language, SFX) and the in-memory sounds stored inside soundbanks (archive BANK, docs/formats/banks.md).
Sources, in order: Sources, in order:
1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the WAD fingerprint matches; 1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the game's fingerprint matches;
2. otherwise a header scan of the WAD (a few seconds, cached in the user cache), classified with 2. otherwise a scan of the game (WAD headers: seconds; banks: minutes; cached in the user cache), classified
`classify` and labelled with data/catalog/characters.csv (speaker per sound name). with `classify` and labelled with data/catalog/characters.csv (speaker per sound name).
Names come from the game: a WAD entry ID is the Wwise ID (FNV-1) of a soundbank listed in the game's Names come from the game: a WAD entry ID is the Wwise ID (FNV-1) of a soundbank listed in the game's
CookedPCConsole/CookedBanks.txt (`bank_names`); an entry with one WEM is NAME CookedPCConsole/CookedBanks.txt (`bank_names`); an entry with one WEM is NAME
(alias NAME_1), entries with n variations are NAME_1..NAME_n (alias NAME#i); stream-pack media are (alias NAME_1), entries with n variations are NAME_1..NAME_n (alias NAME#i); stream-pack media are
@@ -517,14 +517,16 @@ class Catalog:
def __init__(self, game, archives=None, progress=None): def __init__(self, game, archives=None, progress=None):
self.game = game self.game = game
avail = available_archives(game) avail = available_archives(game)
self.archives = archives or [a for a in (game.language(), "SFX", banks.ARCHIVE) if a in avail] self.archives = archives or [a for a in (game.voice_archive(game.language()), "SFX", banks.ARCHIVE) if a in avail]
self.sounds = [] self.sounds = []
self.locations = {} # BANK media id -> [Location] self.locations = {} # BANK media id -> [Location]
self.heads = {} # WAD sound key -> [Location] of its prefetch heads self.heads = {} # WAD sound key -> [Location] of its prefetch heads
self.bank_sha1 = {} # BANK media id -> original checksum ('' if unknown), every language
bank_sounds_ = [] bank_sounds_ = []
if banks.ARCHIVE in avail: if banks.ARCHIVE in avail:
# always loaded: speech lines may have prefetch heads that must be written with them # always loaded: speech lines may have prefetch heads that must be written with them
bank_sounds_, self.locations = load_banks(game, progress) bank_sounds_, self.locations = load_banks(game, progress)
self.bank_sha1 = {int(s.target, 16): s.sha1 for s in bank_sounds_}
for ls in self.locations.values(): for ls in self.locations.values():
for l in ls: for l in ls:
if l.kind == banks.PREFETCH_HEAD and l.sound: if l.kind == banks.PREFETCH_HEAD and l.sound:

View File

@@ -1,17 +1,20 @@
"""ackit - Batman: Arkham City audio toolkit (command line). """ackit - Batman: Arkham City audio toolkit (command line).
Everyday use, by sound name (see `ackit find`): Everyday use, by sound name (see `ackit find`):
ackit find joker tape search names, categories, characters ackit find joker tape search names, categories, characters
ackit info Speech_TAPE_JOKER_1_4 details, size limit, modified state ackit info Speech_TAPE_JOKER_1_04 details, size limit, modified state
ackit extract Speech_TAPE_JOKER_1_4 orig.wav save the original (or --current) ackit extract Speech_TAPE_JOKER_1_04 orig.wav save the original (or --current)
ackit replace Speech_TAPE_JOKER_1_4 line.wav encode + install a replacement ackit replace Speech_TAPE_JOKER_1_04 line.wav encode + install a replacement
ackit revert Speech_TAPE_JOKER_1_4 put the original back (--all for everything) ackit revert Speech_TAPE_JOKER_1_04 put the original back (--all: every sound and text)
ackit status what you have changed ackit status what you have changed
ackit text find --lang JPN batman subtitles and menu text (any of the 11 languages)
ackit export-mod my.acmod / import-mod my.acmod share your changes 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. The game folder is detected from Steam (Windows, Linux, Proton); override with --game DIR. --lang picks the
language (default: the game's); languages without their own voices use the INT voice lines, like the game.
""" """
import argparse import argparse
import json
import os import os
import sys import sys
@@ -36,7 +39,7 @@ def _workspace(a, archives=None):
st = settings.load() st = settings.load()
game = _game(a) game = _game(a)
if archives is None and getattr(a, "lang", None): if archives is None and getattr(a, "lang", None):
archives = [a.lang, "SFX", "BANK"] archives = [game.voice_archive(a.lang), "SFX", "BANK"]
return Workspace(game, a.store or st.get("store_root"), archives, 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), full_backup=not getattr(a, "no_full_backup", False) and st.get("full_backup", True),
progress=lambda n, t: None) progress=lambda n, t: None)
@@ -73,7 +76,8 @@ def cmd_list(a):
c = a.character.lower() 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())] 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: if a.modified:
rows = [s for s in rows if s.key in ws.modified_keys()] mod = set(ws.modified_keys())
rows = [s for s in rows if s.key in mod]
if a.csv: if a.csv:
from .catalog import write_csv from .catalog import write_csv
write_csv(a.csv, rows) write_csv(a.csv, rows)
@@ -121,6 +125,9 @@ def _process(a, x, sr, ws, s):
x = audio.trim(x, sr, float(start or 0), float(end) if end else None) x = audio.trim(x, sr, float(start or 0), float(end) if end else None)
if a.trim_silence: if a.trim_silence:
x = audio.trim_silence(x, rate=sr) x = audio.trim_silence(x, rate=sr)
if a.fx:
from . import fx
x = fx.render(x, sr, json.load(open(a.fx, encoding="utf-8")), s.channels)
if a.match_loudness: if a.match_loudness:
ref, _ = ws.original_audio(s) ref, _ = ws.original_audio(s)
x = audio.match_loudness(x, ref) x = audio.match_loudness(x, ref)
@@ -131,6 +138,14 @@ def _process(a, x, sr, ws, s):
return x return x
def cmd_fx_template(a):
from . import fx
with open(a.out, "w", encoding="utf-8") as f:
json.dump(fx.defaults(), f, indent=1)
print("wrote %s: set \"on\": true on the effects you want (blocks: %s)"
% (a.out, ", ".join(b.key for b in fx.BLOCKS)))
def cmd_replace(a): def cmd_replace(a):
ws = _workspace(a) ws = _workspace(a)
s = ws.sound(a.name) s = ws.sound(a.name)
@@ -178,6 +193,23 @@ def cmd_revert(a):
print("reverted %d sound(s)" % len(done)) print("reverted %d sound(s)" % len(done))
def cmd_restore_backup(a):
ws = _workspace(a)
full = sorted(ws.store.data.get("full", {}))
if not full:
print("no full copies in this store (%s)" % ws.store.root)
return
if not a.yes:
print("would copy back (or rebuild) %d file(s) from the full copies in %s:" % (len(full), ws.store.root))
for rel in full:
print(" " + rel)
print("Sounds and texts in these files are forgotten; sounds of WADs without a full copy stay recorded.\n"
"Use this only if the originals store is damaged (normally: ackit revert --all). Run again with --yes.")
return
done = ws.restore_from_full_backup(progress=print)
print("restored %d file(s); %d sound(s) still recorded" % (len(done), len(ws.store.entries)))
def cmd_status(a): def cmd_status(a):
ws = _workspace(a) ws = _workspace(a)
keys = ws.modified_keys() keys = ws.modified_keys()
@@ -286,7 +318,7 @@ def cmd_game(a):
from .workspace import default_store_root from .workspace import default_store_root
print("game folder: ", g.root) print("game folder: ", g.root)
print("archives: ", ", ".join("%s=%s" % (k, g.rel(v)) for k, v in g.archives.items())) print("archives: ", ", ".join("%s=%s" % (k, g.rel(v)) for k, v in g.archives.items()))
print("speech language: ", g.language()) print("language: ", "%s (voices: %s)" % (g.language(), g.voice_archive(g.language())))
print("originals store: ", a.store or st.get("store_root") or default_store_root(g)) print("originals store: ", a.store or st.get("store_root") or default_store_root(g))
print("settings file: ", settings.path()) print("settings file: ", settings.path())
@@ -311,7 +343,7 @@ def cmd_verify(a):
ok = selfcheck.run(ws, log=print) ok = selfcheck.run(ws, log=print)
sys.exit(0 if ok else 1) sys.exit(0 if ok else 1)
from .verify import ingame from .verify import ingame
if a.lang and a.lang != ws.game.language(): if a.lang and a.lang != ws.game.language(): # voices and texts follow the game's setting
raise GameError("the game plays its own language (%s); set it in Steam (game properties > Language) to " raise GameError("the game plays its own language (%s); set it in Steam (game properties > Language) to "
"test %s in the game" % (ws.game.language(), a.lang)) "test %s in the game" % (ws.game.language(), a.lang))
if a.banks: if a.banks:
@@ -344,6 +376,7 @@ def build_parser():
proc = argparse.ArgumentParser(add_help=False) 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", 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("--trim-silence", action="store_true", help="cut leading/trailing silence")
proc.add_argument("--fx", metavar="FILE", help="effects settings (JSON, see `ackit fx-template`)")
proc.add_argument("--gain", type=float, metavar="DB", help="volume change in dB") 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("--normalize", action="store_true", help="peak-normalize to -1 dBFS")
proc.add_argument("--match-loudness", action="store_true", help="match the original's loudness") proc.add_argument("--match-loudness", action="store_true", help="match the original's loudness")
@@ -362,6 +395,9 @@ def build_parser():
s.add_argument("--group"); s.add_argument("--category"); s.add_argument("--character") 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("--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_argument("--modified", action="store_true"); s.add_argument("--csv", metavar="FILE")
s = add("fx-template", cmd_fx_template, "write effects settings with every effect off (edit, then "
"use with replace --fx)")
s.add_argument("out")
s = add("info", cmd_info, "show details of a sound (or of a .wem file)") s = add("info", cmd_info, "show details of a sound (or of a .wem file)")
s.add_argument("name") s.add_argument("name")
s = add("extract", cmd_extract, "save a sound as .wav (decoded) or .wem") s = add("extract", cmd_extract, "save a sound as .wav (decoded) or .wem")
@@ -371,9 +407,12 @@ def build_parser():
s.add_argument("name"); s.add_argument("audio") 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("apply", cmd_apply, "replace every sound named by the files in a folder", (enc,))
s.add_argument("folder") s.add_argument("folder")
s = add("revert", cmd_revert, "put original sounds back", aliases=["restore"]) s = add("revert", cmd_revert, "put original sounds back (--all: every sound and text)", aliases=["restore"])
s.add_argument("names", nargs="*"); s.add_argument("--all", action="store_true") s.add_argument("names", nargs="*"); s.add_argument("--all", action="store_true")
add("status", cmd_status, "list the sounds you modified") add("status", cmd_status, "list the sounds and texts you modified")
s = add("restore-backup", cmd_restore_backup, "last resort if the originals store is damaged: put back the "
"full file copies it keeps (lists them; --yes to do it)")
s.add_argument("--yes", action="store_true")
s = add("text", cmd_text, "subtitles and menu text: find WORDS | show KEY | set KEY TEXT | revert KEY/--all") 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("action", choices=["find", "show", "set", "revert"])
s.add_argument("args", nargs="*") s.add_argument("args", nargs="*")

508
ackit/fx.py Normal file
View File

@@ -0,0 +1,508 @@
"""Audio effects for replacements (any sound: voices, effects, music), set by hand. The blocks cover the effects
found in the game's sounds (docs/history/mission4_dubbing_plan.md sections 9-10).
`BLOCKS` is the single description of every effect block and its parameters (the editor builds its controls from
it). Settings are plain dicts {block key: {"on": bool, param key: value}} (JSON-safe). `render` applies the blocks
that are on, in the order of `BLOCKS`; random parts (glitches, reverse reverb, noise) use a fixed seed, so the same
settings always give the same audio. Audio is float32 (frames, channels) like `ackit.audio`; effects can make it
longer (glitch repeats, echo and reverb tails).
Built on pedalboard (GPL-3.0): filters, distortion, delay, reverb, codec and Rubber Band pitch/formant shifting;
the rest (ring modulator, glitches, noise, reverse reverb, channel layout) is numpy.
"""
from collections import namedtuple
import numpy as np
Param = namedtuple("Param", "key label kind lo hi default step unit tip", defaults=("", ""))
Block = namedtuple("Block", "key label tip params", defaults=((),))
BLOCKS = (
Block("pitch", "Pitch", "Pitch shifter (moves the sound and its formants together, like the game's Clayface), "
"optional formant shift and a pitch that drifts over time (Mr. Freeze).", (
Param("shift", "Shift", "float", -24, 24, 0.0, 0.5, "st", "Pitch shift in semitones"),
Param("formant", "Formant", "float", -12, 12, 0.0, 0.5, "st",
"Moves only the formants (voice size/character), the pitch stays"),
Param("drift", "Drift", "float", 0, 12, 0.0, 0.1, "st", "Pitch over time: depth of the slow swing"),
Param("drift_rate", "Drift rate", "float", 0.05, 5, 0.3, 0.05, "Hz", "Speed of the swing"))),
Block("octaves", "Octave layers", "Pitch-shifted copies of the sound mixed under it (Ra's al Ghul, Poison Ivy).", (
Param("interval1", "Layer 1", "float", -24, 12, -12.0, 1, "st", "Interval of the first layer"),
Param("level1", "Level 1", "float", -40, 6, -6.0, 0.5, "dB", "Level of the first layer"),
Param("interval2", "Layer 2", "float", -24, 12, -24.0, 1, "st", "Interval of the second layer"),
Param("level2", "Level 2", "float", -60, 6, -60.0, 0.5, "dB", "Level of the second layer (-60 = off)"))),
Block("robot", "Robot", "Ring modulator and metallic comb (Mr. Freeze's suit).", (
Param("ring_freq", "Ring freq", "float", 10, 1000, 80.0, 1, "Hz", "Ring modulator carrier frequency"),
Param("ring_mix", "Ring mix", "float", 0, 1, 0.5, 0.05, "", "0 = dry, 1 = fully modulated"),
Param("comb_ms", "Comb", "float", 0, 20, 0.0, 0.1, "ms", "Metallic comb delay (0 = off)"),
Param("comb_feedback", "Comb feedback", "float", 0, 0.95, 0.6, 0.05, "", "Ringing of the comb"))),
Block("glitch", "Glitch", "Short pieces of the sound repeated right after themselves, optionally at another "
"pitch (the Riddler's hacked broadcasts). Makes the line longer.", (
Param("rate", "Events", "float", 0, 20, 3.0, 0.5, "/10 s", "How many glitches per 10 seconds of audio"),
Param("length", "Length", "float", 20, 400, 120.0, 5, "ms", "Length of the repeated piece"),
Param("repeats", "Repeats", "int", 1, 6, 2, 1, "", "How many times a piece repeats"),
Param("pitch", "Pitch", "float", -12, 12, 5.0, 0.5, "st", "Pitch of the repeats (0 = same)"),
Param("seed", "Variation", "int", 0, 999, 1, 1, "", "Another number = glitches at other places"))),
Block("eq", "Band-pass EQ", "Low and high cut with a mid boost: radio, phone, phone microphone, TV speaker, "
"small speakers.", (
Param("low_cut", "Low cut", "float", 20, 2000, 300.0, 10, "Hz", "Frequencies below are removed"),
Param("high_cut", "High cut", "float", 500, 20000, 3400.0, 50, "Hz", "Frequencies above are removed"),
Param("slope", "Slope", "int", 6, 48, 24, 6, "dB/oct", "How steeply the cuts fall"),
Param("mid_freq", "Mid freq", "float", 200, 5000, 1500.0, 10, "Hz", "Centre of the boost"),
Param("mid_gain", "Mid gain", "float", -12, 18, 6.0, 0.5, "dB", "Boost (or cut) around the centre"),
Param("mid_q", "Mid Q", "float", 0.3, 5, 1.0, 0.1, "", "Width of the boost (higher = narrower)"))),
Block("distortion", "Distortion", "Speaker/radio distortion, clipping, low bit depth, phone codec.", (
Param("drive", "Drive", "float", 0, 40, 10.0, 0.5, "dB", "Soft saturation (0 = off)"),
Param("clip", "Clip", "float", -30, 0, 0.0, 0.5, "dB", "Hard clipping threshold (0 = off)"),
Param("bits", "Bits", "int", 4, 16, 16, 1, "", "Bit depth (16 = off)"),
Param("gsm", "Phone codec", "bool", 0, 1, False, 1, "", "GSM mobile phone codec"))),
Block("noise", "Noise", "Radio static or hiss under the sound.", (
Param("level", "Level", "float", -60, 0, -30.0, 0.5, "dB", "Level relative to the sound"),
Param("colour", "Colour", "float", 200, 8000, 2000.0, 50, "Hz", "Centre of the noise band"),
Param("crackle", "Crackle", "float", 0, 1, 0.0, 0.05, "", "Random clicks and pops"))),
Block("echo", "Echo", "Delay (PA announcements: about 120-170 ms; very short times widen the sound).", (
Param("time", "Time", "float", 1, 1000, 160.0, 1, "ms", "Delay time"),
Param("feedback", "Feedback", "float", 0, 0.9, 0.3, 0.05, "", "How many repeats"),
Param("mix", "Mix", "float", 0, 1, 0.35, 0.05, "", "Level of the echo"))),
Block("reverb", "Reverb", "Room reverb (a generated room response: early reflections and a tail that gets "
"darker as it fades), and an occasional reverse swell before phrases (Riddler, Ivy).", (
Param("decay", "Decay", "float", 0.1, 6, 0.6, 0.05, "s", "Reverb time (RT60): small room 0.3, "
"hall 2, church 4"),
Param("mix", "Mix", "float", 0, 1, 0.25, 0.05, "", "Dry sound against reverb: 0 = dry only, "
"1 = reverb only"),
Param("predelay", "Pre-delay", "float", 0, 150, 10.0, 1, "ms", "Gap before the reverb starts "
"(bigger spaces: longer)"),
Param("early", "Reflections", "float", 0, 1, 0.5, 0.05, "", "Early echoes from nearby walls"),
Param("damping", "Damping", "float", 0, 1, 0.5, 0.05, "", "How much darker the tail gets "
"(soft rooms high, tiled rooms low)"),
Param("width", "Width", "float", 0, 1, 1.0, 0.05, "", "Different reverb per channel (2+ channels)"),
Param("reverse", "Reverse", "float", 0, 1, 0.0, 0.05, "", "Level of the reverse swells (0 = off)"),
Param("reverse_every", "Reverse often", "float", 0, 1, 0.3, 0.05, "",
"Share of the phrases that get a reverse swell"),
Param("seed", "Variation", "int", 0, 999, 1, 1, "", "Another number = swells on other phrases"))),
Block("channels", "Channels", "Only for sounds with 2-5 channels (L R C SL SR): stereo width, pan over time, "
"the PA layout (dry sound in the centre, echo in the surrounds).", (
Param("width", "Width", "float", 0, 30, 0.0, 0.5, "ms", "Delay of the right side (0 = off)"),
Param("pan_rate", "Pan rate", "float", 0, 2, 0.0, 0.05, "Hz", "Pan over time (0 = off)"),
Param("pan_depth", "Pan depth", "float", 0, 1, 0.5, 0.05, "", "How far the sound moves"),
Param("front", "Front L/R", "float", -60, 0, -12.0, 0.5, "dB", "Front L/R level when there is a centre"),
Param("surround_delay", "Surround delay", "float", 0, 500, 160.0, 1, "ms",
"Echo delay of the surrounds (4-5 channels)"),
Param("surround", "Surround", "float", -60, 0, -9.0, 0.5, "dB", "Level of the surrounds"))),
)
BY_KEY = {b.key: b for b in BLOCKS}
# Starting points (values set in place; the user tunes them by ear). Per block: {block: [(name, values)]}.
PRESETS = {
"pitch": [("Down (Clayface)", {"shift": -5.0, "formant": 0.0, "drift": 0.0}),
("Deep, same voice", {"shift": -3.0, "formant": 2.0, "drift": 0.0}),
("Up", {"shift": 4.0, "formant": 0.0, "drift": 0.0}),
("Drift (Mr. Freeze)", {"shift": 0.0, "formant": 0.0, "drift": 1.5, "drift_rate": 0.35})],
"octaves": [("One octave down", {"interval1": -12.0, "level1": -4.0, "level2": -60.0}),
("Giant (two down)", {"interval1": -12.0, "level1": -3.0, "interval2": -24.0, "level2": -9.0}),
("Fifth down", {"interval1": -7.0, "level1": -8.0, "level2": -60.0})],
"robot": [("Suit (Mr. Freeze)", {"ring_freq": 60.0, "ring_mix": 0.3, "comb_ms": 4.0, "comb_feedback": 0.4}),
("Metallic", {"ring_freq": 30.0, "ring_mix": 0.1, "comb_ms": 9.0, "comb_feedback": 0.7}),
("Dalek", {"ring_freq": 30.0, "ring_mix": 1.0, "comb_ms": 0.0})],
"glitch": [("Light", {"rate": 2.0, "length": 90.0, "repeats": 1, "pitch": 0.0}),
("Hacked (Riddler)", {"rate": 4.0, "length": 120.0, "repeats": 2, "pitch": 5.0}),
("Heavy", {"rate": 8.0, "length": 70.0, "repeats": 3, "pitch": -7.0})],
"eq": [("Radio", {"low_cut": 400.0, "high_cut": 3200.0, "slope": 24, "mid_freq": 1800.0, "mid_gain": 6.0,
"mid_q": 1.0}),
("Phone", {"low_cut": 300.0, "high_cut": 3400.0, "slope": 36, "mid_freq": 1500.0, "mid_gain": 4.0,
"mid_q": 0.8}),
("Phone mic (tape)", {"low_cut": 200.0, "high_cut": 5000.0, "slope": 18, "mid_freq": 2500.0,
"mid_gain": 5.0, "mid_q": 0.7}),
("TV speaker", {"low_cut": 250.0, "high_cut": 6000.0, "slope": 18, "mid_freq": 1200.0, "mid_gain": 3.0,
"mid_q": 0.7}),
("Megaphone / PA", {"low_cut": 500.0, "high_cut": 3000.0, "slope": 30, "mid_freq": 2000.0,
"mid_gain": 8.0, "mid_q": 1.2})],
"distortion": [("Speaker", {"drive": 8.0, "clip": 0.0, "bits": 16, "gsm": False}),
("Overdriven radio", {"drive": 18.0, "clip": -6.0, "bits": 16, "gsm": False}),
("Phone codec", {"drive": 0.0, "clip": 0.0, "bits": 16, "gsm": True}),
("Lo-fi", {"drive": 4.0, "clip": 0.0, "bits": 8, "gsm": False})],
"noise": [("Radio static", {"level": -26.0, "colour": 2500.0, "crackle": 0.1}),
("Soft hiss", {"level": -36.0, "colour": 6000.0, "crackle": 0.0}),
("Crackle", {"level": -40.0, "colour": 1500.0, "crackle": 0.5})],
"echo": [("PA announcement", {"time": 160.0, "feedback": 0.3, "mix": 0.35}),
("Slapback", {"time": 90.0, "feedback": 0.0, "mix": 0.3}),
("Short (width)", {"time": 12.0, "feedback": 0.0, "mix": 0.3}),
("Canyon", {"time": 400.0, "feedback": 0.45, "mix": 0.3})],
"reverb": [("Small room", {"decay": 0.35, "mix": 0.15, "predelay": 3.0, "early": 0.6, "damping": 0.6}),
("Room", {"decay": 0.7, "mix": 0.18, "predelay": 8.0, "early": 0.5, "damping": 0.5}),
("Tiled / cell", {"decay": 1.0, "mix": 0.22, "predelay": 5.0, "early": 0.8, "damping": 0.15}),
("Hall", {"decay": 2.0, "mix": 0.22, "predelay": 25.0, "early": 0.4, "damping": 0.5}),
("Church", {"decay": 4.0, "mix": 0.26, "predelay": 40.0, "early": 0.3, "damping": 0.4}),
("Outdoors (city)", {"decay": 1.5, "mix": 0.15, "predelay": 60.0, "early": 0.15, "damping": 0.7})],
"channels": [("PA layout", {"width": 0.0, "pan_rate": 0.0, "front": -12.0, "surround_delay": 160.0,
"surround": -9.0}),
("Wide stereo", {"width": 12.0, "pan_rate": 0.0, "front": 0.0, "surround": -12.0,
"surround_delay": 20.0}),
("Slow pan (Mr. Freeze)", {"width": 0.0, "pan_rate": 0.25, "pan_depth": 0.6, "front": -6.0})],
}
# Whole presets (several blocks) for the sounds heard in the game (docs/history/mission4_dubbing_plan.md
# section 9): every other block off. (name, {block: preset name of PRESETS or values})
LOOKS = [
("Radio", {"eq": "Radio", "distortion": "Speaker", "noise": "Radio static"}),
("Phone", {"eq": "Phone", "distortion": "Phone codec"}),
("Tape / phone mic", {"eq": "Phone mic (tape)", "noise": "Soft hiss"}),
("TV in a room", {"eq": "TV speaker", "reverb": "Small room"}),
("PA announcement", {"eq": "Megaphone / PA", "echo": "PA announcement", "channels": "PA layout"}),
("Mr. Freeze", {"pitch": "Drift (Mr. Freeze)", "robot": "Suit (Mr. Freeze)", "channels": "Slow pan (Mr. Freeze)"}),
("Clayface", {"pitch": "Down (Clayface)"}),
("Riddler broadcast", {"glitch": "Hacked (Riddler)", "eq": "Phone", "echo": "Short (width)",
"reverb": {"decay": 0.6, "mix": 0.11, "reverse": 0.6, "reverse_every": 0.3}}),
("Giant (Ra's al Ghul)", {"octaves": "Giant (two down)", "pitch": {"formant": -3.0},
"reverb": "Hall"}),
("Car radio", {"eq": "Radio", "distortion": "Overdriven radio", "noise": "Radio static",
"pitch": {"formant": 3.0}}),
]
def preset(block, name):
"""Values of a block preset (without 'on')."""
return dict(next(v for n, v in PRESETS[block] if n == name))
def with_preset(settings, block, name):
"""Settings with `block` on and set to preset `name`."""
s = normalize(settings)
s[block].update(preset(block, name), on=True)
return normalize(s)
def look(name):
"""Settings of a whole preset (LOOKS): its blocks on (preset or values over the defaults), every other off."""
s = defaults()
for block, v in dict(LOOKS)[name].items():
s[block].update(preset(block, v) if isinstance(v, str) else v, on=True)
return normalize(s)
LAYOUTS = {2: "L R", 3: "L R C", 4: "L R SL SR", 5: "L R C SL SR", 6: "L R C LFE SL SR"} # WAVE channel masks
def defaults():
"""Every block off, every parameter at its default."""
return {b.key: dict({"on": False}, **{p.key: p.default for p in b.params}) for b in BLOCKS}
RENAMED = {("reverb", "mix"): "wet"} # parameter -> its name in settings of earlier versions
def normalize(settings):
"""Complete and clamp settings (unknown keys dropped), so old or hand-edited settings always render."""
out = defaults()
for b in BLOCKS:
s = (settings or {}).get(b.key) or {}
out[b.key]["on"] = bool(s.get("on", False))
for p in b.params:
v = s.get(p.key, s.get(RENAMED.get((b.key, p.key)), p.default))
try:
v = bool(v) if p.kind == "bool" else int(round(float(v))) if p.kind == "int" else float(v)
except (TypeError, ValueError):
v = p.default
out[b.key][p.key] = v if p.kind == "bool" else min(max(v, p.lo), p.hi)
return out
def active(settings):
return any((settings or {}).get(b.key, {}).get("on") for b in BLOCKS)
def summary(settings):
"""Short text of the blocks that are on, e.g. 'Pitch, Band-pass EQ'."""
return ", ".join(b.label for b in BLOCKS if (settings or {}).get(b.key, {}).get("on"))
# --- helpers -------------------------------------------------------------------------------------
def _db(v):
return 10 ** (v / 20.0)
def _pb(plugins, x, rate):
"""Run pedalboard plugins on (frames, channels) audio."""
import pedalboard
y = pedalboard.Pedalboard(list(plugins))(np.ascontiguousarray(x.T, dtype=np.float32), rate)
return y.T
def _shift(x, rate, semitones, formants=False):
"""Pitch shift without changing the length (Rubber Band); `semitones` may be a per-frame curve."""
import pedalboard
st = semitones if np.ndim(semitones) else float(semitones)
y = pedalboard.time_stretch(np.ascontiguousarray(x.T, dtype=np.float32), rate,
pitch_shift_in_semitones=st, preserve_formants=formants)
y = y.T
if len(y) != len(x): # same length as the input (Rubber Band may differ by a few frames)
y = y[:len(x)] if len(y) > len(x) else np.pad(y, ((0, len(x) - len(y)), (0, 0)))
return y
def _rms(x):
return float(np.sqrt(np.mean(np.square(x)))) if len(x) else 0.0
def _envelope_db(x, rate, hop_s=0.01):
hop = max(1, int(rate * hop_s))
m = x.mean(axis=1)
n = len(m) // hop
if n == 0:
return np.zeros(0)
return 10 * np.log10(np.mean(m[:n * hop].reshape(n, hop) ** 2, axis=1) + 1e-12)
def _phrase_starts(x, rate, gap=0.15):
"""Frame indices (in samples) where speech starts after at least `gap` seconds of quiet."""
env = _envelope_db(x, rate)
if not len(env):
return []
on = env > np.max(env) - 30
starts, quiet = [], int(gap / 0.01)
run = quiet # the start of the audio counts as after a pause
for i, a in enumerate(on):
if a and run >= quiet:
starts.append(int(i * 0.01 * rate))
run = 0 if a else run + 1
return starts
def _band(x, rate, low=None, high=None, order=4):
"""Zero-phase Butterworth high-pass (`low`) and/or low-pass (`high`) magnitude applied in the frequency domain
(exact slope of 6 dB/octave per order, flat pass band; pedalboard's cuts are first order only)."""
pad = int(0.2 * rate)
n = 1 << int(np.ceil(np.log2(len(x) + 2 * pad)))
f = np.fft.rfftfreq(n, 1 / rate)
h = np.ones_like(f)
with np.errstate(divide="ignore"):
if low:
h /= np.sqrt(1 + (low / np.maximum(f, 1e-9)) ** (2 * order))
if high and high < rate / 2:
h /= np.sqrt(1 + (f / high) ** (2 * order))
spec = np.fft.rfft(np.pad(x, ((pad, 0), (0, 0))), n, axis=0) * h[:, None]
return np.fft.irfft(spec, n, axis=0)[pad:pad + len(x)].astype(np.float32)
def _with_tail(fn, x, rate, seconds):
"""Run `fn` on x padded with `seconds` of silence, then drop the silent end (never shorter than x)."""
pad = np.zeros((int(seconds * rate), x.shape[1]), np.float32)
y = fn(np.concatenate([x, pad]))
lvl = np.max(np.abs(y), axis=1)
loud = np.flatnonzero(lvl > max(np.max(lvl, initial=0.0), 1e-9) * 10 ** (-60 / 20))
end = max(len(x), (loud[-1] + 1) if len(loud) else len(x))
return y[:end]
def _fade(seg, rate, ms=5):
n = min(len(seg) // 2, int(rate * ms / 1000))
if n:
r = np.linspace(0, 1, n, dtype=np.float32)[:, None]
seg[:n] *= r
seg[-n:] *= r[::-1]
return seg
# --- blocks --------------------------------------------------------------------------------------
def _pitch(x, rate, p):
if p["formant"]:
x = _shift(_shift(x, rate, p["formant"]), rate, -p["formant"], formants=True)
if p["drift"]:
t = np.arange(len(x)) / rate
return _shift(x, rate, p["shift"] + p["drift"] * np.sin(2 * np.pi * p["drift_rate"] * t))
return _shift(x, rate, p["shift"]) if p["shift"] else x
def _octaves(x, rate, p):
y = x.copy()
for i in (1, 2):
if p["level%d" % i] > -60 and p["interval%d" % i]:
y += _shift(x, rate, p["interval%d" % i]) * _db(p["level%d" % i])
return y
def _robot(x, rate, p):
t = (np.arange(len(x)) / rate)[:, None]
y = x * (1 - p["ring_mix"]) + x * np.sin(2 * np.pi * p["ring_freq"] * t) * p["ring_mix"]
if p["comb_ms"] > 0:
import pedalboard
y = _pb([pedalboard.Delay(p["comb_ms"] / 1000.0, p["comb_feedback"], 0.5)], y, rate)
return y.astype(np.float32)
def _glitch(x, rate, p):
n = int(round(p["rate"] * len(x) / rate / 10))
seg = int(rate * p["length"] / 1000)
if n <= 0 or seg < 8 or len(x) <= seg:
return x
env = _envelope_db(x, rate)
voiced = np.flatnonzero(env > np.max(env) - 25) * int(rate * 0.01) if len(env) else np.zeros(0, int)
voiced = voiced[voiced + seg < len(x)]
if not len(voiced):
return x
rng = np.random.default_rng(p["seed"])
at = np.unique(rng.choice(voiced, size=min(n, len(voiced)), replace=False))
parts, last = [], 0
for a in at:
if a < last:
continue
piece = _fade(x[a:a + seg].copy(), rate)
parts.append(x[last:a + seg])
for k in range(p["repeats"]):
parts.append(_fade(_shift(piece, rate, p["pitch"] * (k + 1)), rate) if p["pitch"] else piece)
last = a + seg
parts.append(x[last:])
return np.concatenate(parts).astype(np.float32)
def _eq(x, rate, p):
import pedalboard
y = _band(x, rate, p["low_cut"], p["high_cut"], max(1, int(p["slope"]) // 6))
if p["mid_gain"] and p["mid_freq"] < rate / 2 * 0.95:
y = _pb([pedalboard.PeakFilter(p["mid_freq"], p["mid_gain"], p["mid_q"])], y, rate)
return y
def _distortion(x, rate, p):
import pedalboard
plugins = []
if p["drive"] > 0:
plugins.append(pedalboard.Distortion(p["drive"]))
if p["clip"] < 0:
plugins.append(pedalboard.Clipping(p["clip"]))
if p["bits"] < 16:
plugins.append(pedalboard.Bitcrush(p["bits"]))
if p["gsm"]:
plugins.append(pedalboard.GSMFullRateCompressor())
if not plugins:
return x
y = _pb(plugins, x, rate)
r = _rms(y)
return y * (_rms(x) / r) if r > 0 else y # same loudness as before: only the colour changes
def _noise(x, rate, p):
rng = np.random.default_rng(0)
n = _band(rng.standard_normal((len(x), 1)).astype(np.float32), rate, p["colour"] / 2, p["colour"] * 2, 2)
if p["crackle"] > 0:
clicks = (rng.random(len(x)) < p["crackle"] * 20 / rate) * rng.uniform(-1, 1, len(x))
n = n / (_rms(n) or 1) + clicks[:, None].astype(np.float32) * 8
r = _rms(n)
return x + (n / r if r else n) * _rms(x) * _db(p["level"])
def _echo(x, rate, p):
import pedalboard
fb = p["feedback"]
tail = p["time"] / 1000 * (1 + (np.log(1e-3) / np.log(fb) if fb > 0.01 else 0))
return _with_tail(lambda a: _pb([pedalboard.Delay(p["time"] / 1000.0, fb, p["mix"])], a, rate),
x, rate, min(tail, 6.0))
REVERB_BANDS = ((0, 500), (500, 2000), (2000, 6000), (6000, None))
REVERB_DARK = (0.0, 0.3, 0.6, 0.85) # share of the decay a band loses at damping 1 (highs die first)
def room_response(rate, channels, decay, predelay=10.0, early=0.5, damping=0.5, width=1.0, seed=7):
"""Generated room impulse response -> float32 (frames, channels), energy 1 per channel: sparse early
reflections, then a dense exponentially decaying noise tail in 4 bands, the higher ones shorter with damping.
Each channel gets its own noise (decorrelated), mixed towards a common one as `width` goes to 0."""
rng = np.random.default_rng(seed)
n = int(rate * (decay * 1.2 + predelay / 1000)) + 1
t = np.arange(n) / rate
pre = int(rate * predelay / 1000)
def one(r):
tail = np.zeros(n, np.float32)
noise = r.standard_normal((n, 1)).astype(np.float32)
for (lo, hi), dark in zip(REVERB_BANDS, REVERB_DARK):
rt = max(0.05, decay * (1 - damping * dark))
tail += _band(noise, rate, lo or None, hi, 4)[:, 0] * 10 ** (-3 * t / rt)
tail *= np.minimum(1, t / 0.015) # the dense tail builds up over ~15 ms
er = np.zeros(n, np.float32)
span = 0.01 + 0.03 * min(decay, 2) # reflections within 10-70 ms
for k in range(12):
i = int(rate * r.uniform(0.002, span))
er[i] += r.choice((-1, 1)) * (1 - k / 14) * 3
h = tail / (np.sqrt(np.sum(tail ** 2)) or 1) + early * er / (np.sqrt(np.sum(er ** 2)) or 1)
return np.concatenate([np.zeros(pre, np.float32), h])[:n]
common = one(np.random.default_rng(seed + 1000))
out = np.stack([width * one(rng) + (1 - width) * common for _ in range(channels)], axis=1)
return (out / np.sqrt(np.sum(out ** 2, axis=0, keepdims=True)).clip(1e-9)).astype(np.float32)
def _convolve(x, ir):
"""Per channel FFT convolution (full length)."""
n = len(x) + len(ir) - 1
m = 1 << int(np.ceil(np.log2(n)))
return np.fft.irfft(np.fft.rfft(x, m, axis=0) * np.fft.rfft(ir, m, axis=0), m, axis=0)[:n].astype(np.float32)
def _reverb(x, rate, p):
ir = room_response(rate, x.shape[1], p["decay"], p["predelay"], p["early"], p["damping"], p["width"])
y = x
if p["mix"] > 0:
wet = _convolve(x, ir)
wet *= _rms(x) / (_rms(wet) or 1) # the reverb alone is as loud as the sound
a = p["mix"] * np.pi / 2 # equal-power crossfade: steady level at any mix
y = np.concatenate([x, np.zeros((len(wet) - len(x), x.shape[1]), np.float32)]) * np.cos(a)
y = y + wet * np.sin(a)
lvl = np.max(np.abs(y), axis=1) # drop the inaudible end of the tail
loud = np.flatnonzero(lvl > max(np.max(lvl, initial=0.0), 1e-9) * 10 ** (-60 / 20))
y = y[:max(len(x), loud[-1] + 1 if len(loud) else len(x))]
if p["reverse"] > 0:
rng = np.random.default_rng(p["seed"])
swell_ir = room_response(rate, x.shape[1], max(0.6, p["decay"]), 0, p["early"], p["damping"], p["width"])
y = y.copy()
for s in _phrase_starts(x, rate):
if rng.random() >= p["reverse_every"] or s < rate // 20:
continue
head = x[s:s + int(0.3 * rate)][::-1]
sw = _convolve(head, swell_ir)[::-1]
sw = sw[:len(sw) - len(head)] # the swell ends where the phrase starts
a = max(0, s - len(sw))
sw = sw[len(sw) - (s - a):]
peak = float(np.max(np.abs(sw), initial=0.0))
if peak > 0:
y[a:s] += sw / peak * float(np.max(np.abs(x[s:s + int(0.3 * rate)]), initial=0.0)) * p["reverse"]
return y
def _channels(x, rate, p, channels):
"""Mono sound -> `channels` outputs in WAVE order (LAYOUTS)."""
v = x.mean(axis=1)
names = LAYOUTS.get(channels, " ".join(["L"] * channels)).split()
t = np.arange(len(v)) / rate
pan = p["pan_depth"] * np.sin(2 * np.pi * p["pan_rate"] * t) if p["pan_rate"] > 0 else np.zeros(len(v))
ang = (pan + 1) * np.pi / 4 # equal power: centre = both sides at 1.0
g_left, g_right = np.cos(ang) * np.sqrt(2), np.sin(ang) * np.sqrt(2)
def delayed(sig, ms):
d = int(rate * ms / 1000)
return np.concatenate([np.zeros(d, np.float32), sig])[:len(sig)] if d else sig
front = _db(p["front"]) if "C" in names else 1.0
sur = delayed(v, p["surround_delay"]) * _db(p["surround"])
out = []
for nm in names:
out.append({"L": v * front * g_left, "R": delayed(v, p["width"]) * front * g_right, "C": v,
"LFE": np.zeros_like(v), "SL": sur * g_left, "SR": sur * g_right}[nm])
return np.stack(out, axis=1).astype(np.float32)
_ORDER = (("pitch", _pitch), ("octaves", _octaves), ("robot", _robot), ("glitch", _glitch), ("eq", _eq),
("distortion", _distortion), ("noise", _noise), ("echo", _echo), ("reverb", _reverb))
def render(x, rate, settings, channels=None):
"""Apply the blocks that are on -> float32 (frames, channels). `channels` = the target sound's channel count:
with the Channels block on and 2+ channels the result has that many channels in the game's order; otherwise
the channel count of `x` is kept (the encoder up/down-mixes as before)."""
s = normalize(settings)
x = np.asarray(x, dtype=np.float32)
x = x[:, None] if x.ndim == 1 else x
if not len(x):
return x
for key, fn in _ORDER:
if s[key]["on"]:
x = np.asarray(fn(x, rate, s[key]), dtype=np.float32)
if s["channels"]["on"] and channels and channels >= 2:
x = _channels(x, rate, s["channels"], channels)
return np.nan_to_num(x).astype(np.float32)

View File

@@ -123,6 +123,14 @@ class Game:
def languages(self): def languages(self):
return [a for a in self.archives if a != "SFX"] return [a for a in self.archives if a != "SFX"]
def voice_archive(self, lang):
"""The speech archive used for a language: its own, or INT for the languages the game has no voices for
(the game plays the INT voices with their own subtitles and menu text)."""
lang = (lang or "INT").upper()
if lang in self.languages:
return lang
return "INT" if "INT" in self.languages else self.languages[0]
def wad(self, archive): def wad(self, archive):
try: try:
return self.archives[archive.upper()] return self.archives[archive.upper()]
@@ -153,8 +161,16 @@ class Game:
dirs.append(os.path.join(pfx, docs, "WB Games", "Batman Arkham City GOTY", "BmGame", "Config")) dirs.append(os.path.join(pfx, docs, "WB Games", "Batman Arkham City GOTY", "BmGame", "Config"))
return [d for d in dirs if os.path.isdir(d)] return [d for d in dirs if os.path.isdir(d)]
def all_languages(self):
"""Every language of the game: the voice languages (speech WADs) and the text languages (folders of
BmGame/Localization; the game plays the INT voices for those without their own)."""
loc = _iglob_one(self.root, "BmGame", "Localization")
texts = [d.upper() for d in os.listdir(loc) if os.path.isdir(os.path.join(loc, d))] if loc else []
return sorted(set(self.languages) | set(texts))
def language(self): def language(self):
"""Speech language the game is set to (falls back to INT, then the first available).""" """The language the game is set to (voices or texts only, e.g. JPN; `voice_archive` gives its speech
archive); falls back to INT, then the first voice language."""
for d in self.config_dirs(): for d in self.config_dirs():
for f in ("UserEngine.ini", "BmEngine.ini"): for f in ("UserEngine.ini", "BmEngine.ini"):
p = os.path.join(d, f) p = os.path.join(d, f)
@@ -163,7 +179,7 @@ class Game:
raw = open(p, "rb").read() raw = open(p, "rb").read()
txt = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("latin-1") txt = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("latin-1")
m = re.search(r"^\s*Language\s*=\s*(\w+)", txt, re.M | re.I) m = re.search(r"^\s*Language\s*=\s*(\w+)", txt, re.M | re.I)
if m and m.group(1).upper() in self.archives: if m and m.group(1).upper() in self.all_languages():
return m.group(1).upper() return m.group(1).upper()
if "INT" in self.archives: if "INT" in self.archives:
return "INT" return "INT"

View File

@@ -64,6 +64,21 @@ def parse(raw, lang, fname):
return out return out
def encoding(raw):
"""Encoding of a file's bytes: 'utf-16', 'utf-8' or 'cp1252'."""
return "utf-16" if raw[:2] == b"\xff\xfe" else "utf-8" if raw[:3] == b"\xef\xbb\xbf" else "cp1252"
def check_value(value, enc, fname):
"""Raise TextError if `value` cannot be written into a file of encoding `enc` (what `write` refuses)."""
if "\r" in value or "\n" in value:
raise TextError("a menu text cannot contain line breaks (use the game's own markers, e.g. \\n)")
try:
_encode(value, enc)
except UnicodeEncodeError:
raise TextError("%s is stored as %s and cannot hold these characters" % (fname, enc)) from None
def write(raw, lang, fname, values): def write(raw, lang, fname, values):
"""New file bytes with {entry key: new value} applied (keys not in the file raise TextError).""" """New file bytes with {entry key: new value} applied (keys not in the file raise TextError)."""
text, enc = _decode(raw) text, enc = _decode(raw)
@@ -73,8 +88,7 @@ def write(raw, lang, fname, values):
e = by_key.get(key) e = by_key.get(key)
if e is None: if e is None:
raise TextError("no text %s in %s" % (key, fname)) raise TextError("no text %s in %s" % (key, fname))
if "\r" in value or "\n" in value: check_value(value, enc, fname)
raise TextError("a menu text cannot contain line breaks (use the game's own markers, e.g. \\n)")
line = lines[e.line] line = lines[e.line]
end = line[len(line.rstrip("\r\n")):] end = line[len(line.rstrip("\r\n")):]
head = line[:line.index("=") + 1] head = line[:line.index("=") + 1]

View File

@@ -41,7 +41,7 @@ def export_mod(ws, path, keys, name="", author="", description="", texts=()):
e = ws.store.get(key) e = ws.store.get(key)
if not e or not e.get("applied"): if not e or not e.get("applied"):
raise ValueError("%s is not modified" % key) raise ValueError("%s is not modified" % key)
s = ws.catalog.by_key.get(key) s = ws.sound_for_key(key) # also a change made in another language (its name and line)
wem = ws.store.get_blob(e["applied"]) wem = ws.store.get_blob(e["applied"])
x, sr = audio.decode_wem(wem) x, sr = audio.decode_wem(wem)
buf = io.BytesIO() buf = io.BytesIO()

View File

@@ -36,12 +36,33 @@ class SubtitleError(ValueError):
pass pass
# Windows-1252 as Windows itself maps it: the 5 bytes Python's cp1252 leaves undefined stand for the same code
# points (U+0081 ...). One line is UTF-8 bytes in a cp1252 FString in 4 languages (Speech_TAPE_J_Answer_1_00,
# docs/formats/subtitles.md); the RUS and KOR ones contain such bytes. Read and written back losslessly, as stored.
_CP1252_UNDEFINED = frozenset((0x81, 0x8D, 0x8F, 0x90, 0x9D))
def _decode_cp1252(raw):
try:
return raw.decode("cp1252")
except UnicodeDecodeError:
return "".join(chr(x) if x in _CP1252_UNDEFINED else bytes([x]).decode("cp1252") for x in raw)
def _encode_cp1252(text):
"""UnicodeEncodeError for a character that is not in Windows-1252."""
try:
return text.encode("cp1252")
except UnicodeEncodeError:
return b"".join(bytes([ord(c)]) if ord(c) in _CP1252_UNDEFINED else c.encode("cp1252") for c in text)
def read_fstring(b, o): def read_fstring(b, o):
"""-> (text, end offset, enc)""" """-> (text, end offset, enc)"""
n = struct.unpack_from("<i", b, o)[0] n = struct.unpack_from("<i", b, o)[0]
if n >= 0: if n >= 0:
raw = bytes(b[o + 4:o + 4 + n]) raw = bytes(b[o + 4:o + 4 + n])
return raw.split(b"\0", 1)[0].decode("cp1252", "replace"), o + 4 + n, "a" return _decode_cp1252(raw.split(b"\0", 1)[0]), o + 4 + n, "a"
raw = bytes(b[o + 4:o + 4 - 2 * n]) raw = bytes(b[o + 4:o + 4 - 2 * n])
return raw.decode("utf-16-le", "replace").split("\0", 1)[0], o + 4 - 2 * n, "w" return raw.decode("utf-16-le", "replace").split("\0", 1)[0], o + 4 - 2 * n, "w"
@@ -53,7 +74,7 @@ def encode_fstring(text, size, enc):
raise SubtitleError("the text must not contain NUL characters") raise SubtitleError("the text must not contain NUL characters")
if enc == "a": if enc == "a":
try: try:
raw = text.encode("cp1252") raw = _encode_cp1252(text)
except UnicodeEncodeError: except UnicodeEncodeError:
raise SubtitleError("this subtitle is stored as Western European (Windows-1252) text; '%s' cannot be " raise SubtitleError("this subtitle is stored as Western European (Windows-1252) text; '%s' cannot be "
"written" "written"
@@ -72,7 +93,7 @@ def encode_fstring(text, size, enc):
def _cp1252(c): def _cp1252(c):
try: try:
c.encode("cp1252") _encode_cp1252(c)
return True return True
except UnicodeEncodeError: except UnicodeEncodeError:
return False return False

View File

@@ -5,31 +5,45 @@ Keys (also used in mod packages):
MENU:<LANG>:<File>:<Section>:<Key>[#n] a menu/UI text (docs/formats/text.md) MENU:<LANG>:<File>:<Section>:<Key>[#n] a menu/UI text (docs/formats/text.md)
Records live in the originals store (data["texts"]): {key: {"orig", "applied", "files", ...}}. The files Records live in the originals store (data["texts"]): {key: {"orig", "applied", "files", ...}}. The files
holding the texts are derived files of the Workspace: each is rebuilt from its original copy plus every edit holding the texts are derived files of the Workspace: each is rebuilt from its original copy plus every edit
applied now (sounds and texts), so reverting the last edit restores it byte-for-byte. applied now (sounds and texts), so reverting the last edit restores it byte-for-byte. Writes hold the
workspace's lock (Workspace.lock).
""" """
import os import os
import time import time
from . import loctext, subtitles from . import banks, loctext, subtitles
from .catalog import load_subtitles from .catalog import _shipped_index, catalog_dir, load_subtitles
from .game import GameError from .game import GameError
ORIGINAL, MODIFIED, LOST, CHANGED, FOREIGN = "original", "modified", "lost", "changed", "foreign" ORIGINAL, MODIFIED, LOST, CHANGED, FOREIGN = "original", "modified", "lost", "changed", "foreign"
def _locked(f):
"""Run a Texts method holding the workspace's write lock."""
def run(self, *a, **kw):
with self.ws.lock:
return f(self, *a, **kw)
run.__name__, run.__doc__ = f.__name__, f.__doc__
return run
class TextConflictError(GameError): class TextConflictError(GameError):
def __init__(self, keys): def __init__(self, keys):
self.keys = keys self.keys = keys
super().__init__("These texts were changed outside this tool (Steam update or 'verify', or another mod " super().__init__("These texts were changed outside this tool (Steam update or 'verify', or another mod "
"tool): %s.\nRevert them or confirm to overwrite." % ", ".join(keys[:5]) "tool): %s.\nRevert them or confirm to overwrite."
+ (" ..." if len(keys) > 5 else "")) % (", ".join(keys[:5]) + (" ..." if len(keys) > 5 else "")))
class Texts: class Texts:
def __init__(self, ws): def __init__(self, ws):
self.ws = ws self.ws = ws
self._subs = {} # lang -> ([SubLine], {key: [SubLoc]}) self._subs = {} # lang -> ([SubLine], {key: [SubLoc]})
self._sub_text = {} # lang -> {(speech, line): original text} (catalog)
self._menu = {} # lang -> [Entry] self._menu = {} # lang -> [Entry]
self._menu_paths = {} # lang -> {file name: path}
self._menu_file = {} # path -> ((size, mtime), encoding, {entry key: Entry})
self._shipped_subs = None # (by file: [SubLoc], {(lang, speech, line): text}) of the shipped catalog
self.loc_root = _find_dir(ws.game.root, "BmGame", "Localization") self.loc_root = _find_dir(ws.game.root, "BmGame", "Localization")
# --- languages and lists --------------------------------------------------------------------- # --- languages and lists ---------------------------------------------------------------------
@@ -37,7 +51,6 @@ class Texts:
return loctext.languages(self.loc_root) if self.loc_root else [] return loctext.languages(self.loc_root) if self.loc_root else []
def subtitle_languages(self): def subtitle_languages(self):
from .catalog import _shipped_index
langs = sorted(_shipped_index().get("SUB", {}).get("lines", {})) langs = sorted(_shipped_index().get("SUB", {}).get("lines", {}))
return langs or self.menu_languages() return langs or self.menu_languages()
@@ -54,8 +67,11 @@ class Texts:
def for_sound(self, sound, lang=None): def for_sound(self, sound, lang=None):
"""Subtitle lines of a speech sound, in `lang` (default: the sound's own language). Languages without """Subtitle lines of a speech sound, in `lang` (default: the sound's own language). Languages without
their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines.""" their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines.
lang = lang or (sound.languages[0] if sound.languages else sound.archive) A sound shared by every language (an in-memory line stored once) defaults to the game's language."""
game = self.ws.game
lang = lang or (sound.languages[0] if sound.languages else
sound.archive if sound.archive in game.languages else game.language())
if lang not in self.subtitle_languages(): if lang not in self.subtitle_languages():
return [] return []
return [s for s in self.subtitles(lang) if sound.key in s.sounds] return [s for s in self.subtitles(lang) if sound.key in s.sounds]
@@ -77,20 +93,44 @@ class Texts:
def modified(self): def modified(self):
return [k for k, r in self.records().items() if r.get("applied") is not None] return [k for k, r in self.records().items() if r.get("applied") is not None]
def _menu_path(self, key):
"""Path of the file holding menu text `key` (MENU:<LANG>:<File>:...)."""
parts = key.split(":", 3)
if len(parts) < 4 or not self.loc_root:
raise GameError("no menu text %s" % key[len("MENU:"):])
lang = parts[1]
if lang not in self._menu_paths:
self._menu_paths[lang] = loctext.files(self.loc_root, lang)
path = self._menu_paths[lang].get(parts[2])
if not path:
raise GameError("no menu text %s" % key[len("MENU:"):])
return path
def _menu_entries(self, path, lang):
"""(encoding, {entry key: Entry}) of one menu file as it is now (parsed again only when it changed)."""
st = os.stat(path)
stamp = (st.st_size, st.st_mtime_ns)
hit = self._menu_file.get(path)
if not hit or hit[0] != stamp:
raw = open(path, "rb").read()
hit = (stamp, loctext.encoding(raw),
{e.key: e for e in loctext.parse(raw, lang.upper(), os.path.basename(path))})
self._menu_file[path] = hit
return hit[1], hit[2]
def _menu_entry(self, key): def _menu_entry(self, key):
lang = key.split(":")[1] path = self._menu_path(key)
raw = key[len("MENU:"):] e = self._menu_entries(path, key.split(":")[1])[1].get(key[len("MENU:"):])
for e in loctext.load(self.loc_root, lang): if e is None:
if e.key == raw: raise GameError("no menu text %s" % key[len("MENU:"):])
return e return e
raise GameError("no menu text %s" % raw)
def files(self, key): def files(self, key):
"""Absolute paths of the files holding a text.""" """Absolute paths of the files holding a text."""
if key.startswith("SUB:"): if key.startswith("SUB:"):
return sorted({self.ws.bank_path(l.file) for l in self.subtitle_locations(key)}) return sorted({self.ws.bank_path(l.file) for l in self.subtitle_locations(key)})
e = self._menu_entry(key) self._menu_entry(key)
return [loctext.files(self.loc_root, e.lang)[e.file]] return [self._menu_path(key)]
def current(self, key): def current(self, key):
"""The text in the game files now.""" """The text in the game files now."""
@@ -98,22 +138,20 @@ class Texts:
locs = self.subtitle_locations(key) locs = self.subtitle_locations(key)
if not locs: if not locs:
raise GameError("no subtitle %s" % key) raise GameError("no subtitle %s" % key)
from .banks import open_file
l = locs[0] l = locs[0]
b = open_file(self.ws.bank_path(l.file)).read(l.pos, l.size) b = banks.open_file(self.ws.bank_path(l.file)).read(l.pos, l.size)
return subtitles.read_fstring(b, 0)[0] return subtitles.read_fstring(b, 0)[0]
return self._menu_entry(key).value return self._menu_entry(key).value
def catalog_text(self, key): def catalog_text(self, key):
"""Original text according to the shipped catalog (subtitles only), or None.""" """Original text according to the catalog (subtitles only), or None."""
if not key.startswith("SUB:"): if not key.startswith("SUB:"):
return None return None
_, lang, rest = key.split(":", 2) _, lang, rest = key.split(":", 2)
speech, line = rest.rsplit(":", 1) speech, line = rest.rsplit(":", 1)
for s in self.subtitles(lang): if lang not in self._sub_text:
if s.speech == speech and s.line == int(line): self._sub_text[lang] = {(s.speech, s.line): s.text for s in self.subtitles(lang)}
return s.text return self._sub_text[lang].get((speech, int(line)))
return None
def original(self, key): def original(self, key):
r = self.records().get(key) r = self.records().get(key)
@@ -141,24 +179,54 @@ class Texts:
for l in self.subtitle_locations(key)), default=0) for l in self.subtitle_locations(key)), default=0)
def check(self, key, text): def check(self, key, text):
"""Raise SubtitleError / TextError if `text` cannot be written for `key`.""" """Raise SubtitleError / TextError (ValueError) if `text` cannot be written for `key`, GameError if
there is no such text."""
if key.startswith("SUB:"): if key.startswith("SUB:"):
for l in self.subtitle_locations(key): for l in self.subtitle_locations(key):
subtitles.encode_fstring(text, l.size, l.enc) subtitles.encode_fstring(text, l.size, l.enc)
else: else:
e = self._menu_entry(key) path = self._menu_path(key)
path = loctext.files(self.loc_root, e.lang)[e.file] self._menu_entry(key)
loctext.write(open(path, "rb").read(), e.lang, e.file, {e.key: text}) loctext.check_value(text, self._menu_entries(path, key.split(":")[1])[0], os.path.basename(path))
def unoriginal_subtitles(self, rel):
"""Subtitle strings in package `rel` (cooked-relative) that differ from the shipped catalog: [SUB key]
(none if the catalog is not the game's)."""
if self._shipped_subs is None:
self._shipped_subs = ({}, {})
shipped = _shipped_index().get("SUB", {})
loc = os.path.join(catalog_dir(), subtitles.LOCATIONS)
if shipped.get("fingerprint") == banks.game_fingerprint(self.ws.game.cooked) and os.path.exists(loc):
by_file, texts = self._shipped_subs
for ls in subtitles.read_locations(loc).values():
for l in ls:
by_file.setdefault(l.file, []).append(l)
for lang in shipped.get("lines", {}):
p = os.path.join(catalog_dir(), "SUB_%s.csv" % lang)
if os.path.exists(p):
texts.update({(lang, s.speech, s.line): s.text for s in subtitles.read_texts(p, lang)})
by_file, texts = self._shipped_subs
out = []
locs = sorted(by_file.get(rel, []), key=lambda l: l.pos)
if locs:
r = banks.open_file(self.ws.bank_path(rel))
for l in locs:
want = texts.get((l.lang, l.speech, l.line))
if want is not None and subtitles.read_fstring(r.read(l.pos, l.size), 0)[0] != want:
out.append(subtitles.key(l.lang, l.speech, l.line))
return out
# --- writing -------------------------------------------------------------------------------- # --- writing --------------------------------------------------------------------------------
@_locked
def apply(self, changes, force=False, progress=None, meta=None): def apply(self, changes, force=False, progress=None, meta=None):
"""changes: {key: new text}. Every file holding one of them is rebuilt once.""" """changes: {key: new text}. Every file holding one of them is rebuilt once."""
for k, v in changes.items(): for k, v in changes.items():
self.check(k, v) self.check(k, v)
conflicts = [k for k in changes if self.state(k) in (CHANGED, FOREIGN)] conflicts = [k for k in changes if self.state(k) in (CHANGED, FOREIGN)]
paths = {p for k in changes for p in self.files(k)} files = {k: self.files(k) for k in changes}
changed = set(self.ws.changed_files(paths)) paths = {p for ps in files.values() for p in ps}
conflicts += [k for k in changes if k not in conflicts and set(self.files(k)) & changed] changed = set(self.ws.changed_files(paths)) | set(self.ws.unoriginal_files(paths))
conflicts += [k for k in changes if k not in conflicts and set(files[k]) & changed]
if conflicts and not force: if conflicts and not force:
raise TextConflictError(conflicts) raise TextConflictError(conflicts)
for p in paths: for p in paths:
@@ -167,12 +235,13 @@ class Texts:
now = time.time() now = time.time()
for k, v in changes.items(): for k, v in changes.items():
if k not in recs: if k not in recs:
recs[k] = {"orig": self.current(k), "files": [self.ws.game.rel(p) for p in self.files(k)], recs[k] = {"orig": self.current(k), "files": [self.ws.game.rel(p) for p in files[k]],
"first_modified": now, "applied": None} "first_modified": now, "applied": None}
recs[k].update(applied=v, applied_at=now, meta=meta or {}) recs[k].update(applied=v, applied_at=now, meta=meta or {})
self.ws.store.save() self.ws.store.save()
self._sync(paths, progress) self._sync(paths, progress)
@_locked
def revert(self, keys, progress=None): def revert(self, keys, progress=None):
recs = self.records() recs = self.records()
keys = [k for k in keys if k in recs] keys = [k for k in keys if k in recs]

View File

@@ -8,6 +8,7 @@ Patching rewrites only the blocks that contain changed bytes (stored as one LZO
lzo.compress_stored) and moves the chunks after them; every other byte of the file stays identical. lzo.compress_stored) and moves the chunks after them; every other byte of the file stays identical.
""" """
import struct import struct
import threading
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from . import lzo from . import lzo
@@ -41,6 +42,7 @@ class Package:
self.file_size = f.seek(0, 2) self.file_size = f.seek(0, 2)
self._cache = OrderedDict() self._cache = OrderedDict()
self._cache_n = cache_blocks self._cache_n = cache_blocks
self._lock = threading.Lock()
# summary ----------------------------------------------------------------------------------- # summary -----------------------------------------------------------------------------------
def _parse_summary(self, b, f): def _parse_summary(self, b, f):
@@ -157,9 +159,11 @@ class Package:
# reading ----------------------------------------------------------------------------------- # reading -----------------------------------------------------------------------------------
def _block(self, i, f=None): def _block(self, i, f=None):
if i in self._cache: with self._lock: # the editor reads one package from several background jobs
self._cache.move_to_end(i) data = self._cache.get(i)
return self._cache[i] if data is not None:
self._cache.move_to_end(i)
return data
bl = self.blocks[i] bl = self.blocks[i]
own = f is None own = f is None
f = f or open(self.path, "rb") f = f or open(self.path, "rb")
@@ -169,9 +173,10 @@ class Package:
finally: finally:
if own: if own:
f.close() f.close()
self._cache[i] = data with self._lock:
if len(self._cache) > self._cache_n: self._cache[i] = data
self._cache.popitem(last=False) if len(self._cache) > self._cache_n:
self._cache.popitem(last=False)
return data return data
def _block_index(self, off): def _block_index(self, off):

View File

@@ -399,8 +399,9 @@ def check_speakers(rep, rows, col):
def check_menu(rep, game): def check_menu(rep, game):
"""Menu texts: the same keys in every language (facts; differences are the game's own).""" """Menu texts: the same keys in every language (facts; differences are the game's own)."""
from .. import loctext from .. import loctext
root = os.path.join(game.root, "BmGame", "Localization") from ..texts import _find_dir
if not os.path.isdir(root): root = _find_dir(game.root, "BmGame", "Localization") # case-insensitive (Linux)
if not root:
return return
keys = {} keys = {}
for lang in loctext.languages(root): for lang in loctext.languages(root):
@@ -443,7 +444,7 @@ def run(game, archives=None, quick=False, log=print, progress=None):
check_subtitle_lines(rep, rows_all) check_subtitle_lines(rep, rows_all)
check_speakers(rep, rows_all, col) check_speakers(rep, rows_all, col)
check_menu(rep, game) check_menu(rep, game)
characters = load_characters(lang=game.language()) characters = load_characters(lang=game.voice_archive(game.language()))
known = {n.lower() for s in cat.sounds for n in s.names()} known = {n.lower() for s in cat.sounds for n in s.names()}
# speaker names are the same in every language; only meaningful for the build the lists were made for # speaker names are the same in every language; only meaningful for the build the lists were made for
if any(a in game.languages and is_shipped(game, a) for a in archives): if any(a in game.languages and is_shipped(game, a) for a in archives):

View File

@@ -104,9 +104,14 @@ def running_game_pids():
return [int(m.group(1)) for m in re.finditer(r'"BatmanAC\.exe","(\d+)"', out)] return [int(m.group(1)) for m in re.finditer(r'"BatmanAC\.exe","(\d+)"', out)]
def kill_game(): def kill_game(timeout=20):
"""Kill the game and wait until it has exited: it keeps the WADs and packages open until then, so a revert
right after taskkill would find them locked."""
for pid in running_game_pids(): for pid in running_game_pids():
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True) subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True)
t0 = time.time()
while running_game_pids() and time.time() - t0 < timeout:
time.sleep(0.25)
def launch_game(exe, extra_args=()): def launch_game(exe, extra_args=()):

View File

@@ -65,7 +65,9 @@ def run(ws, sample=300, log=print):
if n >= sample // max(1, len(ws.game.archives)): if n >= sample // max(1, len(ws.game.archives)):
break break
log("%-4s layout %s, rebuilt %d/%d sample WEMs byte-identical" % (arch, "OK" if not probs else probs[:2], good, n)) log("%-4s layout %s, rebuilt %d/%d sample WEMs byte-identical" % (arch, "OK" if not probs else probs[:2], good, n))
ok &= not probs and good >= n - 2 # 4 looping music tracks are known exceptions # known exceptions (docs/formats/wem.md): a few SFX WEMs (music seek tables and sample counts, 4-channel
# ambience with a loop chunk) and one INT speech WEM (avg-bytes/s field) are not reproduced byte-for-byte
ok &= not probs and good >= n - 2
bank = [s for s in ws.catalog.sounds if s.archive == "BANK"] bank = [s for s in ws.catalog.sounds if s.archive == "BANK"]
if bank: if bank:
n = good = 0 n = good = 0

View File

@@ -10,14 +10,19 @@ In-memory sounds (archive BANK) and the prefetch heads of speech lines live in l
packages (ackit/banks.py); a replacement fills each in-memory slot exactly (wem.fill). Before the first packages (ackit/banks.py); a replacement fills each in-memory slot exactly (wem.fill). Before the first
change to such a file a verified full copy is kept, and the change to such a file a verified full copy is kept, and the
file is always rebuilt as "original copy + every replacement currently applied", so reverting the last file is always rebuilt as "original copy + every replacement currently applied", so reverting the last
change restores it byte-for-byte and any later operation repairs an interrupted one. change restores it byte-for-byte and any later operation repairs an interrupted one. That copy is taken only
from a file that is original according to the catalog (`unoriginal_files`), else the write is a conflict.
Writes are serialized (`Workspace.lock`): the editor runs them as background jobs, and two rebuilds of one
file at the same time would lose one of the edits.
""" """
import functools
import hashlib import hashlib
import os import os
import threading
from collections import defaultdict from collections import defaultdict
from . import audio, banks from . import audio, banks
from .catalog import Catalog from .catalog import Catalog, available_archives, is_shipped, load_archive
from .encoder import EncodeError, encode_fit from .encoder import EncodeError, encode_fit
from .game import Game, GameError from .game import Game, GameError
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
@@ -41,25 +46,50 @@ class ConflictError(GameError):
self.sounds = sounds self.sounds = sounds
super().__init__("These sounds were changed outside this tool (Steam update or 'verify', or " super().__init__("These sounds were changed outside this tool (Steam update or 'verify', or "
"another mod tool): %s.\nRevert them or confirm to overwrite." "another mod tool): %s.\nRevert them or confirm to overwrite."
% ", ".join(s.name for s in sounds[:5]) + (" ..." if len(sounds) > 5 else "")) % (", ".join(s.name for s in sounds[:5]) + (" ..." if len(sounds) > 5 else "")))
def default_store_root(game): def default_store_root(game):
return os.path.join(user_dir("data"), "stores", game.store_id) return os.path.join(user_dir("data"), "stores", game.store_id)
def locked(f):
"""Run a Workspace method holding its write lock (Texts has the same wrapper: texts._locked)."""
@functools.wraps(f)
def run(self, *a, **kw):
with self.lock:
return f(self, *a, **kw)
return run
class Workspace: class Workspace:
def __init__(self, game_dir, store_root=None, archives=None, full_backup=True, progress=None): def __init__(self, game_dir, store_root=None, archives=None, full_backup=True, progress=None):
self.game = game_dir if isinstance(game_dir, Game) else Game(game_dir) self.game = game_dir if isinstance(game_dir, Game) else Game(game_dir)
self.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root) self.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root)
self.catalog = Catalog(self.game, archives, progress) self.catalog = Catalog(self.game, archives, progress)
self.full_backup = full_backup self.full_backup = full_backup
self.lock = threading.RLock() # one write (apply, revert, file rebuild) at a time
self._by_file = None # cooked-relative file -> [Location] (unoriginal_files)
self._shipped = None # the bank catalog is the shipped one (has checksums)
self._wad_rows = {} # voice archive -> {key: Sound} (heads' recordings)
self._other = {} # archive not loaded here -> Catalog (sound_for_key)
self.texts = Texts(self) self.texts = Texts(self)
# reading ----------------------------------------------------------------------------------- # reading -----------------------------------------------------------------------------------
def sound(self, name_or_key): def sound(self, name_or_key):
return self.catalog.resolve(name_or_key) return self.catalog.resolve(name_or_key)
def sound_for_key(self, key):
"""The Sound of a key, also from an archive not loaded here (a change made in another language), or
None."""
if key in self.catalog.by_key:
return self.catalog.by_key[key]
archive = key.split(":")[0]
if archive not in self._other:
self._other[archive] = Catalog(self.game, [archive]) if archive in available_archives(self.game) \
else None
return self._other[archive].by_key.get(key) if self._other[archive] else None
def wad_path(self, s): def wad_path(self, s):
return self.game.wad(s.archive) return self.game.wad(s.archive)
@@ -241,6 +271,55 @@ class Workspace:
out.append(path) out.append(path)
return out return out
def unoriginal_files(self, paths):
"""Bank files and packages this store has never written that are not the game's original according to
the shipped catalog: an in-memory sound, the bank copy of a voice recording or a subtitle in them differs
(another originals store or another tool changed them). Their current content must not become this
store's "original copy". Menu files and other game builds have no catalog checksums: not checked."""
out = []
for path in sorted(set(paths)):
if self._derived().get(self.game.rel(path)) or self.texts.is_menu_file(path):
continue
rel = os.path.relpath(path, self.game.cooked).replace(os.sep, "/")
if not self._bank_copies_original(rel) or self.texts.unoriginal_subtitles(rel):
out.append(path)
return out
def _bank_copies_original(self, rel):
if self._by_file is None:
self._by_file = defaultdict(list)
for ls in self.catalog.locations.values():
for l in ls:
self._by_file[l.file].append(l)
locs = sorted(self._by_file.get(rel, []), key=lambda l: l.offset)
if self._shipped is None:
self._shipped = is_shipped(self.game, banks.ARCHIVE)
if not locs or not self._shipped:
return True
r = banks.open_file(self.bank_path(rel))
for l in locs:
cur = r.read(l.offset, l.size)
if l.kind == banks.MEMORY:
want = self.catalog.bank_sha1.get(l.media)
if want and sha1_16(cur) != want:
return False
elif l.sound: # a bank copy of a WAD recording: the start of the original
orig = self._wad_original(l.sound)
if orig is None or cur != (orig + bytes(l.size))[:l.size]:
return False
return True
def _wad_original(self, key):
"""Original bytes of a WAD recording (this store's record, or the game's when it matches the catalog)."""
if self.store.get(key):
return self.store.original(key)
archive, _, target = key.partition(":")
if archive not in self._wad_rows:
self._wad_rows[archive] = {s.key: s for s in load_archive(self.game, archive)}
s = self._wad_rows[archive].get(key)
cur = read_target(self.game.wad(archive), target)
return None if s and s.sha1 and sha1_16(cur) != s.sha1 else cur
def _audio_edits(self, rel): def _audio_edits(self, rel):
"""[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative).""" """[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative)."""
out = [] out = []
@@ -271,6 +350,7 @@ class Workspace:
edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel) edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel)
return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read() return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
@locked
def sync_files(self, paths, progress=None): def sync_files(self, paths, progress=None):
"""Rebuild each derived file from its original copy and the edits applied now (atomic writes).""" """Rebuild each derived file from its original copy and the edits applied now (atomic writes)."""
for path in sorted(set(paths)): for path in sorted(set(paths)):
@@ -310,6 +390,7 @@ class Workspace:
self.store.save() # the store says what the files must contain before they change self.store.save() # the store says what the files must contain before they change
self.sync_files(files, progress) self.sync_files(files, progress)
@locked
def apply(self, changes, force=False, progress=None): def apply(self, changes, force=False, progress=None):
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file. A recording stored """changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file. A recording stored
in two places of a WAD gets the same bytes in both (Workspace.copies).""" in two places of a WAD gets the same bytes in both (Workspace.copies)."""
@@ -326,7 +407,8 @@ class Workspace:
for s, _, _ in changes: for s, _, _ in changes:
if self.state(s) in (CHANGED, FOREIGN): if self.state(s) in (CHANGED, FOREIGN):
conflicts.append(s) conflicts.append(s)
changed = set(self.changed_files([self.bank_path(l.file) for s, _, _ in changes for l in self.locations(s)])) paths = [self.bank_path(l.file) for s, _, _ in changes for l in self.locations(s)]
changed = set(self.changed_files(paths)) | set(self.unoriginal_files(paths))
conflicts += [s for s, _, _ in changes conflicts += [s for s, _, _ in changes
if s not in conflicts and {self.bank_path(l.file) for l in self.locations(s)} & changed] if s not in conflicts and {self.bank_path(l.file) for l in self.locations(s)} & changed]
if conflicts and not force: if conflicts and not force:
@@ -337,6 +419,7 @@ class Workspace:
for a, items in by_arch.items(): for a, items in by_arch.items():
self._write(a, items, progress) self._write(a, items, progress)
@locked
def revert(self, sounds, progress=None): def revert(self, sounds, progress=None):
"""Put the stored originals back (sounds without a record are skipped). Returns reverted.""" """Put the stored originals back (sounds without a record are skipped). Returns reverted."""
by_arch = defaultdict(list) by_arch = defaultdict(list)
@@ -354,31 +437,50 @@ class Workspace:
self.store.gc() self.store.gc()
return done return done
@locked
def revert_all(self, progress=None): def revert_all(self, progress=None):
sounds = [self.catalog.by_key[k] for k in list(self.store.entries) if k in self.catalog.by_key] """Revert every sound this store has changed (texts: Texts.revert_all)."""
missing = [k for k in self.store.entries if k not in self.catalog.by_key] # records of archives not loaded in this catalog (e.g. another language) included
if missing: sounds = [s for s in map(self.sound_for_key, list(self.store.entries)) if s]
# records of archives not loaded in this catalog (e.g. another language)
other = Catalog(self.game, sorted({k.split(":")[0] for k in missing}))
sounds += [other.by_key[k] for k in missing if k in other.by_key]
return self.revert(sounds, progress) return self.revert(sounds, progress)
def restore_from_full_backup(self): @locked
"""Last-resort fallback: copy back every full backup taken by this store.""" def forget(self, keys=(), text_keys=()):
"""Stop tracking changes (the game files are kept as they are)."""
for k in keys:
self.store.forget(k)
for k in text_keys:
self.texts.records().pop(k, None)
self.store.save()
self.store.gc()
@locked
def restore_from_full_backup(self, progress=None):
"""Last resort when the originals store is damaged (`ackit restore-backup`): put back the full copies.
A WAD (and its pack indexes) is copied back and its sounds are forgotten; bank files, packages and menu
files (always copied) are rebuilt from their copies with the edits of the sounds still recorded, i.e.
those of WADs without a full copy (full backups off), which stay modified and revertible. Returns the
restored files (game-relative)."""
import shutil
full = self.store.data.get("full", {})
derived = self._derived()
out = [] out = []
for rel in self.store.data.get("full", {}): for rel in full:
src = self.store.full_copy(rel) if rel in derived:
continue
dst = os.path.join(self.game.root, rel) dst = os.path.join(self.game.root, rel)
self.game.check_writable(dst) self.game.check_writable(dst)
tmp = dst + ".ackit-tmp" tmp = dst + ".ackit-tmp"
import shutil shutil.copy2(self.store.full_copy(rel), tmp)
shutil.copy2(src, tmp)
os.replace(tmp, dst) os.replace(tmp, dst)
out.append(rel) out.append(rel)
for rec in self._derived().values(): entries = self.store.data["entries"]
rec["written_fp"] = None for k, e in list(entries.items()):
if e["archive"] == banks.ARCHIVE or e.get("wad") in full:
del entries[k]
self.store.data["texts"] = {} self.store.data["texts"] = {}
self.store.data["entries"] = {}
self.store.save() self.store.save()
paths = [os.path.join(self.game.root, rel) for rel in derived if rel in full]
self.sync_files(paths, progress)
self.store.gc() self.store.gc()
return out return sorted(out + [rel for rel in derived if rel in full])

View File

@@ -163,7 +163,8 @@ class Recorder(QObject):
self.chunks = [] self.chunks = []
self.rate = 48000 self.rate = 48000
self.channels = 1 self.channels = 1
self.fmt_float = False self.sample = "<i2" # numpy type of the device's samples
self._rest = b"" # bytes of an incomplete frame, completed by the next read
self._sim = None self._sim = None
def set_device(self, device): def set_device(self, device):
@@ -173,7 +174,7 @@ class Recorder(QObject):
return self.source is not None or self._sim is not None return self.source is not None or self._sim is not None
def start(self): def start(self):
self.chunks = [] self.chunks, self._rest = [], b""
dev = self.device if (self.device is not None and not self.device.isNull()) else ( dev = self.device if (self.device is not None and not self.device.isNull()) else (
QMediaDevices.defaultAudioInput() if audio_enabled() else None) QMediaDevices.defaultAudioInput() if audio_enabled() else None)
if dev is None or dev.isNull(): if dev is None or dev.isNull():
@@ -186,7 +187,8 @@ class Recorder(QObject):
if not dev.isFormatSupported(fmt): if not dev.isFormatSupported(fmt):
fmt = dev.preferredFormat() fmt = dev.preferredFormat()
self.rate, self.channels = fmt.sampleRate(), fmt.channelCount() self.rate, self.channels = fmt.sampleRate(), fmt.channelCount()
self.fmt_float = fmt.sampleFormat() == QAudioFormat.SampleFormat.Float F = QAudioFormat.SampleFormat
self.sample = {F.Float: "<f4", F.Int32: "<i4", F.UInt8: "u1"}.get(fmt.sampleFormat(), "<i2")
self.source = QAudioSource(dev, fmt, self) self.source = QAudioSource(dev, fmt, self)
self.io = self.source.start() self.io = self.source.start()
if self.io is None: if self.io is None:
@@ -202,13 +204,20 @@ class Recorder(QObject):
self._ingest(bytes(self.io.readAll())) self._ingest(bytes(self.io.readAll()))
def _ingest(self, raw): def _ingest(self, raw):
raw = self._rest + raw
frame = np.dtype(self.sample).itemsize * self.channels
n = len(raw) // frame * frame
raw, self._rest = raw[:n], raw[n:]
if not raw: if not raw:
return return
if self.fmt_float: x = np.frombuffer(raw, dtype=self.sample)
x = np.frombuffer(raw, dtype="<f4") if self.sample == "<i2":
else: x = x.astype(np.float32) / 32768.0
x = np.frombuffer(raw[: len(raw) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0 elif self.sample == "<i4":
x = x[: len(x) // self.channels * self.channels].reshape(-1, self.channels) x = (x / 2147483648.0).astype(np.float32)
elif self.sample == "u1":
x = (x.astype(np.float32) - 128.0) / 128.0
x = x.reshape(-1, self.channels)
self.chunks.append(x) self.chunks.append(x)
self.level.emit(float(np.max(np.abs(x))) if len(x) else 0.0) self.level.emit(float(np.max(np.abs(x))) if len(x) else 0.0)
self.elapsed.emit(sum(len(c) for c in self.chunks) / float(self.rate)) self.elapsed.emit(sum(len(c) for c in self.chunks) / float(self.rate))
@@ -216,8 +225,8 @@ class Recorder(QObject):
# simulated input (tests / no microphone) ---------------------------------------------------- # simulated input (tests / no microphone) ----------------------------------------------------
def start_simulated(self, x, rate, realtime=True): def start_simulated(self, x, rate, realtime=True):
"""Feed `x` as if it came from a microphone (20 ms blocks).""" """Feed `x` as if it came from a microphone (20 ms blocks)."""
self.chunks = [] self.chunks, self._rest = [], b""
self.rate, self.channels, self.fmt_float = rate, x.shape[1], True self.rate, self.channels, self.sample = rate, x.shape[1], "<f4"
blocks = [x[i:i + rate // 50] for i in range(0, len(x), rate // 50)] blocks = [x[i:i + rate // 50] for i in range(0, len(x), rate // 50)]
if not realtime: if not realtime:
for b in blocks: for b in blocks:

455
app/edit.py Normal file
View File

@@ -0,0 +1,455 @@
"""Edit tab: the one place where sounds are replaced. The sounds come from the Sounds tab (Edit / Add to edit
list) into an edit list; each keeps its replacement (recorded or loaded), its trim/level values and its effects
(ackit/fx.py) in the user data folder until you apply it to the game. Layout: edit list | editor (subtitle,
listen, replacement, actions) | effects. Plan: docs/history/mission4_dubbing_plan.md."""
import json
import os
import re
import shutil
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal
from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (QAbstractItemView, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton,
QScrollArea, QSplitter, QTableView, QVBoxLayout, QWidget)
from ackit import audio, fx
from ackit.paths import user_dir
from . import icons
from .editor import EditorPanel
from .effects import EffectsRack
class EditStore:
"""The edit list and, per sound key, its replacement audio (WAV) and settings: <user data>/edits/index.json +
replacements/*.wav. (Data of the first versions, <user data>/dubs/ and edits/takes/, is moved here once.)"""
def __init__(self, root=None):
self.root = root or os.path.join(user_dir("data"), "edits")
old = os.path.join(os.path.dirname(self.root), "dubs")
if root is None and not os.path.exists(self.root) and os.path.isdir(old):
shutil.move(old, self.root)
takes = os.path.join(self.root, "takes")
if os.path.isdir(takes) and not os.path.exists(os.path.join(self.root, "replacements")):
os.rename(takes, os.path.join(self.root, "replacements"))
os.makedirs(os.path.join(self.root, "replacements"), exist_ok=True)
self.path = os.path.join(self.root, "index.json")
try:
data = json.load(open(self.path, encoding="utf-8"))
except (OSError, ValueError):
data = {}
if "items" not in data: # first version: {key: item}, no list
data = {"list": [k for k, v in data.items() if v.get("take")], "items": data}
self.list, self.items = list(data.get("list", [])), dict(data.get("items", {}))
for v in self.items.values(): # first versions: "take" = the source's name
if "take" in v:
v.setdefault("source", v.pop("take"))
def save(self):
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"list": self.list, "items": self.items}, f, indent=1, ensure_ascii=False)
os.replace(tmp, self.path)
def get(self, key):
return self.items.get(key, {})
def audio_path(self, key):
return os.path.join(self.root, "replacements", re.sub(r"[^A-Za-z0-9_.-]", "_", key) + ".wav")
def has_replacement(self, key):
return bool(self.get(key).get("source")) and os.path.exists(self.audio_path(key))
def load_replacement(self, key):
return audio.load(self.audio_path(key)) if self.has_replacement(key) else None
def save_replacement(self, key, x, rate, name):
audio.save_wav(self.audio_path(key), x, rate)
self.update(key, source=name)
def update(self, key, **fields):
self.items[key] = dict(self.get(key), **fields)
self.save()
def add(self, keys):
new = [k for k in keys if k not in self.list]
self.list += new
if new:
self.save()
return new
def remove(self, keys):
"""Off the list; the replacements and settings stay (adding the sound again brings them back)."""
self.list = [k for k in self.list if k not in set(keys)]
self.save()
# --- edit list -----------------------------------------------------------------------------------
LCOLS = ["Sound", "", "FX", ""] # Saved and Game have icons in the header
L_NAME, L_SAVED, L_FX, L_GAME = range(len(LCOLS))
class EditListModel(QAbstractTableModel):
def __init__(self, store, parent=None):
super().__init__(parent)
self.store = store
self.sounds = []
self.modified = set()
def reset(self, sounds, modified):
self.beginResetModel()
self.sounds, self.modified = list(sounds), set(modified)
self.endResetModel()
def refresh(self, modified=None):
if modified is not None:
self.modified = set(modified)
if self.sounds:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.sounds) - 1, len(LCOLS) - 1))
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.sounds)
def columnCount(self, parent=QModelIndex()):
return len(LCOLS)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if orientation != Qt.Orientation.Horizontal:
return None
if role == Qt.ItemDataRole.DisplayRole:
return LCOLS[section]
if role == Qt.ItemDataRole.DecorationRole:
return {L_SAVED: icons.icon("record"), L_GAME: icons.icon("apply", icons.ACCENT)}.get(section)
if role == Qt.ItemDataRole.ToolTipRole:
return {L_SAVED: "A replacement is saved (user data folder)", L_FX: "Number of effects on",
L_GAME: "Applied: the replacement is in the game"}.get(section)
return None
def state(self, s):
if s.key in self.modified:
return "Applied"
return "Replacement saved" if self.store.has_replacement(s.key) else "Not started"
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
s, c = self.sounds[index.row()], index.column()
rec = self.store.get(s.key)
n_fx = sum(1 for b in fx.BLOCKS if (rec.get("fx") or {}).get(b.key, {}).get("on"))
if role == Qt.ItemDataRole.DisplayRole:
if c == L_NAME:
return s.name
if c == L_FX:
return str(n_fx) if n_fx else ""
return ""
if role == Qt.ItemDataRole.DecorationRole:
if c == L_SAVED and self.store.has_replacement(s.key):
return icons.icon("record")
if c == L_GAME and s.key in self.modified:
return icons.icon("apply", icons.ACCENT)
if role == Qt.ItemDataRole.FontRole and c == L_NAME and s.key in self.modified:
f = QFont()
f.setBold(True)
return f
if role == Qt.ItemDataRole.ToolTipRole:
tip = "%s\n%s • %s\n%s" % (s.name, s.group + " / " + s.category, s.character or "—", self.state(s))
if n_fx:
tip += "\nEffects: " + fx.summary(rec.get("fx"))
return tip
return None
# --- the tab -------------------------------------------------------------------------------------
class EditPanel(QWidget):
message = Signal(str)
def __init__(self, ctx, parent=None, store=None):
super().__init__(parent)
self.ctx = ctx # main window: .ws, .runner, .player, .settings, ._after_change
self.store = store or EditStore()
self._restoring = False
self._build()
def _build(self):
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
left = QWidget()
ll = QVBoxLayout(left)
ll.setContentsMargins(6, 6, 0, 6)
ll.setSpacing(6)
title = QLabel("EDIT LIST")
f = title.font()
f.setBold(True)
title.setFont(f)
ll.addWidget(title)
self.filter = QLineEdit()
self.filter.setPlaceholderText("Filter the list…")
self.filter.setClearButtonEnabled(True)
self.filter.textChanged.connect(lambda _: self._apply_filter())
ll.addWidget(self.filter)
self.model = EditListModel(self.store, self)
self.table = QTableView()
self.table.setModel(self.model)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.table.verticalHeader().hide()
self.table.verticalHeader().setDefaultSectionSize(22)
self.table.setAlternatingRowColors(True)
self.table.setWordWrap(False)
self.table.setTextElideMode(Qt.TextElideMode.ElideLeft) # names differ at their end
h = self.table.horizontalHeader()
h.setSectionResizeMode(L_NAME, QHeaderView.ResizeMode.Stretch)
for c in (L_SAVED, L_FX, L_GAME):
h.setSectionResizeMode(c, QHeaderView.ResizeMode.ResizeToContents)
self.table.selectionModel().currentRowChanged.connect(
lambda cur, _: self.load(self.model.sounds[cur.row()]) if cur.isValid() else None)
ll.addWidget(self.table, 1)
self.lbl_count = QLabel()
self.lbl_count.setObjectName("Muted")
self.lbl_count.setWordWrap(True)
ll.addWidget(self.lbl_count)
row = QHBoxLayout()
self.b_remove = QPushButton("Remove")
self.b_remove.setToolTip("Take the selected sounds off the list (their replacements and settings are kept)")
self.b_remove.clicked.connect(self.remove_selected)
self.b_clear = QPushButton("Remove applied")
self.b_clear.setToolTip("Take every sound whose replacement is in the game off the list")
self.b_clear.clicked.connect(self.remove_applied)
row.addWidget(self.b_remove, 1)
row.addWidget(self.b_clear, 1)
ll.addLayout(row)
self.b_use = QPushButton("Use these effects for the selected")
self.b_use.setToolTip("Give every selected sound of the list the effects shown on the right (their "
"replacements stay)")
self.b_use.clicked.connect(self.use_for_selected)
ll.addWidget(self.b_use)
self.editor = EditorPanel(self.ctx)
self.editor.message.connect(self.message)
self.editor.source_changed.connect(self._source_changed)
self.editor.edited.connect(lambda: self._save_timer.start())
self.editor.applied.connect(self._after_write)
self.editor.reverted.connect(self._after_write)
self.editor.b_prev.clicked.connect(lambda: self.step(-1))
self.editor.b_next.clicked.connect(lambda: self.step(1))
es = QScrollArea()
es.setWidget(self.editor)
es.setWidgetResizable(True)
mid = QWidget() # the editor scrolls; its action bar stays visible
ml = QVBoxLayout(mid)
ml.setContentsMargins(0, 0, 0, 0)
ml.setSpacing(0)
ml.addWidget(es, 1)
self.editor.layout().removeWidget(self.editor.action_bar)
ml.addWidget(self.editor.action_bar)
self.rack = EffectsRack()
self.rack.changed.connect(self._effects_changed)
rs = QScrollArea()
rs.setWidget(self.rack)
rs.setWidgetResizable(True)
rs.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
rs.setMinimumWidth(self.rack.minimumSizeHint().width() + rs.verticalScrollBar().sizeHint().width() + 4)
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(left)
split.addWidget(mid)
split.addWidget(rs)
split.setStretchFactor(1, 1)
split.setSizes([250, 100000, 360])
split.setChildrenCollapsible(False)
lay.addWidget(split)
self.split = split
ctx = Qt.ShortcutContext.WidgetWithChildrenShortcut
QShortcut(QKeySequence("Alt+Left"), self, lambda: self.step(-1), context=ctx)
QShortcut(QKeySequence("Alt+Right"), self, lambda: self.step(1), context=ctx)
QShortcut(QKeySequence(Qt.Key.Key_Space), self.table, self.editor.toggle_play,
context=Qt.ShortcutContext.WidgetShortcut)
QShortcut(QKeySequence(Qt.Key.Key_Delete), self.table, self.remove_selected,
context=Qt.ShortcutContext.WidgetShortcut)
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(300)
self._save_timer.timeout.connect(self._save_settings)
self._nav()
# --- list ------------------------------------------------------------------------------------
def populate(self, ws):
"""The list's sounds that the loaded catalog has (sounds of another language stay on the list)."""
cur = self.editor.sound
sounds = [ws.catalog.by_key[k] for k in self.store.list if k in ws.catalog.by_key]
self.model.reset(sounds, ws.modified_keys())
self.editor.refresh_devices()
self._apply_filter()
if cur and cur.key in {s.key for s in sounds}:
self._select(ws.catalog.by_key[cur.key])
elif cur:
self.editor.set_sound(None)
self._nav()
def refresh(self, modified=None):
self.model.refresh(modified)
self._count()
def _count(self):
n, applied = len(self.model.sounds), sum(1 for s in self.model.sounds if s.key in self.model.modified)
other = len(self.store.list) - n
self.lbl_count.setText("%d sound(s), %d applied%s" % (n, applied, "; %d of another language hidden" % other
if other else "") if n or other else
"Empty: select sounds in the Sounds tab and press Edit.")
def _apply_filter(self):
words = self.filter.text().lower().split()
for r, s in enumerate(self.model.sounds):
hay = (s.name + " " + s.character + " " + s.category).lower()
self.table.setRowHidden(r, not all(w in hay for w in words))
self._count()
def add(self, sounds, open_first=False):
"""Put sounds on the list (once); with open_first, show the first of them."""
ws = self.ctx.ws
self.store.add([s.key for s in sounds])
known = {s.key for s in self.model.sounds}
if any(s.key not in known for s in sounds):
self.model.reset([ws.catalog.by_key[k] for k in self.store.list if k in ws.catalog.by_key],
ws.modified_keys())
self._apply_filter()
if open_first and sounds:
self._select(sounds[0])
self._nav()
def _row(self, s):
return next((i for i, x in enumerate(self.model.sounds) if x.key == s.key), -1)
def _select(self, s):
r = self._row(s)
if r < 0:
return False
idx = self.model.index(r, L_NAME)
if self.table.isRowHidden(r):
self.filter.clear()
if self.table.currentIndex().row() == r and self.editor.sound and self.editor.sound.key == s.key:
return True
self.table.setCurrentIndex(idx)
self.table.scrollTo(idx)
return True
def select_name(self, name):
s = next((x for x in self.model.sounds if x.name == name), None)
return bool(s) and self._select(s)
def step(self, d):
"""Previous / next visible sound of the list."""
rows = [r for r in range(len(self.model.sounds)) if not self.table.isRowHidden(r)]
if not rows:
return
cur = self.table.currentIndex().row()
nxt = [r for r in rows if (r > cur if d > 0 else r < cur)]
if nxt:
self._select(self.model.sounds[nxt[0] if d > 0 else nxt[-1]])
def _nav(self):
rows = [r for r in range(len(self.model.sounds)) if not self.table.isRowHidden(r)]
cur = self.table.currentIndex().row()
self.editor.b_prev.setEnabled(any(r < cur for r in rows))
self.editor.b_next.setEnabled(any(r > cur for r in rows))
def selected_sounds(self):
return [self.model.sounds[i.row()] for i in self.table.selectionModel().selectedRows()]
def remove_selected(self):
self._remove(self.selected_sounds())
def remove_applied(self):
self._remove([s for s in self.model.sounds if s.key in self.model.modified])
def _remove(self, sounds):
if not sounds:
return
self.store.remove([s.key for s in sounds])
gone = {s.key for s in sounds}
if self.editor.sound and self.editor.sound.key in gone:
self.editor.set_sound(None)
self.model.reset([s for s in self.model.sounds if s.key not in gone], self.model.modified)
self._apply_filter()
self._nav()
def use_for_selected(self):
settings = self.rack.settings()
sounds = self.selected_sounds()
for s in sounds:
self.store.update(s.key, fx=settings)
self.refresh()
self.message.emit("Effects set for %d sound(s)" % len(sounds))
# --- one sound -------------------------------------------------------------------------------
def flush(self):
"""Write pending edit values now (switching sounds, window closing)."""
if self._save_timer.isActive():
self._save_timer.stop()
self._save_settings()
def load(self, s):
"""Show `s` with its saved replacement, edit values and effects."""
self.flush()
self._restoring = True
try:
rec = self.store.get(s.key) if s else {}
settings = fx.normalize(rec.get("fx"))
self.rack.set_settings(settings)
self.rack.set_channels(s.channels if s else 1)
self.editor.effects = settings
self.editor.set_sound(s)
ed = self.editor
saved = self.store.load_replacement(s.key) if s else None
if saved:
ed.set_source(saved[0], saved[1], rec.get("source") or "replacement", restoring=True)
a, b = rec.get("trim") or (0, 0)
if b > a:
ed.sp_a.setValue(a)
ed.sp_b.setValue(b)
ed.sp_gain.setValue(rec.get("gain", 0.0))
ed.cb_norm.setChecked(rec.get("normalize", False))
ed.cb_match.setChecked(rec.get("match", s.group == "Speech"))
else:
ed.cb_match.setChecked(bool(s) and s.group == "Speech") # voices: as loud as the original
finally:
self._restoring = False
self._save_timer.stop()
self._nav()
def _source_changed(self):
s, ed = self.editor.sound, self.editor
if self._restoring or not s or not ed.src:
return
self.store.save_replacement(s.key, ed.src[0], ed.src[1], ed.src_name)
self._save_settings()
self.refresh()
def _effects_changed(self, settings):
if self._restoring:
return
self.editor.set_effects(settings)
self._save_timer.start()
def _save_settings(self):
s, ed = self.editor.sound, self.editor
if self._restoring or not s:
return
rec = {"fx": self.rack.settings(), "gain": ed.sp_gain.value(), "normalize": ed.cb_norm.isChecked(),
"match": ed.cb_match.isChecked()}
if ed.src:
rec["trim"] = [ed.sp_a.value(), ed.sp_b.value()]
old = self.store.get(s.key)
if not ed.src and not fx.active(rec["fx"]) and not old:
return # nothing to keep for an untouched sound
if any(old.get(k) != v for k, v in rec.items()):
self.store.update(s.key, **rec)
self.refresh()
def _after_write(self, key):
self.ctx._after_change(key)
s = self.editor.sound
if s and s.key == key:
QTimer.singleShot(0, lambda: self.load(s) if self.editor.sound is s else None)

View File

@@ -1,14 +1,16 @@
"""Single-sound editor: listen, substitute (file or recording), process, apply / revert.""" """Single-sound editor of the Edit tab: listen, replace (file or recording), process with effects (`effects`,
ackit/fx.py, between the trim and the loudness steps), apply / revert."""
import json
import os import os
import numpy as np import numpy as np
from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QGridLayout, QGroupBox, QSplitter, from PySide6.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, QSlider, QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, QSizePolicy, QSlider,
QVBoxLayout, QWidget) QVBoxLayout, QWidget)
from ackit import audio, workspace as W from ackit import audio, fx, workspace as W
from . import icons, theme from . import icons, theme
from .audioio import Recorder, input_devices, find_device from .audioio import Recorder, input_devices, find_device
from .spinner import Spinner from .spinner import Spinner
@@ -29,6 +31,8 @@ class EditorPanel(QWidget):
applied = Signal(str) # sound key applied = Signal(str) # sound key
reverted = Signal(str) reverted = Signal(str)
message = Signal(str) message = Signal(str)
source_changed = Signal() # a new replacement source (file, recording)
edited = Signal() # trim / gain / normalize / loudness changed
def __init__(self, ctx, parent=None): def __init__(self, ctx, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -39,6 +43,10 @@ class EditorPanel(QWidget):
self.src = None # replacement source (x, rate) before processing self.src = None # replacement source (x, rate) before processing
self.src_name = "" self.src_name = ""
self.playing_what = None self.playing_what = None
self.effects = None # effects settings (ackit.fx) or None
self._src_gen = 0 # identity of the current source (effects cache key)
self._fx_cache = (None, None) # (key, rendered audio)
self._fx_pending = None
self.recorder = Recorder(self) self.recorder = Recorder(self)
self._build() self._build()
self.set_sound(None) self.set_sound(None)
@@ -66,14 +74,17 @@ class EditorPanel(QWidget):
def _wave(self, kind, selectable=False): def _wave(self, kind, selectable=False):
w = Waveform(selectable=selectable) w = Waveform(selectable=selectable)
w.accent, w.bg = theme.WAVE[kind], theme.WAVE_BG w.accent, w.bg = theme.WAVE[kind], theme.WAVE_BG
w.setMinimumHeight(44) # three rows + the rest fit the Edit tab without scrolling
w.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # and grow with the window
return w return w
def _build(self): def _build(self):
"""Header on top; below, side by side (resizable): LISTEN | REPLACE (with the apply buttons) | SUBTITLE.""" """Top to bottom: header, SUBTITLE (when the sound has one), LISTEN (original, installed and replacement,
each with its play button), REPLACEMENT (record / load, trim, level), then the action bar."""
self.setObjectName("EditorPanel") self.setObjectName("EditorPanel")
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(8, 6, 8, 6) lay.setContentsMargins(8, 4, 8, 2)
lay.setSpacing(4) lay.setSpacing(3)
self.title = QLabel() self.title = QLabel()
self.title.setObjectName("EditorTitle") self.title.setObjectName("EditorTitle")
self.title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
@@ -93,23 +104,41 @@ class EditorPanel(QWidget):
self.meta.setObjectName("Muted") self.meta.setObjectName("Muted")
lay.addWidget(self.meta) lay.addWidget(self.meta)
# listen ---------------------------------------------------------------------------------- # the subtitle, large ------------------------------------------------------------------------
from .textpanel import SubtitlePanel
self.subtitle_box = QGroupBox("SUBTITLE")
sl = QVBoxLayout(self.subtitle_box)
sl.setContentsMargins(8, 8, 8, 8)
self.subtitle = SubtitlePanel(self.ctx, self.subtitle_box)
sl.addWidget(self.subtitle)
lay.addWidget(self.subtitle_box)
# listen: one row per version, its play button on the left -----------------------------------
g = QGroupBox("LISTEN") g = QGroupBox("LISTEN")
gl = QGridLayout(g) gl = QGridLayout(g)
gl.setContentsMargins(8, 8, 8, 8) gl.setContentsMargins(8, 8, 8, 8)
gl.setVerticalSpacing(4) gl.setVerticalSpacing(4)
gl.setHorizontalSpacing(8)
self.wf_orig = self._wave("orig") self.wf_orig = self._wave("orig")
self.wf_inst = self._wave("inst") self.wf_inst = self._wave("inst")
self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"), "Play the original (Space)", icons.STEEL) self.wf_new = self._wave("new", selectable=True)
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e") self.wf_new.setToolTip("Click: play from here. Right-drag (or Shift+drag): select the part to keep.")
self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"),
"Play the game's original (Space plays the replacement once there is one)", icons.STEEL)
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now",
"#8f4a4e")
self.b_play_new = self._btn("Replacement", "play", lambda: self.play("new"),
"Play the replacement as it will be applied (trim, effects, level)", icons.ACCENT)
for i, (b, w) in enumerate(((self.b_play_orig, self.wf_orig), (self.b_play_inst, self.wf_inst),
(self.b_play_new, self.wf_new))):
b.setMinimumWidth(110)
gl.addWidget(b, i, 0)
gl.addWidget(w, i, 1)
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)") self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
# playback options next to the volume: icons with tooltips self.b_ab = self._btn("A/B", "ab", self.ab_switch, "Switch between the original and the replacement at the same "
self.b_ab = self._btn("", "ab", self.ab_switch, "A/B: switch between the original and the replacement at " "position, to compare", icons.ACCENT)
"the same position", icons.ACCENT) self.cb_loop = self._btn("Loop", "loop", None, "Repeat playback")
self.cb_loop = self._btn("", "loop", None, "Loop: repeat playback")
self.cb_loop.setCheckable(True) self.cb_loop.setCheckable(True)
for b in (self.b_ab, self.cb_loop):
b.setFixedWidth(b.sizeHint().height() + 4)
self.sl_vol = QSlider(Qt.Orientation.Horizontal) self.sl_vol = QSlider(Qt.Orientation.Horizontal)
self.sl_vol.setRange(0, 100) self.sl_vol.setRange(0, 100)
self.sl_vol.setValue(80) self.sl_vol.setValue(80)
@@ -120,71 +149,65 @@ class EditorPanel(QWidget):
self.lbl_time.setObjectName("Muted") self.lbl_time.setObjectName("Muted")
self.lbl_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self.lbl_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.lbl_time.setMinimumWidth(self.lbl_time.fontMetrics().horizontalAdvance("00:00.00") + 4) self.lbl_time.setMinimumWidth(self.lbl_time.fontMetrics().horizontalAdvance("00:00.00") + 4)
gl.addWidget(QLabel("Original"), 0, 0) tr = QHBoxLayout()
gl.addWidget(self.wf_orig, 0, 1) tr.setSpacing(6)
self.lbl_inst = QLabel("Installed") for w in (self.b_stop, self.b_ab, self.cb_loop):
gl.addWidget(self.lbl_inst, 1, 0) tr.addWidget(w)
gl.addWidget(self.wf_inst, 1, 1) tr.addSpacing(10)
gl.setRowStretch(2, 1) # controls at the bottom, level with the other columns' actions tr.addWidget(QLabel("Volume"))
gl.addLayout(self._fill((self.b_play_orig, 1), (self.b_play_inst, 1), (self.b_stop, 1)), 4, 0, 1, 2) tr.addWidget(self.sl_vol, 1)
vol = QHBoxLayout() # playback options: A/B, loop, volume, position tr.addWidget(self.lbl_time)
vol.setSpacing(6) gl.addLayout(tr, 3, 0, 1, 2)
vol.addWidget(self.b_ab)
vol.addWidget(self.cb_loop)
vol.addSpacing(6)
vol.addWidget(QLabel("Volume"))
vol.addWidget(self.sl_vol, 1)
vol.addWidget(self.lbl_time)
gl.addLayout(vol, 3, 0, 1, 2)
gl.setColumnStretch(1, 1) gl.setColumnStretch(1, 1)
self.wf_orig.seekRequested.connect(lambda t: self.play("orig", t)) self.wf_orig.seekRequested.connect(lambda t: self.play("orig", t))
self.wf_inst.seekRequested.connect(lambda t: self.play("inst", t)) self.wf_inst.seekRequested.connect(lambda t: self.play("inst", t))
listen = g self.wf_new.seekRequested.connect(lambda t: self.play("new", t))
self.wf_new.selectionChanged.connect(self._selection)
for r in range(3):
gl.setRowStretch(r, 1) # the waveforms take the free height of the window
lay.addWidget(g, 1)
# replace --------------------------------------------------------------------------------- # replacement: source, trim, level -----------------------------------------------------------
g = QGroupBox("REPLACE") g = QGroupBox("REPLACEMENT")
rl = QVBoxLayout(g) rl = QVBoxLayout(g)
rl.setContentsMargins(8, 8, 8, 8) rl.setContentsMargins(8, 8, 8, 8)
rl.setSpacing(6) rl.setSpacing(6)
src = QHBoxLayout() src = QHBoxLayout()
src.setSpacing(6) src.setSpacing(6)
self.b_load = self._btn("Load file…", "open", self.load_file_dialog, self.b_rec = self._btn("Record", "record", self.toggle_record, "Record from the selected input")
"WAV, FLAC, OGG, OPUS, MP3, AIFF (M4A/WMA need ffmpeg installed) (Ctrl+O)") self.b_rec.setObjectName("Primary")
self.b_rec.setMinimumWidth(150)
self.cmb_in = QComboBox() self.cmb_in = QComboBox()
self.cmb_in.setMinimumWidth(110) self.cmb_in.setMinimumWidth(110)
self.cmb_in.setToolTip("Recording device") self.cmb_in.setToolTip("Recording device")
self.b_rec = self._btn("Record", "record", self.toggle_record, "Record from the selected input")
self.meter = QProgressBar() self.meter = QProgressBar()
self.meter.setRange(0, 100) self.meter.setRange(0, 100)
self.meter.setTextVisible(False) self.meter.setTextVisible(False)
self.meter.setFixedWidth(60) self.meter.setFixedWidth(80)
self.meter.setToolTip("Input level") self.meter.setToolTip("Input level")
self.lbl_rec = QLabel("") self.lbl_rec = QLabel("")
self.lbl_rec.setObjectName("Muted") self.lbl_rec.setObjectName("Muted")
src.addWidget(self.b_load, 1) # the row fills the column: file | microphone, level self.b_load = self._btn("Load file…", "open", self.load_file_dialog,
"WAV, FLAC, OGG, OPUS, MP3, AIFF (M4A/WMA need ffmpeg installed) (Ctrl+O)")
src.addWidget(self.b_rec)
src.addWidget(self.cmb_in, 1)
src.addWidget(self.meter)
src.addWidget(self.lbl_rec)
src.addSpacing(10) src.addSpacing(10)
src.addWidget(self.cmb_in, 2) src.addWidget(self.b_load)
src.addWidget(self.b_rec, 1)
for w in (self.meter, self.lbl_rec):
src.addWidget(w)
rl.addLayout(src) rl.addLayout(src)
self.wf_new = self._wave("new", selectable=True)
self.wf_new.setToolTip("Click: play from here. Right-drag (or Shift+drag): select the part to keep.")
self.wf_new.seekRequested.connect(lambda t: self.play("new", t))
self.wf_new.selectionChanged.connect(self._selection)
rl.addWidget(self.wf_new)
self.sp_a = QDoubleSpinBox() self.sp_a = QDoubleSpinBox()
self.sp_b = QDoubleSpinBox() self.sp_b = QDoubleSpinBox()
for s_ in (self.sp_a, self.sp_b): for s_ in (self.sp_a, self.sp_b):
s_.setDecimals(2) s_.setDecimals(2)
s_.setSuffix(" s") s_.setSuffix(" s")
s_.setRange(0, 36000) s_.setRange(0, 36000)
s_.setMaximumWidth(80) s_.setMaximumWidth(90)
s_.valueChanged.connect(self._changed) s_.valueChanged.connect(self._changed)
self.sp_gain = QDoubleSpinBox() self.sp_gain = QDoubleSpinBox()
self.sp_gain.setRange(-40, 40) self.sp_gain.setRange(-40, 40)
self.sp_gain.setSuffix(" dB") self.sp_gain.setSuffix(" dB")
self.sp_gain.setMaximumWidth(80) self.sp_gain.setMaximumWidth(90)
self.sp_gain.valueChanged.connect(self._changed) self.sp_gain.valueChanged.connect(self._changed)
self.cb_norm = QCheckBox("Normalize") self.cb_norm = QCheckBox("Normalize")
self.cb_match = QCheckBox("Match loudness") self.cb_match = QCheckBox("Match loudness")
@@ -192,17 +215,14 @@ class EditorPanel(QWidget):
self.cb_norm.toggled.connect(self._changed) self.cb_norm.toggled.connect(self._changed)
self.cb_match.toggled.connect(self._changed) self.cb_match.toggled.connect(self._changed)
self.b_silence = self._btn("Trim silence", None, self.trim_silence, "Cut silence at the start and end") self.b_silence = self._btn("Trim silence", None, self.trim_silence, "Cut silence at the start and end")
self.b_play_new = self._btn("Replacement", "play", lambda: self.play("new"), "Play the processed replacement", proc = QGridLayout() # labels in one column, the fields aligned
icons.ACCENT)
# edit controls in a grid: labels in one column, the fields aligned, playback on the right
proc = QGridLayout()
proc.setHorizontalSpacing(6) proc.setHorizontalSpacing(6)
proc.setVerticalSpacing(6) proc.setVerticalSpacing(6)
proc.addWidget(QLabel("Keep"), 0, 0) proc.addWidget(QLabel("Keep"), 0, 0)
proc.addWidget(self.sp_a, 0, 1) proc.addWidget(self.sp_a, 0, 1)
proc.addWidget(QLabel("to"), 0, 2, Qt.AlignmentFlag.AlignCenter) proc.addWidget(QLabel("to"), 0, 2, Qt.AlignmentFlag.AlignCenter)
proc.addWidget(self.sp_b, 0, 3) proc.addWidget(self.sp_b, 0, 3)
proc.addLayout(self._fill((self.b_silence, 1), (self.b_play_new, 1)), 0, 4) # the rest of the width proc.addWidget(self.b_silence, 0, 4)
proc.addWidget(QLabel("Gain"), 1, 0) proc.addWidget(QLabel("Gain"), 1, 0)
proc.addWidget(self.sp_gain, 1, 1) proc.addWidget(self.sp_gain, 1, 1)
checks = QHBoxLayout() checks = QHBoxLayout()
@@ -211,7 +231,7 @@ class EditorPanel(QWidget):
checks.addWidget(self.cb_match) checks.addWidget(self.cb_match)
checks.addStretch(1) checks.addStretch(1)
proc.addLayout(checks, 1, 2, 1, 3) proc.addLayout(checks, 1, 2, 1, 3)
proc.setColumnStretch(4, 1) proc.setColumnStretch(5, 1)
rl.addLayout(proc) rl.addLayout(proc)
info = QHBoxLayout() info = QHBoxLayout()
self.lbl_new = QLabel() self.lbl_new = QLabel()
@@ -227,37 +247,33 @@ class EditorPanel(QWidget):
info.addWidget(self.warn_icon, 0, Qt.AlignmentFlag.AlignTop) info.addWidget(self.warn_icon, 0, Qt.AlignmentFlag.AlignTop)
info.addWidget(self.lbl_warn, 1) info.addWidget(self.lbl_warn, 1)
rl.addLayout(info) rl.addLayout(info)
# apply (belongs to the replacement) ------------------------------------------------------ lay.addWidget(g)
# actions -----------------------------------------------------------------------------------
self.b_prev = self._btn("Previous", "prev", None, "Previous sound of the edit list (Alt+Left)")
self.b_next = self._btn("Next", "next", None, "Next sound of the edit list (Alt+Right)")
self.b_apply = self._btn("Apply to game", "apply", self.apply, self.b_apply = self._btn("Apply to game", "apply", self.apply,
"Encode the replacement and install it in the game files (Ctrl+S)", "#ffffff") "Encode the replacement and install it in the game files (Ctrl+S)", "#ffffff")
self.b_apply.setObjectName("Primary") self.b_apply.setObjectName("Primary")
self.b_revert = self._btn("Revert", "revert", self.revert, "Put the game's original back") self.b_revert = self._btn("Revert", "revert", self.revert, "Put the game's original back")
self.b_export = self._btn("Save WAV…", "save", self.export_wav, self.b_export = self._btn("Save WAV…", "save", self.export_wav,
"Save the original (or the replacement) as a WAV file") "Save the original (or the replacement as applied) as a WAV file")
self.busy = Spinner(16) self.busy = Spinner(16)
self.lbl_busy = QLabel() self.lbl_busy = QLabel()
info.addWidget(self.busy, 0, Qt.AlignmentFlag.AlignTop) # encoding/writing: next to the status self.action_bar = QWidget() # the Edit tab keeps it below its scroll area (always visible)
info.addWidget(self.lbl_busy, 0, Qt.AlignmentFlag.AlignTop) act = QHBoxLayout(self.action_bar)
act = self._fill((self.b_apply, 2), (self.b_revert, 1), (self.b_export, 1)) act.setContentsMargins(8, 4, 8, 6)
rl.addStretch(1) act.setSpacing(6)
rl.addLayout(act) act.addWidget(self.b_prev, 1)
replace = g act.addSpacing(10)
act.addWidget(self.b_apply, 3)
# subtitle of this sound (edited side by side with the audio) ------------------------------- act.addWidget(self.b_revert, 1)
from .textpanel import SubtitleBox act.addWidget(self.b_export, 1)
gs = QGroupBox("SUBTITLE") act.addWidget(self.busy)
gsl = QVBoxLayout(gs) act.addWidget(self.lbl_busy)
gsl.setContentsMargins(8, 8, 8, 8) act.addSpacing(10)
self.subtitle = SubtitleBox(self.ctx, gs) act.addWidget(self.b_next, 1)
gsl.addWidget(self.subtitle, 1) lay.addWidget(self.action_bar)
self.columns = QSplitter(Qt.Orientation.Horizontal)
self.columns.setChildrenCollapsible(False)
self.columns.setHandleWidth(4)
for w, stretch in ((listen, 3), (replace, 4), (gs, 3)):
self.columns.addWidget(w)
self.columns.setStretchFactor(self.columns.count() - 1, stretch)
lay.addWidget(self.columns, 1)
self.recorder.level.connect(lambda v: self.meter.setValue(int(min(1.0, v) * 100))) self.recorder.level.connect(lambda v: self.meter.setValue(int(min(1.0, v) * 100)))
self.recorder.elapsed.connect(lambda t: self.lbl_rec.setText("recording %s" % fmt_s(t))) self.recorder.elapsed.connect(lambda t: self.lbl_rec.setText("recording %s" % fmt_s(t)))
@@ -303,13 +319,16 @@ class EditorPanel(QWidget):
self.recorder.stop() self.recorder.stop()
self.sound, self.orig, self.installed, self.src = s, None, None, None self.sound, self.orig, self.installed, self.src = s, None, None, None
enabled = s is not None enabled = s is not None
for w in self.findChildren(QPushButton) + [self.sp_a, self.sp_b, self.sp_gain, self.cb_norm, self.cb_match]: # the action bar may live outside this widget (the Edit tab keeps it below its scroll area)
buttons = set(self.findChildren(QPushButton)) | set(self.action_bar.findChildren(QPushButton))
for w in list(buttons) + [self.sp_a, self.sp_b, self.sp_gain, self.cb_norm, self.cb_match]:
w.setEnabled(enabled) w.setEnabled(enabled)
self.wf_orig.set_audio(None, 1, "select a sound" if s is None else "loading…", busy=s is not None) self.wf_orig.set_audio(None, 1, "select a sound" if s is None else "loading…", busy=s is not None)
self.wf_inst.set_audio(None, 1, "") self.wf_inst.set_audio(None, 1, "")
self.wf_new.set_audio(None, 1, "load a file or record a replacement") self.wf_new.set_audio(None, 1, "load a file or record a replacement")
self.lbl_new.setText("") self.lbl_new.setText("")
self.subtitle.set_sound(s) self.subtitle.set_sound(s)
self.subtitle_box.setVisible(bool(self.subtitle.keys))
if s is None: if s is None:
self.title.setText("No sound selected") self.title.setText("No sound selected")
self.meta.setText("Search and select a sound in the list.") self.meta.setText("Search and select a sound in the list.")
@@ -378,8 +397,9 @@ class EditorPanel(QWidget):
self.ctx.runner.start(lambda job: audio.load(path), done, failed) self.ctx.runner.start(lambda job: audio.load(path), done, failed)
return result[0] if result else True return result[0] if result else True
def set_source(self, x, sr, name): def set_source(self, x, sr, name, restoring=False):
self.src, self.src_name = (np.asarray(x, np.float32), sr), name self.src, self.src_name = (np.asarray(x, np.float32), sr), name
self._src_gen += 1
dur = audio.duration(x, sr) dur = audio.duration(x, sr)
self.sp_a.blockSignals(True) self.sp_a.blockSignals(True)
self.sp_b.blockSignals(True) self.sp_b.blockSignals(True)
@@ -390,6 +410,8 @@ class EditorPanel(QWidget):
self.sp_a.blockSignals(False) self.sp_a.blockSignals(False)
self.sp_b.blockSignals(False) self.sp_b.blockSignals(False)
self._update_new() self._update_new()
if not restoring:
self.source_changed.emit()
def toggle_record(self): def toggle_record(self):
if self.recorder.is_recording(): if self.recorder.is_recording():
@@ -425,15 +447,65 @@ class EditorPanel(QWidget):
def _changed(self, *_): def _changed(self, *_):
self._reproc.start() self._reproc.start()
self.edited.emit()
def processed(self): def set_effects(self, settings):
"""Replacement audio after trim / gain / normalize / loudness matching -> (x, rate) or None.""" self.effects = settings
if not self.src: self._changed()
return None
def _fx_key(self):
return (self._src_gen, self.sp_a.value(), self.sp_b.value(), json.dumps(self.effects, sort_keys=True),
self.sound.channels if self.sound else None)
def _effected(self, render=True):
"""Trimmed replacement with the effects (cached). With render=False: None while not rendered yet."""
x, sr = self.src x, sr = self.src
a, b = self.sp_a.value(), self.sp_b.value() a, b = self.sp_a.value(), self.sp_b.value()
if b > a: if b > a:
x = audio.trim(x, sr, a, b) x = audio.trim(x, sr, a, b)
if not fx.active(self.effects):
return x
key = self._fx_key()
if self._fx_cache[0] == key:
return self._fx_cache[1]
if not render:
return None
y = fx.render(x, sr, self.effects, key[-1])
self._fx_cache = (key, y)
return y
def _render_effects(self):
"""Render the effects in the background (spinner in the replacement waveform), then show the result."""
key = self._fx_key()
if self._fx_pending == key:
return
self._fx_pending = key
x, sr = self.src
a, b = self.sp_a.value(), self.sp_b.value()
if b > a:
x = audio.trim(x, sr, a, b)
settings = self.effects
self.wf_new.set_audio(None, 1, "applying effects…", busy=True)
def done(y):
if self._fx_pending == key:
self._fx_pending = None
self._fx_cache = (key, y)
if self.src and self._fx_key() == key:
self._update_new()
def failed(e, tb):
self._fx_pending = None
self._failed("The effects failed", e)
self.ctx.runner.start(lambda job: fx.render(x, sr, settings, key[-1]), done, failed)
def processed(self):
"""Replacement audio after trim / effects / gain / normalize / loudness matching -> (x, rate) or None."""
if not self.src:
return None
sr = self.src[1]
x = self._effected()
if self.cb_match.isChecked() and self.orig is not None: if self.cb_match.isChecked() and self.orig is not None:
x = audio.match_loudness(x, self.orig[0]) x = audio.match_loudness(x, self.orig[0])
if self.cb_norm.isChecked(): if self.cb_norm.isChecked():
@@ -443,6 +515,9 @@ class EditorPanel(QWidget):
return x, sr return x, sr
def _update_new(self): def _update_new(self):
if self.src and self._effected(render=False) is None:
self._render_effects()
return
p = self.processed() p = self.processed()
if p is None: if p is None:
self.wf_new.set_audio(None, 1, "load a file or record a replacement") self.wf_new.set_audio(None, 1, "load a file or record a replacement")
@@ -453,7 +528,9 @@ class EditorPanel(QWidget):
self.wf_new.set_audio(x, sr) self.wf_new.set_audio(x, sr)
dur = audio.duration(x, sr) dur = audio.duration(x, sr)
s = self.sound s = self.sound
self.lbl_new.setText("%s: %.2f s, peak %.1f dBFS" % (self.src_name, dur, audio.peak_db(x))) on = fx.summary(self.effects)
self.lbl_new.setText("%s%s: %.2f s, peak %.1f dBFS" % (self.src_name, " + " + on if on else "", dur,
audio.peak_db(x)))
warnings = [] warnings = []
if s and not s.is_pack_media and dur > s.seconds * 1.15 + 0.25: if s and not s.is_pack_media and dur > s.seconds * 1.15 + 0.25:
warnings.append("Longer than the original (%.2f s): it will be squeezed into the original's size " warnings.append("Longer than the original (%.2f s): it will be squeezed into the original's size "
@@ -475,6 +552,7 @@ class EditorPanel(QWidget):
if not a: if not a:
return return
self.playing_what = what self.playing_what = what
self.ctx.player.owner = self # two editors share the player: only this one shows playheads
self.ctx.player.play(a[0], a[1], start, loop=self.cb_loop.isChecked(), label=what) self.ctx.player.play(a[0], a[1], start, loop=self.cb_loop.isChecked(), label=what)
def toggle_play(self): def toggle_play(self):
@@ -498,6 +576,8 @@ class EditorPanel(QWidget):
w.set_playhead(None) w.set_playhead(None)
def _pos(self, t): def _pos(self, t):
if getattr(self.ctx.player, "owner", self) is not self:
return
self.lbl_time.setText(fmt_s(t)) self.lbl_time.setText(fmt_s(t))
wf = {"orig": self.wf_orig, "inst": self.wf_inst, "new": self.wf_new}.get(self.playing_what) wf = {"orig": self.wf_orig, "inst": self.wf_inst, "new": self.wf_new}.get(self.playing_what)
if wf: if wf:
@@ -533,6 +613,8 @@ class EditorPanel(QWidget):
return return
ws = self.ctx.ws ws = self.ctx.ws
meta = {"source": self.src_name} meta = {"source": self.src_name}
if fx.active(self.effects):
meta["effects"] = fx.summary(self.effects)
def work(job): def work(job):
job.report("encoding…") job.report("encoding…")

328
app/effects.py Normal file
View File

@@ -0,0 +1,328 @@
"""Effects panel of the Edit tab, built from ackit.fx (BLOCKS, PRESETS, LOOKS), sized to fit without scrolling:
a preset list (several effects at once), the effect chain (one line per effect: tick = on, click = show its
settings) and the settings of the selected effect (its preset buttons, one slider per parameter with a readable
value: kHz, ms, dB, %; logarithmic for frequencies and times). Double-click a slider to reset it; moving a
slider or pressing a preset turns its effect on."""
import json
import math
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QAbstractItemView, QApplication, QCheckBox, QComboBox, QGridLayout, QHBoxLayout,
QLabel, QListWidget, QListWidgetItem, QPushButton, QSlider, QStackedWidget,
QVBoxLayout, QWidget)
from ackit import fx
STEPS = 1000
def is_log(p):
return p.kind != "bool" and p.lo > 0 and p.hi / p.lo >= 20
def fmt(p, v):
"""Value as people read it: 3.4 kHz, 160 ms, -6.0 dB, +4.0 st, 35 %."""
if p.kind == "bool":
return "on" if v else "off"
if p.unit == "Hz":
return "%.1f kHz" % (v / 1000) if v >= 1000 else ("%.2f Hz" % v if v < 10 else "%d Hz" % round(v))
if p.unit in ("st", "dB"):
return "%+.1f %s" % (v, p.unit) if v else "0 %s" % p.unit
if p.unit == "ms":
return "%.1f ms" % v if v < 10 else "%d ms" % round(v)
if p.unit == "s":
return "%.2f s" % v
if not p.unit and p.kind == "float" and p.hi <= 1:
return "%d %%" % round(v * 100)
if p.kind == "int":
return "%d%s" % (v, " " + p.unit if p.unit else "")
return "%.1f %s" % (v, p.unit) if p.unit else "%.2f" % v
class Slider(QSlider):
"""Horizontal slider that ignores the mouse wheel unless focused (scrolling the panel never changes values)
and resets to its default on double-click."""
reset = Signal()
def __init__(self):
super().__init__(Qt.Orientation.Horizontal)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def wheelEvent(self, e):
if self.hasFocus():
super().wheelEvent(e)
else:
e.ignore()
def mouseDoubleClickEvent(self, e):
self.reset.emit()
class ParamControl(QWidget):
"""One parameter: name | slider | value (or a check box for on/off parameters)."""
changed = Signal()
def __init__(self, p, parent=None):
super().__init__(parent)
self.p = p
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(8)
self.name = QLabel(p.label)
self.name.setMinimumWidth(92)
self.name.setToolTip(p.tip)
lay.addWidget(self.name)
if p.kind == "bool":
self.check = QCheckBox()
self.check.setToolTip(p.tip)
self.check.toggled.connect(lambda _: self.changed.emit())
lay.addWidget(self.check, 1)
self.slider = self.value_lbl = None
return
self.check = None
self.slider = Slider()
self.slider.setRange(0, self.steps())
self.slider.setToolTip("%s\nDouble-click: back to %s" % (p.tip, fmt(p, p.default)))
self.value_lbl = QLabel()
self.value_lbl.setMinimumWidth(self.value_lbl.fontMetrics().horizontalAdvance("-10.0 kHz") + 4)
self.value_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.slider.valueChanged.connect(self._moved)
self.slider.reset.connect(lambda: (self.set_value(p.default), self.changed.emit()))
lay.addWidget(self.slider, 1)
lay.addWidget(self.value_lbl)
def steps(self):
p = self.p
return int(round((p.hi - p.lo) / p.step)) if p.kind == "int" else STEPS
def to_pos(self, v):
p = self.p
if p.kind == "int":
return int(round((v - p.lo) / p.step))
f = math.log(v / p.lo) / math.log(p.hi / p.lo) if is_log(p) else (v - p.lo) / (p.hi - p.lo)
return int(round(min(max(f, 0.0), 1.0) * STEPS))
def from_pos(self, i):
p = self.p
if p.kind == "int":
return int(p.lo + i * p.step)
f = i / STEPS
v = p.lo * (p.hi / p.lo) ** f if is_log(p) else p.lo + f * (p.hi - p.lo)
q = p.step or 0.01 # keep values tidy: rounded to the parameter's step (finer when low)
if is_log(p) and v < 10 * q:
q = q / 10
return round(round(v / q) * q, 6)
def _moved(self, i):
self._value = self.from_pos(i)
self.value_lbl.setText(fmt(self.p, self._value))
self.changed.emit()
def value(self):
if self.check is not None:
return self.check.isChecked()
return self._value
def set_value(self, v):
if self.check is not None:
self.check.blockSignals(True)
self.check.setChecked(bool(v))
self.check.blockSignals(False)
return
self._value = v
self.slider.blockSignals(True)
self.slider.setValue(self.to_pos(v))
self.slider.blockSignals(False)
self.value_lbl.setText(fmt(self.p, v))
class EffectsRack(QWidget):
changed = Signal(dict)
def __init__(self, parent=None):
super().__init__(parent)
self.controls, self.pages, self.items = {}, {}, {}
lay = QVBoxLayout(self)
lay.setContentsMargins(6, 6, 6, 6)
lay.setSpacing(6)
head = QHBoxLayout()
title = QLabel("EFFECTS")
f = title.font()
f.setBold(True)
title.setFont(f)
head.addWidget(title, 1)
self.b_reset = QPushButton("Reset")
self.b_reset.setToolTip("Every effect off, every value back to its default")
self.b_copy = QPushButton("Copy")
self.b_copy.setToolTip("Copy these settings (to paste them on another sound)")
self.b_paste = QPushButton("Paste")
self.b_paste.setToolTip("Use copied settings on this sound")
for b in (self.b_reset, self.b_copy, self.b_paste):
head.addWidget(b)
lay.addLayout(head)
self.cmb_preset = QComboBox()
self.cmb_preset.addItem("Start from a preset…")
for name, blocks in fx.LOOKS:
self.cmb_preset.addItem(name)
self.cmb_preset.setItemData(self.cmb_preset.count() - 1, "Turns on: " + ", ".join(
fx.BY_KEY[k].label for k in blocks) + " (every other effect off)", Qt.ItemDataRole.ToolTipRole)
self.cmb_preset.setToolTip("Sets several effects at once, like sounds heard in the game")
self.cmb_preset.activated.connect(self._preset_chosen)
lay.addWidget(self.cmb_preset)
self.chain = QListWidget() # the effects in their order: tick = on, select = settings
self.chain.setStyleSheet("QListWidget::item { padding: 3px 2px; } " # selected: grey, so a ticked
"QListWidget::item:selected { background: #3a3a3a; color: #ffffff; }") # box shows
self.chain.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.chain.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
for blk in fx.BLOCKS:
it = QListWidgetItem(blk.label)
it.setFlags(it.flags() | Qt.ItemFlag.ItemIsUserCheckable)
it.setCheckState(Qt.CheckState.Unchecked)
it.setToolTip(blk.tip)
it.setData(Qt.ItemDataRole.UserRole, blk.key)
self.chain.addItem(it)
self.items[blk.key] = it
rows = self.chain.sizeHintForRow(0) * len(fx.BLOCKS)
self.chain.setFixedHeight(rows + 2 * self.chain.frameWidth() + 2)
self.chain.itemChanged.connect(self._item_changed)
self.chain.currentItemChanged.connect(lambda cur, _: cur and self.show_block(
cur.data(Qt.ItemDataRole.UserRole)))
lay.addWidget(self.chain)
hint = QLabel("Tick an effect to use it, click its name for its settings. They run top to bottom.")
hint.setObjectName("Muted")
hint.setWordWrap(True)
lay.addWidget(hint)
self.stack = QStackedWidget()
for blk in fx.BLOCKS:
page = self._page(blk)
self.pages[blk.key] = page
self.stack.addWidget(page)
lay.addWidget(self.stack)
lay.addStretch(1)
self.b_reset.clicked.connect(lambda: (self.set_settings(fx.defaults()), self._emit()))
self.b_copy.clicked.connect(lambda: QApplication.clipboard().setText(json.dumps(self.settings())))
self.b_paste.clicked.connect(self.paste)
self.chain.setCurrentRow(0)
def _page(self, blk):
page = QWidget()
v = QVBoxLayout(page)
v.setContentsMargins(0, 4, 0, 0)
v.setSpacing(3)
name = QLabel(blk.label)
f = name.font()
f.setBold(True)
name.setFont(f)
v.addWidget(name)
about = QLabel(blk.tip)
about.setObjectName("Muted")
about.setWordWrap(True)
v.addWidget(about)
if fx.PRESETS.get(blk.key):
pg = QGridLayout()
pg.setSpacing(4)
for i, (pname, _) in enumerate(fx.PRESETS[blk.key]):
b = QPushButton(pname)
b.setToolTip("Turn %s on with the values '%s' (then adjust by ear)" % (blk.label, pname))
b.clicked.connect(lambda _=False, k=blk.key, n=pname: self.apply_preset(k, n))
pg.addWidget(b, i // 2, i % 2)
v.addLayout(pg)
for p in blk.params:
c = ParamControl(p)
c.set_value(p.default)
c.changed.connect(lambda k=blk.key: self._param_changed(k))
v.addWidget(c)
self.controls[(blk.key, p.key)] = c
v.addStretch(1)
return page
# --- on / off and the selected effect ----------------------------------------------------------
def is_on(self, key):
return self.items[key].checkState() == Qt.CheckState.Checked
def set_on(self, key, on):
self.items[key].setCheckState(Qt.CheckState.Checked if on else Qt.CheckState.Unchecked)
def show_block(self, key):
self.stack.setCurrentWidget(self.pages[key])
if self.chain.currentItem() is not self.items[key]:
self.chain.setCurrentItem(self.items[key])
def _style(self, key):
f = QFont(self.chain.font())
f.setBold(self.is_on(key)) # effects that are on stand out in the chain
self.items[key].setFont(f)
def _item_changed(self, it):
key = it.data(Qt.ItemDataRole.UserRole)
self.chain.blockSignals(True)
self._style(key)
self.chain.blockSignals(False)
self._emit()
def _param_changed(self, key):
if not self.is_on(key):
self.set_on(key, True) # emits through _item_changed
else:
self._emit()
def set_channels(self, n):
layout = fx.LAYOUTS.get(n, "")
self.chain.blockSignals(True)
self.items["channels"].setText("Channels (%s)" % ("mono: no effect" if n <= 1 else "%d ch, %s" % (n, layout)))
self.chain.blockSignals(False)
# --- settings ----------------------------------------------------------------------------------
def _emit(self):
if not self.signalsBlocked():
self.changed.emit(self.settings())
def settings(self):
out = {}
for b in fx.BLOCKS:
d = {"on": self.is_on(b.key)}
for p in b.params:
d[p.key] = self.controls[(b.key, p.key)].value()
out[b.key] = d
return fx.normalize(out)
def set_settings(self, settings):
s = fx.normalize(settings)
self.blockSignals(True)
self.chain.blockSignals(True)
for b in fx.BLOCKS:
self.set_on(b.key, s[b.key]["on"])
self._style(b.key)
for p in b.params:
self.controls[(b.key, p.key)].set_value(s[b.key][p.key])
self.chain.blockSignals(False)
self.blockSignals(False)
def apply_preset(self, block, name):
self.set_settings(fx.with_preset(self.settings(), block, name))
self.show_block(block)
self._emit()
def apply_look(self, name):
self.set_settings(fx.look(name))
on = [b.key for b in fx.BLOCKS if self.is_on(b.key)]
if on:
self.show_block(on[0])
self._emit()
def _preset_chosen(self, i):
if i > 0:
self.apply_look(self.cmb_preset.itemText(i))
self.cmb_preset.setCurrentIndex(0)
def paste(self):
try:
s = json.loads(QApplication.clipboard().text())
except ValueError:
return
if isinstance(s, dict) and any(k in s for k in fx.BY_KEY):
self.set_settings(s)
self._emit()

View File

@@ -146,6 +146,25 @@ def _draw(p, name, c, s):
p.drawPath(half) p.drawPath(half)
p.setBrush(Qt.BrushStyle.NoBrush) p.setBrush(Qt.BrushStyle.NoBrush)
p.drawEllipse(rect) p.drawEllipse(rect)
elif name in ("prev", "next"): # chevron
d = -1 if name == "prev" else 1
x0 = s / 2 - d * s * 0.12
p.drawLine(QPointF(x0, s * 0.24), QPointF(x0 + d * s * 0.24, s / 2))
p.drawLine(QPointF(x0 + d * s * 0.24, s / 2), QPointF(x0, s * 0.76))
elif name == "add": # plus
p.drawLine(QPointF(s / 2, s * 0.22), QPointF(s / 2, s * 0.78))
p.drawLine(QPointF(s * 0.22, s / 2), QPointF(s * 0.78, s / 2))
elif name == "edit": # pencil
p.translate(s / 2, s / 2)
p.rotate(-45)
p.drawRect(QRectF(-s * 0.30, -s * 0.09, s * 0.48, s * 0.18))
path = QPainterPath()
path.moveTo(s * 0.18, -s * 0.09)
path.lineTo(s * 0.36, 0)
path.lineTo(s * 0.18, s * 0.09)
p.drawPath(path)
p.rotate(45)
p.translate(-s / 2, -s / 2)
elif name == "search": elif name == "search":
p.drawEllipse(QRectF(s * 0.18, s * 0.18, s * 0.46, s * 0.46)) p.drawEllipse(QRectF(s * 0.18, s * 0.18, s * 0.46, s * 0.46))
p.drawLine(QPointF(s * 0.58, s * 0.58), QPointF(s * 0.84, s * 0.84)) p.drawLine(QPointF(s * 0.58, s * 0.58), QPointF(s * 0.84, s * 0.84))

View File

@@ -44,7 +44,15 @@ def smoke_test(game_dir=None, report=None):
ws = Workspace(Game(gd), store_root=os.path.join(td, "store")) ws = Workspace(Game(gd), store_root=os.path.join(td, "store"))
s = next(x for x in ws.catalog.sounds if x.group == "Speech" and 1.0 < x.seconds < 4.0) s = next(x for x in ws.catalog.sounds if x.group == "Speech" and 1.0 < x.seconds < 4.0)
x, sr = audio.decode_wem(ws.original_bytes(s)) x, sr = audio.decode_wem(ws.original_bytes(s))
wem, info = ws.prepare(s, x[::-1].copy(), sr) from ackit import fx # effects (pedalboard) on the way, as the Edit tab does
st = fx.defaults()
for k in ("pitch", "eq", "reverb"):
st[k]["on"] = True
st["pitch"]["shift"] = -3.0
y = fx.render(x[::-1].copy(), sr, st, s.channels)
print("effects OK: %s (pedalboard %s), %.2f s -> %.2f s"
% (fx.summary(st), __import__("pedalboard").__version__, len(x) / sr, len(y) / sr))
wem, info = ws.prepare(s, y, sr)
out = os.path.join(td, os.path.basename(ws.wad_path(s))) out = os.path.join(td, os.path.basename(ws.wad_path(s)))
inject(ws.wad_path(s), {s.target: wem}, {s.target: ws.limit(s)}, out_path=out) inject(ws.wad_path(s), {s.target: wem}, {s.target: ws.limit(s)}, out_path=out)
assert read_target(out, s.target) == wem assert read_target(out, s.target) == wem

View File

@@ -1,12 +1,13 @@
"""Main window. Tabs: Sounds (filters + sound table on top, single-sound editor below, with the sound's """Main window. Tabs: Sounds (browse: filters + sound table, a preview below; app/preview.py), Edit (the edit list,
subtitle), Subtitles and Menu text (app/textpanel.py).""" the editor and the effects: app/edit.py; the only place where sounds are replaced), Subtitles and Menu text
(app/textpanel.py)."""
import os import os
from PySide6.QtCore import QByteArray, QEvent, QItemSelectionModel, QSize, Qt, QTimer from PySide6.QtCore import QByteArray, QItemSelectionModel, QSize, Qt, QTimer
from PySide6.QtGui import QFont, QKeySequence, QShortcut from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout, from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox,
QPushButton, QScrollArea, QSplitter, QTableView, QTabWidget, QToolBar, QVBoxLayout, QPushButton, QSplitter, QTableView, QTabWidget, QToolBar, QVBoxLayout,
QWidget) QWidget)
from ackit import settings as settings_mod, workspace as W from ackit import settings as settings_mod, workspace as W
@@ -17,15 +18,15 @@ from ackit.texts import TextConflictError
from . import icons, theme from . import icons, theme
from .audioio import Player, find_device, output_devices from .audioio import Player, find_device, output_devices
from .dialogs import ExportModDialog, ImportModDialog, SettingsDialog, pick_game_dir from .dialogs import ExportModDialog, ImportModDialog, SettingsDialog, pick_game_dir
from .editor import EditorPanel from .edit import EditPanel
from .models import C_FAV, C_NAME, COLS, SoundFilter, SoundModel from .models import C_FAV, C_NAME, COLS, SoundFilter, SoundModel
from .preview import PreviewPanel
from .spinner import BusyOverlay, Spinner from .spinner import BusyOverlay, Spinner
from .textpanel import TextPanel from .textpanel import TextPanel
from .userdata import UserData from .userdata import UserData
from .workers import Runner from .workers import Runner
SIDEBAR_MIN, SIDEBAR_MAX = 220, 320 SIDEBAR_MIN, SIDEBAR_MAX = 220, 320
TABLE_MIN = 160 # the sound list never gets smaller than this when fitting the editor
ANY = "Any" ANY = "Any"
@@ -39,8 +40,7 @@ def editor_languages(game, language=None):
"""(language, voices): the selected language (default: the game's) and the speech archive used for it """(language, voices): the selected language (default: the game's) and the speech archive used for it
(the language itself, or INT for languages the game has no voices for).""" (the language itself, or INT for languages the game has no voices for)."""
lang = (language or game.language() or "INT").upper() lang = (language or game.language() or "INT").upper()
voices = lang if lang in game.languages else ("INT" if "INT" in game.languages else game.languages[0]) return lang, game.voice_archive(lang)
return lang, voices
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -100,7 +100,7 @@ class MainWindow(QMainWindow):
tb.addWidget(self.lbl_voices) tb.addWidget(self.lbl_voices)
tb.addSeparator() tb.addSeparator()
self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all) self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all)
self.act_revert_all.setToolTip("Put every original sound back") self.act_revert_all.setToolTip("Put every original sound and text back (all languages)")
tb.addAction(icons.icon("export"), "Export mod…", self.export_mod_dialog) tb.addAction(icons.icon("export"), "Export mod…", self.export_mod_dialog)
tb.addAction(icons.icon("import"), "Import mod…", self.import_mod_dialog) tb.addAction(icons.icon("import"), "Import mod…", self.import_mod_dialog)
tb.addSeparator() tb.addSeparator()
@@ -197,20 +197,16 @@ class MainWindow(QMainWindow):
self.table.sortByColumn(C_NAME, Qt.SortOrder.AscendingOrder) self.table.sortByColumn(C_NAME, Qt.SortOrder.AscendingOrder)
self.table.selectionModel().currentRowChanged.connect(self._current_changed) self.table.selectionModel().currentRowChanged.connect(self._current_changed)
self.table.clicked.connect(self._clicked) self.table.clicked.connect(self._clicked)
self.table.doubleClicked.connect(lambda _: self.editor.play("orig")) self.table.doubleClicked.connect(lambda _: self.preview.play("orig"))
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._context_menu) self.table.customContextMenuRequested.connect(self._context_menu)
# editor # preview of the selected sound (no editing here)
self.editor = EditorPanel(self) self.preview = PreviewPanel(self)
self.editor.applied.connect(self._after_change) self.preview.edit_requested.connect(self.edit_selected)
self.editor.reverted.connect(self._after_change) self.preview.add_requested.connect(self.add_selected_to_edit)
self.editor.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
scroll = QScrollArea()
scroll.setWidget(self.editor)
scroll.setWidgetResizable(True)
# sidebar | sound list on top, the editor below across the whole width # sidebar | sound list on top, the preview below across the whole width
hs = QSplitter(Qt.Orientation.Horizontal) hs = QSplitter(Qt.Orientation.Horizontal)
hs.addWidget(side) hs.addWidget(side)
hs.addWidget(self.table) hs.addWidget(self.table)
@@ -218,18 +214,19 @@ class MainWindow(QMainWindow):
hs.setSizes([SIDEBAR_MAX, 100000]) hs.setSizes([SIDEBAR_MAX, 100000])
self.vsplit = QSplitter(Qt.Orientation.Vertical) self.vsplit = QSplitter(Qt.Orientation.Vertical)
self.vsplit.addWidget(hs) self.vsplit.addWidget(hs)
self.vsplit.addWidget(scroll) self.vsplit.addWidget(self.preview)
self.vsplit.setStretchFactor(0, 1) # extra height goes to the list; the editor keeps its fitted height self.vsplit.setStretchFactor(0, 1) # extra height goes to the list
self.vsplit.setStretchFactor(1, 0) self.vsplit.setStretchFactor(1, 0)
self.scroll = scroll self.vsplit.setChildrenCollapsible(False)
self._fitted = False # sections: sounds (browse), edit, subtitles, menu text
self._user_sized = False # once the divider is dragged, keep the user's choice
self.vsplit.splitterMoved.connect(lambda *_: setattr(self, "_user_sized", True))
self.editor.installEventFilter(self)
# sections: sounds, subtitles, menu text
self.tabs = QTabWidget() self.tabs = QTabWidget()
self.tabs.setDocumentMode(True) self.tabs.setDocumentMode(True)
self.tabs.addTab(self.vsplit, icons.icon("wave"), "Sounds") self.tabs.addTab(self.vsplit, icons.icon("wave"), "Sounds")
self.edit = EditPanel(self)
self.editor = self.edit.editor # the one sound editor
self.tabs.addTab(self.edit, icons.icon("edit"), "Edit")
self.edit.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
self.editor.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
self.subs = TextPanel(self, "subs") self.subs = TextPanel(self, "subs")
self.menu = TextPanel(self, "menu") self.menu = TextPanel(self, "menu")
self.tabs.addTab(self.subs, icons.icon("ab"), "Subtitles") self.tabs.addTab(self.subs, icons.icon("ab"), "Subtitles")
@@ -256,12 +253,15 @@ class MainWindow(QMainWindow):
sc = Qt.ShortcutContext.WindowShortcut sc = Qt.ShortcutContext.WindowShortcut
QShortcut(QKeySequence("Ctrl+F"), self, self.search.setFocus, context=sc) QShortcut(QKeySequence("Ctrl+F"), self, self.search.setFocus, context=sc)
QShortcut(QKeySequence("Ctrl+S"), self, lambda: self.editor.apply(), context=sc) edit = Qt.ShortcutContext.WidgetWithChildrenShortcut # editing shortcuts: in the Edit tab only
QShortcut(QKeySequence("Ctrl+O"), self, lambda: self.editor.load_file_dialog(), context=sc) QShortcut(QKeySequence("Ctrl+S"), self.edit, lambda: self.editor.apply(), context=edit)
QShortcut(QKeySequence("Escape"), self, self.editor.stop, context=sc) QShortcut(QKeySequence("Ctrl+O"), self.edit, lambda: self.editor.load_file_dialog(), context=edit)
QShortcut(QKeySequence(Qt.Key.Key_Space), self.table, self.editor.toggle_play, QShortcut(QKeySequence("Escape"), self, self.player.stop, context=sc)
context=Qt.ShortcutContext.WidgetShortcut) wsc = Qt.ShortcutContext.WidgetShortcut
QShortcut(QKeySequence(Qt.Key.Key_F2), self.table, self.edit_notes, context=Qt.ShortcutContext.WidgetShortcut) QShortcut(QKeySequence(Qt.Key.Key_Space), self.table, self.preview.toggle_play, context=wsc)
QShortcut(QKeySequence(Qt.Key.Key_Return), self.table, self.edit_selected, context=wsc)
QShortcut(QKeySequence(Qt.Key.Key_Enter), self.table, self.edit_selected, context=wsc)
QShortcut(QKeySequence(Qt.Key.Key_F2), self.table, self.edit_notes, context=wsc)
# --- dialogs (overridable in tests) ----------------------------------------------------------- # --- dialogs (overridable in tests) -----------------------------------------------------------
def confirm(self, title, text): def confirm(self, title, text):
@@ -356,6 +356,7 @@ class MainWindow(QMainWindow):
fill(self.cmb_arch, list(ws.catalog.archives)) fill(self.cmb_arch, list(ws.catalog.archives))
fill(self.cmb_tag, self.userdata.all_tags()) fill(self.cmb_tag, self.userdata.all_tags())
self._filters_changed() self._filters_changed()
self.edit.populate(ws)
def _fill_categories(self): def _fill_categories(self):
g = self.cmb_group.currentText() g = self.cmb_group.currentText()
@@ -406,7 +407,22 @@ class MainWindow(QMainWindow):
def _current_changed(self, cur, prev): def _current_changed(self, cur, prev):
if cur.isValid(): if cur.isValid():
self.editor.set_sound(self.proxy.sound_at(cur)) self.preview.set_sound(self.proxy.sound_at(cur))
def edit_selected(self):
"""Selected sounds onto the edit list; the Edit tab opens on the first."""
sel = self.selected_sounds() or ([self.preview.sound] if self.preview.sound else [])
if sel:
self.edit_sounds(sel)
def edit_sounds(self, sounds):
self.edit.add(sounds, open_first=True)
self.tabs.setCurrentWidget(self.edit)
def add_selected_to_edit(self):
sel = self.selected_sounds()
self.edit.add(sel)
self.statusBar().showMessage("%d sound(s) on the edit list (Edit tab)" % len(sel), 5000)
def _clicked(self, index): def _clicked(self, index):
if index.column() == C_FAV: if index.column() == C_FAV:
@@ -430,7 +446,10 @@ class MainWindow(QMainWindow):
if not sel: if not sel:
return return
m = QMenu(self) m = QMenu(self)
m.addAction("Play original", lambda: self.editor.play("orig")) m.addAction(icons.icon("edit"), "Edit (Enter)", self.edit_selected)
m.addAction(icons.icon("add"), "Add to edit list", self.add_selected_to_edit)
m.addSeparator()
m.addAction("Play original (Space)", lambda: self.preview.play("orig"))
m.addAction("Notes && tags… (F2)", self.edit_notes) m.addAction("Notes && tags… (F2)", self.edit_notes)
m.addAction("Toggle favorite", lambda: self._toggle_fav(sel)) m.addAction("Toggle favorite", lambda: self._toggle_fav(sel))
m.addSeparator() m.addSeparator()
@@ -493,6 +512,8 @@ class MainWindow(QMainWindow):
if panel.kind in self._text_loaded: if panel.kind in self._text_loaded:
panel.refresh() panel.refresh()
self.editor.subtitle.set_sound(self.editor.sound) self.editor.subtitle.set_sound(self.editor.sound)
if self.preview.sound:
self.preview.set_sound(self.preview.sound)
def show_sound(self, key): def show_sound(self, key):
"""Sounds tab with `key` selected (from a subtitle).""" """Sounds tab with `key` selected (from a subtitle)."""
@@ -516,8 +537,12 @@ class MainWindow(QMainWindow):
# --- changes ---------------------------------------------------------------------------------- # --- changes ----------------------------------------------------------------------------------
def _after_change(self, key=None): def _after_change(self, key=None):
self.model.refresh_keys(modified=self.ws.modified_keys()) # the view refilters/re-sorts itself modified = self.ws.modified_keys()
self.model.refresh_keys(modified=modified) # the view refilters/re-sorts itself
self._update_count() self._update_count()
self.edit.refresh(modified)
if self.preview.sound:
self.preview.set_sound(self.preview.sound)
def revert_sounds(self, sounds): def revert_sounds(self, sounds):
sounds = [s for s in sounds if self.ws.store.get(s.key)] sounds = [s for s in sounds if self.ws.store.get(s.key)]
@@ -528,35 +553,52 @@ class MainWindow(QMainWindow):
"Reverted %d sound(s)." % len(sounds)) "Reverted %d sound(s)." % len(sounds))
def revert_all(self): def revert_all(self):
"""Every change of this store: sounds, subtitles and menu texts (every language)."""
if not self.ws: if not self.ws:
return return
n = len(self.ws.store.entries) n, t = len(self.ws.store.entries), len(self.ws.texts.records())
if not n: if not n and not t:
self.inform("Revert all", "All sounds are already original.") self.inform("Revert all", "All sounds and texts are already original.")
return return
if not self.confirm("Revert all", "Put the original back for all %d changed sound(s)?" % n): if not self.confirm("Revert all", "Put the original back for all %d changed sound(s) and %d changed "
"text(s)?" % (n, t)):
return return
self._run_change("Reverting everything…", lambda job: self.ws.revert_all(job.report),
"All sounds are original again.")
def _run_change(self, busy, fn, ok_msg): def work(job):
self.ws.revert_all(job.report)
self.ws.texts.revert_all(job.report)
self._run_change("Reverting everything…", work, "All sounds and texts are original again.")
def _run_change(self, busy, fn, ok_msg, ask_force=False):
"""Run a change as a background job. ask_force: fn(job, force); when files were changed outside this
tool, ask the user and run it again with force=True."""
self.statusBar().showMessage(busy) self.statusBar().showMessage(busy)
self.spinner.show() self.spinner.show()
def done(_): def done(_):
self.spinner.hide() self.spinner.hide()
self.statusBar().showMessage(ok_msg, 8000) self.statusBar().showMessage(ok_msg, 8000)
self._after_change() self._refresh_after_change()
self._texts_changed()
if self.editor.sound:
self.editor.set_sound(self.editor.sound)
self.check_problems() self.check_problems()
def failed(e, tb): def failed(e, tb):
self.spinner.hide() self.spinner.hide()
if self.ws:
self._refresh_after_change() # part of the change may have been written
if ask_force and isinstance(e, (W.ConflictError, TextConflictError)):
if self.confirm("Changed outside this tool", "%s\n\nOverwrite them?" % e):
self._run_change(busy, lambda job: fn(job, True), ok_msg)
return
self.inform("Error", str(e)) self.inform("Error", str(e))
self.runner.start(fn, done, failed, lambda m: self.statusBar().showMessage(m)) self.runner.start((lambda job: fn(job, False)) if ask_force else fn, done, failed,
lambda m: self.statusBar().showMessage(m))
def _refresh_after_change(self):
self._after_change()
self._texts_changed()
if self.editor.sound:
self.edit.load(self.editor.sound)
# --- changed underneath ----------------------------------------------------------------------- # --- changed underneath -----------------------------------------------------------------------
def check_problems(self): def check_problems(self):
@@ -628,12 +670,8 @@ class MainWindow(QMainWindow):
def forget_problems(self): def forget_problems(self):
if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."): if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."):
return return
for s, _ in getattr(self, "problems", []): self.ws.forget([s.key for s, _ in getattr(self, "problems", [])],
self.ws.store.forget(s.key) [k for k, _ in getattr(self, "text_problems", [])])
for k, _ in getattr(self, "text_problems", []):
self.ws.texts.records().pop(k, None)
self.ws.store.save()
self.ws.store.gc()
self.banner.hide() self.banner.hide()
self._after_change() self._after_change()
@@ -680,25 +718,22 @@ class MainWindow(QMainWindow):
if dlg and dlg.exec() != QDialog.DialogCode.Accepted: if dlg and dlg.exec() != QDialog.DialogCode.Accepted:
return return
ws = self.ws ws = self.ws
prepared = []
def work(job): def work(job, force):
prepare_import(ws, path, items, progress=job.report) if not prepared: # encode once (a confirmed overwrite runs this again)
prepare_import(ws, path, items, progress=job.report)
prepared.append(True)
source = {"source": "mod:" + os.path.basename(path)} source = {"source": "mod:" + os.path.basename(path)}
changes = [(im.sound, im.wem, source) for im in items if im.wem] changes = [(im.sound, im.wem, source) for im in items if im.wem]
if changes: if changes:
try: ws.apply(changes, force=force, progress=job.report)
ws.apply(changes, progress=job.report)
except W.ConflictError:
ws.apply(changes, force=True, progress=job.report)
texts = {k: v for k, v, problem in match_texts(ws, man) if not problem} texts = {k: v for k, v, problem in match_texts(ws, man) if not problem}
if texts: if texts:
try: ws.texts.apply(texts, force=force, progress=job.report, meta=source)
ws.texts.apply(texts, progress=job.report, meta=source)
except TextConflictError:
ws.texts.apply(texts, force=True, progress=job.report, meta=source)
return len(changes) + len(texts) return len(changes) + len(texts)
self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path)) self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path), ask_force=True)
# --- settings --------------------------------------------------------------------------------- # --- settings ---------------------------------------------------------------------------------
def settings_dialog(self): def settings_dialog(self):
@@ -706,7 +741,16 @@ class MainWindow(QMainWindow):
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
old = dict(self.settings) old = dict(self.settings)
self.settings.update(dlg.values()) new = dlg.values()
if self.ws and (new.get("store_root") or None) != (old.get("store_root") or None) and (
self.ws.store.entries or self.ws.texts.records()):
# the changes are recorded in the current store only; another store would take the modified files
# as originals (and could not revert these changes)
self.inform("Settings", "The originals store keeps the originals of the changes you have applied. "
"Use Revert all before choosing another store folder; the store was not "
"changed.")
new["store_root"] = old.get("store_root")
self.settings.update(new)
self.save_settings() self.save_settings()
self._apply_output_device() self._apply_output_device()
self.sidebar.configure(self.settings) self.sidebar.configure(self.settings)
@@ -726,31 +770,8 @@ class MainWindow(QMainWindow):
except OSError: except OSError:
pass pass
def fit_editor(self):
"""Give the editor panel the height of its contents (everything visible), the list gets the rest."""
total = sum(self.vsplit.sizes()) or self.vsplit.height()
lay = self.editor.layout()
need = self.editor.sizeHint().height()
if lay.hasHeightForWidth(): # wrapped labels: height at the actual width
need = max(need, lay.heightForWidth(self.scroll.viewport().width()))
need += 2 * self.scroll.frameWidth()
if self.editor.minimumSizeHint().width() > self.scroll.viewport().width(): # narrow window: room for the h-bar
need += self.scroll.horizontalScrollBar().sizeHint().height()
editor_h = min(need, max(0, total - TABLE_MIN))
self.vsplit.setSizes([total - editor_h, editor_h])
def eventFilter(self, obj, e):
if obj is self.editor and e.type() == QEvent.Type.LayoutRequest and self._fitted and not self._user_sized:
QTimer.singleShot(0, self.fit_editor)
return super().eventFilter(obj, e)
def showEvent(self, e):
super().showEvent(e)
if not self._fitted:
self._fitted = True
QTimer.singleShot(0, self.fit_editor)
def closeEvent(self, e): def closeEvent(self, e):
self.player.stop() self.player.stop()
self.edit.flush()
self.save_settings() self.save_settings()
super().closeEvent(e) super().closeEvent(e)

181
app/preview.py Normal file
View File

@@ -0,0 +1,181 @@
"""Sounds tab: preview of the selected sound (listen, read its subtitle) with the way into the Edit tab. No
editing here: editing is the Edit tab's job."""
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from ackit import audio, subtitles, workspace as W
from . import icons, theme
from .editor import STATE_TEXT
from .waveform import Waveform
class PreviewPanel(QWidget):
edit_requested = Signal() # open the selected sounds in the Edit tab
add_requested = Signal() # add them to the edit list (stay here)
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx # main window: .ws, .runner, .player, .language
self.sound = None
self.orig = self.installed = None
self.playing_what = None
self._build()
self.set_sound(None)
def _build(self):
self.setObjectName("EditorPanel")
lay = QHBoxLayout(self)
lay.setContentsMargins(8, 6, 8, 6)
lay.setSpacing(12)
info = QVBoxLayout()
info.setSpacing(3)
self.title = QLabel()
self.title.setObjectName("EditorTitle")
self.title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
f = self.title.font()
f.setPointSizeF(f.pointSizeF() * 1.2)
f.setBold(True)
self.title.setFont(f)
self.meta = QLabel()
self.meta.setObjectName("Muted")
self.meta.setWordWrap(True)
self.state = QLabel()
self.sub = QLabel()
self.sub.setWordWrap(True)
self.sub.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
for w in (self.title, self.meta, self.state, self.sub):
info.addWidget(w)
info.addStretch(1)
lay.addLayout(info, 3)
# player: the transport buttons together, the waveforms under them (click = play from there)
player = QVBoxLayout()
player.setSpacing(4)
tr = QHBoxLayout()
tr.setSpacing(6)
self.b_orig = self._btn("Play original", "play", lambda: self.play("orig"), "Play the game's original (Space)",
icons.STEEL)
self.b_inst = self._btn("Play installed", "play", lambda: self.play("inst"),
"Play what the game has now (your replacement)", "#8f4a4e")
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
for b in (self.b_orig, self.b_inst, self.b_stop):
tr.addWidget(b)
tr.addStretch(1)
player.addLayout(tr)
waves = QGridLayout()
waves.setVerticalSpacing(3)
waves.setHorizontalSpacing(6)
self.wf_orig, self.wf_inst = Waveform(), Waveform()
for i, (w, kind, name) in enumerate(((self.wf_orig, "orig", "Original"), (self.wf_inst, "inst", "Installed"))):
w.accent, w.bg = theme.WAVE[kind], theme.WAVE_BG
w.setMinimumHeight(40)
lab = QLabel(name)
lab.setObjectName("Muted")
waves.addWidget(lab, i, 0)
waves.addWidget(w, i, 1)
waves.setColumnStretch(1, 1)
player.addLayout(waves)
self.wf_orig.seekRequested.connect(lambda t: self.play("orig", t))
self.wf_inst.seekRequested.connect(lambda t: self.play("inst", t))
lay.addLayout(player, 4)
# editing: into the Edit tab (on the right, apart from the player)
acts = QVBoxLayout()
acts.setSpacing(6)
self.b_edit = self._btn("Edit", "edit", self.edit_requested.emit,
"Open the selected sound(s) in the Edit tab (Enter)", "#ffffff")
self.b_edit.setObjectName("Primary")
self.b_add = self._btn("Add to edit list", "add", self.add_requested.emit,
"Put the selected sounds on the Edit tab's list and stay here")
for b in (self.b_edit, self.b_add):
acts.addWidget(b)
acts.addStretch(1)
lay.addSpacing(8)
lay.addLayout(acts, 1)
self.ctx.player.position.connect(self._pos)
self.ctx.player.playing.connect(lambda on: None if on else self._clear_playheads())
@staticmethod
def _btn(text, icon, slot, tip, color=None):
b = QPushButton(text)
b.setIcon(icons.icon(icon, color or icons.TEXT))
b.clicked.connect(lambda _=False: slot())
b.setToolTip(tip)
return b
def set_sound(self, s):
self.stop()
self.sound, self.orig, self.installed = s, None, None
for b in (self.b_orig, self.b_inst, self.b_edit, self.b_add, self.b_stop):
b.setEnabled(s is not None)
self.wf_orig.set_audio(None, 1, "select a sound" if s is None else "loading…", busy=s is not None)
self.wf_inst.set_audio(None, 1, "")
if s is None:
self.title.setText("No sound selected")
self.meta.setText("Select a sound in the list to listen to it; Edit opens it for changing.")
self.state.setText("")
self.sub.setText("")
return
self.title.setText(s.name)
who = s.character or (("mentions " + s.characters_inferred) if s.characters_inferred else "—")
self.meta.setText("%s / %s • %s • %.2f s, %d Hz, %s • %s"
% (s.group, s.category, who, s.seconds, s.rate,
{1: "mono", 2: "stereo"}.get(s.channels, "%d ch" % s.channels), s.key))
ws = self.ctx.ws
subs = ws.texts.for_sound(s, self.ctx.language) if ws else []
self.sub.setText("“%s”" % " / ".join(ws.texts.current(_key(x)) or x.text for x in subs) if subs else "")
key = s.key
def work(job):
cur = ws.current_bytes(s)
st = ws.state(s, cur)
o = audio.decode_wem(ws.original_bytes(s))
return key, st, o, audio.decode_wem(cur) if st != W.ORIGINAL else None
def failed(e, tb):
self.wf_orig.set_audio(None, 1, "could not load: %s" % e)
self.ctx.runner.start(work, self._loaded, failed)
def _loaded(self, r):
key, st, o, inst = r
if not self.sound or self.sound.key != key:
return
self.orig, self.installed = o, inst
self.wf_orig.set_audio(*o)
self.wf_inst.set_audio(*(inst or (None, 1)), placeholder="same as the original")
self.b_inst.setEnabled(inst is not None)
self.state.setText(STATE_TEXT.get(st, st))
def play(self, what, start=0.0):
a = {"orig": self.orig, "inst": self.installed}.get(what)
if not a:
return
self.playing_what = what
self.ctx.player.owner = self
self.ctx.player.play(a[0], a[1], start, label=what)
def toggle_play(self):
if self.ctx.player.is_playing():
self.stop()
else:
self.play("orig")
def stop(self):
self.ctx.player.stop()
self._clear_playheads()
def _clear_playheads(self):
for w in (self.wf_orig, self.wf_inst):
w.set_playhead(None)
def _pos(self, t):
if getattr(self.ctx.player, "owner", None) is not self:
return
wf = {"orig": self.wf_orig, "inst": self.wf_inst}.get(self.playing_what)
if wf:
wf.set_playhead(t)
def _key(sub):
return subtitles.key(sub.lang, sub.speech, sub.line)

View File

@@ -6,8 +6,8 @@ length), menu texts into the localization files. Revert per text or all; changed
""" """
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, QTableView, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QSplitter,
QVBoxLayout, QWidget) QTableView, QVBoxLayout, QWidget)
from ackit import subtitles from ackit import subtitles
from ackit.texts import CHANGED, FOREIGN, LOST, MODIFIED, ORIGINAL, TextConflictError from ackit.texts import CHANGED, FOREIGN, LOST, MODIFIED, ORIGINAL, TextConflictError
@@ -240,6 +240,102 @@ class SubtitleBox(QWidget):
self.win.runner.start(lambda job: fn(job, False), done, failed) self.win.runner.start(lambda job: fn(job, False), done, failed)
class SubtitleEdit(QPlainTextEdit):
"""A subtitle line as a large wrapping text box (one line of text in the game: Enter is ignored and pasted
line breaks become spaces). Has the QLineEdit calls SubtitleBox uses: text(), textChanged."""
def __init__(self, text, parent=None):
super().__init__(parent)
f = self.font()
f.setPointSizeF(f.pointSizeF() * 1.3)
self.setFont(f)
self.setPlainText(text)
self.setTabChangesFocus(True)
self.setFixedHeight(self.fontMetrics().lineSpacing() * 2 + 14) # 2 lines at the Edit tab's width
def text(self):
return self.toPlainText()
def setText(self, text):
self.setPlainText(text)
def keyPressEvent(self, e):
if e.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
e.ignore()
return
super().keyPressEvent(e)
def insertFromMimeData(self, source):
self.insertPlainText(" ".join(source.text().split()))
class SubtitlePanel(SubtitleBox):
"""The subtitle of the sound in the Edit tab: large text, the game's original under it (and the
English line when editing another language), room counter, Apply / Revert / Show in list."""
def __init__(self, win, parent=None):
super().__init__(win, parent)
self.lines = QVBoxLayout()
self.lines.setContentsMargins(0, 0, 0, 0)
self.lines.setSpacing(4)
outer = self.layout()
outer.insertLayout(0, self.lines)
outer.setSpacing(4)
row = outer.itemAt(outer.count() - 1).layout() # one row: room counter | Apply | Revert | Show in list
for w in (self.lbl, self.b_open):
outer.removeWidget(w)
row.insertWidget(0, self.lbl, 3)
row.addWidget(self.b_open, 1)
self._parts = []
self._english = {}
def _english_text(self, ln):
if ln.lang == "INT":
return None
if not self._english:
try:
self._english = {(x.speech, x.line): x.text for x in self.win.ws.texts.subtitles("INT")}
except Exception: # noqa: BLE001 - only a reference text
self._english = {None: None}
return self._english.get((ln.speech, ln.line))
def set_sound(self, s):
for w in self._parts:
self.lines.removeWidget(w)
w.deleteLater()
self._parts, self.edits, self.keys = [], [], []
ws = self.win.ws
lines = ws.texts.for_sound(s, self.win.language) if ws and s else []
if not lines:
self.lbl.setText("No subtitle for this sound." if s else "")
for b in (self.b_apply, self.b_revert, self.b_open):
b.setEnabled(False)
return
tx = ws.texts
for i, ln in enumerate(lines):
k = subtitles.key(ln.lang, ln.speech, ln.line)
e = SubtitleEdit(tx.records().get(k, {}).get("applied") or ln.text)
e.setToolTip("Room: %d characters" % ln.room)
e.textChanged.connect(self._check)
self.lines.addWidget(e)
part = " (part %d of %d)" % (i + 1, len(lines)) if len(lines) > 1 else ""
ref = "Original%s: %s" % (part, ln.text)
en = self._english_text(ln)
if en and en != ln.text:
ref += "\nEnglish: " + en
r = QLabel(ref)
r.setObjectName("Muted")
r.setWordWrap(True)
r.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.lines.addWidget(r)
self._parts += [e, r]
self.edits.append(e)
self.keys.append(k)
self.b_open.setEnabled(True)
self.b_revert.setEnabled(any(k in tx.records() for k in self.keys))
self._check()
class TextPanel(QWidget): class TextPanel(QWidget):
"""kind 'subs' (subtitles) or 'menu' (menu text).""" """kind 'subs' (subtitles) or 'menu' (menu text)."""
changed = Signal() changed = Signal()

View File

@@ -58,8 +58,9 @@ QLineEdit, QComboBox, QDoubleSpinBox, QPlainTextEdit, QListWidget, QTreeWidget {
QLineEdit:focus, QComboBox:focus, QDoubleSpinBox:focus, QPlainTextEdit:focus { border-color: %(ACCENT)s; } QLineEdit:focus, QComboBox:focus, QDoubleSpinBox:focus, QPlainTextEdit:focus { border-color: %(ACCENT)s; }
QComboBox QAbstractItemView { background: #1b1b1b; border: 1px solid %(CONTROL)s; QComboBox QAbstractItemView { background: #1b1b1b; border: 1px solid %(CONTROL)s;
selection-background-color: rgba(176,36,44,90); } selection-background-color: rgba(176,36,44,90); }
QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #555555; border-radius: 3px; background: #101010; } QCheckBox::indicator, QGroupBox::indicator, QListView::indicator { width: 14px; height: 14px; border: 1px solid #555555; border-radius: 3px;
QCheckBox::indicator:checked { background: %(ACCENT)s; border-color: %(ACCENT)s; } background: #101010; }
QCheckBox::indicator:checked, QGroupBox::indicator:checked, QListView::indicator:checked { background: %(ACCENT)s; border-color: %(ACCENT)s; }
QProgressBar { background: #101010; border: 1px solid %(CONTROL)s; border-radius: 3px; height: 10px; } QProgressBar { background: #101010; border: 1px solid %(CONTROL)s; border-radius: 3px; height: 10px; }
QProgressBar::chunk { background: %(ACCENT)s; border-radius: 2px; } QProgressBar::chunk { background: %(ACCENT)s; border-radius: 2px; }
QSlider::groove:horizontal { height: 4px; background: #333333; border-radius: 2px; } QSlider::groove:horizontal { height: 4px; background: #333333; border-radius: 2px; }

Binary file not shown.

View File

@@ -8,7 +8,7 @@
| `SUB_locations.csv.gz` | every stored copy of each subtitle string (dialogue objects in the packages) | | `SUB_locations.csv.gz` | every stored copy of each subtitle string (dialogue objects in the packages) |
| `characters.csv` | `name,character,language`: verified speaker per line name (empty `language`: every language, the same line everywhere; a language code would label a sound of that language only - none needs it now) | | `characters.csv` | `name,character,language`: verified speaker per line name (empty `language`: every language, the same line everywhere; a language code would label a sound of that language only - none needs it now) |
| `index.json` | per archive: fingerprint of the game files the CSV was built from (`ackit/game.py: wad_fingerprint`, `ackit/banks.py: game_fingerprint`) and counts | | `index.json` | per archive: fingerprint of the game files the CSV was built from (`ackit/game.py: wad_fingerprint`, `ackit/banks.py: game_fingerprint`) and counts |
| `source/<lang>/*.txt` | **the curated original lists** (one file per character, one wemscan-style file name per line, e.g. `Speech_TAPE_JOKER_1_4.wem`). `Files.txt` = all files, `Stats.txt` = counts, `Checkpoint.txt` = progress marker. Never edited by tools. | | `source/<lang>/*.txt` | **the curated original lists** (one file per character, one wemscan-style file name per line, e.g. `Speech_TAPE_JOKER_1_4.wem`). `Files.txt` = all files, `Stats.txt` = counts. Never edited by tools. |
All CSVs are UTF-8 with a header row. They contain only names and metadata (no game audio). All CSVs are UTF-8 with a header row. They contain only names and metadata (no game audio).
@@ -68,7 +68,7 @@ scans the game itself and applies the same rules (no checksums then; the bank sc
to the lines, so a label then applies to the same recording in every language. to the lines, so a label then applies to the same recording in every language.
3. Run `python tools/build_catalog.py` - it scans every archive and all soundbanks of the installed game (all 3. Run `python tools/build_catalog.py` - it scans every archive and all soundbanks of the installed game (all
languages present; ~5 minutes) and rewrites the CSVs, the location indexes, `characters.csv` and `index.json`. languages present; ~5 minutes) and rewrites the CSVs, the location indexes, `characters.csv` and `index.json`.
It stops before writing anything if a sound differs from the current catalog (a modified game); after a game It stops before writing anything if a sound or a subtitle differs from the current catalog (a modified game); after a game
update run it with `--accept-changes`. update run it with `--accept-changes`.
4. Run `ackit verify --catalog` (no issues expected). 4. Run `ackit verify --catalog` (no issues expected).
New character labels need a display name in `ackit/classify.py: USER_LABELS` (otherwise the file name is used). New character labels need a display name in `ackit/classify.py: USER_LABELS` (otherwise the file name is used).

View File

@@ -8138,7 +8138,7 @@ Speech_TAPE_JOKER_5_16,0,아예 샤피를 미치게 만든 건 네가 아니라
Speech_TAPE_JOKER_5_17,0,고맙네.,4,w,INT:3F21770A:5 Speech_TAPE_JOKER_5_17,0,고맙네.,4,w,INT:3F21770A:5
Speech_TAPE_JOKER_5_18,0,"자, 네 과거를 사람들에게 알리고 싶지 않다면 남의 과거를 캐묻고 다니는 일은 그만둬.",48,w,INT:3F21770A:12 Speech_TAPE_JOKER_5_18,0,"자, 네 과거를 사람들에게 알리고 싶지 않다면 남의 과거를 캐묻고 다니는 일은 그만둬.",48,w,INT:3F21770A:12
Speech_TAPE_JOKER_5_19,0,의사를 하나 더 보내줘. 다른 의사의 진단도 받아보고 싶군.,33,w,INT:3F21770A:0 Speech_TAPE_JOKER_5_19,0,의사를 하나 더 보내줘. 다른 의사의 진단도 받아보고 싶군.,33,w,INT:3F21770A:0
Speech_TAPE_J_Answer_1_00,0,"한 통ì<EFBFBD>˜ ë¶€ìž,중 전화가 있습니다.",44,a, Speech_TAPE_J_Answer_1_00,0,"한 통의 ë¶€ìž,중 전화가 있습니다.",44,a,
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,왜 진작에 이 생각을 못 했을까? 세상에 둘도 없는 최고의 친구와 직통 회선을 연결하는 거 말야.,54,w,INT:4709E5BE:0 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,왜 진작에 이 생각을 못 했을까? 세상에 둘도 없는 최고의 친구와 직통 회선을 연결하는 거 말야.,54,w,INT:4709E5BE:0
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,생각해 봐. 심심하면 언제든 통화 할 수 있어. 우리 사인 한층 각별해지고 있지. 이젠 이메일을 주고받거나 낭만적인 저녁 식사를 하는 일만 남았어.,82,w,INT:4709E5BE:1 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,생각해 봐. 심심하면 언제든 통화 할 수 있어. 우리 사인 한층 각별해지고 있지. 이젠 이메일을 주고받거나 낭만적인 저녁 식사를 하는 일만 남았어.,82,w,INT:4709E5BE:1
Speech_TAPE_J_Answer_2_01,0,한 통의 부재중 전화가 있습니다.,18,w,INT:4709E5BD:1 Speech_TAPE_J_Answer_2_01,0,한 통의 부재중 전화가 있습니다.,18,w,INT:4709E5BD:1
Can't render this file because it is too large.

View File

@@ -8345,7 +8345,7 @@ Speech_TAPE_JOKER_5_16,0,"Теперь еще попробуй рассказа
Speech_TAPE_JOKER_5_17,0,Благодарю.,10,w,INT:3F21770A:5 Speech_TAPE_JOKER_5_17,0,Благодарю.,10,w,INT:3F21770A:5
Speech_TAPE_JOKER_5_18,0,"Вот тебе совет. Если не хочешь, чтобы кто-то узнал о твоем прошлом, перестань совать нос в мое.",95,w,INT:3F21770A:12 Speech_TAPE_JOKER_5_18,0,"Вот тебе совет. Если не хочешь, чтобы кто-то узнал о твоем прошлом, перестань совать нос в мое.",95,w,INT:3F21770A:12
Speech_TAPE_JOKER_5_19,0,"И не забудь прислать еще одного дока, док. Нужна консультация.",62,w,INT:3F21770A:0 Speech_TAPE_JOKER_5_19,0,"И не забудь прислать еще одного дока, док. Нужна консультация.",62,w,INT:3F21770A:0
Speech_TAPE_J_Answer_1_00,0,У ваÑ<EFBFBD> один пропущенный звонок,54,a, Speech_TAPE_J_Answer_1_00,0,У вас один пропущенный звонок,54,a,
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,И почему я раньше об этом не подумал? Прямая связь с моим лучшим на свете другом!,81,w,INT:4709E5BE:0 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,И почему я раньше об этом не подумал? Прямая связь с моим лучшим на свете другом!,81,w,INT:4709E5BE:0
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,"Только представь - я смогу позвонить тебе сразу, как только заскучаю! Мы так близки друг другу.",95,w,INT:4709E5BE:1 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,"Только представь - я смогу позвонить тебе сразу, как только заскучаю! Мы так близки друг другу.",95,w,INT:4709E5BE:1
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,1,Следующий шаг - переписка по электронной почте и романтические ужины.,69,w,INT:4709E5BE:1 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,1,Следующий шаг - переписка по электронной почте и романтические ужины.,69,w,INT:4709E5BE:1
Can't render this file because it is too large.

View File

@@ -73,8 +73,8 @@
}, },
"named_by_event": 13501, "named_by_event": 13501,
"named_by_bank": 1854, "named_by_bank": 1854,
"copies": 93826, "copies": 93702,
"files": 1499, "files": 1498,
"prefetch_heads": { "prefetch_heads": {
"recordings": 445, "recordings": 445,
"copies": 2185, "copies": 2185,

View File

@@ -1 +0,0 @@
10816

View File

@@ -1,7 +1,8 @@
# Building Unheard City # Building Unheard City
The build is a PyInstaller **one-folder** bundle: Python, Qt (incl. Qt Multimedia), libsndfile (libvorbis, The build is a PyInstaller **one-folder** bundle: Python, Qt (incl. Qt Multimedia), libsndfile (libvorbis,
FLAC, opus, mpg123) and soxr are all inside; nothing else is installed or needed at runtime. Build on each FLAC, opus, mpg123), soxr and pedalboard (the effects) are all inside; nothing else is installed or needed at
runtime. Build on each
target OS (no cross-compiling). target OS (no cross-compiling).
## Windows (x86-64) ## Windows (x86-64)
@@ -42,6 +43,9 @@ QT_QPA_PLATFORM=offscreen pytest -q
python tools/build_app.py # -> dist/UnheardCity-<ver>-linux-x86_64.tar.gz python tools/build_app.py # -> dist/UnheardCity-<ver>-linux-x86_64.tar.gz
QT_QPA_PLATFORM=offscreen dist/UnheardCity/UnheardCity --smoke-test --game /path/to/game QT_QPA_PLATFORM=offscreen dist/UnheardCity/UnheardCity --smoke-test --game /path/to/game
``` ```
First built with version 3.4.0 (Debian 13 in a container, Python 3.14.8 built from source because Debian has no
3.14 package): ~124 MB tar.gz, ~330 MB unpacked; the smoke test passes with `PATH=/usr/bin:/bin` and the GUI
starts.
Notes: Notes:
- The `soundfile` and `soxr` wheels for manylinux bundle libsndfile/libsoxr; the PySide6 wheels bundle Qt and - The `soundfile` and `soxr` wheels for manylinux bundle libsndfile/libsoxr; the PySide6 wheels bundle Qt and
Qt Multimedia's FFmpeg backend (PulseAudio/PipeWire output). The target desktop only needs the usual X11/ Qt Multimedia's FFmpeg backend (PulseAudio/PipeWire output). The target desktop only needs the usual X11/

View File

@@ -22,6 +22,13 @@ The first toolkit used ffmpeg (decoding inputs and libvorbis encoding) and vgmst
ffmpeg: an ffmpeg found on the system is used if present, otherwise the user gets a clear "convert to WAV/MP3" ffmpeg: an ffmpeg found on the system is used if present, otherwise the user gets a clear "convert to WAV/MP3"
message. Bundling ffmpeg (~80-100 MB, GPL/LGPL build care) for rare formats was judged not worth it. message. Bundling ffmpeg (~80-100 MB, GPL/LGPL build care) for rare formats was judged not worth it.
- **Resampling:** `soxr` (LGPL-2.1). - **Resampling:** `soxr` (LGPL-2.1).
- **Effects for replacements (any sound):** `pedalboard` (Spotify, GPL-3.0, compatible with this project's GPL-3.0; it
statically contains JUCE and the Rubber Band library): filters, distortion, codec, delay and pitch/formant
shifting (`ackit/fx.py`). The reverb is not pedalboard's (Freeverb sounded metallic): a generated room response
(early reflections + a 4-band noise tail, the highs decaying first) convolved with numpy's FFT. Chosen over
writing the DSP ourselves (user: "just use some libraries") and over scipy (no ready pitch shifter). Its
high/low-pass filters are first order only, so the voice-band cuts are Butterworth magnitudes applied with
numpy's FFT. Wheels for Python 3.14 on Windows and Linux (0.9.26); it needs only numpy.
So there are **no vendor binaries to fetch** (no `tools/fetch_vendor` script was needed): every native So there are **no vendor binaries to fetch** (no `tools/fetch_vendor` script was needed): every native
dependency comes from version-pinned PyPI wheels (which embed libsndfile, libvorbis, libogg, FLAC, opus, mpg123, dependency comes from version-pinned PyPI wheels (which embed libsndfile, libvorbis, libogg, FLAC, opus, mpg123,
Qt and Qt Multimedia's FFmpeg backend) that PyInstaller bundles, resolved relative to the app (never `PATH`). Qt and Qt Multimedia's FFmpeg backend) that PyInstaller bundles, resolved relative to the app (never `PATH`).
@@ -30,7 +37,7 @@ plus official texts kept in `tools/licenses/` (sources in SOURCES.txt; no networ
its decode/encode/inject smoke test with `PATH` reduced to `C:\Windows` (no ffmpeg, vgmstream or Python). its decode/encode/inject smoke test with `PATH` reduced to `C:\Windows` (no ffmpeg, vgmstream or Python).
**Licenses:** PySide6/Qt LGPL-3.0 (dynamic linking, unmodified Qt shared libraries in the one-folder build, **Licenses:** PySide6/Qt LGPL-3.0 (dynamic linking, unmodified Qt shared libraries in the one-folder build,
source at https://code.qt.io), libsndfile LGPL-2.1, mpg123 LGPL-2.1, soxr LGPL-2.1, libvorbis/libogg/FLAC/opus source at https://code.qt.io), libsndfile LGPL-2.1, mpg123 LGPL-2.1, soxr LGPL-2.1, libvorbis/libogg/FLAC/opus
BSD, numpy BSD, Python PSF. One-folder (not one-file) builds keep the LGPL libraries replaceable. BSD, numpy BSD, pedalboard GPL-3.0, Python PSF. One-folder (not one-file) builds keep the LGPL libraries replaceable.
The only Windows-only part is the in-game test harness (`ackit verify --ingame`, uses WinDbg/cdb and WASAPI The only Windows-only part is the in-game test harness (`ackit verify --ingame`, uses WinDbg/cdb and WASAPI
loopback); it is a developer tool and not part of the editor. loopback); it is a developer tool and not part of the editor.
@@ -38,19 +45,32 @@ loopback); it is a developer tool and not part of the editor.
- **Originals store** per game folder: `<user data>/UnheardCity/stores/<id>/` (Windows `%APPDATA%`, Linux - **Originals store** per game folder: `<user data>/UnheardCity/stores/<id>/` (Windows `%APPDATA%`, Linux
`~/.local/share/unheardcity`), id = hash of the game path; configurable (`--store`, editor settings). `~/.local/share/unheardcity`), id = hash of the game path; configurable (`--store`, editor settings).
Before a sound is first changed, its original WEM bytes (SHA-256-named blob) and metadata are saved; Before a sound is first changed, its original WEM bytes (SHA-256-named blob) and metadata are saved;
the applied replacement is saved too. **Revert** re-injects the stored original; **Revert all** does this for the applied replacement is saved too. **Revert** re-injects the stored original; **Revert all** (editor toolbar,
every record. Stream-pack padding bytes are stored so pack reverts are byte-identical. `ackit revert --all`) does this for every record of the store, sounds and texts, in every language. Stream-pack
padding bytes are stored so pack reverts are byte-identical. The uninstaller counts sound and text records and
does not offer to delete the store while any is left.
- **One store per game at a time:** the changes are recorded only in the store that made them. The editor
refuses to switch to another store folder while changes are applied (Revert all first). A store takes the
full copy of a soundbank or package only from a file that matches the shipped catalog (every in-memory
sound's checksum, every bank copy of a voice recording, every subtitle; `Workspace.unoriginal_files`), so a
second store (`--store`) or another tool's change is a conflict, never a new "original". (Menu files and other
game builds have no catalog checksums to check.)
- **Full-file fallback:** before the first change to a WAD (and its pack index), one verified full copy is kept - **Full-file fallback:** before the first change to a WAD (and its pack index), one verified full copy is kept
in the store (`full/`), default on, can be disabled. Used by "Restore from full backup" if the store is damaged. in the store (`full/`), default on, can be disabled. If the store is damaged, `ackit restore-backup --yes`
copies the WADs back (their sounds are forgotten) and rebuilds the soundbanks, packages and localization files
from their copies, keeping the sounds of WADs without a full copy recorded and revertible.
Soundbank files, packages and localization files always get this copy (they are rebuilt from it, 4b/4c). Soundbank files, packages and localization files always get this copy (they are rebuilt from it, 4b/4c).
- **Why the user data folder:** it survives app updates and reinstalls, needs no write access next to the game, - **Why the user data folder:** it survives app updates and reinstalls, needs no write access next to the game,
and works the same on Linux/Proton. Steam never touches it. and works the same on Linux/Proton. Steam never touches it.
- **Safe writes:** every WAD is rebuilt into a temp file and atomically renamed over the original, pack indexes - **Safe writes:** every WAD is rebuilt into a temp file and atomically renamed over the original, pack indexes
first. Several sounds of one WAD are written in one pass. A locked WAD (game running) gives a clear message. first. Several sounds of one WAD are written in one pass. A locked WAD (game running) gives a clear message.
Writes are serialized (`Workspace.lock`): the editor runs them as background jobs, and two rebuilds of one
package at once (e.g. a sound and its subtitle applied side by side) would otherwise lose one edit.
- **Changed underneath:** each sound's state is computed from its bytes: original / modified (ours) / lost - **Changed underneath:** each sound's state is computed from its bytes: original / modified (ours) / lost
(ours was replaced by the original, e.g. Steam "verify") / changed (neither: update or another tool) / (ours was replaced by the original, e.g. Steam "verify") / changed (neither: update or another tool) /
foreign (never touched by us, but not the catalog's original checksum). Writing over changed/foreign sounds foreign (never touched by us, but not the catalog's original checksum). Writing over changed/foreign sounds
requires explicit confirmation; the catalog is used only when the WAD fingerprint matches. or texts requires explicit confirmation (editor: a question, also when installing a mod; CLI: `--force`); the
catalog is used only when the game's fingerprint matches.
## 4. Size rule ## 4. Size rule
Speech/ambience variations are addressed by offset+size from BKPK tables inside compressed `.upk` packages, Speech/ambience variations are addressed by offset+size from BKPK tables inside compressed `.upk` packages,
@@ -77,7 +97,7 @@ tables): high risk for little gain, not done.
optional like the WAD copies: the file is rebuilt from it). The file content is always "original copy + optional like the WAD copies: the file is rebuilt from it). The file content is always "original copy +
replacements applied now", so reverting the last one restores the original bytes exactly, and an interrupted replacements applied now", so reverting the last one restores the original bytes exactly, and an interrupted
operation is repaired by the next one. Conflicts: the whole-file SHA-1 must be the original's or the last one operation is repaired by the next one. Conflicts: the whole-file SHA-1 must be the original's or the last one
ackit wrote. ackit wrote, and before the copy is taken the file must match the catalog (section 3).
- **Prefetch heads and whole copies:** 396 voice recordings (81 lines, 78-80 recordings per language) have - **Prefetch heads and whole copies:** 396 voice recordings (81 lines, 78-80 recordings per language) have
their first bytes copied into banks, and 49 more are stored whole in banks (same media ID, identical bytes); their first bytes copied into banks, and 49 more are stored whole in banks (same media ID, identical bytes);
a replacement of such a recording rewrites those copies too, otherwise the game could play the old line. a replacement of such a recording rewrites those copies too, otherwise the game could play the old line.
@@ -130,8 +150,9 @@ copy per voice language) is filtered the same way, so the sound list never mixes
WAD voice lines), numbered `NAME#2` in the event's own order when it plays two versions; the other 637 keep WAD voice lines), numbered `NAME#2` in the event's own order when it plays two versions; the other 637 keep
the name of their English (INT) recording's position. The position in each language stays an alias (`NAME#i`). the name of their English (INT) recording's position. The position in each language stays an alias (`NAME#i`).
- A recording stored twice in a WAD (5 per language) is written and reverted in both places. - A recording stored twice in a WAD (5 per language) is written and reverted in both places.
- The catalog records the ORIGINAL bytes: `tools/build_catalog.py` stops before writing anything if a sound - The catalog records the ORIGINAL bytes and texts: `tools/build_catalog.py` stops before writing anything if a
differs from the current catalog (a modified game), unless `--accept-changes` (a game update). sound or a subtitle differs from the current catalog (a modified game), unless `--accept-changes` (a game
update).
- Category scheme: `docs/categories.md`. - Category scheme: `docs/categories.md`.
## 6. Repository cleanup choices ## 6. Repository cleanup choices
@@ -148,15 +169,22 @@ copy per voice language) is filtered the same way, so the sound list never mixes
store does not need it. store does not need it.
## 7. Editor UX choices ## 7. Editor UX choices
- One window with three tabs. *Sounds*: filters (left) and sound table on top, the editor of the selected - One window with four tabs; browsing and editing are separate (user, mission 4: "the browser for browsing, the
sound below across the full width, in three resizable columns (Listen | Replace | Subtitle). Inside each edit tab for editing, not both for both"). *Sounds*: filters (left), sound table, and below it a compact
column the controls are aligned, not packed in rows that wrap: Replace's edit fields in a grid with one preview (details, subtitle as text, original/installed waveforms with play buttons, **Edit** / **Add to edit
label column, and every column's actions in one full-width bar at its bottom, level across the three list**); no editing tools: details | player (Play original, Play installed, Stop together above the
columns (Listen: Original | Installed | Stop; Replace: Apply to game, twice as wide, | Revert | Save WAV; waveforms) | Edit, Add to edit list. *Edit*: the edit list (sounds sent from the browser, kept in the user
Subtitle: Apply | Revert). Every row of buttons fills its column's width (Load file / device / Record; Trim data folder with each sound's replacement audio, trim/level values and effects; `app/edit.py`) | the editor
silence / Replacement; Show in list). A/B and Loop are icon buttons with tooltips next to the volume. The editor fits a 1000-pixel-wide window without horizontal scrolling (measured: (`app/editor.py`, top to bottom: SUBTITLE large, with the original and the English line, room counter and
minimum 984 px with the window's vertical scroll bar). *Subtitles* and *Menu text*: search, filters, table, editor. The primary flow buttons on one row; LISTEN = original, installed and replacement, one row each with its play button, the
(search -> select -> Space to listen -> Load file/Record -> Apply) needs no menus; power features (tags, waveforms take the free height, transport row; REPLACEMENT = Record / input / level / Load file, trim and
level in an aligned grid) with its action bar (Previous | Apply to game | Revert | Save WAV | Next) pinned
below | the effects (`app/effects.py`: a preset list, the effect chain = one line per effect, tick = on, click
= its settings shown below: preset buttons and sliders with readable values, logarithmic for frequencies and
times). No scrolling at 1400 x 900 and 1280 x 800 (tested). Wording is generic (replacement, effects), not
dubbing-specific: the tab edits every kind of sound. *Subtitles* and *Menu text*:
search, filters, table, editor. The primary flow
(search -> select -> Space to listen -> Edit -> Record/Load file -> Apply -> Next) needs no menus; power features (tags,
favorites, multi-select revert, mod packages, settings) are in the toolbar and context menu. favorites, multi-select revert, mod packages, settings) are in the toolbar and context menu.
- Long work (loading, encoding, writing 0.5 GB WADs) runs in background jobs with status messages; the - Long work (loading, encoding, writing 0.5 GB WADs) runs in background jobs with status messages; the
window stays responsive. Errors are shown as plain sentences (game running/locked file, wrong folder, window stays responsive. Errors are shown as plain sentences (game running/locked file, wrong folder,

View File

@@ -10,11 +10,11 @@ Survey of the installed game (buildid 3718300), 2026-10-09.
| where | banks | unique in-memory WEMs | | where | banks | unique in-memory WEMs |
|-------|-------|-----------------------| |-------|-------|-----------------------|
| loose `SFX\*.bnk` (Batman/Catwoman footsteps, 32 surfaces each, + `TST_Prep_Media`) | 65 | 1,117 | | loose `SFX\*.bnk` (Batman/Catwoman footsteps, 32 surfaces each, + `TST_Prep_Media`) | 65 | 1,117 |
| banks embedded in `.upk` packages (in 1,432 of the 2,650 packages) | 3,340 | 14,802 | | banks embedded in `.upk` packages (in 1,431 of the 2,648 packages) | 3,340 | 14,802 |
All of them are in the catalog (archive `BANK`). All of them are in the catalog (archive `BANK`).
The two sets share 119 media IDs: 15,800 unique media IDs in 96,011 places (bank copies). 396 of them are The two sets share 119 media IDs: 15,800 unique media IDs in 95,887 places (bank copies). 396 of them are
**prefetch heads** of streamed WAD sounds and 49 are **whole copies** of WAD recordings (below), so there are **prefetch heads** of streamed WAD sounds and 49 are **whole copies** of WAD recordings (below), so there are
**15,355 in-memory sounds**. All copies of a **15,355 in-memory sounds**. All copies of a
media ID are byte-identical except `1C898731` (two variants). The WADs, by comparison, are fully covered: the media ID are byte-identical except `1C898731` (two variants). The WADs, by comparison, are fully covered: the
@@ -47,7 +47,7 @@ HIRC Sound object (type 2) payload: u32 object id, u32 plugin id (`0x00040001` =
(0 = in memory, 1 = streamed, 2 = prefetch), u32 media id, u32 bank id of the media; for in-memory sources (0 = in memory, 1 = streamed, 2 = prefetch), u32 media id, u32 bank id of the media; for in-memory sources
then u32 offset, u32 size, where offset = `DATA` payload position (from the start of the bank) + DIDX offset then u32 offset, u32 size, where offset = `DATA` payload position (from the start of the bank) + DIDX offset
and size = DIDX size (checked on every in-memory source of the footstep banks and of `BmGame.upk`). and size = DIDX size (checked on every in-memory source of the footstep banks and of `BmGame.upk`).
Source kinds over all bank copies: 115,861 in-memory sources of the bank's own media; 2,233 in-memory sources Source kinds over all bank copies: 115,737 in-memory sources of the bank's own media; 2,233 in-memory sources
with bank id 0 whose media is not in the same bank (loaded by another bank); 133,075 streamed sources; with bank id 0 whose media is not in the same bank (loaded by another bank); 133,075 streamed sources;
2,101 **prefetch** sources. Every streamed or prefetched media ID is in a BKPK table, i.e. a WAD sound 2,101 **prefetch** sources. Every streamed or prefetched media ID is in a BKPK table, i.e. a WAD sound
(`wad.md`). A prefetch source's media is also in the bank's DIDX: that in-memory copy is the **head** of the (`wad.md`). A prefetch source's media is also in the bank's DIDX: that in-memory copy is the **head** of the
@@ -57,7 +57,7 @@ WAD sounds have such heads (2,101 copies); replacing one of them must rewrite th
story lines) are story lines) are
also stored **whole** in banks: an in-memory source with the WAD recording's own media ID, byte-identical to also stored **whole** in banks: an in-memory source with the WAD recording's own media ID, byte-identical to
the WAD WEM (84 copies). They are handled like heads: part of the WAD line, written and reverted with it, not the WAD WEM (84 copies). They are handled like heads: part of the WAD line, written and reverted with it, not
sounds of their own (`catalog._whole_recordings`). Heads and whole copies together: 445 recordings, 2,185 sounds of their own (`catalog.whole_recordings`). Heads and whole copies together: 445 recordings, 2,185
copies (`BANK_locations.csv.gz` kind `p`). copies (`BANK_locations.csv.gz` kind `p`).
## Localized banks ## Localized banks
@@ -106,8 +106,8 @@ and the game ties the recordings together itself:
- The packages are LZO-compressed UE3 packages (`upk.md`); ackit decompresses them itself and searches the - The packages are LZO-compressed UE3 packages (`upk.md`); ackit decompresses them itself and searches the
uncompressed data for `BKHD` (banks) and `BKPK` (WAD indexes). (The first survey used Gildor's uncompressed data for `BKHD` (banks) and `BKPK` (WAD indexes). (The first survey used Gildor's
decompress.exe; `ackit/upk.py` gives identical data.) decompress.exe; `ackit/upk.py` gives identical data.)
- Bank blobs in packages: 41,798 for 3,340 bank IDs. Many banks are copied into several packages (e.g. - Bank blobs in packages: 41,797 for 3,340 bank IDs. Many banks are copied into several packages (e.g.
`CHR_CatwomanFoleySuit` 31 times, `CHR_BatmanFoleyBelt` 26). 302 bank IDs occur as several blobs with `CHR_CatwomanFoleySuit` 31 times, `CHR_BatmanFoleyBelt` 25). 302 bank IDs occur as several blobs with
*different* media subsets (often one WEM each, several per package), so a bank's media set is the union *different* media subsets (often one WEM each, several per package), so a bank's media set is the union
of all its blobs. of all its blobs.
- Largest: `CHR_RobinFoleyCape` 300 WEMs, `CHR_BatmanFoleyCape` 282, `CHR_Clayface` 263, - Largest: `CHR_RobinFoleyCape` 300 WEMs, `CHR_BatmanFoleyCape` 282, `CHR_Clayface` 263,

View File

@@ -14,6 +14,10 @@ the UE3 packages (`upk.md`), holding the subtitle text of all 11 languages. acki
to 5 lines. to 5 lines.
- Strings are stored as cp1252 (positive FString length) or UTF-16 (negative length): almost all RUS, JPN and - Strings are stored as cp1252 (positive FString length) or UTF-16 (negative length): almost all RUS, JPN and
KOR strings and 85 % of POL are UTF-16; the other languages have 17-56 UTF-16 strings each. KOR strings and 85 % of POL are UTF-16; the other languages have 17-56 UTF-16 strings each.
4 cp1252 strings hold UTF-8 bytes instead (the same line in KOR, POL, POR, RUS: `Speech_TAPE_J_Answer_1_00`;
the KOR one with a damaged byte; the game's own data). ackit reads cp1252 as Windows maps it (every byte, also
the 5 that Python's cp1252 leaves undefined, which the RUS and KOR ones contain), so they are shown as stored
and every string is written back byte-for-byte.
## Scripted subtitles (`RSubtitle`) ## Scripted subtitles (`RSubtitle`)
41 more subtitles (radio and encryption-card lines) are `RSubtitle` objects in `Startup.upk`: the same layout, 41 more subtitles (radio and encryption-card lines) are `RSubtitle` objects in `Startup.upk`: the same layout,

View File

@@ -1,6 +1,6 @@
# UE3 packages (`*.upk`) # UE3 packages (`*.upk`)
Arkham City's 2,650 packages in `BmGame\CookedPCConsole` are Unreal Engine 3 packages, version 805, Arkham City's 2,648 packages in `BmGame\CookedPCConsole` are Unreal Engine 3 packages, version 805,
licensee 101. 2,647 are fully LZO-compressed, 2 are stored uncompressed, and `UnrealX.upk` (512 bytes) licensee 101. 2,647 are fully LZO-compressed, 2 are stored uncompressed, and `UnrealX.upk` (512 bytes)
is not a readable package. ackit reads and patches them with `ackit/upk.py` and `ackit/lzo.py` (no external is not a readable package. ackit reads and patches them with `ackit/upk.py` and `ackit/lzo.py` (no external
tools). Soundbanks inside them: `banks.md`. tools). Soundbanks inside them: `banks.md`.

View File

@@ -0,0 +1,284 @@
# Plan: dubbing voice lines, with the game's voice effects
Status (2026-10-27): stage 0 done (section 9); the scope changed after it (section 10: manual effect controls,
no automatic profiles); stages 1-2 of section 10 done (effects engine, Dubbing tab). The user's answers are in section 8. Sections 1-7 are the original proposal, written
from the code, the docs and the shipped catalog CSVs; the steps marked **(game)** read the game files.
The game is also installed on the Linux machine (Steam Flatpak, mounted into the podman container at
`/home/gora/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Batman Arkham City GOTY`;
the container's HOME differs, so it is passed with `--game`), so the read-only stages can run there; in-game
checks still need the Windows PC.
Sections 3-7 name modules and files that were proposed but not built after the scope change (`ackit/fxdetect.py`,
`ackit/dub.py`, `data/catalog/FX.csv`, `fx_profiles.json`, `docs/formats/effects.md`); what was built is in
sections 10-11 and `NOTES.md`: `ackit/fx.py`, `app/edit.py`, `app/editor.py`, `app/effects.py`, `app/preview.py`.
## 1. The problem
A dub replaces a character's lines with new recordings. Some original lines are not dry: they sound like a
telephone or radio (band-limited EQ), a PA announcement over the city (echo, several speakers), a room (reverb),
a suit or a machine (Mr. Freeze). A dry dub next to them sounds wrong. Two questions:
1. **Where is the effect applied?** This decides everything:
- **Baked into the WEM** (applied in the studio, or by Wwise when the bank was built, *rendered* effect):
the dry dub loses it, so the dub must get the effect before it is written.
- **Applied by the game at run time** (a Wwise effect on the Sound, on a parent container or on a bus, an
environmental/reverb zone, attenuation, obstruction): the game also applies it to the replacement. The
dub must stay **dry**, or it gets the effect twice.
The user heard effects "in game and here": the ones heard *here* (the editor plays the WEM) are baked; the ones
heard only in game are run-time and need nothing.
2. **Which effects exist?** Unknown, so they have to be found in the game data (section 3).
## 2. What the catalog already shows (INT, measured on the shipped `data/catalog/INT.csv`)
Cheap signals that are already in the catalog (`rate`, `channels`) and point to baked effects:
| signal | lines | groups (examples) | likely effect |
|--------|-------|-------------------|---------------|
| 3 or more channels | 858 WAD speech rows (mostly 5 ch, then 4 and 3 ch), plus 86 in-memory 5 ch | Predator chatter 251, story 211, Riddler 90: `PRED_RIDDLER`, `PRED_J`, `PRED_STRANGE`, `PRED_MAYOR`, `RIDDLER_R`, `GAME_OVER`, `COURT_B`, `UNDER_S` | PA / hacked-speaker broadcasts: one voice spread over several channels, probably with delays (echo) between them |
| 32,000 Hz | 110 (102 are `CHAR_FREEZE`) | Mr. Freeze | suit voice filter |
| 11,025 / 12,000 Hz | 91 | `CHR_RadioScanner_*` | police radio (band-limited) |
| 6,000 Hz | 25 | `PSO_CarRadio_Chatter_*` | car radio |
| category *Radio & TV* (`Speech_RADIO`, `Speech_TV`, `Speech_CAMERAS`, `DIA_`) | see `categories.md` | Vicki Vale reports, TV, cameras | radio/TV EQ, maybe noise |
These are hypotheses until someone listens and the metadata scan (3a) confirms them. Two things follow already:
- A mono dub of a 5-channel line is copied to every channel today (`audio.to_channels`): the spatial echo between
the channels is lost. Multichannel lines need a per-channel layout (gain + delay per channel) in their effect.
- Low-rate lines limit the band themselves (6 kHz = nothing above 3 kHz), so part of the "radio" sound is just
the encode rate; the rest (low cut, mid boost, distortion, noise) has to be added.
## 3. Finding every effect the game uses
Three independent sources, from exact to fuzzy. Every result goes into the catalog, so the editor can say
for each line: *baked: Radio EQ (from metadata)*, *baked?: echo, 3 channels (analysis, 0.8)*, *game adds: room
reverb (run time, record dry)*.
### 3a. Wwise metadata (exact, where it exists) **(game)**
The banks (version 56, Wwise 2011.1) describe effects in HIRC; `ackit/bnk.py` reads only the Sound sources today.
To add (`ackit/bnk.py` + a survey tool, like the bank survey of mission 3):
- Parse the **node base parameters** of Sounds, containers, actor-mixers and buses: the effect list (slot,
effect object ID, and a **"rendered"** flag in this bank version, per wwiser's v56 layout, to confirm on the
game's banks), the parent ID (effects are inherited unless the child overrides them), the output bus.
- Parse the **effect objects** (HIRC effect / share-set type; the type number for v56 to confirm): plugin ID
(Audiokinetic Parametric EQ, Delay, RoomVerb, Matrix Reverb, Peak Limiter, Compressor, Expander, Pitch
Shifter, Flanger, Guitar Distortion, Tremolo, ...; plugin ID = company + type + number) and its **parameters**
(EQ band frequencies/gains/Q, delay time and feedback, reverb time ...). The `FXPR` chunk the parser already
recognizes and `ENVS` in `Init.bnk` (environments, i.e. run-time reverb zones) belong here too.
- Walk each line's Sound object up the parent chain to the master bus: list of `(effect, plugin, params,
where: sound/parent/bus, rendered or run time)`.
- Line identity helps: Sound objects are the same in every language (`banks.md` *Lines*), so a line's chain is
the same in all 5 languages.
Expected result: run-time effects (bus reverbs, environments) are fully known, with parameters. Rendered
effects are known **with their exact parameters**, which is the best case for a dub. Effects applied in the
studio before the files reached Wwise leave **no metadata at all**: likely many of the radio/PA lines. Those need 3b.
### 3b. Signal analysis of the WEMs (no metadata needed) **(game, ~minutes over all speech)**
`ackit/fxdetect.py` (numpy only), per recording, a small feature vector:
- **Band limits**: frequency below which / above which 1 % of the energy lies (telephone ~300-3,400 Hz), steepness.
- **Effect EQ curve**: long-term average spectrum (LTAS) of the line divided by the LTAS of the **same speaker's
dry lines** (speaker from `characters.csv` / the line's speaker). The voice cancels out and what is left is the
effect's frequency response: directly usable as the dub's EQ.
- **Reverb**: energy decay after speech offsets (Schroeder-style decay fit -> RT60 estimate), direct-to-
reverberant ratio, tail length after the last word.
- **Echo / delay**: peaks in the autocorrelation of the onset envelope, or in the cepstrum, at lags 30-800 ms;
for multichannel lines the cross-correlation **between channels** (delay and gain per channel).
- **Distortion / noise**: crest factor, clipping, spectral flatness of the gaps between words (static, hiss),
harmonics added to a narrow band.
- **Modulation / pitch**: envelope modulation 2-20 Hz (tremolo), pitch and formant shift against the speaker's
dry lines (masks, Freeze, Clayface, synthesized voices).
Then **cluster** the vectors (e.g. k-means on normalized features, or simple thresholds first) and **listen to a
few lines per cluster** (the user names it: "Radio scanner", "Freeze suit", "Strange PA", "Riddler hacked
speakers", "Interview tape", ...). The names and cluster labels go into a profile table (3d). Cross-language
check: the 5 recordings of one line must land in the same cluster; disagreements are lines to listen to.
### 3c. Comparing with the game's output (optional, run-time effects only) **(game)**
The in-game harness can already play sounds and record the loopback (`ackit/verify/ingame.py`, `loopback.py`).
For a sample of lines with a run-time effect, the same features computed on the game's output vs on the WEM
show what the game adds. **Never save raw captures** (CLAUDE.md: they can contain voice chat): compute the
features in memory and keep only the numbers. Useful to confirm, not needed for the dub itself.
### 3d. What gets stored
- `data/catalog/FX.csv`: per line (`line` identity, language-independent where the metadata says so): `profile`,
`baked` (yes / no / unknown), `source` (metadata / analysis / manual), `confidence`, `runtime` (what the game
adds, for the "record dry" hint). Built by `tools/build_catalog.py` from the original files only, like the rest.
- `data/catalog/fx_profiles.json`: the profiles (section 4), with where each parameter came from.
- `docs/formats/effects.md` (format of the HIRC effect data, survey figures) and claims in `tools/check_docs.py`
for every figure; `ackit verify --catalog` re-derives the metadata part.
- Manual labels of the user live in user data (like notes/tags), never in `source/`.
## 4. Re-creating the effects on a dub
An **effect profile** = a chain of simple, deterministic processors with parameters (data, not code), rendered by
`ackit/fx.py`:
| block | parameters | source of the numbers |
|-------|------------|-----------------------|
| high-pass / low-pass / peaking / shelf (biquads) | freq, gain, Q | metadata EQ, or fitted to the 3b EQ curve |
| matched EQ (FIR) | the 3b curve itself | analysis (generic fallback for any baked EQ) |
| delay / echo | time, feedback, mix, per-channel delays/gains | metadata, or 3b autocorrelation / inter-channel lags |
| reverb | RT60, pre-delay, damping, mix | metadata (RoomVerb), or 3b decay fit; convolution with a generated IR (filtered, exponentially decaying noise) keeps it deterministic |
| distortion / bit-crush / noise bed | drive, bits, noise level and colour | 3b, tuned by ear |
| compressor / limiter | threshold, ratio | metadata, or by ear |
| channel layout | per-channel gain + delay (replaces the plain mono copy for 3-5 channel lines) | 3b inter-channel analysis |
The rendered dub then goes through the existing chain: loudness matched to the original, encoded at the
original's rate/channels, fitted to the size limit (`Workspace.prepare`). A reverb or echo tail makes the audio
longer: the editor's length warning and size fitting already cover that, but a dub UI should show the room.
**DSP dependency** (user: "just use some libraries", no own DSP, no VST hosting):
- Effects: **pedalboard** (Spotify, GPL-3.0, compatible with this project's GPL-3.0): ready filters (high/low-
pass, peak, shelf), reverb, delay, chorus, phaser, distortion, bit-crush, GSM compression (telephone),
compressor/limiter, pitch shift. A profile is a list of pedalboard plugins with parameters; the per-channel
layout (gain + delay per channel) is a few numpy lines. To check before stage 1: wheels for the project's Python
(3.14) on Windows and Linux, and that PyInstaller bundles it.
- Analysis (3b): numpy (FFT, correlations), scipy only if a step really needs it.
- Decision goes into `docs/decisions.md` like the other dependencies, licenses into `THIRD_PARTY_LICENSES.txt`.
## 5. Where to build it: new app or the existing editor?
| | Separate dubbing app on `ackit` | Inside Unheard City (new **Dubbing** tab) |
|-|-------------------------------|------------------------------------------|
| Write safety | `Workspace.lock` is a `threading.RLock`: it serializes writes **inside one process** only. Two apps open on the same game and store can write one package at the same time (the lost-edit bug of 2026-10-13, across processes). Needs a cross-process file lock + "changed outside" handling in both apps. | Same process, same lock, same store, same changed-outside banner: nothing new. |
| Reuse | Re-implements catalog loading (background jobs, spinners), language setting, line identity, subtitles, waveform, player/recorder, encode/fit, apply/revert, mod export/import, settings, packaging. | All of that exists (`app/editor.py`, `audioio.py`, `waveform.py`, `textpanel.py`, `modpack.py`). |
| Users | Two programs, two installs, two settings files, two originals stores to keep apart. | One program; a dub is just another kind of change (and a mod package). |
| Complexity | The editor stays as it is. | `mainwindow.py` (774 lines) and `editor.py` (590) must not grow into one blob: the tab is its own package. |
| UX | Free to design a session-based workflow. | Same freedom inside a tab; the Sounds tab stays for one-off replacements. |
**Recommendation: build it into Unheard City, with the engine in `ackit`.**
- `ackit/fx.py` (profiles + rendering), `ackit/fxdetect.py` (features, clustering), `ackit/dub.py` (dub
sessions/takes: files, states; no Qt), CLI `ackit fx survey | scan | render` and `ackit dub ...`: all headless,
testable, usable from scripts.
- `app/dubbing/` (own package): the **Dubbing** tab.
- The **Sounds** editor gets one small row: *Effect: [profile ▾] (detected: Radio EQ, metadata)* and a *game
adds ... record dry* hint, so single replacements benefit too.
- A separate program makes sense only later, if voice actors record **without the game** (remote dubbing): a
small recorder that reads a script export and writes a take pack (WAVs + JSON), which Unheard City imports.
It never touches game files, so the lock problem does not arise.
## 6. The Dubbing tab (workflow)
1. **Session**: language + character (or a filter: category, a list of lines). Lists the character's lines via the
line identity; shows the script (subtitle of the loaded language, the INT text as reference), the original's
length, the effect profile and baked/run-time status, and a state per line: *to do / recorded / approved /
applied*. Sessions and takes are stored in user data (`%APPDATA%\UnheardCity\dubs\...`), never in the game.
2. **Record**: play the original as a cue, count-in, record, auto-trim silence, several takes per line, pick one;
length vs the original (and optional time-stretch to fit, if pedalboard or a WSOLA stretch is added).
3. **Process**: clean-up (high-pass, gate), loudness match to the original (exists), the effect profile
(suggested from FX.csv, editable, A/B against the original with and without the effect).
4. **Apply** the approved lines in one `Workspace.apply` call (it takes a list); **export** the session as a mod
package (`modpack.export_mod`, with edited subtitles if the dub's wording changed, within their room).
5. Non-destructive: raw takes + parameters are kept, so a changed profile re-renders every line.
Warnings the tab must show (from the existing data):
- Lines the game shares between languages (167 in-memory voice lines stored once for every language; 616-651 WAD
lines per language identical to INT, `banks.md` *Lines*): dubbing one changes it for every language that uses it.
- Size limit (speech in place, never larger than the original WEM): long takes plus reverb tails get squeezed or
refused; show the room.
- Subtitle room (NUL-padded in place).
- Lines without a speaker label or with a disputed one (19 listed in `categories.md`).
## 7. Stages (one commit each: code + tests + docs + NOTES status)
0. **Evidence (game)**: HIRC effect survey (3a) and a first feature scan (3b) over all speech; listen to samples
per cluster with the user; write `docs/formats/effects.md` with figures (+ `check_docs` claims). Outcome: how
many lines are baked vs run time, and the list of profiles. **This decides the size of everything after it.**
1. `ackit/fx.py` on pedalboard: profiles JSON, render; tests with synthetic signals (an EQ's
measured response, a delay's lag, an RT60 measured back from the rendered reverb).
2. `ackit/fxdetect.py` + `FX.csv` / `fx_profiles.json` in `build_catalog.py`; `verify --catalog` checks; CLI.
Test: render a profile onto a dry synthetic voice, detect it back.
3. Sounds editor: effect row + run-time hint; GUI tests.
4. `ackit/dub.py` + Dubbing tab (sessions, takes, script, batch apply, mod export); GUI tests.
5. Real-game round trips (`tools/e2e_features.py` / `e2e_editor.py` cases for a dubbed line with an effect,
reverted in `finally`), the user listens in game, packaging (`build_app.py`, licenses), version 3.4.0 or 4.0.0.
## 8. Decisions (user, 2026-10-27)
- **All characters** can be dubbed, from the existing catalogs (no per-character priority): every effect group
found in stage 0 needs a profile.
- **Approximate effects** are enough; profiles are tuned later by ear (the Riddler lines' effect is complex).
- **Libraries, not own DSP**: pedalboard for effects (section 4); no VST hosting, no time stretching for now.
- **Everyone has the game**: no separate recorder or take packs; everything lives in Unheard City (section 5).
- Still open, out of scope unless asked: audio inside video files (pre-rendered movies carry their own sound
track, not in the WADs or banks; to check whether the game has any with speech).
## 9. Stage 0 results (2026-10-27, INT, read-only)
Measured with scratch scripts (HIRC survey of every bank, signal features of 12,430 INT speech recordings),
then the user listened to 77 candidate groups (`external/fx_samples/INDEX.md`, git-ignored). Figures here are a
record of that day, not re-checked by `tools/check_docs.py`.
**Metadata (Wwise, v56).** Layouts from wwiser: node base params = u8 override-parent-fx, u8 count, [u8 bypass,
count x {u8 slot, u32 fx id, u8 share-set, u8 rendered}], u32 output bus, u32 parent; a Sound has them after its
source (+ u8 source bits); a bus (v56) has u8 is-environmental at payload offset 33, ducks of 17 bytes, then its
effects. Effect objects are HIRC types 0x12 (share-set) / 0x13 (custom): u32 plugin, u32 size, parameters.
- Run time (the game adds them; the files are clean, so a dub is recorded **dry**): McDSP FutzBox share-set
`4272216A` (in 1,125 speech banks, on 464 actor-mixer slots, RTPC-driven) -> 2,858 speech rows; Parametric EQ
on a bus (30 rows) or on the sound (3); the ML1 limiter on the top bus (everything); 84 RoomVerb + 6 Matrix
Reverb share-sets in `Init` = the environments the game switches per area.
- Rendered (baked by Wwise): 33 speech rows (car radio 25, radio scanner 3, museum thug-to-Penguin 4, Steel HQ
1); the slot's fx id is zeroed, so the effect type is lost.
- Everything else that is baked was made in the studio and has no metadata.
**Channels.** Speech WEMs with 2-5 channels carry the WAVE channel mask: 2 ch `0x3` (L R), 3 ch `0x7` (L R C),
4 ch `0x33` (L R SL SR), 5 ch `0x37` (L R C SL SR); ackit decodes and encodes them in that order. PA lines put
the dry voice in C and an echo 160-173 ms later in the surrounds (Harley/HQ 121 ms; Freeze's quad rears 73 ms);
Riddler, Ivy and Ra's 5-channel lines are processed differently per channel.
**What the user heard** (per group; "no effects" groups omitted: thug/Ninja/Catwoman/JR efforts, combat barks,
Riddler `RIDDLER_R`, DLC `PDLC_PDLC`/`PDLC_EMOTES`/Robin/Harley, Outcast, Penguin `MUSEUM_B`, Steel/Church Harley,
Bully hobo, Talia `UNDER_B`, `CH_ZERO` Strange, Strange/Black Mask game-over):
| effect heard | groups |
|--------------|--------|
| radio EQ | radio scanner, Alfred/Oracle/Penguin/Strange/Ra's comms, copter, cameras, Gordon radio, car radio |
| phone EQ | Penguin PRED, Zsasz calls, Two-Face court, Joker (PRED, theatre), Strange/Mayor/Harley PA, Riddler (Enigma, cameos), Vicki Vale radio scan, Penguin exhibits |
| phone-mic EQ / TV-speaker EQ | interview tapes (Vicki Vale, Joker), Strange on TV, Joker church message, `UNDER_C` TV |
| radio noise | copter, car radio, radio scanner (Vicki), Vicki Vale radio scan, map radio |
| distortion (slight, speaker) | Penguin exhibits, radio scanner (Vicki), `UNDER_C` TV |
| echo (PA delay) | Strange/Mayor/Harley/Joker/Two-Face PA and court lines, Catwoman side mission (Strange) |
| very short delay (width) | Riddler cameos, radio scanner (Riddler) |
| reverb (small/short room, slight wide room) | Riddler PRED, Harley 5 ch, Strange TV, Joker church, Talia sequence, Harley DLC, game-over lines (barely) |
| reverse reverb (occasional) | Riddler (PRED, cameos, radio scanner), Poison Ivy |
| glitches (parts repeat at another pitch), small repeats | Riddler (PRED, cameos, Enigma, radio scanner) |
| pitch shift, fixed, down | Clayface |
| pitch shift over time + robot + pan over time | Mr. Freeze |
| octaves overlaid (down), formant shift, stereo width | Ra's al Ghul (giant, trials), Poison Ivy |
| formant shift, unintelligible voice | car radio |
## 10. Scope after stage 0 (user, 2026-10-27)
Matching effects automatically per line is too complex: the Dubbing section gets **manual controls for every
effect found** (section 9), which the user sets by ear. Dropped: automatic profiles, `FX.csv` /
`fx_profiles.json`, detection in the catalog build (stage 2 of section 7). Kept: pedalboard, the Dubbing tab in
Unheard City, everything else of sections 5-6.
Effects rack (`ackit/fx.py`, one table of blocks and parameters that the UI is generated from; every block off
by default; fixed order; seeded randomness, so a setting always renders the same audio):
| # | block | controls | built on |
|---|-------|----------|----------|
| 1 | Pitch | shift (semitones), formant shift, drift depth + rate (pitch over time) | `pedalboard.time_stretch` (pitch curve, formants) |
| 2 | Octave layers | two layers: interval + level | `time_stretch` pitch, mixed |
| 3 | Robot | ring modulator frequency + mix, metallic comb (delay, feedback) | numpy |
| 4 | Glitch | events per 10 s, length, repeats, pitch of the repeats, seed | numpy + `time_stretch` |
| 5 | Voice band (EQ) | low cut, high cut, slope, mid boost (freq, gain, Q) | `HighpassFilter`, `LowpassFilter`, `PeakFilter` |
| 6 | Distortion | drive, clip, bit depth, phone codec (GSM) | `Distortion`, `Clipping`, `Bitcrush`, `GSMFullRateCompressor` |
| 7 | Noise | level, colour (band), crackle | numpy |
| 8 | Echo | time, feedback, mix | `Delay` |
| 9 | Reverb | room size, damping, wet, width; reverse reverb amount + how often | `Reverb` (reversed for the reverse swell) |
| 10 | Channels (2-5 ch lines) | width (L/R delay), auto-pan rate + depth, surround echo delay + level, dry voice in C | numpy, channel order of section 9 |
Then the existing chain: loudness match / normalize / gain, encode within the size limit.
Stages from here (one commit each; 1 and 2 done on 2026-10-27, see NOTES.md):
1. `ackit/fx.py` + tests (synthetic signals: each block measured back) + pedalboard dependency
(`pyproject.toml`, `docs/decisions.md`, licenses).
2. Dubbing tab (`app/dubbing/`): line list (character/category/search filters, state per line), the existing
sound editor embedded (record/load, trim, A/B, subtitle, apply/revert), the effects rack between the take and
the loudness step, settings saved per line in user data; GUI tests; user guide.
3. Real-game round trip of a dubbed line with effects, packaging (PyInstaller + pedalboard), version bump.
## 11. UI revision (user, 2026-10-27, after trying stages 1-2)
- The subtitle box was too small for dubbing, and the Sounds tab and the Dubbing tab both had an editor:
browsing and editing are now separate. **Sounds** only browses (list + a compact preview with **Edit** / **Add
to edit list**); **Dubbing** became **Edit**, for every kind of sound, with an edit list instead of a second
browser, the subtitle as a large **script**, and Previous / Next.
- The effects panel was "raw": sliders with readable values (log scale for frequencies and times), **looks**
for the voices heard in the game (section 9) and **preset buttons** per effect that set the sliders in place.
- The reverb (pedalboard's Freeverb) sounded poor: replaced by a generated room response (decay, pre-delay,
reflections, damping, width) with presets (small room to church, tiled cell, outdoors).
- Second round: the browser's player keeps Play/Stop together (editing actions apart on the right); the Edit tab
fits the window without scrolling (effects as a chain with one settings area); the app and docs use generic
terms (replacement, effects, subtitle), since the Edit tab is for every kind of sound, not only dubbing.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 315 KiB

View File

@@ -23,6 +23,7 @@ game is set to (Steam > game properties > Language) and applies to everything yo
effects). The game has voices in English (INT), French, Italian, German and Spanish; for the other languages effects). The game has voices in English (INT), French, Italian, German and Spanish; for the other languages
(Latin American Spanish, Polish, Portuguese, Russian, Japanese, Korean) it plays the English voices, so the (Latin American Spanish, Polish, Portuguese, Russian, Japanese, Korean) it plays the English voices, so the
editor shows those and says *voices: INT* next to the list. editor shows those and says *voices: INT* next to the list.
- **Edit**: the sounds of the edit list in that language (sounds of another language stay on the list, hidden).
- **Subtitles** and **Menu text**: the texts of that language. - **Subtitles** and **Menu text**: the texts of that language.
Changing it reloads the lists; the next start is again in the game's language. Changing it reloads the lists; the next start is again in the game's language.
Some voice lines sound English in every language: the game itself ships the English recording for most Some voice lines sound English in every language: the game itself ships the English recording for most
@@ -51,45 +52,97 @@ the same line and are changed and reverted together.
- Click a column header to sort (length, size, status, ...). Ctrl/Shift-click selects several rows. - Click a column header to sort (length, size, status, ...). Ctrl/Shift-click selects several rows.
- Archive **BANK** holds the short sounds stored inside soundbanks (footsteps, foley, combat, - Archive **BANK** holds the short sounds stored inside soundbanks (footsteps, foley, combat,
weapons, menu sounds, grunts). Many are named after the game event that plays them (`Play_...`), the weapons, menu sounds, grunts). Many are named after the game event that plays them (`Play_...`), the
rest after their bank (`CHR_BatclawGrapple_3`). The editor shows the bank and how many copies the game keeps of rest after their bank (`CHR_BatclawGrapple_3`). The Edit tab shows the bank and how many copies the game keeps
the sound; a replacement is written into every copy. of the sound; a replacement is written into every copy.
![Browsing by category](img/browse.png) ![Browsing by category](img/browse.png)
## Listen ## Listen
The editor under the list shows the selected sound in three columns side by side (drag the dividers to The **Sounds** tab is for finding and listening; it does not change anything. Below the list, the selected sound
resize them): **Listen**, **Replace** and **Subtitle**. Select a row and press **Space** (or double-click) to is shown with its details and subtitle (left), a player (middle) and the way into the Edit tab (right).
play the original. Each column ends with its own buttons on one line. In **Listen**: **Play original** (or Space, or double-click the row), **Play installed** (what the game has now, if you
- **Original** / **Installed** play the game's original and what is currently in the game; **Stop** (Esc). replaced it) and **Stop** (Esc) sit together above the waveforms; click on a waveform to play from that point.
Click on a waveform to play from that point.
- Above them: **A/B** (switch between the original and your replacement at the same position, to compare),
**Loop**, **Volume** and the playback position. The icon buttons show their names when you point at them.
## Replace To change sounds, select them (Ctrl/Shift-click for several) and press **Edit** (or Enter, or right-click >
![Editor](img/editor.png) **Edit**): they go onto the **edit list** and the **Edit** tab opens on the first. **Add to edit list** puts them
1. **Load file…** (Ctrl+O): WAV, FLAC, OGG, OPUS, MP3 or AIFF. (M4A/WMA work only if ffmpeg is installed.) there without leaving the list, e.g. to collect all the lines of a character or all the sounds of a weapon
Or choose a microphone in the list and press **Record**, then **Stop recording**. Listen with first.
**Replacement**; record again as often as you like.
2. Adjust: **Keep … to** trims (or right-drag on the replacement waveform), **Trim silence**, **Gain**,
**Normalize**, **Match loudness** (to the original; recommended for voice lines).
3. **Apply to game** (Ctrl+S). The audio is converted to the original's format and written into the game files.
The row turns bold and shows *Modified*.
Size rule: voice lines and effects must fit into the original's size (the *Max size* column), because the ## Edit
game reads exactly that many bytes. Longer audio is encoded at lower quality to fit; if it still does not fit ![Edit tab](img/editor.png)
you are asked to shorten it. Music (`Music_…`) can be any length. A warning appears when your audio is longer The **Edit** tab is where sounds are replaced: voice lines, music, ambience, sound effects. Everything fits in
than the original, since the game may cut it off. the window: left the **edit list**, middle the sound you are working on, right its effects.
- **Edit list**: filter it, **Remove** sounds you are done with, or **Remove applied**. Columns: a dot = a
replacement is saved, FX = number of effects on, a tick = applied to the game. **Previous** / **Next**
(Alt+Left / Alt+Right) walk the list.
1. **Subtitle** (voice lines only): shown large, with the game's original (and the English line when you edit
another language) under it. You can rewrite it there and **Apply** it (see *Subtitles*).
2. **Listen**: **Original**, **Installed** and **Replacement** each have a play button and a waveform; **A/B**
switches between the original and the replacement at the same position; **Loop**, **Volume**, **Stop**
(Esc).
3. **Replacement**: **Record** (choose the input in the list next to it; press again to stop) or **Load file…**
(Ctrl+O: WAV, FLAC, OGG, OPUS, MP3 or AIFF; M4A/WMA only if ffmpeg is installed), as often as you like.
**Keep … to** trims (or right-drag on the replacement's waveform), **Trim silence**, **Gain**,
**Normalize**, **Match loudness** (to the original; on by default for voice lines). The replacement is saved
at once in your user data folder (`edits`), with its trim, level and effect settings, so you can come back
to it any time; nothing is written to the game until you apply it.
4. **Apply to game** (Ctrl+S). The audio (with its effects) is converted to the original's format and written
into the game files; the list shows a tick and the Sounds tab *Modified*.
Size rule: voice lines and sound effects must fit into the original's size (the *Max size* column), because
the game reads exactly that many bytes. Longer audio is encoded at lower quality to fit; if it still does not
fit you are asked to shorten it. Music (`Music_…`) can be any length. A warning appears when your audio is
longer than the original, since the game may cut it off.
### Effects
Many sounds are not dry in the game (radios, phones, announcements, speakers, Mr. Freeze's suit...); the
replacement gets the effects you turn on, and **Replacement** plays it with them.
- **Start from a preset…** sets several effects at once: Radio, Phone, Tape / phone mic, TV in a room, PA
announcement, Mr. Freeze, Clayface, Riddler broadcast, Giant (Ra's al Ghul), Car radio.
- The **effect chain** lists every effect in the order it runs: tick an effect to turn it on, click its name to
see its settings below. Each effect has preset buttons that set its sliders; then adjust by ear. Moving a
slider turns its effect on; double-click a slider to put it back.
| effect | in the game, for example |
|--------|--------------------------|
| **Pitch** (shift, formant, drift = pitch over time) | Clayface (shift down), Mr. Freeze (drift) |
| **Octave layers** | Ra's al Ghul, Poison Ivy (octaves below the voice) |
| **Robot** (ring modulator, metallic comb) | Mr. Freeze's suit |
| **Glitch** (pieces repeated, at another pitch) | the Riddler's hacked broadcasts |
| **Band-pass EQ** (low/high cut, mid boost) | radio, phone, phone microphone, TV and small speakers |
| **Distortion** (drive, clip, bits, phone codec) | speakers, radios, the car radio |
| **Noise** (level, colour, crackle) | radio static |
| **Echo** | Hugo Strange, the Mayor, Joker and Harley over the city speakers (the *PA announcement* preset: 160 ms) |
| **Reverb** (decay, mix, pre-delay, reflections, damping; reverse swells before phrases) | rooms, TV, halls; reverse: the Riddler, Poison Ivy |
| **Channels** (sounds with 2-5 channels) | stereo width, pan over time, the PA layout: dry sound in the centre, echo in the surrounds |
**Copy** / **Paste** move settings between sounds; **Use these effects for the selected** gives every selected
sound of the edit list the same settings (e.g. all of Mr. Freeze's lines), keeping their replacements.
**Reset** turns everything off.
Some effects are added by the game itself while it plays, not stored in the sound (the radio sound of many
thugs and street conversations, the reverb of rooms and areas): if the original sounds clean here but not in
the game, keep your replacement clean too, or the effect is applied twice. Glitch, echo and reverb make a sound
longer, which counts for the size rule.
## Undo ## Undo
- **Revert** in the editor puts that sound back exactly. - **Revert** in the Edit tab puts that sound back exactly (the replacement stays saved for later).
- Right-click rows → **Revert selected**; toolbar **Revert all** puts every original back. - Right-click rows → **Revert selected**; toolbar **Revert all** puts every original back: all sounds,
subtitles and menu texts you changed, in every language.
- Originals are kept in your user data folder (`%APPDATA%\UnheardCity` on Windows, - Originals are kept in your user data folder (`%APPDATA%\UnheardCity` on Windows,
`~/.local/share/unheardcity` on Linux), so reverting works after updating or reinstalling the app too. `~/.local/share/unheardcity` on Linux), so reverting works after updating or reinstalling the app too.
Uninstalling keeps this folder; use **Revert all** before uninstalling if you want the game back to original. Uninstalling keeps this folder; use **Revert all** before uninstalling if you want the game back to original.
Before the first change to a game file, a full copy of it is kept there too: optional for the WAD archives Before the first change to a game file, a full copy of it is kept there too: optional for the WAD archives
(Settings), always for soundbanks, packages and text files (they are rebuilt from that copy). (Settings), always for soundbanks, packages and text files (they are rebuilt from that copy). Only files the
game ships unchanged are copied; a file another tool (or another originals store) has changed is reported as
*changed outside this tool* first.
- The originals of your changes are only in the store that made them, so the store folder (Settings) cannot be
changed while changes are applied: use **Revert all** first.
- If Steam "Verify integrity of game files" or an update undoes your changes, a bar offers **Re-apply my - If Steam "Verify integrity of game files" or an update undoes your changes, a bar offers **Re-apply my
changes** or **Forget them**. Do not use "verify" to undo mods; use Revert all. changes** or **Forget them**. Do not use "verify" to undo mods; use Revert all.
- If the store folder itself is damaged, the full copies can still be put back with the command-line toolkit
that Unheard City is built on (`ackit restore-backup`, see the project README; not part of the editor
download).
## Subtitles ## Subtitles
The **Subtitles** tab lists the subtitle of every speech line in the selected language (see *Language*). The **Subtitles** tab lists the subtitle of every speech line in the selected language (see *Language*).
@@ -100,9 +153,9 @@ The **Subtitles** tab lists the subtitle of every speech line in the selected la
(Windows-1252: accents are fine, other scripts are not); those stored as Unicode (almost all Russian, (Windows-1252: accents are fine, other scripts are not); those stored as Unicode (almost all Russian,
Japanese and Korean ones, most Polish ones) accept any character. The editor says so when a character does Japanese and Korean ones, most Polish ones) accept any character. The editor says so when a character does
not fit. not fit.
- **Go to the sound** opens the speech line in the Sounds tab. In the Sounds tab, the editor of a speech line - **Go to the sound** selects the speech line in the Sounds tab (its subtitle shows in the preview). In the
shows its subtitle in the **Subtitle** column next to the audio: edit both side by side, **Apply** / Edit tab, a voice line's **Subtitle** is shown large above the audio: edit it there, **Apply** / **Revert**
**Revert** the subtitle there, or **Show in list** to jump back to the Subtitles tab. it, or **Show in list** to jump back to the Subtitles tab.
- **Find and replace…** works on the rows the filters show (texts that would not fit are listed and skipped). - **Find and replace…** works on the rows the filters show (texts that would not fit are listed and skipped).
- **Revert** / **Revert all texts here** put the original text back (every copy in every game file). - **Revert** / **Revert all texts here** put the original text back (every copy in every game file).
- About 30 subtitles per language have no sound in the game (texts such as the Arkham City Stories, unused - About 30 subtitles per language have no sound in the game (texts such as the Arkham City Stories, unused
@@ -123,6 +176,7 @@ Click the **★** column to mark favorites. These are stored in your user data f
- **Export mod…**: choose which changed sounds and texts to include and save a `.acmod` file (the exact game - **Export mod…**: choose which changed sounds and texts to include and save a `.acmod` file (the exact game
data plus the audio, so it also works for another language whose original has a different size). data plus the audio, so it also works for another language whose original has a different size).
- **Import mod…**: shows which sounds match this game, then installs them. You can revert them like any change. - **Import mod…**: shows which sounds match this game, then installs them. You can revert them like any change.
If a sound or text the mod replaces was changed outside the editor, you are asked before it is overwritten.
- Changed subtitles and menu texts are exported and imported with the sounds (listed as *Text*); a text stays - Changed subtitles and menu texts are exported and imported with the sounds (listed as *Text*); a text stays
in its language (a French subtitle edit changes the French subtitle). in its language (a French subtitle edit changes the French subtitle).
- In another language, voice lines are matched by the game's own identity of the line, so the same line is - In another language, voice lines are matched by the game's own identity of the line, so the same line is
@@ -131,10 +185,11 @@ Click the **★** column to mark favorites. These are stored in your user data f
## Settings ## Settings
![Settings](img/settings.png) ![Settings](img/settings.png)
Game folder, where originals are kept, the full-backup option and the playback device. The recording device Game folder, where originals are kept (changeable only while nothing is modified), the full-backup option and
the playback device. The recording device
is chosen next to the Record button. **Background**: show or hide the Arkham City wallpaper behind the filter is chosen next to the Record button. **Background**: show or hide the Arkham City wallpaper behind the filter
panel; **Background strength**: how visible it is (10 % by default). panel; **Background strength**: how visible it is (10 % by default).
The editor panel opens tall enough to show all its controls; drag the divider above it to change that. Drag the dividers to resize: the preview under the sound list, and the edit list, editor and effects columns of the Edit tab.
## Troubleshooting ## Troubleshooting
| message | what to do | | message | what to do |

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "ackit" name = "ackit"
version = "3.2.3" version = "3.4.0"
description = "Batman: Arkham City (PC) audio toolkit and editor" description = "Batman: Arkham City (PC) audio toolkit and editor"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -12,6 +12,7 @@ dependencies = [
"numpy>=2.0", "numpy>=2.0",
"soundfile==0.14.0", "soundfile==0.14.0",
"soxr==1.1.0", "soxr==1.1.0",
"pedalboard==0.9.26",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -11,8 +11,14 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def isolated_home(tmp_path, monkeypatch): def isolated_home(tmp_path, monkeypatch):
"""User data/config/cache go to a temp folder (never touch the real settings or stores).""" """User data/config/cache go to a temp folder (never touch the real settings or stores), and the language
the game is set to on this machine (Documents\\WB Games\\...\\UserEngine.ini) does not leak into tests."""
from ackit import game as G
monkeypatch.setenv("ACKIT_HOME", str(tmp_path / "home")) monkeypatch.setenv("ACKIT_HOME", str(tmp_path / "home"))
real = G.Game.config_dirs
mine = os.path.normcase(os.path.join(os.path.expanduser("~"), "Documents", "WB Games"))
monkeypatch.setattr(G.Game, "config_dirs",
lambda self: [d for d in real(self) if not os.path.normcase(d).startswith(mine)])
yield yield

View File

@@ -2,6 +2,7 @@
import hashlib import hashlib
import os import os
import numpy as np
import pytest import pytest
pytest.importorskip("PySide6") pytest.importorskip("PySide6")
@@ -53,6 +54,14 @@ def type_search(w, text):
w._filter_text() w._filter_text()
def edit(w, name):
"""Select a sound in the Sounds tab and open it in the Edit tab (what Enter / the Edit button do)."""
assert w.select_name(name)
w.edit_selected()
assert w.tabs.currentWidget() is w.edit and w.editor.sound.name == name
return w.editor
def test_browse_search_filter(win): def test_browse_search_filter(win):
w, root, _ = win w, root, _ = win
assert w.model.rowCount() == len(w.ws.catalog.sounds) assert w.model.rowCount() == len(w.ws.catalog.sounds)
@@ -92,9 +101,8 @@ def test_replace_apply_revert_byte_identical(win, tmp_path):
w, root, originals = win w, root, originals = win
before = files(root) before = files(root)
type_search(w, "Speech_Synth_Radio_02") type_search(w, "Speech_Synth_Radio_02")
assert w.select_name("Speech_Synth_Radio_02") ed = edit(w, "Speech_Synth_Radio_02")
ed = w.editor assert ed.orig is not None
assert ed.sound.name == "Speech_Synth_Radio_02" and ed.orig is not None
wav = str(tmp_path / "new.wav") wav = str(tmp_path / "new.wav")
x, sr = synth.tone(1.0, 44100, 990) x, sr = synth.tone(1.0, 44100, 990)
audio.save_wav(wav, x, sr) audio.save_wav(wav, x, sr)
@@ -120,8 +128,7 @@ def test_bank_sound_apply_revert_in_editor(win, tmp_path):
before = files(root) before = files(root)
w.cmb_arch.setCurrentText("BANK") w.cmb_arch.setCurrentText("BANK")
assert w.proxy.rowCount() == 7 # 6 neutral + the INT copy of a localized line assert w.proxy.rowCount() == 7 # 6 neutral + the INT copy of a localized line
assert w.select_name("CHR_SynthFoley_1") ed = edit(w, "CHR_SynthFoley_1")
ed = w.editor
assert "bank CHR_SynthFoley (2 copies)" in ed.meta.text() assert "bank CHR_SynthFoley (2 copies)" in ed.meta.text()
wav = str(tmp_path / "foley.wav") wav = str(tmp_path / "foley.wav")
audio.save_wav(wav, *synth.tone(0.2, 44100, 1300)) audio.save_wav(wav, *synth.tone(0.2, 44100, 1300))
@@ -151,7 +158,9 @@ def test_subtitles_tab_edit_and_link(win):
key = p.current.key key = p.current.key
assert w.ws.texts.state(key) == "modified" and p.model.modified[key] == "Scan two." assert w.ws.texts.state(key) == "modified" and p.model.modified[key] == "Scan two."
p.go_to_sound.emit(p.current.sound) # "Go to the sound" p.go_to_sound.emit(p.current.sound) # "Go to the sound"
assert w.tabs.currentIndex() == 0 and w.editor.sound.name == "Speech_Synth_Radio_02" assert w.tabs.currentIndex() == 0 and w.preview.sound.name == "Speech_Synth_Radio_02"
assert "Scan two." in w.preview.sub.text() # the browser shows it, read-only
w.edit_selected()
box = w.editor.subtitle box = w.editor.subtitle
assert [e.text() for e in box.edits] == ["Scan two.", "Second part."] assert [e.text() for e in box.edits] == ["Scan two.", "Second part."]
box.edits[1].setText("Part two.") box.edits[1].setText("Part two.")
@@ -173,7 +182,7 @@ def test_one_language_for_everything(win):
w.tabs.setCurrentWidget(w.subs) w.tabs.setCurrentWidget(w.subs)
assert w.subs.lang == "RUS" and "RUS" in w.subs.lbl_lang.text() assert w.subs.lang == "RUS" and "RUS" in w.subs.lbl_lang.text()
w.tabs.setCurrentIndex(0) w.tabs.setCurrentIndex(0)
w.select_name("Speech_Synth_Radio_02") edit(w, "Speech_Synth_Radio_02")
assert [e.text() for e in w.editor.subtitle.edits] == ["Строка два"] assert [e.text() for e in w.editor.subtitle.edits] == ["Строка два"]
w.tabs.setCurrentWidget(w.menu) w.tabs.setCurrentWidget(w.menu)
assert w.menu.lang is None and w.menu.model.rowCount() == 0 # no RUS menu files in the synthetic game assert w.menu.lang is None and w.menu.model.rowCount() == 0 # no RUS menu files in the synthetic game
@@ -223,6 +232,60 @@ def test_mod_with_texts(win, tmp_path):
tx.revert_all() tx.revert_all()
def test_revert_all_reverts_sounds_and_texts(win):
w, root, _ = win
before = files(root)
tx = w.ws.texts
tx.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Undo me.", "MENU:INT:FRONTEND.int:Menu:Quit": "Leave"})
s = w.ws.sound("CHR_RadioScanner_1")
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
w.revert_all()
assert files(root) == before and not w.ws.store.entries and not tx.records()
def test_mod_import_asks_before_overwriting(win, tmp_path):
"""Files changed outside the editor are overwritten by a mod only when the user confirms."""
w, root, originals = win
s = w.ws.sound("CHR_RadioScanner_1")
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
pkg = str(tmp_path / "m.acmod")
w.export_mod_dialog(path=pkg, keys=[s.key], title="m")
other = synth.wem(0.4, 150)
inject(w.ws.wad_path(s), {s.target: other}, {s.target: w.ws.limit(s)}) # another tool writes the slot
asked = []
w.confirm = lambda *a: asked.append(a) or False
w.import_mod_dialog(path=pkg, confirm=False)
assert asked and w.ws.current_bytes(s) == other # declined: left alone
w.confirm = lambda *a: True
w.import_mod_dialog(path=pkg, confirm=False)
assert w.ws.state(s) == W.MODIFIED
w.revert_all()
def test_store_cannot_change_while_changes_are_applied(win, tmp_path, monkeypatch):
from app import mainwindow
w, root, _ = win
s = w.ws.sound("CHR_RadioScanner_1")
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
store = w.ws.store.root
class Dlg:
def __init__(self, settings, parent):
pass
def exec(self):
return mainwindow.QDialog.DialogCode.Accepted
def values(self):
return dict(w.settings, store_root=str(tmp_path / "elsewhere"))
monkeypatch.setattr(mainwindow, "SettingsDialog", Dlg)
told = []
w.inform = lambda *a: told.append(a)
w.settings_dialog()
assert told and w.settings.get("store_root") != str(tmp_path / "elsewhere") and w.ws.store.root == store
w.revert_all()
def test_text_undone_outside_shows_banner(win): def test_text_undone_outside_shows_banner(win):
w, root, _ = win w, root, _ = win
tx = w.ws.texts tx = w.ws.texts
@@ -239,8 +302,7 @@ def test_text_undone_outside_shows_banner(win):
def test_record_simulated_input(win): def test_record_simulated_input(win):
w, root, _ = win w, root, _ = win
w.select_name("CHR_RadioScanner_1") ed = edit(w, "CHR_RadioScanner_1")
ed = w.editor
x, sr = synth.tone(0.5, 48000, 500) x, sr = synth.tone(0.5, 48000, 500)
ed.recorder.start_simulated(x, sr, realtime=True) ed.recorder.start_simulated(x, sr, realtime=True)
assert ed.recorder.is_recording() assert ed.recorder.is_recording()
@@ -273,7 +335,7 @@ def test_notes_tags_favorites(win):
def test_mod_export_import(win, tmp_path): def test_mod_export_import(win, tmp_path):
w, root, _ = win w, root, _ = win
w.select_name("Speech_Synth_Radio_02") edit(w, "Speech_Synth_Radio_02")
x, sr = synth.tone(1.0, 24000, 1200) x, sr = synth.tone(1.0, 24000, 1200)
w.editor.set_source(x, sr, "tone") w.editor.set_source(x, sr, "tone")
w.editor.apply() w.editor.apply()
@@ -290,7 +352,7 @@ def test_mod_export_import(win, tmp_path):
def test_changed_underneath_banner(win): def test_changed_underneath_banner(win):
w, root, originals = win w, root, originals = win
w.select_name("CHR_RadioScanner_1") edit(w, "CHR_RadioScanner_1")
x, sr = synth.tone(0.5, 24000, 1500) x, sr = synth.tone(0.5, 24000, 1500)
w.editor.set_source(x, sr, "tone") w.editor.set_source(x, sr, "tone")
w.editor.apply() w.editor.apply()
@@ -323,7 +385,7 @@ def test_selection_survives_sort_and_filter(win):
from app.models import C_LEN from app.models import C_LEN
w, _, _ = win w, _, _ = win
assert w.select_name("Speech_Synth_Radio_02") assert w.select_name("Speech_Synth_Radio_02")
s = w.editor.sound s = w.preview.sound
w.table.sortByColumn(C_LEN, Qt.SortOrder.DescendingOrder) w.table.sortByColumn(C_LEN, Qt.SortOrder.DescendingOrder)
type_search(w, "radio") type_search(w, "radio")
cur = w.table.currentIndex() cur = w.table.currentIndex()
@@ -355,9 +417,13 @@ def test_spinners_while_loading(win, tmp_path):
w, root, _ = win w, root, _ = win
held = w.runner = HeldRunner(w.runner) held = w.runner = HeldRunner(w.runner)
w.select_name("CHR_RadioScanner_1") w.select_name("CHR_RadioScanner_1")
assert w.editor.wf_orig.busy and w.editor.wf_orig._timer.isActive() assert w.preview.wf_orig.busy and w.preview.wf_orig._timer.isActive()
held.release() held.release()
assert not w.editor.wf_orig.busy and not w.editor.wf_orig._timer.isActive() assert not w.preview.wf_orig.busy and not w.preview.wf_orig._timer.isActive()
w.edit_selected()
assert w.editor.wf_orig.busy
held.release()
assert not w.editor.wf_orig.busy
wav = str(tmp_path / "a.wav") wav = str(tmp_path / "a.wav")
audio.save_wav(wav, *synth.tone(0.3, 22050, 700)) audio.save_wav(wav, *synth.tone(0.3, 22050, 700))
w.editor.load_file(wav) w.editor.load_file(wav)
@@ -379,11 +445,130 @@ def test_background_toggle_and_filters_look_alike(win):
assert not any(c.isEditable() for c in (w.cmb_group, w.cmb_cat, w.cmb_char, w.cmb_arch, w.cmb_tag)) assert not any(c.isEditable() for c in (w.cmb_group, w.cmb_cat, w.cmb_char, w.cmb_arch, w.cmb_tag))
def test_editor_panel_fits_its_contents(win): def test_browser_only_browses_edit_tab_fits(win):
"""The Sounds tab has no editing tools (player + Edit); the Edit tab needs no scrolling at 1400 x 900 and
1280 x 800, even for a speech line with a two-part subtitle."""
from PySide6.QtWidgets import QPushButton
w, root, _ = win w, root, _ = win
w.resize(1400, 900) texts = {b.text() for b in w.vsplit.findChildren(QPushButton)}
assert {"Edit", "Add to edit list", "Play original", "Play installed", "Stop"} <= texts
assert not texts & {"Record", "Apply to game", "Load file…", "Revert"}
w.show() w.show()
QTest.qWait(100) for size in ((1400, 900), (1280, 800)):
need = w.editor.sizeHint().height() w.resize(*size)
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need) edit(w, "Speech_Synth_Radio_02") # two subtitle parts: the tallest case
assert w.vsplit.sizes()[0] >= 160 QTest.qWait(100)
assert w.editor.subtitle_box.isVisible() and len(w.editor.subtitle.edits) == 2
for area in (w.editor.parentWidget().parentWidget(), w.edit.rack.parentWidget().parentWidget()):
assert not area.verticalScrollBar().isVisible() and not area.horizontalScrollBar().isVisible(), size
edit(w, "CHR_SynthFoley_1")
assert not w.editor.subtitle_box.isVisible() # no subtitle: no subtitle box
def test_recorder_sample_formats(qapp):
"""Input devices may deliver 16/32-bit integer, 8-bit or float samples, and reads can split a frame."""
import numpy as np
from app.audioio import Recorder
r = Recorder()
x = np.array([[0.5, -0.25], [0.125, -1.0]], np.float32)
for sample, raw in (("<i2", (x * 32768).astype("<i2").tobytes()), ("<i4", (x * 2147483648.0).astype("<i4").tobytes()),
("u1", (x * 128 + 128).astype("u1").tobytes()), ("<f4", x.astype("<f4").tobytes())):
r.chunks, r._rest, r.channels, r.sample = [], b"", 2, sample
r._ingest(raw[:3])
r._ingest(raw[3:])
got, _ = r.stop()
assert np.allclose(got, x, atol=1 / 128), sample
def test_edit_list_replacement_effects_apply_revert(win, tmp_path):
"""Edit tab: sounds come from the browser onto the edit list; a replacement and effect settings are kept per sound
(user data), applied with the effects, reverted byte-identically; Previous / Next walk the list."""
from ackit import fx
w, root, _ = win
e = w.edit
before = files(root)
a, b = w.ws.sound("Speech_Synth_Radio_02"), w.ws.sound("Speech_TAPE_JOKER_1")
w.edit_sounds([a, b])
assert w.tabs.currentWidget() is e and [s.key for s in e.model.sounds] == [a.key, b.key]
ed = w.editor
assert ed.sound is a and not ed.b_prev.isEnabled() and ed.b_next.isEnabled()
assert ed.b_apply.isEnabled() and ed.b_export.isEnabled() and ed.b_rec.isEnabled()
wav = str(tmp_path / "new.wav")
audio.save_wav(wav, *synth.tone(1.2, 44100, 440))
assert ed.load_file(wav)
assert e.store.has_replacement(a.key) and e.model.state(a) == "Replacement saved"
dry = ed.processed()[0].copy()
e.rack.apply_look("Radio") # EQ, distortion, static
assert fx.summary(ed.effects) == "Band-pass EQ, Distortion, Noise"
wet = ed.processed()[0]
assert not np.allclose(wet[:len(dry)], dry, atol=1e-3)
e.flush()
assert e.store.get(a.key)["fx"]["eq"]["on"] is True
ed.apply()
assert w.ws.state(a) == W.MODIFIED and w.ws.store.get(a.key)["meta"]["effects"] == fx.summary(ed.effects)
assert a.key in w.model.modified and e.model.state(a) == "Applied"
QTest.qWait(50)
ed.b_next.click() # the next sound: no replacement, no effects
assert ed.sound is b and ed.src is None and not e.rack.is_on("eq") and ed.b_prev.isEnabled()
ed.b_prev.click() # back: replacement, values and effects return
assert ed.sound is a and ed.src is not None and e.rack.is_on("eq") and ed.cb_match.isChecked()
ed.revert()
QTest.qWait(50)
assert files(root) == before and not w.ws.store.entries
assert e.store.has_replacement(a.key) # the replacement stays for later
e.table.selectRow(1)
e.remove_selected()
assert [s.key for s in e.model.sounds] == [a.key] and e.store.list == [a.key]
def test_effects_panel_looks_presets_sliders(win):
from PySide6.QtCore import QItemSelectionModel
from ackit import fx
w, _, _ = win
w.edit_sounds([w.ws.sound("CHR_RadioScanner_1"), w.ws.sound("CHR_RadioScanner_3")])
rack = w.edit.rack
rack.apply_preset("reverb", "Hall")
s = rack.settings()
assert s["reverb"]["on"] and s["reverb"]["decay"] == 2.0 and rack.stack.currentWidget() is rack.pages["reverb"]
rack.show_block("echo")
rack.controls[("echo", "time")].slider.setValue(100) # moving a slider turns its effect on
assert rack.settings()["echo"]["on"] and rack.items["echo"].font().bold()
rack.set_on("echo", False)
assert not rack.settings()["echo"]["on"]
c = rack.controls[("eq", "low_cut")] # log slider, readable value
c.slider.setValue(c.slider.maximum())
assert rack.settings()["eq"]["low_cut"] == 2000 and c.value_lbl.text() == "2.0 kHz"
c.slider.reset.emit() # double-click: back to the default
assert rack.settings()["eq"]["low_cut"] == 300
rack.cmb_preset.activated.emit(rack.cmb_preset.findText("PA announcement")) # the preset list
assert rack.cmb_preset.currentIndex() == 0
s = rack.settings()
assert [b.key for b in fx.BLOCKS if s[b.key]["on"]] == ["eq", "echo", "channels"] and s["echo"]["time"] == 160
rack.b_copy.click()
rack.b_reset.click()
assert not fx.active(rack.settings())
rack.b_paste.click()
assert rack.settings()["echo"]["time"] == 160
sm = w.edit.table.selectionModel()
for r in range(w.edit.model.rowCount()):
sm.select(w.edit.model.index(r, 0), QItemSelectionModel.SelectionFlag.Select |
QItemSelectionModel.SelectionFlag.Rows)
w.edit.use_for_selected()
assert all(fx.summary(w.edit.store.get(k)["fx"]) == "Band-pass EQ, Echo, Channels"
for k in w.edit.store.list)
rack.set_channels(5)
assert "L R C SL SR" in rack.items["channels"].text()
def test_edit_store_moves_data_of_the_first_versions(qapp, tmp_path):
"""Replacements saved by the first versions (a "take" field, the takes/ folder) are found under the new names."""
import json
from app.edit import EditStore
root = tmp_path / "edits"
(root / "takes").mkdir(parents=True)
key = "INT:B49CDF48:1"
audio.save_wav(str(root / "takes" / "INT_B49CDF48_1.wav"), *synth.tone(0.2, 22050, 300))
json.dump({key: {"take": "recording", "gain": 1.5}}, open(root / "index.json", "w")) # first version: no list
st = EditStore(str(root))
assert st.list == [key] and st.get(key)["source"] == "recording" and "take" not in st.get(key)
assert st.has_replacement(key) and st.load_replacement(key) is not None and not (root / "takes").exists()

View File

@@ -180,3 +180,19 @@ def test_every_name_resolves_to_its_sound(real_game_dir):
for lang in g.languages: for lang in g.languages:
cat = Catalog(g, [lang, "SFX", "BANK"]) cat = Catalog(g, [lang, "SFX", "BANK"])
assert all(cat.resolve(s.name) is s for s in cat.sounds), lang assert all(cat.resolve(s.name) is s for s in cat.sounds), lang
def test_build_catalog_stops_on_changed_subtitles(tmp_path):
"""tools/build_catalog.py refuses to record a modified game: changed subtitles count like changed sounds."""
import importlib.util
from types import SimpleNamespace
from ackit import subtitles
spec = importlib.util.spec_from_file_location("build_catalog", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools", "build_catalog.py"))
bc = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bc)
row = subtitles.SubLine("INT", "Speech_X", 0, "Original.", 12, "a", "")
subtitles.write_texts(str(tmp_path / "SUB_INT.csv"), [row])
game = SimpleNamespace(archives={})
assert bc.changed_since_catalog(game, str(tmp_path), [], {"INT": [row]}) == []
assert bc.changed_since_catalog(game, str(tmp_path), [], {"INT": [row._replace(text="Edited.")]}) == \
["SUB:INT:Speech_X:0"]

View File

@@ -35,6 +35,42 @@ def test_cli_workflow(synth_game, tmp_path, capsys):
run(capsys, "revert", "Speech_Synth_Radio_02", *g) run(capsys, "revert", "Speech_Synth_Radio_02", *g)
def test_cli_replace_with_voice_effects(synth_game, tmp_path, capsys):
import json
root, _ = synth_game
g = ["--game", root, "--lang", "INT", "--store", str(tmp_path / "store")]
tpl = str(tmp_path / "fx.json")
assert "wrote" in run(capsys, "fx-template", tpl)
s = json.load(open(tpl))
s["eq"]["on"] = True
json.dump(s, open(tpl, "w"))
wav = str(tmp_path / "line.wav")
audio.save_wav(wav, *synth.tone(1.0, 44100, 660))
assert "replaced Speech_Synth_Radio_02" in run(capsys, "replace", "Speech_Synth_Radio_02", wav, "--fx", tpl, *g)
run(capsys, "revert", "--all", *g)
assert "game files are original" in run(capsys, "status", *g)
def test_cli_text_language_without_voices(synth_game, capsys):
"""--lang works for the languages that have only texts (INT voices are loaded for them, like the editor)."""
root, _ = synth_game
out = run(capsys, "text", "find", "--kind", "subs", "--game", root, "--lang", "RUS", "Строка")
assert "SUB:RUS:%s:0" % synth.SPEECH_EVENT in out and "1 match" in out
def test_cli_restore_backup(synth_game, tmp_path, capsys):
root, _ = synth_game
g = ["--game", root, "--lang", "INT", "--store", str(tmp_path / "store")]
wav = str(tmp_path / "line.wav")
audio.save_wav(wav, *synth.tone(1.0, 44100, 660))
run(capsys, "replace", "Speech_Synth_Radio_02", wav, *g)
out = run(capsys, "restore-backup", *g)
assert "would copy back" in out and "--yes" in out # lists only
assert "Speech_Synth_Radio_02" in run(capsys, "status", *g)
assert "restored" in run(capsys, "restore-backup", "--yes", *g)
assert "game files are original" in run(capsys, "status", *g)
def test_cli_errors_are_friendly(synth_game, capsys): def test_cli_errors_are_friendly(synth_game, capsys):
root, _ = synth_game root, _ = synth_game
with pytest.raises(SystemExit) as e: with pytest.raises(SystemExit) as e:

View File

@@ -59,6 +59,13 @@ def test_audio_helpers():
assert audio.to_channels(x, 1).shape == (48000, 1) assert audio.to_channels(x, 1).shape == (48000, 1)
assert audio.to_channels(x[:, :1], 6).shape == (48000, 6) assert audio.to_channels(x[:, :1], 6).shape == (48000, 6)
assert abs(audio.peak_db(audio.normalize(x, -3.0)) + 3.0) < 0.01 assert abs(audio.peak_db(audio.normalize(x, -3.0)) + 3.0) < 0.01
# down-mix keeps every channel: a voice only in the centre of 5.1 goes to both stereo sides at -3 dB (the
# standard fold-down) and to mono at its own level
c = np.zeros((100, 6), np.float32)
c[:, 2] = 0.5
assert np.allclose(audio.to_channels(c, 2), 0.5 * 0.7071) and np.allclose(audio.to_channels(c, 1), 0.5)
same = np.repeat(x[:, :1], 2, axis=1) # identical L/R: mono = either side, no clipping
assert np.allclose(audio.to_channels(same, 1)[:, 0], x[:, 0], atol=1e-6)
assert audio.trim(x, sr, 0.25, 0.75).shape[0] == 24000 assert audio.trim(x, sr, 0.25, 0.75).shape[0] == 24000
assert audio.resample(x, 48000, 24000).shape[0] == 24000 assert audio.resample(x, 48000, 24000).shape[0] == 24000

225
tests/test_fx.py Normal file
View File

@@ -0,0 +1,225 @@
"""Effects (ackit/fx.py): each block measured back on synthetic signals."""
import numpy as np
import pytest
from ackit import fx
SR = 44100
def tone(freq, seconds=1.0, amp=0.3):
t = np.arange(int(SR * seconds)) / SR
return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)[:, None]
def impulse(seconds=1.5, at=0.1):
x = np.zeros((int(SR * seconds), 1), np.float32)
x[int(SR * at)] = 1.0
return x
def only(key, **params):
s = fx.defaults()
s[key]["on"] = True
s[key].update(params)
return s
def peak_freq(x, lo=20, hi=None):
spec = np.abs(np.fft.rfft(x[:, 0] * np.hanning(len(x))))
f = np.fft.rfftfreq(len(x), 1 / SR)
m = (f >= lo) & (f <= (hi or SR / 2))
return f[m][np.argmax(spec[m])]
def band_db(x, f0, width=0.05):
spec = np.abs(np.fft.rfft(x[:, 0] * np.hanning(len(x)))) ** 2
f = np.fft.rfftfreq(len(x), 1 / SR)
m = (f > f0 * (1 - width)) & (f < f0 * (1 + width))
return 10 * np.log10(spec[m].sum() + 1e-20)
def test_defaults_off_and_identity():
s = fx.defaults()
assert not fx.active(s) and fx.summary(s) == ""
x = tone(440)
assert np.array_equal(fx.render(x, SR, s), x)
def test_normalize_fills_clamps_and_drops():
s = fx.normalize({"eq": {"on": 1, "low_cut": 5, "bogus": 3}, "pitch": {"shift": "x"}, "nope": {}})
assert s["eq"]["on"] is True and s["eq"]["low_cut"] == 20 and "bogus" not in s["eq"]
assert s["pitch"]["shift"] == 0.0 and "nope" not in s
assert s["glitch"]["repeats"] == 2 and isinstance(s["glitch"]["repeats"], int)
assert fx.summary(s) == "Band-pass EQ"
def test_pitch_shift_octave_up():
y = fx.render(tone(220), SR, only("pitch", shift=12))
assert abs(peak_freq(y) - 440) < 10
def test_formant_shift_keeps_pitch():
t = np.arange(SR) / SR
x = sum(np.sin(2 * np.pi * 150 * k * t) / k for k in range(1, 20)).astype(np.float32)[:, None] * 0.1
y = fx.render(x, SR, only("pitch", formant=6))
assert abs(peak_freq(y, 100, 200) - 150) < 5
assert not np.allclose(y, x, atol=1e-3)
def test_pitch_drift_moves_over_time():
y = fx.render(tone(440, 4), SR, only("pitch", drift=4, drift_rate=0.5))
peaks = [peak_freq(y[i:i + 8192]) for i in range(SR // 2, len(y) - 8192, SR // 4)]
assert max(peaks) - min(peaks) > 100
def test_octave_layer_below():
y = fx.render(tone(440), SR, only("octaves", interval1=-12, level1=0))
assert band_db(y, 220) > band_db(tone(440), 220) + 30
def test_ring_modulator_sidebands():
y = fx.render(tone(1000), SR, only("robot", ring_freq=100, ring_mix=1.0))
assert band_db(y, 900, 0.01) > band_db(y, 1000, 0.01) + 20
assert band_db(y, 1100, 0.01) > band_db(y, 1000, 0.01) + 20
def test_glitch_longer_and_seeded():
x = tone(300, 3)
a = fx.render(x, SR, only("glitch", rate=5, length=100, repeats=2, pitch=0, seed=1))
b = fx.render(x, SR, only("glitch", rate=5, length=100, repeats=2, pitch=0, seed=1))
c = fx.render(x, SR, only("glitch", rate=5, length=100, repeats=2, pitch=0, seed=2))
assert np.array_equal(a, b)
assert len(a) > len(x) and len(a) - len(x) == int(SR * 0.1) * 2 * round(5 * 3 / 10)
assert not np.array_equal(a, c[:len(a)]) or len(a) != len(c)
def test_eq_band_pass():
x = (tone(100) + tone(1500) + tone(8000)) / 3
y = fx.render(x, SR, only("eq", low_cut=300, high_cut=3400, slope=24, mid_gain=0))
assert band_db(y, 100) < band_db(x, 100) - 20
assert band_db(y, 8000) < band_db(x, 8000) - 20
assert abs(band_db(y, 1500) - band_db(x, 1500)) < 0.5
def test_distortion_adds_harmonics_same_loudness():
x = tone(200)
y = fx.render(x, SR, only("distortion", drive=20))
assert band_db(y, 600) > band_db(x, 600) + 20
assert abs(np.sqrt(np.mean(y ** 2)) - np.sqrt(np.mean(x ** 2))) < 1e-3
@pytest.mark.parametrize("opt", [{"clip": -12, "drive": 0}, {"bits": 4, "drive": 0}, {"gsm": True, "drive": 0}])
def test_distortion_options_change_audio(opt):
x = tone(200)
assert not np.allclose(fx.render(x, SR, only("distortion", **opt)), x, atol=1e-3)
def test_noise_level_relative_to_voice():
x = tone(500, 2)
y = fx.render(x, SR, only("noise", level=-20, colour=2000))
n = y - x
ratio = 20 * np.log10(np.sqrt(np.mean(n ** 2)) / np.sqrt(np.mean(x ** 2)))
assert abs(ratio + 20) < 1
def test_echo_delay_and_tail():
x = impulse()
y = fx.render(x, SR, only("echo", time=160, feedback=0, mix=0.5))
assert len(y) >= len(x)
d = np.argsort(np.abs(y[:, 0]))[-2:]
assert abs(sorted(d)[1] - sorted(d)[0] - int(SR * 0.16)) <= 1 # pedalboard rounds the delay
def test_reverb_tail_and_reverse_swell():
x = impulse()
y = fx.render(x, SR, only("reverb", mix=0.5, decay=1.0))
assert np.sum(y[int(SR * 0.3):] ** 2) > 1e-4
v = np.zeros((SR * 2, 1), np.float32)
v[SR:SR + SR // 2] = tone(300, 0.5)
r = fx.render(v, SR, only("reverb", mix=0.0, reverse=1.0, reverse_every=1.0))
assert np.sum(r[SR // 2:SR] ** 2) > 1e-3 and np.sum(v[SR // 2:SR] ** 2) == 0
def test_channels_pa_layout():
x = impulse(1.0)
s = only("channels", front=-12, surround=-6, surround_delay=160, width=0)
y = fx.render(x, SR, s, channels=5) # L R C SL SR
assert y.shape[1] == 5
i = int(SR * 0.1)
assert y[i, 2] == pytest.approx(1.0)
assert y[i, 0] == pytest.approx(10 ** (-12 / 20), rel=1e-3)
assert np.argmax(np.abs(y[:, 3])) == i + int(SR * 0.16)
assert np.max(np.abs(y[:, 3])) == pytest.approx(10 ** (-6 / 20), rel=1e-3)
assert fx.render(x, SR, s, channels=1).shape[1] == 1 # mono target: block has no effect
def test_channels_width_and_pan():
y = fx.render(impulse(1.0), SR, only("channels", width=10), channels=2)
assert np.argmax(np.abs(y[:, 1])) - np.argmax(np.abs(y[:, 0])) == int(SR * 0.01)
p = fx.render(tone(300, 2), SR, only("channels", pan_rate=1, pan_depth=1), channels=2)
left = [np.sqrt(np.mean(p[i:i + 2000, 0] ** 2)) for i in range(0, len(p) - 2000, 4410)]
assert max(left) > 2 * min(left) + 1e-3
def test_all_blocks_together_finite():
s = fx.defaults()
for b in fx.BLOCKS:
s[b.key]["on"] = True
s["distortion"]["gsm"] = True
x = tone(220, 2) + tone(1300, 2) * 0.3
y = fx.render(x, SR, s, channels=4)
assert y.shape[1] == 4 and np.isfinite(y).all() and len(y) >= len(x)
assert np.array_equal(y, fx.render(x, SR, s, channels=4)) # deterministic
def rt60(ir):
"""Reverb time from the Schroeder backward integral (-5 to -25 dB, extrapolated to -60)."""
e = np.cumsum((ir ** 2)[::-1])[::-1]
db = 10 * np.log10(e / e[0] + 1e-30)
a, b = np.argmax(db < -5), np.argmax(db < -25)
return (b - a) / SR * 3
@pytest.mark.parametrize("decay", [0.3, 1.0, 3.0])
def test_room_response_decay_measured_back(decay):
ir = fx.room_response(SR, 1, decay, predelay=0, early=0.0, damping=0.0)
assert abs(rt60(ir[:, 0]) - decay) < 0.15 * decay
assert np.sum(ir ** 2) == pytest.approx(1.0, rel=1e-3)
def test_room_response_damping_predelay_width():
ir = fx.room_response(SR, 1, 1.5, predelay=0, early=0.0, damping=1.0)[:, 0]
late = ir[int(0.5 * SR):][:, None]
assert band_db(late, 8000, 0.3) < band_db(late, 300, 0.3) - 20 # the highs die first
pre = fx.room_response(SR, 1, 0.5, predelay=40)[:, 0]
assert np.max(np.abs(pre[:int(0.039 * SR)])) == 0 and np.max(np.abs(pre[int(0.04 * SR):])) > 0
wide = fx.room_response(SR, 2, 0.5, width=1.0)
mono = fx.room_response(SR, 2, 0.5, width=0.0)
assert abs(np.corrcoef(wide.T)[0, 1]) < 0.2 and np.corrcoef(mono.T)[0, 1] > 0.99
def test_presets_and_looks_are_valid():
for block, items in fx.PRESETS.items():
keys = {p.key for p in fx.BY_KEY[block].params}
for name, values in items:
assert set(values) <= keys, (block, name)
s = fx.with_preset(fx.defaults(), block, name)
assert s[block]["on"] and all(s[block][k] == values[k] for k in values), (block, name)
x = tone(300, 1)
for name, _ in fx.LOOKS:
s = fx.look(name)
assert fx.active(s)
y = fx.render(x, SR, s, channels=5)
assert np.isfinite(y).all() and len(y) >= len(x)
def test_reverb_mix_dry_to_wet_and_old_name():
x = impulse(1.0)
i = int(SR * 0.1)
assert np.array_equal(fx.render(x, SR, only("reverb", mix=0.0)), x) # 0: dry only
wet = fx.render(x, SR, only("reverb", mix=1.0, predelay=30, early=0.0))
assert abs(wet[i, 0]) < 1e-3 and np.max(np.abs(wet[i + int(SR * 0.03):])) > 0 # 1: no dry impulse left
half = fx.render(x, SR, only("reverb", mix=0.5, predelay=30, early=0.0))
assert half[i, 0] == pytest.approx(np.cos(np.pi / 4), rel=1e-3) # equal power
assert fx.normalize({"reverb": {"on": True, "wet": 0.4}})["reverb"]["mix"] == 0.4 # older settings

View File

@@ -46,3 +46,17 @@ def test_not_a_game_folder(tmp_path):
import pytest import pytest
with pytest.raises(G.GameError): with pytest.raises(G.GameError):
G.Game(str(tmp_path)) G.Game(str(tmp_path))
def test_language_without_voices(synth_game, tmp_path, monkeypatch):
"""A game set to a language with texts only (e.g. JPN) is in that language, with the INT voices."""
root, _ = synth_game
os.makedirs(os.path.join(root, "BmGame", "Localization", "JPN"), exist_ok=True)
cfg = tmp_path / "cfg"
cfg.mkdir()
(cfg / "UserEngine.ini").write_bytes("[Engine.Engine]\r\nLanguage=Jpn\r\n".encode("utf-16"))
monkeypatch.setattr(G.Game, "config_dirs", lambda self: [str(cfg)])
g = G.Game(root)
assert g.language() == "JPN" and g.voice_archive(g.language()) == "INT"
from ackit.catalog import Catalog
assert "INT" in Catalog(g).archives # the default catalog loads the INT voices

View File

@@ -40,7 +40,7 @@ def test_stream_packs_noop_identical(real_game_dir):
def test_builder_reproduces_game_wems(real_game_dir): def test_builder_reproduces_game_wems(real_game_dir):
g = Game(real_game_dir) g = Game(real_game_dir)
lang = g.language() lang = g.voice_archive(g.language())
w = Wad(g.wad(lang)) w = Wad(g.wad(lang))
rnd = random.Random(5) rnd = random.Random(5)
n = 0 n = 0

View File

@@ -1,5 +1,6 @@
"""Subtitles (dialogue objects in packages) and menu text (localization files): parse, apply, revert.""" """Subtitles (dialogue objects in packages) and menu text (localization files): parse, apply, revert."""
import hashlib import hashlib
import struct
import os import os
import pytest import pytest
@@ -36,6 +37,10 @@ def test_fstring_encoding():
subtitles.encode_fstring("too long", 10, "a") subtitles.encode_fstring("too long", 10, "a")
with pytest.raises(subtitles.SubtitleError): with pytest.raises(subtitles.SubtitleError):
subtitles.encode_fstring("→", 10, "a") # not in cp1252 subtitles.encode_fstring("→", 10, "a") # not in cp1252
# every byte reads back as it was, also the 5 that Python's cp1252 leaves undefined (UTF-8 in two game strings)
raw = struct.pack("<i", 7) + "Уж".encode("utf-8") + b"\x81\x8d\0"
text = subtitles.read_fstring(raw, 0)[0]
assert subtitles.encode_fstring(text, len(raw), "a") == raw
def test_menu_parse_and_write(): def test_menu_parse_and_write():
@@ -51,6 +56,15 @@ def test_menu_parse_and_write():
loctext.write(raw, "INT", fname, {"INT:FRONTEND.int:Menu:Start": "two\nlines"}) loctext.write(raw, "INT", fname, {"INT:FRONTEND.int:Menu:Start": "two\nlines"})
def test_menu_check_value():
loctext.check_value("Fine", "utf-16", "F.int")
loctext.check_value("Città", "cp1252", "F.int")
with pytest.raises(loctext.TextError):
loctext.check_value("two\nlines", "utf-16", "F.int")
with pytest.raises(loctext.TextError):
loctext.check_value("Привет", "cp1252", "F.int") # not in the file's encoding
# --- subtitles through the workspace ------------------------------------------------------------ # --- subtitles through the workspace ------------------------------------------------------------
def test_subtitle_catalog_and_link(synth_game, tmp_path): def test_subtitle_catalog_and_link(synth_game, tmp_path):
root, _ = synth_game root, _ = synth_game

View File

@@ -119,6 +119,101 @@ def test_full_backup_taken_once(synth_game):
assert rel in restored and ws.state(s) == W.ORIGINAL assert rel in restored and ws.state(s) == W.ORIGINAL
def test_restore_from_full_backup_keeps_wads_without_a_copy(synth_game, tmp_path):
"""Without full WAD backups, a restore puts the packages back but keeps the WAD sounds recorded (they stay
modified and revertible)."""
root, _ = synth_game
before = files(root)
ws = W.Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"], full_backup=False)
wad_s = ws.sound("CHR_RadioScanner_1")
bank_s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
ws.apply([(wad_s, synth.wem(0.5, 1200), {}), (bank_s, synth.fitting_wem(ws, bank_s, 0.1, 1900), {})])
ws.texts.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Restored."})
ws.restore_from_full_backup()
assert ws.state(bank_s) == W.ORIGINAL and not ws.texts.records()
assert ws.state(wad_s) == W.MODIFIED and list(ws.store.entries) == [wad_s.key]
ws.revert_all()
assert files(root) == before
def test_writes_are_serialized(synth_game, tmp_path, monkeypatch):
"""The editor runs writes as background jobs: a sound and a subtitle written into one package at the same
time both end up in it (a slow rebuild of the first must not overwrite the second)."""
import threading
import time
root, _ = synth_game
ws = W.Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"])
s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
wem = synth.fitting_wem(ws, s, 0.1, 1900)
build = W.Workspace._build
def slow(self, path, orig):
out = build(self, path, orig)
time.sleep(0.5 if threading.current_thread().name == "sound" else 0)
return out
monkeypatch.setattr(W.Workspace, "_build", slow)
key = "SUB:INT:%s:0" % synth.SPEECH_EVENT
a = threading.Thread(name="sound", target=lambda: ws.apply([(s, wem, {})]))
b = threading.Thread(target=lambda: (time.sleep(0.15), ws.texts.apply({key: "Both."})))
a.start(), b.start()
a.join(), b.join()
assert ws.state(s) == W.MODIFIED and ws.texts.state(key) == "modified"
def test_another_store_does_not_take_modified_files_as_originals(synth_game, tmp_path):
"""A second originals store (Settings > Originals store changed, `--store`) must not copy a package that the
first store modified as its "original": the catalog's checksums and texts show it is not original."""
from ackit.originals import sha1_16
from ackit.texts import TextConflictError
root, _ = synth_game
A = W.Workspace(root, store_root=str(tmp_path / "A"), archives=["INT", "SFX", "BANK"])
s = A.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
orig_sha = sha1_16(A.current_bytes(s))
sub = "SUB:INT:%s:0" % synth.SPEECH_EVENT
rel = A.locations(s)[0].file
A.apply([(s, synth.fitting_wem(A, s, 0.1, 1900), {})])
A.texts.apply({sub: "Edited."})
def store_b():
# the synthetic game has no shipped catalog: give B the checksums and texts a shipped one would have
B = W.Workspace(root, store_root=str(tmp_path / "B"), archives=["INT", "SFX", "BANK"])
B._shipped = True
B.catalog.bank_sha1 = {synth.DLG_MEDIA: orig_sha}
B.texts._shipped_subs = ({}, {})
return B
B = store_b()
assert B.unoriginal_files([B.bank_path(rel)]) == [B.bank_path(rel)]
with pytest.raises(TextConflictError):
B.texts.apply({"SUB:INT:%s:1" % synth.SPEECH_EVENT: "Other."})
A.revert([s])
B = store_b()
by_file = {}
for l in B.texts.subtitle_locations(sub):
by_file.setdefault(l.file, []).append(l)
B.texts._shipped_subs = (by_file, {("INT", synth.SPEECH_EVENT, 0): "Scanner line two."})
assert B.texts.unoriginal_subtitles(rel) == [sub] # A's subtitle is still applied
A.texts.revert_all()
assert B.unoriginal_files([B.bank_path(rel)]) == []
def test_mod_export_of_a_change_made_in_another_language(synth_game, tmp_path):
"""A change made while another language was loaded keeps its name and line identity in a mod package."""
import json
import zipfile
root, _ = synth_game
store = str(tmp_path / "store")
every = W.Workspace(root, store_root=store, archives=["SFX", "BANK"]) # every language's lines
s = every.catalog.by_key["BANK:%08X" % synth.GRUNT_MEDIA["ITA"]]
every.apply([(s, synth.fitting_wem(every, s, 0.1, 900), {})])
ws = W.Workspace(root, store_root=store, archives=["INT", "SFX", "BANK"]) # now INT is loaded
assert s.key not in ws.catalog.by_key
pkg = str(tmp_path / "m.acmod")
export_mod(ws, pkg, ws.modified_keys(), "m")
it = json.loads(zipfile.ZipFile(pkg).read("manifest.json"))["items"][0]
assert (it["name"], it["line"]) == (s.name, s.line) and s.line
assert ws.revert_all() and not ws.store.entries
def test_mod_from_another_language_matched_by_line(synth_game, tmp_path): def test_mod_from_another_language_matched_by_line(synth_game, tmp_path):
"""A voice line from another language is found through the game's line identity (its WAD position there """A voice line from another language is found through the game's line identity (its WAD position there
can differ); a voice line from a package without line identities is never guessed by name.""" can differ); a voice line from a package without line identities is never guessed by name."""

View File

@@ -65,6 +65,7 @@ def main():
import collect_licenses import collect_licenses
collect_licenses.main(os.path.join(out, "THIRD_PARTY_LICENSES.txt")) collect_licenses.main(os.path.join(out, "THIRD_PARTY_LICENSES.txt"))
shutil.copy("README.md", os.path.join(out, "README.md")) shutil.copy("README.md", os.path.join(out, "README.md"))
shutil.copy("LICENSE", os.path.join(out, "LICENSE"))
docs = os.path.join(out, "docs") docs = os.path.join(out, "docs")
os.makedirs(docs, exist_ok=True) os.makedirs(docs, exist_ok=True)
for f in ("user-guide.md",): for f in ("user-guide.md",):

View File

@@ -12,7 +12,8 @@ Outputs
data/catalog/SUB_locations.csv.gz every stored copy of each subtitle string data/catalog/SUB_locations.csv.gz every stored copy of each subtitle string
data/catalog/index.json fingerprints the CSVs were built from + counts data/catalog/index.json fingerprints the CSVs were built from + counts
Run it only on clean game files (`ackit status` must say nothing is modified). It stops before writing Run it only on clean game files (`ackit status` must say nothing is modified). It stops before writing
anything if a sound differs from the current catalog (a modified game); after a game update use --accept-changes. anything if a sound or a subtitle differs from the current catalog (a modified game); after a game update use
--accept-changes. (Menu text has no catalog.)
""" """
import argparse import argparse
import collections import collections
@@ -33,7 +34,7 @@ from ackit.inject import read_target # noqa: E402
from ackit.paths import catalog_dir # noqa: E402 from ackit.paths import catalog_dir # noqa: E402
from ackit.wad import Wad # noqa: E402 from ackit.wad import Wad # noqa: E402
SKIP = {"files.txt", "stats.txt", "checkpoint.txt"} SKIP = {"files.txt", "stats.txt"}
def read_user_lists(src_dir): def read_user_lists(src_dir):
@@ -88,9 +89,16 @@ def build_characters(game, src_root, final):
return rows, unmatched, ambiguous, skipped return rows, unmatched, ambiguous, skipped
def changed_since_catalog(game, out_dir, bank_snd): def changed_since_catalog(game, out_dir, bank_snd, texts=None):
"""Keys whose original checksum in the existing catalog differs from the game files now.""" """Keys whose original in the existing catalog differs from the game files now: sounds (checksums) and
subtitles (texts: {lang: [SubLine]} scanned now)."""
out = [] out = []
for lang, rows in (texts or {}).items():
p = os.path.join(out_dir, "SUB_%s.csv" % lang)
if os.path.exists(p):
old = {(s.speech, s.line): s.text for s in subtitles.read_texts(p, lang)}
out += [subtitles.key(lang, s.speech, s.line) for s in rows
if (s.speech, s.line) in old and old[(s.speech, s.line)] != s.text]
for arch, path in game.archives.items(): for arch, path in game.archives.items():
p = os.path.join(out_dir, "%s.csv" % arch) p = os.path.join(out_dir, "%s.csv" % arch)
if not os.path.exists(p): if not os.path.exists(p):
@@ -133,11 +141,12 @@ def main():
for k, d in lines.issues[:10]: for k, d in lines.issues[:10]:
print(" issue:", k, d) print(" issue:", k, d)
# safety: the catalog records ORIGINAL bytes. A sound that differs from the current catalog means a modified # safety: the catalog records ORIGINAL bytes and texts. A sound or subtitle that differs from the current
# game (an applied change, another tool) unless the game itself was updated -> stop before writing anything. # catalog means a modified game (an applied change, another tool) unless the game itself was updated -> stop
changed = changed_since_catalog(game, out_dir, bank_snd) # before writing anything.
changed = changed_since_catalog(game, out_dir, bank_snd, texts)
if changed and not a.accept_changes: if changed and not a.accept_changes:
print("STOP: %d sound(s) differ from the current catalog, e.g. %s." % (len(changed), changed[:5])) print("STOP: %d sound(s) or subtitle(s) differ from the current catalog, e.g. %s." % (len(changed), changed[:5]))
print("Revert all changes (ackit status / ackit revert --all) and run again; after a game update use " print("Revert all changes (ackit status / ackit revert --all) and run again; after a game update use "
"--accept-changes.") "--accept-changes.")
sys.exit(2) sys.exit(2)
@@ -193,7 +202,7 @@ def main():
"fingerprint": banks.game_fingerprint(game.cooked), "sounds": len(sounds), "groups": dict(g), "fingerprint": banks.game_fingerprint(game.cooked), "sounds": len(sounds), "groups": dict(g),
"named_by_event": by_event, "named_by_bank": len(sounds) - by_event, "named_by_event": by_event, "named_by_bank": len(sounds) - by_event,
"copies": sum(1 for l in locs if l.kind == banks.MEMORY), "files": len(sc.files), "copies": sum(1 for l in locs if l.kind == banks.MEMORY), "files": len(sc.files),
# bank copies of WAD recordings: prefetch heads and whole in-memory copies (catalog._whole_recordings) # bank copies of WAD recordings: prefetch heads and whole in-memory copies (catalog.whole_recordings)
"prefetch_heads": {"recordings": len({l.media for l in heads}), "copies": len(heads), "prefetch_heads": {"recordings": len({l.media for l in heads}), "copies": len(heads),
"unmapped": len({l.media for l in heads if not l.sound})}, "unmapped": len({l.media for l in heads if not l.sound})},
"issues": ["%s: %s" % i for i in sc.issues]} "issues": ["%s: %s" % i for i in sc.issues]}

View File

@@ -240,6 +240,16 @@ def catalog_claims(cat):
west = [enc[L]["w"] for L in langs if L not in ("RUS", "JPN", "KOR", "POL")] west = [enc[L]["w"] for L in langs if L not in ("RUS", "JPN", "KOR", "POL")]
claim("docs/formats/subtitles.md", "%d %% of POL are UTF-16; the other languages have %s UTF-16 strings each" claim("docs/formats/subtitles.md", "%d %% of POL are UTF-16; the other languages have %s UTF-16 strings each"
% (pol, rng(west))) % (pol, rng(west)))
utf8 = [] # cp1252 strings whose bytes are UTF-8 text (the game's data)
for L, t in sorted(texts.items()):
for s in t:
if s.enc == "a":
u = subtitles._encode_cp1252(s.text).decode("utf-8", "replace")
multi = sum(1 for c in u if ord(c) > 0x7F and c != "\ufffd") # valid multi-byte characters
if multi and u.count("\ufffd") <= multi // 4: # (one corrupt byte tolerated)
utf8.append((L, s.speech))
claim("docs/formats/subtitles.md", "%d cp1252 strings hold UTF-8 bytes instead (the same line in %s: `%s`"
% (len(utf8), ", ".join(L for L, _ in utf8), "`, `".join(sorted({sp for _, sp in utf8}))))
with gzip.open(os.path.join(cat, subtitles.LOCATIONS), "rt", newline="") as f: with gzip.open(os.path.join(cat, subtitles.LOCATIONS), "rt", newline="") as f:
sublocs = sum(1 for _ in csv.DictReader(f)) sublocs = sum(1 for _ in csv.DictReader(f))
claim("REPORT.md", "Edited in place in every copy (%s copies)" % n(sublocs)) claim("REPORT.md", "Edited in place in every copy (%s copies)" % n(sublocs))
@@ -293,7 +303,8 @@ def index_claims(cat):
% sub["scripted_objects"]) % sub["scripted_objects"])
claim("REPORT.md", "(%s speech lines x %d languages) and" % (n(sub["dialogue_lines"]), len(sub["lines"]))) 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("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 ---------------------------------------------------------------------------- # --- the game's own files ----------------------------------------------------------------------------
@@ -369,6 +380,7 @@ def scan_claims(game):
claim("docs/formats/banks.md", "| banks embedded in `.upk` packages (in %s of the %s packages) | %s |" 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(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)))) 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 " 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"]))) "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" claim("docs/formats/banks.md", "(loaded by another bank); %s streamed sources; %s **prefetch** sources"
@@ -505,6 +517,39 @@ def wem_claims():
% (n(total - len(bad)), n(total), exceptions)) % (n(total - len(bad)), n(total), exceptions))
def originality_claims(game):
"""Workspace.unoriginal_files on every file with sounds or subtitles of the (original) game: no false alarm."""
import tempfile
from ackit import subtitles
from ackit.workspace import Workspace
with tempfile.TemporaryDirectory() as td:
ws = Workspace(game, store_root=td, archives=[game.voice_archive("INT"), "SFX", "BANK"])
rels = {l.file for ls in ws.catalog.locations.values() for l in ls}
rels |= {l.file for ls in subtitles.read_locations(os.path.join(catalog_dir(), subtitles.LOCATIONS)).values()
for l in ls}
bad = ws.unoriginal_files([ws.bank_path(r) for r in rels])
claim("REPORT.md", "checked on all %s files with sounds or subtitles: %s" % (
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(): def test_claims():
import subprocess import subprocess
out = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"], out = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"],
@@ -526,6 +571,7 @@ def main():
cat = catalog_dir() cat = catalog_dir()
catalog_claims(cat) catalog_claims(cat)
index_claims(cat) index_claims(cat)
fx_claims()
test_claims() test_claims()
gd = a.game or default_game_dir(settings.load()) gd = a.game or default_game_dir(settings.load())
game = Game(gd) if gd else None game = Game(gd) if gd else None
@@ -535,6 +581,7 @@ def main():
if not game: if not game:
raise SystemExit("--scan needs the game") raise SystemExit("--scan needs the game")
scan_claims(game) scan_claims(game)
originality_claims(game)
if a.wems: if a.wems:
if not game: if not game:
raise SystemExit("--wems needs the game") raise SystemExit("--wems needs the game")

View File

@@ -73,6 +73,9 @@ def components():
dist_file("soxr", "LICENSE-PFFFT.txt")), dist_file("soxr", "LICENSE-PFFFT.txt")),
("NumPy (incl. bundled OpenBLAS / runtime libraries)", ver("numpy"), "BSD-3-Clause and others (see text)", ("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")), "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")), ("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", ("pycparser", ver("pycparser"), "BSD-3-Clause", "https://github.com/eliben/pycparser",
dist_file("pycparser", "LICENSE")), dist_file("pycparser", "LICENSE")),

View File

@@ -1,15 +1,19 @@
"""Real end-to-end test of the EDITOR code path against the installed game (Windows, Steam running, WinDbg). """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); 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); 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; 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; 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. 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, 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 python tools/e2e_editor.py
""" """
import hashlib import hashlib
import os import os
import shutil
import sys import sys
import tempfile import tempfile
@@ -29,11 +33,18 @@ def sha(p):
def main(): def main():
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from ackit import settings as settings_mod
from app.mainwindow import MainWindow from app.mainwindow import MainWindow
from app.workers import Runner from app.workers import Runner
app = QApplication([]) app = QApplication([])
w = MainWindow(settings={"full_backup": True}, runner=Runner(sync=True)) # the user's settings (same game folder and originals store as `ackit status`), never saved: the window
# 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.confirm = lambda *a: True
w.editor.confirm = lambda *a: True w.editor.confirm = lambda *a: True
errors = [] errors = []
@@ -49,6 +60,7 @@ def main():
ws.texts.revert_all() ws.texts.revert_all()
print("E2E EDITOR %s" % ("PASS" if ok else "FAIL")) print("E2E EDITOR %s" % ("PASS" if ok else "FAIL"))
w.close() w.close()
shutil.rmtree(edits, ignore_errors=True)
return 0 if ok else 1 return 0 if ok else 1
@@ -66,6 +78,7 @@ def run(w, ws, errors):
w.search.setText("Music_393BC53A") w.search.setText("Music_393BC53A")
w._filter_text() w._filter_text()
assert w.select_name("Music_393BC53A") assert w.select_name("Music_393BC53A")
w.edit_selected() # the Edit tab, on this sound
x, sr = ingame.probe_signal(30.0) x, sr = ingame.probe_signal(30.0)
probe = os.path.join(td, "probe.wav") probe = os.path.join(td, "probe.wav")
audio.save_wav(probe, x, sr) audio.save_wav(probe, x, sr)
@@ -88,6 +101,7 @@ def run(w, ws, errors):
tb = sha(twad) tb = sha(twad)
w.clear_filters() w.clear_filters()
assert w.select_name(t.name) assert w.select_name(t.name)
w.edit_selected()
y, ysr = audio.decode_wem(ws.original_bytes(t)) y, ysr = audio.decode_wem(ws.original_bytes(t))
line = os.path.join(td, "line.wav") line = os.path.join(td, "line.wav")
audio.save_wav(line, y[::-1], ysr) # reversed speech, same length 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): 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 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) 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 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 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. 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 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.game import Game, default_game_dir # noqa: E402
from ackit.modpack import export_mod, import_mod, match_mod # noqa: E402 from ackit.modpack import export_mod, import_mod, match_mod # noqa: E402
from ackit.wem import fill # 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)))) 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(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--game") ap.add_argument("--game")
@@ -202,6 +237,8 @@ def run_checks(game, lang, voices, td, ws, first):
else: else:
check("6 %s sound + subtitle in one package" % lang, False, "no such package found") 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 # 7. cross-language mod: the same game line, not the same WAD position
from ackit.catalog import read_csv from ackit.catalog import read_csv
from ackit.paths import catalog_dir from ackit.paths import catalog_dir

View File

@@ -14,10 +14,14 @@ $Data = @((Join-Path $env:APPDATA "UnheardCity"), (Join-Path $env:LOCALAPPDATA "
function Say($msg) { if (-not $Quiet) { Write-Host $msg } } function Say($msg) { if (-not $Quiet) { Write-Host $msg } }
function Modified-Count { function Modified-Count {
# sounds still changed in the game, according to the originals stores # sounds and texts (subtitles, menu text) still changed in the game, according to the originals stores
$n = 0 $n = 0
Get-ChildItem -Path (Join-Path $Data[0] "stores\*\store.json") -ErrorAction SilentlyContinue | ForEach-Object { Get-ChildItem -Path (Join-Path $Data[0] "stores\*\store.json") -ErrorAction SilentlyContinue | ForEach-Object {
try { $n += @((Get-Content -Raw -LiteralPath $_.FullName | ConvertFrom-Json).entries.PSObject.Properties).Count } catch {} try {
$s = Get-Content -Raw -LiteralPath $_.FullName | ConvertFrom-Json
$n += @($s.entries.PSObject.Properties).Count
if ($s.texts) { $n += @($s.texts.PSObject.Properties).Count }
} catch {}
} }
return $n return $n
} }
@@ -26,8 +30,8 @@ try {
if (Get-Process -Name "UnheardCity" -ErrorAction SilentlyContinue) { throw "$Name is running. Close it first." } if (Get-Process -Name "UnheardCity" -ErrorAction SilentlyContinue) { throw "$Name is running. Close it first." }
$mods = Modified-Count $mods = Modified-Count
if ($mods -gt 0 -and -not $Quiet) { if ($mods -gt 0 -and -not $Quiet) {
Write-Host "$mods sound(s) are still modified in the game. To restore the originals, open $Name and use" Write-Host "$mods sound(s) or text(s) are still modified in the game. To restore the originals, open $Name and"
Write-Host "'Revert all' BEFORE uninstalling (your originals store is kept either way)." Write-Host "use 'Revert all' BEFORE uninstalling (your originals store is kept either way)."
if ((Read-Host "Uninstall anyway? [y/N]") -notmatch '^(y|yes)$') { exit 1 } if ((Read-Host "Uninstall anyway? [y/N]") -notmatch '^(y|yes)$') { exit 1 }
} }
if (-not $Quiet -and -not $RemoveUserData -and $mods -eq 0 -and (Test-Path $Data[0])) { if (-not $Quiet -and -not $RemoveUserData -and $mods -eq 0 -and (Test-Path $Data[0])) {

View File

@@ -1,6 +1,6 @@
"""Render the user-guide screenshots headless (docs/img/*.png). Read-only: never applies anything. """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 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. game's English) the way a user does, through the Language list.
@@ -22,7 +22,10 @@ def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--game") ap.add_argument("--game")
ap.add_argument("--lang", default="INT", help="language shown in the screenshots (default: INT)") 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() a = ap.parse_args()
only = set(a.only.split(","))
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from ackit import audio from ackit import audio
from app.dialogs import SettingsDialog from app.dialogs import SettingsDialog
@@ -49,31 +52,19 @@ def main():
app.processEvents() app.processEvents()
# status bar: show the store where users have it, not this temporary folder (no local user names) # 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")) 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.cmb_char.setCurrentText("Joker")
w._filters_changed() w._filters_changed()
w.search.setText("") w.search.setText("")
w._filter_text() w._filter_text()
w.fit_editor()
rows = [w.proxy.sound_at(w.proxy.index(r, 1)) for r in range(w.proxy.rowCount())] 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)) target = max(with_sub or rows, key=lambda s: min(s.seconds, 6.0))
w.select_name(target.name) w.select_name(target.name)
app.processEvents() app.processEvents()
# a replacement loaded for display only (not applied) if "main" in only:
x, sr = w.editor.orig w.grab().save(os.path.join(out, "main.png"))
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"))
# 2: category browsing # 2: category browsing
w.clear_filters() w.clear_filters()
w.cmb_group.setCurrentText("Speech") w.cmb_group.setCurrentText("Speech")
@@ -83,14 +74,35 @@ def main():
w.search.setText("joker") w.search.setText("joker")
w._filter_text() w._filter_text()
app.processEvents() 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 # 3: settings
d = SettingsDialog(w.settings, w) if "settings" in only:
d.bg_on.setChecked(True) # show the user's default (on), only the screenshots run without it d = SettingsDialog(w.settings, w)
d.show() d.bg_on.setChecked(True) # show the user's default (on), only the screenshots run without it
app.processEvents() d.show()
d.grab().save(os.path.join(out, "settings.png")) app.processEvents()
d.close() 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() w.close()
print("screenshots in", out) print("screenshots in", out)