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

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

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

@@ -167,8 +167,84 @@
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).
- OPEN: channel order for >2 channels unverified; 19 speaker labels to check by listening; the Linux build was
never built or run (no Linux here).
- Mission 4 (2026-10-27, plan: `docs/history/mission4_dubbing_plan.md`): dubbing every character's lines with
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
- Game: Steam AppID 200260 "Batman: Arkham City GOTY", `C:\Program Files (x86)\Steam\steamapps\common\Batman Arkham
@@ -187,6 +263,14 @@
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).
- 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
- Findings, failed hypotheses, in-game experiment log E0-E14: `docs/reverse-engineering/findings.md`.
- Formats: `docs/formats/`. Decoder addresses: `docs/reverse-engineering/decoder.md`.

View File

@@ -8,9 +8,10 @@ game patch, no external programs.
- **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
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
sections. One Language setting (default: the game's) applies to sounds, subtitles and menu text.
soundbanks: footsteps, foley, combat, weapons, UI), filter by category and character, listen; replace or
record in the Edit tab (an edit list, the subtitle shown large, effects for radios, phones, speakers,
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.
How it works in one paragraph: the game's Wwise 2011.1 decoder only knows the standard Vorbis codebooks, so ackit
@@ -38,6 +39,8 @@ localization files. Details in `docs/`.
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 replace Speech_TAPE_JOKER_1_04 my_line.wav --match-loudness
ackit fx-template fx.json # effects, all off: set "on" for band-pass EQ, echo, reverb...
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)

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
editor Unheard City (mission 2).
Briefs: `docs/history/mission3_audio_text_prompt.md`, `docs/history/mission4_dubbing_plan.md` (plan, the effects
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)
- **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,
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
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.
Names: 13,501 BANK sounds are named after the Wwise event that plays them (`Play_...`, `Speech_...`), the
other 1,854 after their bank.
@@ -34,24 +35,37 @@ editor Unheard City (mission 2).
- **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.
- **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
editor shows Listen / Replace / Subtitle side by side (resizable columns, no scrolling at usual window sizes);
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.
(languages without voices use the INT ones, like the game); tabs *Sounds* / *Edit* / *Subtitles* / *Menu text*.
*Sounds* only browses (filters, list, a player with Play original / Play installed / Stop, Edit / Add to edit
list). *Edit* is where every kind of sound is replaced: an edit list (Previous / Next) whose sounds keep their
replacement audio, trim/level values and effects in the user data folder until applied; the subtitle shown
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
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 (also when
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,447 files with sounds or
(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**: 148 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a
- **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
name of every language's catalog resolves to its own sound), offscreen GUI incl. list order, selection and
loading overlays) pass. `ackit verify` PASS (incl.
name of every language's catalog resolves to its own sound), offscreen GUI incl. list order, selection,
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).
- **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
@@ -60,15 +74,19 @@ editor Unheard City (mission 2).
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
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
(`ackit status`). The catalog builder refuses to run on a modified game, so the catalog always describes the
found in the game's memory, no crash); `tools/e2e_editor.py` PASS. 2026-10-27 (Linux, the game's Flatpak
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.
- **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:
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); 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).
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
- **Checked by a person in game** (2026-10-10), not automatically (nothing of this plays or shows on the title
@@ -94,14 +112,16 @@ editor Unheard City (mission 2).
`<Bank>_<n>`.
- 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).
- Two copies of media `1C898731` differ; its checksum is left empty. Channel order for 4-6 channel sounds is
unverified. M4A/AAC/WMA input needs an ffmpeg on the system.
- Two copies of media `1C898731` differ; its checksum is left empty. Channel order of 3-6 channel sounds:
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,
cached); it gets the same line identities, but no original checksums.
## Next steps
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.
3. Listen to the 19 lines whose speaker label differs from the game's dialogue speaker field
2. 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`)
and rebuild the catalog.

View File

