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:
7
.claudio
Normal file
7
.claudio
Normal 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
|
||||
88
NOTES.md
88
NOTES.md
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
62
REPORT.md
62
REPORT.md
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
16
ackit/cli.py
16
ackit/cli.py
@@ -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
508
ackit/fx.py
Normal 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
455
app/edit.py
Normal 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)
|
||||
258
app/editor.py
258
app/editor.py
@@ -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
328
app/effects.py
Normal 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()
|
||||
19
app/icons.py
19
app/icons.py
@@ -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))
|
||||
|
||||
10
app/main.py
10
app/main.py
@@ -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
|
||||
|
||||
@@ -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
181
app/preview.py
Normal 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)
|
||||
100
app/textpanel.py
100
app/textpanel.py
@@ -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()
|
||||
|
||||
@@ -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.
@@ -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,
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
284
docs/history/mission4_dubbing_plan.md
Normal file
284
docs/history/mission4_dubbing_plan.md
Normal 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 |
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||

|
||||
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
|
||||

|
||||
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 |
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
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
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -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
225
tests/test_fx.py
Normal 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
|
||||
@@ -303,7 +303,8 @@ def index_claims(cat):
|
||||
% sub["scripted_objects"])
|
||||
claim("REPORT.md", "(%s speech lines x %d languages) and" % (n(sub["dialogue_lines"]), len(sub["lines"])))
|
||||
claim("REPORT.md", "in %d scripted `RSubtitle` objects" % sub["scripted_objects"])
|
||||
claim("docs/formats/banks.md", "(in %s of the 2,650 packages)" % n(b["files"] - 67)) # 67 loose .bnk with banks
|
||||
claim("docs/formats/banks.md", "(in %s of the " % n(b["files"] - 67)) # 67 loose .bnk with banks; the
|
||||
# total number of packages needs the game: scan_claims
|
||||
|
||||
|
||||
# --- the game's own files ----------------------------------------------------------------------------
|
||||
@@ -379,6 +380,7 @@ def scan_claims(game):
|
||||
claim("docs/formats/banks.md", "| banks embedded in `.upk` packages (in %s of the %s packages) | %s |"
|
||||
% (n(sum(1 for f in sc.files if f.lower().endswith(".upk"))), n(len(glob.glob(os.path.join(game.cooked, "*.upk")))),
|
||||
n(len(blobs))))
|
||||
claim("docs/formats/upk.md", "Arkham City's %s packages in" % n(len(glob.glob(os.path.join(game.cooked, "*.upk")))))
|
||||
claim("docs/formats/banks.md", "Source kinds over all bank copies: %s in-memory sources of the bank's own media; %s "
|
||||
"in-memory sources with bank id 0" % (n(kinds["memory own"]), n(kinds["memory other"])))
|
||||
claim("docs/formats/banks.md", "(loaded by another bank); %s streamed sources; %s **prefetch** sources"
|
||||
@@ -530,6 +532,24 @@ def originality_claims(game):
|
||||
n(len(rels)), "no false alarm" if not bad else "%d false alarms" % len(bad)), "Workspace.unoriginal_files")
|
||||
|
||||
|
||||
def fx_claims():
|
||||
"""Effects (ackit/fx.py), the dependency pin and the Edit tab's tested window sizes."""
|
||||
from ackit import fx
|
||||
pa = fx.preset("echo", "PA announcement")["time"]
|
||||
claim("docs/user-guide.md", "(the *PA announcement* preset: %d ms)" % pa, "fx.PRESETS")
|
||||
looks = [name for name, _ in fx.LOOKS]
|
||||
claim("docs/user-guide.md", "sets several effects at once: %s, %s." % (", ".join(looks[:-1]), looks[-1]),
|
||||
"fx.LOOKS")
|
||||
for b in fx.BLOCKS:
|
||||
claim("docs/user-guide.md", "| **%s**" % b.label, "fx.BLOCKS (effects table)")
|
||||
pin = re.search(r'"pedalboard==([\d.]+)"', open(os.path.join(ROOT, "pyproject.toml")).read()).group(1)
|
||||
claim("docs/decisions.md", "Wheels for Python 3.14 on Windows and Linux (%s)" % pin, "pyproject.toml")
|
||||
sizes = re.search(r"for size in \(\((\d+), (\d+)\), \((\d+), (\d+)\)\)",
|
||||
open(os.path.join(ROOT, "tests", "test_app.py"), encoding="utf-8").read()).groups()
|
||||
claim("docs/decisions.md", "No scrolling at %s x %s and %s x %s (tested)" % sizes, "tests/test_app.py")
|
||||
claim("REPORT.md", "No scrolling at %s x %s or %s x %s." % sizes, "tests/test_app.py")
|
||||
|
||||
|
||||
def test_claims():
|
||||
import subprocess
|
||||
out = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"],
|
||||
@@ -551,6 +571,7 @@ def main():
|
||||
cat = catalog_dir()
|
||||
catalog_claims(cat)
|
||||
index_claims(cat)
|
||||
fx_claims()
|
||||
test_claims()
|
||||
gd = a.game or default_game_dir(settings.load())
|
||||
game = Game(gd) if gd else None
|
||||
|
||||
@@ -73,6 +73,9 @@ def components():
|
||||
dist_file("soxr", "LICENSE-PFFFT.txt")),
|
||||
("NumPy (incl. bundled OpenBLAS / runtime libraries)", ver("numpy"), "BSD-3-Clause and others (see text)",
|
||||
"https://github.com/numpy/numpy", dist_file("numpy", "LICENSE.txt")),
|
||||
("Pedalboard (incl. JUCE and the Rubber Band library, statically linked)", ver("pedalboard"), "GPL-3.0",
|
||||
"https://github.com/spotify/pedalboard", dist_file("pedalboard", "LICENSE") + "\n\n" +
|
||||
dist_file("pedalboard", "NOTICE")),
|
||||
("cffi", ver("cffi"), "MIT-0", "https://github.com/python-cffi/cffi", dist_file("cffi", "LICENSE")),
|
||||
("pycparser", ver("pycparser"), "BSD-3-Clause", "https://github.com/eliben/pycparser",
|
||||
dist_file("pycparser", "LICENSE")),
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"""Real end-to-end test of the EDITOR code path against the installed game (Windows, Steam running, WinDbg).
|
||||
|
||||
1. open the editor (offscreen) on the real game, select the intro music (Music_393BC53A);
|
||||
2. load a 30 s probe file as replacement and press Apply (EditorPanel.apply);
|
||||
1. open the editor (offscreen) on the real game, select the intro music (Music_393BC53A) and press Edit;
|
||||
2. load a 30 s probe file as replacement and press Apply (EditorPanel.apply in the Edit tab);
|
||||
3. start the game under cdb + speaker loopback: the probe must be heard, no crash, setup parsed;
|
||||
4. press Revert (EditorPanel.revert) and check the WAD + pack index are byte-identical to before;
|
||||
5. also apply/revert a speech line in place and check byte identity.
|
||||
Uses the user's settings and originals store (an interrupted run shows up in `ackit status`); the settings file
|
||||
is never written.
|
||||
is never written, and the Edit tab's list and replacements go to a temporary folder (the user's edit list is
|
||||
untouched).
|
||||
|
||||
python tools/e2e_editor.py
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
@@ -40,6 +42,9 @@ def main():
|
||||
# stores its settings on load and on close, which would overwrite the user's file with this test's ones
|
||||
w = MainWindow(settings=dict(settings_mod.load(), full_backup=True), runner=Runner(sync=True))
|
||||
w.save_settings = lambda: None
|
||||
from app.edit import EditStore
|
||||
edits = tempfile.mkdtemp() # not the user's edit list and replacements
|
||||
w.edit.store = w.edit.model.store = EditStore(edits)
|
||||
w.confirm = lambda *a: True
|
||||
w.editor.confirm = lambda *a: True
|
||||
errors = []
|
||||
@@ -55,6 +60,7 @@ def main():
|
||||
ws.texts.revert_all()
|
||||
print("E2E EDITOR %s" % ("PASS" if ok else "FAIL"))
|
||||
w.close()
|
||||
shutil.rmtree(edits, ignore_errors=True)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
@@ -72,6 +78,7 @@ def run(w, ws, errors):
|
||||
w.search.setText("Music_393BC53A")
|
||||
w._filter_text()
|
||||
assert w.select_name("Music_393BC53A")
|
||||
w.edit_selected() # the Edit tab, on this sound
|
||||
x, sr = ingame.probe_signal(30.0)
|
||||
probe = os.path.join(td, "probe.wav")
|
||||
audio.save_wav(probe, x, sr)
|
||||
@@ -94,6 +101,7 @@ def run(w, ws, errors):
|
||||
tb = sha(twad)
|
||||
w.clear_filters()
|
||||
assert w.select_name(t.name)
|
||||
w.edit_selected()
|
||||
y, ysr = audio.decode_wem(ws.original_bytes(t))
|
||||
line = os.path.join(td, "line.wav")
|
||||
audio.save_wav(line, y[::-1], ysr) # reversed speech, same length
|
||||
|
||||
@@ -16,6 +16,8 @@ of the editor and the CLI), read back from the game files, reverted, and the fil
|
||||
7. mod package made with the language's voices and imported into another voice language (INT; FRA for INT):
|
||||
matched by the game's line identity, so the same line (not the same WAD position) is replaced; a text key
|
||||
names its language, so the edited subtitle stays that language's subtitle (the other language's is untouched)
|
||||
8. 5-channel speech line replaced with effects (the Edit tab's chain: EQ, echo, PA channel layout): the
|
||||
decoded game file has the line's channels in the game's order, the surrounds carry the delayed voice
|
||||
Uses temporary originals stores (the user's store and settings are not touched), deleted at the end when every
|
||||
change was reverted (kept, with a warning, otherwise). Whatever happens (a failed check, an exception), every
|
||||
change is reverted before the tool exits; a sound that is not in its original state is never written.
|
||||
@@ -32,7 +34,7 @@ sys.path.insert(0, ROOT)
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
|
||||
from ackit import banks, settings, subtitles # noqa: E402
|
||||
from ackit import audio, banks, fx, settings, subtitles # noqa: E402
|
||||
from ackit.game import Game, default_game_dir # noqa: E402
|
||||
from ackit.modpack import export_mod, import_mod, match_mod # noqa: E402
|
||||
from ackit.wem import fill # noqa: E402
|
||||
@@ -93,6 +95,39 @@ def sound_round_trip(ws, s, label):
|
||||
check(label, ok, "(%s, %d files, %d copies/heads)" % (s.name, len(files), len(ws.locations(s))))
|
||||
|
||||
|
||||
def effects_round_trip(ws, voices, lang):
|
||||
"""8. A replacement through the effects into a 5-channel line (L R C SL SR), read back and decoded."""
|
||||
label = "8 %s 5-channel line replaced with effects" % lang
|
||||
s = next((x for x in ws.catalog.sounds if x.archive == voices and x.group == "Speech" and x.channels == 5
|
||||
and 1.5 < x.seconds < 4 and ws.state(x) == ORIGINAL), None)
|
||||
if s is None:
|
||||
check(label, False, "no original 5-channel speech line found")
|
||||
return
|
||||
new, sr = tone(1.0, 400)
|
||||
new[int(0.6 * sr):] = 0 # a pause, so the echo can be measured after the voice stops
|
||||
st = fx.defaults()
|
||||
st["eq"].update(on=True, low_cut=300, high_cut=3400)
|
||||
st["echo"].update(on=True, time=120, feedback=0.2, mix=0.3)
|
||||
st["channels"].update(on=True, front=-12, surround=-6, surround_delay=160)
|
||||
x = fx.render(new, sr, st, s.channels)
|
||||
wem, _ = ws.prepare(s, x, sr)
|
||||
files = ws.files(s)
|
||||
before = snapshot(files)
|
||||
ws.apply([(s, wem, {"source": "e2e", "effects": fx.summary(st)})])
|
||||
y, ysr = audio.decode_wem(ws.current_bytes(s))
|
||||
ok = ws.state(s) == MODIFIED and y.shape[1] == 5
|
||||
lag = -1.0
|
||||
if ok:
|
||||
def onset(c): # first sample above half of the channel's peak
|
||||
a = np.abs(y[:, c])
|
||||
return int(np.argmax(a > 0.5 * a.max())) / ysr
|
||||
lag = onset(3) - onset(2) # SL vs C: the PA echo delay
|
||||
ok &= abs(lag - 0.16) < 0.02 and np.abs(y[:, 2]).max() > 2 * np.abs(y[:, 0]).max()
|
||||
ws.revert([s])
|
||||
ok &= ws.state(s) == ORIGINAL and snapshot(files) == before and not ws.store.entries
|
||||
check(label, ok, "(%s, surround delay %.3f s)" % (s.name, lag))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--game")
|
||||
@@ -202,6 +237,8 @@ def run_checks(game, lang, voices, td, ws, first):
|
||||
else:
|
||||
check("6 %s sound + subtitle in one package" % lang, False, "no such package found")
|
||||
|
||||
effects_round_trip(ws, voices, lang)
|
||||
|
||||
# 7. cross-language mod: the same game line, not the same WAD position
|
||||
from ackit.catalog import read_csv
|
||||
from ackit.paths import catalog_dir
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Render the user-guide screenshots headless (docs/img/*.png). Read-only: never applies anything.
|
||||
|
||||
python tools/screenshots.py [--game DIR] [--lang INT]
|
||||
python tools/screenshots.py [--game DIR] [--lang INT] [--only main,browse,settings,edit]
|
||||
|
||||
The app starts in the game's language as always; the screenshots then switch to --lang (default INT, the
|
||||
game's English) the way a user does, through the Language list.
|
||||
@@ -22,7 +22,10 @@ def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--game")
|
||||
ap.add_argument("--lang", default="INT", help="language shown in the screenshots (default: INT)")
|
||||
ap.add_argument("--only", default="main,browse,settings,edit",
|
||||
help="which screenshots (comma-separated): main, browse, settings, edit (editor.png)")
|
||||
a = ap.parse_args()
|
||||
only = set(a.only.split(","))
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from ackit import audio
|
||||
from app.dialogs import SettingsDialog
|
||||
@@ -49,31 +52,19 @@ def main():
|
||||
app.processEvents()
|
||||
# status bar: show the store where users have it, not this temporary folder (no local user names)
|
||||
w.lbl_game.setText(w.lbl_game.text().replace(os.path.join(td, "data"), r"%APPDATA%\UnheardCity"))
|
||||
# 1: browser filtered by character
|
||||
# 1: browser filtered by character, a line selected (preview below the list)
|
||||
from PySide6.QtTest import QTest
|
||||
w.cmb_char.setCurrentText("Joker")
|
||||
w._filters_changed()
|
||||
w.search.setText("")
|
||||
w._filter_text()
|
||||
w.fit_editor()
|
||||
rows = [w.proxy.sound_at(w.proxy.index(r, 1)) for r in range(w.proxy.rowCount())]
|
||||
with_sub = [s for s in rows if w.ws.texts.for_sound(s, w.language)] # show the subtitle column too
|
||||
with_sub = [s for s in rows if w.ws.texts.for_sound(s, w.language)] # show the subtitle too
|
||||
target = max(with_sub or rows, key=lambda s: min(s.seconds, 6.0))
|
||||
w.select_name(target.name)
|
||||
app.processEvents()
|
||||
# a replacement loaded for display only (not applied)
|
||||
x, sr = w.editor.orig
|
||||
t = np.arange(len(x)) / sr
|
||||
rep = (0.5 * x[:, :1] * (0.6 + 0.4 * np.sin(2 * np.pi * 0.7 * t))[:, None]).astype(np.float32)
|
||||
path = os.path.join(td, "my_line.wav")
|
||||
audio.save_wav(path, rep, sr)
|
||||
w.editor.load_file(path)
|
||||
w.editor.cb_match.setChecked(True)
|
||||
w.editor._update_new()
|
||||
w.editor.sp_a.setValue(0.2)
|
||||
app.processEvents()
|
||||
w.grab().save(os.path.join(out, "main.png"))
|
||||
from PySide6.QtCore import QPoint, QRect # the editor area, cut from the window (full styling)
|
||||
w.grab(QRect(w.scroll.mapTo(w, QPoint(0, 0)), w.scroll.size())).save(os.path.join(out, "editor.png"))
|
||||
if "main" in only:
|
||||
w.grab().save(os.path.join(out, "main.png"))
|
||||
# 2: category browsing
|
||||
w.clear_filters()
|
||||
w.cmb_group.setCurrentText("Speech")
|
||||
@@ -83,14 +74,35 @@ def main():
|
||||
w.search.setText("joker")
|
||||
w._filter_text()
|
||||
app.processEvents()
|
||||
w.grab().save(os.path.join(out, "browse.png"))
|
||||
if "browse" in only:
|
||||
w.grab().save(os.path.join(out, "browse.png"))
|
||||
# 3: settings
|
||||
d = SettingsDialog(w.settings, w)
|
||||
d.bg_on.setChecked(True) # show the user's default (on), only the screenshots run without it
|
||||
d.show()
|
||||
app.processEvents()
|
||||
d.grab().save(os.path.join(out, "settings.png"))
|
||||
d.close()
|
||||
if "settings" in only:
|
||||
d = SettingsDialog(w.settings, w)
|
||||
d.bg_on.setChecked(True) # show the user's default (on), only the screenshots run without it
|
||||
d.show()
|
||||
app.processEvents()
|
||||
d.grab().save(os.path.join(out, "settings.png"))
|
||||
d.close()
|
||||
# 4: the Edit tab: Mr. Freeze's lines on the edit list, one with a replacement (the original, reshaped,
|
||||
# for display only) and the Mr. Freeze preset
|
||||
if "edit" in only:
|
||||
w.clear_filters()
|
||||
w.cmb_char.setCurrentText("Mr. Freeze")
|
||||
w._filters_changed()
|
||||
freeze = [w.proxy.sound_at(w.proxy.index(r, 1)) for r in range(min(w.proxy.rowCount(), 12))]
|
||||
target = max(freeze, key=lambda s: (bool(w.ws.texts.for_sound(s, w.language)), min(s.seconds, 4.0)))
|
||||
w.edit_sounds([target] + [s for s in freeze if s is not target])
|
||||
app.processEvents()
|
||||
x, sr = w.editor.orig
|
||||
t = np.arange(len(x)) / sr
|
||||
new = (0.5 * x.mean(axis=1, keepdims=True) * (0.6 + 0.4 * np.sin(2 * np.pi * 0.7 * t))[:, None])
|
||||
path = os.path.join(td, "new_line.wav")
|
||||
audio.save_wav(path, new.astype(np.float32), sr)
|
||||
w.editor.load_file(path)
|
||||
w.edit.rack.apply_look("Mr. Freeze")
|
||||
QTest.qWait(1500) # the effects render in the background, then the settings are saved
|
||||
w.grab().save(os.path.join(out, "editor.png"))
|
||||
w.close()
|
||||
print("screenshots in", out)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user