24 KiB
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:
-
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.
-
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
FXPRchunk the parser already recognizes andENVSinInit.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.mdLines), 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 (lineidentity, 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 bytools/build_catalog.pyfrom 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 intools/check_docs.pyfor every figure;ackit verify --catalogre-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.mdlike the other dependencies, licenses intoTHIRD_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), CLIackit fx survey | scan | renderandackit 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)
- 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. - 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).
- 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).
- Apply the approved lines in one
Workspace.applycall (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). - 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.mdLines): 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)
- 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.mdwith figures (+check_docsclaims). Outcome: how many lines are baked vs run time, and the list of profiles. This decides the size of everything after it. ackit/fx.pyon 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).ackit/fxdetect.py+FX.csv/fx_profiles.jsoninbuild_catalog.py;verify --catalogchecks; CLI. Test: render a profile onto a dry synthetic voice, detect it back.- Sounds editor: effect row + run-time hint; GUI tests.
ackit/dub.py+ Dubbing tab (sessions, takes, script, batch apply, mod export); GUI tests.- Real-game round trips (
tools/e2e_features.py/e2e_editor.pycases for a dubbed line with an effect, reverted infinally), 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 inInit= 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):
ackit/fx.py+ tests (synthetic signals: each block measured back) + pedalboard dependency (pyproject.toml,docs/decisions.md, licenses).- 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. - 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.