@@ -3,9 +3,9 @@
Library layers (low -> high):
formats wad, pack, bnk (soundbanks), upk + lzo (UE3 packages), wem, ogg, bits, codebooks, wwvorbis,
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),
inject (WAD writing), originals (store), workspace (apply/revert), modpack (share)
tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows)
"""
__version__ = "3.3.0"
__version__ = "3.4.0"

View File

@@ -14,6 +14,7 @@ The game folder is detected from Steam (Windows, Linux, Proton); override with -
language (default: the game's); languages without their own voices use the INT voice lines, like the game.
"""
import argparse
import json
import os
import sys
@@ -124,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)
if a.trim_silence:
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:
ref, _ = ws.original_audio(s)
x = audio.match_loudness(x, ref)
@@ -134,6 +138,14 @@ def _process(a, x, sr, ws, s):
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):
ws = _workspace(a)
s = ws.sound(a.name)
@@ -364,6 +376,7 @@ def build_parser():
proc = argparse.ArgumentParser(add_help=False)
proc.add_argument("--trim", metavar="START:END", help="keep only this part (seconds), e.g. 0.5:4.2")
proc.add_argument("--trim-silence", action="store_true", help="cut leading/trailing silence")
proc.add_argument("--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("--normalize", action="store_true", help="peak-normalize to -1 dBFS")
proc.add_argument("--match-loudness", action="store_true", help="match the original's loudness")
@@ -382,6 +395,9 @@ def build_parser():
s.add_argument("--group"); s.add_argument("--category"); s.add_argument("--character")
s.add_argument("--inferred", action="store_true", help="also match characters inferred from names")
s.add_argument("--modified", action="store_true"); s.add_argument("--csv", metavar="FILE")
s = add("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_argument("name")
s = add("extract", cmd_extract, "save a sound as .wav (decoded) or .wem")

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)

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 numpy as np
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QGridLayout, QGroupBox, QSplitter,
QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, QSlider,
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, QSizePolicy, QSlider,
QVBoxLayout, QWidget)
from ackit import audio, workspace as W
from ackit import audio, fx, workspace as W
from . import icons, theme
from .audioio import Recorder, input_devices, find_device
from .spinner import Spinner
@@ -29,6 +31,8 @@ class EditorPanel(QWidget):
applied = Signal(str) # sound key
reverted = 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):
super().__init__(parent)
@@ -39,6 +43,10 @@ class EditorPanel(QWidget):
self.src = None # replacement source (x, rate) before processing
self.src_name = ""
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._build()
self.set_sound(None)
@@ -66,14 +74,17 @@ class EditorPanel(QWidget):
def _wave(self, kind, selectable=False):
w = Waveform(selectable=selectable)
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
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")
lay = QVBoxLayout(self)
lay.setContentsMargins(8, 6, 8, 6)
lay.setSpacing(4)
lay.setContentsMargins(8, 4, 8, 2)
lay.setSpacing(3)
self.title = QLabel()
self.title.setObjectName("EditorTitle")
self.title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
@@ -93,25 +104,41 @@ class EditorPanel(QWidget):
self.meta.setObjectName("Muted")
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")
gl = QGridLayout(g)
gl.setContentsMargins(8, 8, 8, 8)
gl.setVerticalSpacing(4)
gl.setHorizontalSpacing(8)
self.wf_orig = self._wave("orig")
self.wf_inst = self._wave("inst")
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.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"),
"Play the original (double-click the row; Space until a replacement is loaded)",
icons.STEEL)
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e")
"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)")
# playback options next to the volume: icons with tooltips
self.b_ab = self._btn("", "ab", self.ab_switch, "A/B: switch between the original and the replacement at "
"the same position", icons.ACCENT)
self.cb_loop = self._btn("", "loop", None, "Loop: repeat playback")
self.b_ab = self._btn("A/B", "ab", self.ab_switch, "Switch between the original and the replacement at the same "
"position, to compare", icons.ACCENT)
self.cb_loop = self._btn("Loop", "loop", None, "Repeat playback")
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.setRange(0, 100)
self.sl_vol.setValue(80)
@@ -122,71 +149,65 @@ class EditorPanel(QWidget):
self.lbl_time.setObjectName("Muted")
self.lbl_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.lbl_time.setMinimumWidth(self.lbl_time.fontMetrics().horizontalAdvance("00:00.00") + 4)
gl.addWidget(QLabel("Original"), 0, 0)
gl.addWidget(self.wf_orig, 0, 1)
self.lbl_inst = QLabel("Installed")
gl.addWidget(self.lbl_inst, 1, 0)
gl.addWidget(self.wf_inst, 1, 1)
gl.setRowStretch(2, 1) # controls at the bottom, level with the other columns' actions
gl.addLayout(self._fill((self.b_play_orig, 1), (self.b_play_inst, 1), (self.b_stop, 1)), 4, 0, 1, 2)
vol = QHBoxLayout() # playback options: A/B, loop, volume, position
vol.setSpacing(6)
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)
tr = QHBoxLayout()
tr.setSpacing(6)
for w in (self.b_stop, self.b_ab, self.cb_loop):
tr.addWidget(w)
tr.addSpacing(10)
tr.addWidget(QLabel("Volume"))
tr.addWidget(self.sl_vol, 1)
tr.addWidget(self.lbl_time)
gl.addLayout(tr, 3, 0, 1, 2)
gl.setColumnStretch(1, 1)
self.wf_orig.seekRequested.connect(lambda t: self.play("orig", 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 ---------------------------------------------------------------------------------
g = QGroupBox("REPLACE")
# replacement: source, trim, level -----------------------------------------------------------
g = QGroupBox("REPLACEMENT")
rl = QVBoxLayout(g)
rl.setContentsMargins(8, 8, 8, 8)
rl.setSpacing(6)
src = QHBoxLayout()
src.setSpacing(6)
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)")
self.b_rec = self._btn("Record", "record", self.toggle_record, "Record from the selected input")
self.b_rec.setObjectName("Primary")
self.b_rec.setMinimumWidth(150)
self.cmb_in = QComboBox()
self.cmb_in.setMinimumWidth(110)
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.setRange(0, 100)
self.meter.setTextVisible(False)
self.meter.setFixedWidth(60)
self.meter.setFixedWidth(80)
self.meter.setToolTip("Input level")
self.lbl_rec = QLabel("")
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.addWidget(self.cmb_in, 2)
src.addWidget(self.b_rec, 1)
for w in (self.meter, self.lbl_rec):
src.addWidget(w)
src.addWidget(self.b_load)
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_b = QDoubleSpinBox()
for s_ in (self.sp_a, self.sp_b):
s_.setDecimals(2)
s_.setSuffix(" s")
s_.setRange(0, 36000)
s_.setMaximumWidth(80)
s_.setMaximumWidth(90)
s_.valueChanged.connect(self._changed)
self.sp_gain = QDoubleSpinBox()
self.sp_gain.setRange(-40, 40)
self.sp_gain.setSuffix(" dB")
self.sp_gain.setMaximumWidth(80)
self.sp_gain.setMaximumWidth(90)
self.sp_gain.valueChanged.connect(self._changed)
self.cb_norm = QCheckBox("Normalize")
self.cb_match = QCheckBox("Match loudness")
@@ -194,17 +215,14 @@ class EditorPanel(QWidget):
self.cb_norm.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_play_new = self._btn("Replacement", "play", lambda: self.play("new"), "Play the processed replacement",
icons.ACCENT)
# edit controls in a grid: labels in one column, the fields aligned, playback on the right
proc = QGridLayout()
proc = QGridLayout() # labels in one column, the fields aligned
proc.setHorizontalSpacing(6)
proc.setVerticalSpacing(6)
proc.addWidget(QLabel("Keep"), 0, 0)
proc.addWidget(self.sp_a, 0, 1)
proc.addWidget(QLabel("to"), 0, 2, Qt.AlignmentFlag.AlignCenter)
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(self.sp_gain, 1, 1)
checks = QHBoxLayout()
@@ -213,7 +231,7 @@ class EditorPanel(QWidget):
checks.addWidget(self.cb_match)
checks.addStretch(1)
proc.addLayout(checks, 1, 2, 1, 3)
proc.setColumnStretch(4, 1)
proc.setColumnStretch(5, 1)
rl.addLayout(proc)
info = QHBoxLayout()
self.lbl_new = QLabel()
@@ -229,37 +247,33 @@ class EditorPanel(QWidget):
info.addWidget(self.warn_icon, 0, Qt.AlignmentFlag.AlignTop)
info.addWidget(self.lbl_warn, 1)
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,
"Encode the replacement and install it in the game files (Ctrl+S)", "#ffffff")
self.b_apply.setObjectName("Primary")
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,
"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.lbl_busy = QLabel()
info.addWidget(self.busy, 0, Qt.AlignmentFlag.AlignTop) # encoding/writing: next to the status
info.addWidget(self.lbl_busy, 0, Qt.AlignmentFlag.AlignTop)
act = self._fill((self.b_apply, 2), (self.b_revert, 1), (self.b_export, 1))
rl.addStretch(1)
rl.addLayout(act)
replace = g
# subtitle of this sound (edited side by side with the audio) -------------------------------
from .textpanel import SubtitleBox
gs = QGroupBox("SUBTITLE")
gsl = QVBoxLayout(gs)
gsl.setContentsMargins(8, 8, 8, 8)
self.subtitle = SubtitleBox(self.ctx, gs)
gsl.addWidget(self.subtitle, 1)
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.action_bar = QWidget() # the Edit tab keeps it below its scroll area (always visible)
act = QHBoxLayout(self.action_bar)
act.setContentsMargins(8, 4, 8, 6)
act.setSpacing(6)
act.addWidget(self.b_prev, 1)
act.addSpacing(10)
act.addWidget(self.b_apply, 3)
act.addWidget(self.b_revert, 1)
act.addWidget(self.b_export, 1)
act.addWidget(self.busy)
act.addWidget(self.lbl_busy)
act.addSpacing(10)
act.addWidget(self.b_next, 1)
lay.addWidget(self.action_bar)
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)))
@@ -305,13 +319,16 @@ class EditorPanel(QWidget):
self.recorder.stop()
self.sound, self.orig, self.installed, self.src = s, None, None, 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)
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_new.set_audio(None, 1, "load a file or record a replacement")
self.lbl_new.setText("")
self.subtitle.set_sound(s)
self.subtitle_box.setVisible(bool(self.subtitle.keys))
if s is None:
self.title.setText("No sound selected")
self.meta.setText("Search and select a sound in the list.")
@@ -380,8 +397,9 @@ class EditorPanel(QWidget):
self.ctx.runner.start(lambda job: audio.load(path), done, failed)
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_gen += 1
dur = audio.duration(x, sr)
self.sp_a.blockSignals(True)
self.sp_b.blockSignals(True)
@@ -392,6 +410,8 @@ class EditorPanel(QWidget):
self.sp_a.blockSignals(False)
self.sp_b.blockSignals(False)
self._update_new()
if not restoring:
self.source_changed.emit()
def toggle_record(self):
if self.recorder.is_recording():
@@ -427,15 +447,65 @@ class EditorPanel(QWidget):
def _changed(self, *_):
self._reproc.start()
self.edited.emit()
def processed(self):
"""Replacement audio after trim / gain / normalize / loudness matching -> (x, rate) or None."""
if not self.src:
return None
def set_effects(self, settings):
self.effects = settings
self._changed()
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
a, b = self.sp_a.value(), self.sp_b.value()
if b > a:
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:
x = audio.match_loudness(x, self.orig[0])
if self.cb_norm.isChecked():
@@ -445,6 +515,9 @@ class EditorPanel(QWidget):
return x, sr
def _update_new(self):
if self.src and self._effected(render=False) is None:
self._render_effects()
return
p = self.processed()
if p is None:
self.wf_new.set_audio(None, 1, "load a file or record a replacement")
@@ -455,7 +528,9 @@ class EditorPanel(QWidget):
self.wf_new.set_audio(x, sr)
dur = audio.duration(x, sr)
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 = []
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 "
@@ -477,6 +552,7 @@ class EditorPanel(QWidget):
if not a:
return
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)
def toggle_play(self):
@@ -500,6 +576,8 @@ class EditorPanel(QWidget):
w.set_playhead(None)
def _pos(self, t):
if getattr(self.ctx.player, "owner", self) is not self:
return
self.lbl_time.setText(fmt_s(t))
wf = {"orig": self.wf_orig, "inst": self.wf_inst, "new": self.wf_new}.get(self.playing_what)
if wf:
@@ -535,6 +613,8 @@ class EditorPanel(QWidget):
return
ws = self.ctx.ws
meta = {"source": self.src_name}
if fx.active(self.effects):
meta["effects"] = fx.summary(self.effects)
def work(job):
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.setBrush(Qt.BrushStyle.NoBrush)
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":
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))

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"))
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))
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)))
inject(ws.wad_path(s), {s.target: wem}, {s.target: ws.limit(s)}, out_path=out)
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
subtitle), Subtitles and Menu text (app/textpanel.py)."""
"""Main window. Tabs: Sounds (browse: filters + sound table, a preview below; app/preview.py), Edit (the edit list,
the editor and the effects: app/edit.py; the only place where sounds are replaced), Subtitles and Menu text
(app/textpanel.py)."""
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.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox,
QPushButton, QScrollArea, QSplitter, QTableView, QTabWidget, QToolBar, QVBoxLayout,
QPushButton, QSplitter, QTableView, QTabWidget, QToolBar, QVBoxLayout,
QWidget)
from ackit import settings as settings_mod, workspace as W
@@ -17,15 +18,15 @@ from ackit.texts import TextConflictError
from . import icons, theme
from .audioio import Player, find_device, output_devices
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 .preview import PreviewPanel
from .spinner import BusyOverlay, Spinner
from .textpanel import TextPanel
from .userdata import UserData
from .workers import Runner
SIDEBAR_MIN, SIDEBAR_MAX = 220, 320
TABLE_MIN = 160 # the sound list never gets smaller than this when fitting the editor
ANY = "Any"
@@ -196,20 +197,16 @@ class MainWindow(QMainWindow):
self.table.sortByColumn(C_NAME, Qt.SortOrder.AscendingOrder)
self.table.selectionModel().currentRowChanged.connect(self._current_changed)
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.customContextMenuRequested.connect(self._context_menu)
# editor
self.editor = EditorPanel(self)
self.editor.applied.connect(self._after_change)
self.editor.reverted.connect(self._after_change)
self.editor.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
scroll = QScrollArea()
scroll.setWidget(self.editor)
scroll.setWidgetResizable(True)
# preview of the selected sound (no editing here)
self.preview = PreviewPanel(self)
self.preview.edit_requested.connect(self.edit_selected)
self.preview.add_requested.connect(self.add_selected_to_edit)
# 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.addWidget(side)
hs.addWidget(self.table)
@@ -217,18 +214,19 @@ class MainWindow(QMainWindow):
hs.setSizes([SIDEBAR_MAX, 100000])
self.vsplit = QSplitter(Qt.Orientation.Vertical)
self.vsplit.addWidget(hs)
self.vsplit.addWidget(scroll)
self.vsplit.setStretchFactor(0, 1) # extra height goes to the list; the editor keeps its fitted height
self.vsplit.addWidget(self.preview)
self.vsplit.setStretchFactor(0, 1) # extra height goes to the list
self.vsplit.setStretchFactor(1, 0)
self.scroll = scroll
self._fitted = False
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.vsplit.setChildrenCollapsible(False)
# sections: sounds (browse), edit, subtitles, menu text
self.tabs = QTabWidget()
self.tabs.setDocumentMode(True)
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.menu = TextPanel(self, "menu")
self.tabs.addTab(self.subs, icons.icon("ab"), "Subtitles")
@@ -255,12 +253,15 @@ class MainWindow(QMainWindow):
sc = Qt.ShortcutContext.WindowShortcut
QShortcut(QKeySequence("Ctrl+F"), self, self.search.setFocus, context=sc)
QShortcut(QKeySequence("Ctrl+S"), self, lambda: self.editor.apply(), context=sc)
QShortcut(QKeySequence("Ctrl+O"), self, lambda: self.editor.load_file_dialog(), context=sc)
QShortcut(QKeySequence("Escape"), self, self.editor.stop, context=sc)
QShortcut(QKeySequence(Qt.Key.Key_Space), self.table, self.editor.toggle_play,
context=Qt.ShortcutContext.WidgetShortcut)
QShortcut(QKeySequence(Qt.Key.Key_F2), self.table, self.edit_notes, context=Qt.ShortcutContext.WidgetShortcut)
edit = Qt.ShortcutContext.WidgetWithChildrenShortcut # editing shortcuts: in the Edit tab only
QShortcut(QKeySequence("Ctrl+S"), self.edit, lambda: self.editor.apply(), context=edit)
QShortcut(QKeySequence("Ctrl+O"), self.edit, lambda: self.editor.load_file_dialog(), context=edit)
QShortcut(QKeySequence("Escape"), self, self.player.stop, context=sc)
wsc = 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) -----------------------------------------------------------
def confirm(self, title, text):
@@ -355,6 +356,7 @@ class MainWindow(QMainWindow):
fill(self.cmb_arch, list(ws.catalog.archives))
fill(self.cmb_tag, self.userdata.all_tags())
self._filters_changed()
self.edit.populate(ws)
def _fill_categories(self):
g = self.cmb_group.currentText()
@@ -405,7 +407,22 @@ class MainWindow(QMainWindow):
def _current_changed(self, cur, prev):
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):
if index.column() == C_FAV:
@@ -429,7 +446,10 @@ class MainWindow(QMainWindow):
if not sel:
return
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("Toggle favorite", lambda: self._toggle_fav(sel))
m.addSeparator()
@@ -492,6 +512,8 @@ class MainWindow(QMainWindow):
if panel.kind in self._text_loaded:
panel.refresh()
self.editor.subtitle.set_sound(self.editor.sound)
if self.preview.sound:
self.preview.set_sound(self.preview.sound)
def show_sound(self, key):
"""Sounds tab with `key` selected (from a subtitle)."""
@@ -515,8 +537,12 @@ class MainWindow(QMainWindow):
# --- changes ----------------------------------------------------------------------------------
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.edit.refresh(modified)
if self.preview.sound:
self.preview.set_sound(self.preview.sound)
def revert_sounds(self, sounds):
sounds = [s for s in sounds if self.ws.store.get(s.key)]
@@ -572,7 +598,7 @@ class MainWindow(QMainWindow):
self._after_change()
self._texts_changed()
if self.editor.sound:
self.editor.set_sound(self.editor.sound)
self.edit.load(self.editor.sound)
# --- changed underneath -----------------------------------------------------------------------
def check_problems(self):
@@ -744,31 +770,8 @@ class MainWindow(QMainWindow):
except OSError:
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):
self.player.stop()
self.edit.flush()
self.save_settings()
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.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, QTableView,
QVBoxLayout, QWidget)
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QSplitter,
QTableView, QVBoxLayout, QWidget)
from ackit import subtitles
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)
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):
"""kind 'subs' (subtitles) or 'menu' (menu text)."""
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; }
QComboBox QAbstractItemView { background: #1b1b1b; border: 1px solid %(CONTROL)s;
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:checked { background: %(ACCENT)s; border-color: %(ACCENT)s; }
QCheckBox::indicator, QGroupBox::indicator, QListView::indicator { width: 14px; height: 14px; border: 1px solid #555555; border-radius: 3px;
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::chunk { background: %(ACCENT)s; border-radius: 2px; }
QSlider::groove:horizontal { height: 4px; background: #333333; border-radius: 2px; }

Binary file not shown.

View File

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

View File

@@ -1,7 +1,8 @@
# Building Unheard City
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).
## 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
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:
- 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/

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"
message. Bundling ffmpeg (~80-100 MB, GPL/LGPL build care) for rare formats was judged not worth it.
- **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
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`).
@@ -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).
**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
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
loopback); it is a developer tool and not part of the editor.
@@ -162,15 +169,22 @@ copy per voice language) is filtered the same way, so the sound list never mixes
store does not need it.
## 7. Editor UX choices
- One window with three tabs. *Sounds*: filters (left) and sound table on top, the editor of the selected
sound below across the full width, in three resizable columns (Listen | Replace | Subtitle). Inside each
column the controls are aligned, not packed in rows that wrap: Replace's edit fields in a grid with one
label column, and every column's actions in one full-width bar at its bottom, level across the three
columns (Listen: Original | Installed | Stop; Replace: Apply to game, twice as wide, | Revert | Save WAV;
Subtitle: Apply | Revert). Every row of buttons fills its column's width (Load file / device / Record; Trim
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:
minimum 984 px with the window's vertical scroll bar). *Subtitles* and *Menu text*: search, filters, table, editor. The primary flow
(search -> select -> Space to listen -> Load file/Record -> Apply) needs no menus; power features (tags,
- One window with four tabs; browsing and editing are separate (user, mission 4: "the browser for browsing, the
edit tab for editing, not both for both"). *Sounds*: filters (left), sound table, and below it a compact
preview (details, subtitle as text, original/installed waveforms with play buttons, **Edit** / **Add to edit
list**); no editing tools: details | player (Play original, Play installed, Stop together above the
waveforms) | Edit, Add to edit list. *Edit*: the edit list (sounds sent from the browser, kept in the user
data folder with each sound's replacement audio, trim/level values and effects; `app/edit.py`) | the editor
(`app/editor.py`, top to bottom: SUBTITLE large, with the original and the English line, room counter and
buttons on one row; LISTEN = original, installed and replacement, one row each with its play button, the
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.
- 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,

View File

@@ -10,11 +10,11 @@ Survey of the installed game (buildid 3718300), 2026-10-09.
| where | banks | unique in-memory WEMs |
|-------|-------|-----------------------|
| 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`).
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
**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
@@ -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
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`).
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;
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
@@ -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
uncompressed data for `BKHD` (banks) and `BKPK` (WAD indexes). (The first survey used Gildor's
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.
`CHR_CatwomanFoleySuit` 31 times, `CHR_BatmanFoleyBelt` 26). 302 bank IDs occur as several blobs with
- 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` 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
of all its blobs.
- Largest: `CHR_RobinFoleyCape` 300 WEMs, `CHR_BatmanFoleyCape` 282, `CHR_Clayface` 263,

View File

@@ -1,6 +1,6 @@
# 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)
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`.

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
(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.
- **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.
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
@@ -51,38 +52,81 @@ the same line and are changed and reverted together.
- 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,
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
the sound; a replacement is written into every copy.
rest after their bank (`CHR_BatclawGrapple_3`). The Edit tab shows the bank and how many copies the game keeps
of the sound; a replacement is written into every copy.
![Browsing by category](img/browse.png)
## Listen
The editor under the list shows the selected sound in three columns side by side (drag the dividers to
resize them): **Listen**, **Replace** and **Subtitle**. Select a row and press **Space** to play the original
(once a replacement is loaded, Space plays the replacement; Space again stops), or double-click it. Each column
ends with its own buttons on one line. In **Listen**:
- **Original** / **Installed** play the game's original and what is currently in the game; **Stop** (Esc).
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.
The **Sounds** tab is for finding and listening; it does not change anything. Below the list, the selected sound
is shown with its details and subtitle (left), a player (middle) and the way into the Edit tab (right).
**Play original** (or Space, or double-click the row), **Play installed** (what the game has now, if you
replaced it) and **Stop** (Esc) sit together above the waveforms; click on a waveform to play from that point.
## Replace
![Editor](img/editor.png)
1. **Load file…** (Ctrl+O): WAV, FLAC, OGG, OPUS, MP3 or AIFF. (M4A/WMA work only if ffmpeg is installed.)
Or choose a microphone in the list and press **Record**, then **Stop recording**. Listen with
**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*.
To change sounds, select them (Ctrl/Shift-click for several) and press **Edit** (or Enter, or right-click >
**Edit**): they go onto the **edit list** and the **Edit** tab opens on the first. **Add to edit list** puts them
there without leaving the list, e.g. to collect all the lines of a character or all the sounds of a weapon
first.
Size rule: voice lines and 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.
## Edit
![Edit tab](img/editor.png)
The **Edit** tab is where sounds are replaced: voice lines, music, ambience, sound effects. Everything fits in
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
- **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: all sounds,
subtitles and menu texts you changed, in every language.
- Originals are kept in your user data folder (`%APPDATA%\UnheardCity` on Windows,
@@ -109,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,
Japanese and Korean ones, most Polish ones) accept any character. The editor says so when a character does
not fit.
- **Go to the sound** opens the speech line in the Sounds tab. In the Sounds tab, the editor of a speech line
shows its subtitle in the **Subtitle** column next to the audio: edit both side by side, **Apply** /
**Revert** the subtitle there, or **Show in list** to jump back to the Subtitles tab.
- **Go to the sound** selects the speech line in the Sounds tab (its subtitle shows in the preview). In the
Edit tab, a voice line's **Subtitle** is shown large above the audio: edit it there, **Apply** / **Revert**
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).
- **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
@@ -145,7 +189,7 @@ Game folder, where originals are kept (changeable only while nothing is modified
the playback device. The recording device
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).
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
| message | what to do |

View File

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

View File

@@ -2,6 +2,7 @@
import hashlib
import os
import numpy as np
import pytest
pytest.importorskip("PySide6")
@@ -53,6 +54,14 @@ def type_search(w, 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):
w, root, _ = win
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
before = files(root)
type_search(w, "Speech_Synth_Radio_02")
assert w.select_name("Speech_Synth_Radio_02")
ed = w.editor
assert ed.sound.name == "Speech_Synth_Radio_02" and ed.orig is not None
ed = edit(w, "Speech_Synth_Radio_02")
assert ed.orig is not None
wav = str(tmp_path / "new.wav")
x, sr = synth.tone(1.0, 44100, 990)
audio.save_wav(wav, x, sr)
@@ -120,8 +128,7 @@ def test_bank_sound_apply_revert_in_editor(win, tmp_path):
before = files(root)
w.cmb_arch.setCurrentText("BANK")
assert w.proxy.rowCount() == 7 # 6 neutral + the INT copy of a localized line
assert w.select_name("CHR_SynthFoley_1")
ed = w.editor
ed = edit(w, "CHR_SynthFoley_1")
assert "bank CHR_SynthFoley (2 copies)" in ed.meta.text()
wav = str(tmp_path / "foley.wav")
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
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"
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
assert [e.text() for e in box.edits] == ["Scan two.", "Second part."]
box.edits[1].setText("Part two.")
@@ -173,7 +182,7 @@ def test_one_language_for_everything(win):
w.tabs.setCurrentWidget(w.subs)
assert w.subs.lang == "RUS" and "RUS" in w.subs.lbl_lang.text()
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] == ["Строка два"]
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
@@ -293,8 +302,7 @@ def test_text_undone_outside_shows_banner(win):
def test_record_simulated_input(win):
w, root, _ = win
w.select_name("CHR_RadioScanner_1")
ed = w.editor
ed = edit(w, "CHR_RadioScanner_1")
x, sr = synth.tone(0.5, 48000, 500)
ed.recorder.start_simulated(x, sr, realtime=True)
assert ed.recorder.is_recording()
@@ -327,7 +335,7 @@ def test_notes_tags_favorites(win):
def test_mod_export_import(win, tmp_path):
w, root, _ = win
w.select_name("Speech_Synth_Radio_02")
edit(w, "Speech_Synth_Radio_02")
x, sr = synth.tone(1.0, 24000, 1200)
w.editor.set_source(x, sr, "tone")
w.editor.apply()
@@ -344,7 +352,7 @@ def test_mod_export_import(win, tmp_path):
def test_changed_underneath_banner(win):
w, root, originals = win
w.select_name("CHR_RadioScanner_1")
edit(w, "CHR_RadioScanner_1")
x, sr = synth.tone(0.5, 24000, 1500)
w.editor.set_source(x, sr, "tone")
w.editor.apply()
@@ -377,7 +385,7 @@ def test_selection_survives_sort_and_filter(win):
from app.models import C_LEN
w, _, _ = win
assert w.select_name("Speech_Synth_Radio_02")
s = w.editor.sound
s = w.preview.sound
w.table.sortByColumn(C_LEN, Qt.SortOrder.DescendingOrder)
type_search(w, "radio")
cur = w.table.currentIndex()
@@ -409,9 +417,13 @@ def test_spinners_while_loading(win, tmp_path):
w, root, _ = win
held = w.runner = HeldRunner(w.runner)
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()
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")
audio.save_wav(wav, *synth.tone(0.3, 22050, 700))
w.editor.load_file(wav)
@@ -433,14 +445,24 @@ 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))
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.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()
for size in ((1400, 900), (1280, 800)):
w.resize(*size)
edit(w, "Speech_Synth_Radio_02") # two subtitle parts: the tallest case
QTest.qWait(100)
need = w.editor.sizeHint().height()
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need)
assert w.vsplit.sizes()[0] >= 160
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):
@@ -456,3 +478,97 @@ def test_recorder_sample_formats(qapp):
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

@@ -35,6 +35,22 @@ def test_cli_workflow(synth_game, tmp_path, capsys):
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

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

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

View File

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

View File

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

View File

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

View File

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