Version 3.2.3: editor layout with aligned controls and full-width button bars; docs on recordings shared between languages

This commit is contained in:
2026-10-11 01:17:35 +02:00
parent b6b96307ca
commit e3040b8e6e
13 changed files with 119 additions and 57 deletions

View File

@@ -121,6 +121,11 @@
plays that as silence; ackit now fills each in-memory copy exactly with a LIST/adtl/labl chunk (`wem.fill`, plays that as silence; ackit now fills each in-memory copy exactly with a LIST/adtl/labl chunk (`wem.fill`,
`Workspace.fits/fill_sizes/prepare`). Verified by the user in game (a beep on every step), e2e_features 89/89. `Workspace.fits/fill_sizes/prepare`). Verified by the user in game (a beep on every step), e2e_features 89/89.
Version 3.2.2. The footstep test was reverted (game original). Version 3.2.2. The footstep test was reverted (game original).
- 2026-10-11 version 3.2.3: editor layout (user: controls "placed in line and go to new line, not pretty"):
aligned grid for the edit fields, every button row fills its column, each column ends with a full-width
action bar level with the others; A/B and Loop are icons next to the volume. Fits a 1000-px window
without horizontal scrolling (minimum 984 px). Docs: recordings the game shares between languages (user saw
English lines "leaking" into other languages: the game's own data, banks.md *Lines*).
- DONE by the user in game: replaced voice lines, edited subtitles and replaced footsteps (after the fill fix). - DONE by the user in game: replaced voice lines, edited subtitles and replaced footsteps (after the fill fix).
- OPEN: channel order for >2 channels unverified; 19 speaker labels to check by listening; the Linux build was - 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). never built or run (no Linux here).

View File

@@ -57,7 +57,7 @@ editor Unheard City (mission 2).
found in the game's memory, no crash); `tools/e2e_editor.py` PASS. Game files original at the end found in the game's memory, no crash); `tools/e2e_editor.py` PASS. Game files original at the end
(`ackit status`). The catalog builder refuses to run on a modified game, so the catalog always describes the (`ackit status`). The catalog builder refuses to run on a modified game, so the catalog always describes the
original files. original files.
- **Package**: `dist/UnheardCity-3.2.2-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to - **Package**: `dist/UnheardCity-3.2.3-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to
the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC: the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC:
21,015 sounds loaded, encode + inject, 9,377 subtitles and 7,020 menu texts loaded, an in-memory sound decoded from a package) and the GUI starts and 21,015 sounds loaded, encode + inject, 9,377 subtitles and 7,020 menu texts loaded, an in-memory sound decoded from a package) and the GUI starts and
quits cleanly. The installer is unchanged since mission 2 (install/upgrade/uninstall verified then). quits cleanly. The installer is unchanged since mission 2 (install/upgrade/uninstall verified then).

View File

@@ -8,4 +8,4 @@ Library layers (low -> high):
inject (WAD writing), originals (store), workspace (apply/revert), modpack (share) inject (WAD writing), originals (store), workspace (apply/revert), modpack (share)
tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows) tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows)
""" """
__version__ = "3.2.2" __version__ = "3.2.3"

View File

@@ -54,6 +54,15 @@ class EditorPanel(QWidget):
b.setToolTip(tip) b.setToolTip(tip)
return b return b
@staticmethod
def _fill(*items):
"""A row of buttons filling the column's width: [(widget, share)], equal edges in every column."""
row = QHBoxLayout()
row.setSpacing(6)
for w, share in items:
row.addWidget(w, share)
return row
def _wave(self, kind, selectable=False): def _wave(self, kind, selectable=False):
w = Waveform(selectable=selectable) w = Waveform(selectable=selectable)
w.accent, w.bg = theme.WAVE[kind], theme.WAVE_BG w.accent, w.bg = theme.WAVE[kind], theme.WAVE_BG
@@ -63,7 +72,7 @@ class EditorPanel(QWidget):
"""Header on top; below, side by side (resizable): LISTEN | REPLACE (with the apply buttons) | SUBTITLE.""" """Header on top; below, side by side (resizable): LISTEN | REPLACE (with the apply buttons) | SUBTITLE."""
self.setObjectName("EditorPanel") self.setObjectName("EditorPanel")
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(10, 6, 10, 6) lay.setContentsMargins(8, 6, 8, 6)
lay.setSpacing(4) lay.setSpacing(4)
self.title = QLabel() self.title = QLabel()
self.title.setObjectName("EditorTitle") self.title.setObjectName("EditorTitle")
@@ -87,41 +96,47 @@ class EditorPanel(QWidget):
# listen ---------------------------------------------------------------------------------- # listen ----------------------------------------------------------------------------------
g = QGroupBox("LISTEN") g = QGroupBox("LISTEN")
gl = QGridLayout(g) gl = QGridLayout(g)
gl.setContentsMargins(8, 8, 8, 8)
gl.setVerticalSpacing(4) gl.setVerticalSpacing(4)
self.wf_orig = self._wave("orig") self.wf_orig = self._wave("orig")
self.wf_inst = self._wave("inst") self.wf_inst = self._wave("inst")
self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"), "Play the original (Space)", icons.STEEL) self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"), "Play the original (Space)", icons.STEEL)
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e") self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e")
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)") self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
self.b_ab = self._btn("A/B", "ab", self.ab_switch, "Switch between original and replacement at the same position", # playback options next to the volume: icons with tooltips
icons.ACCENT) self.b_ab = self._btn("", "ab", self.ab_switch, "A/B: switch between the original and the replacement at "
self.cb_loop = self._btn("Loop", "loop", None, "Repeat playback") "the same position", icons.ACCENT)
self.cb_loop = self._btn("", "loop", None, "Loop: repeat playback")
self.cb_loop.setCheckable(True) self.cb_loop.setCheckable(True)
for b in (self.b_ab, self.cb_loop):
b.setFixedWidth(b.sizeHint().height() + 4)
self.sl_vol = QSlider(Qt.Orientation.Horizontal) self.sl_vol = QSlider(Qt.Orientation.Horizontal)
self.sl_vol.setRange(0, 100) self.sl_vol.setRange(0, 100)
self.sl_vol.setValue(80) self.sl_vol.setValue(80)
self.sl_vol.setMaximumWidth(120) self.sl_vol.setMinimumWidth(80)
self.sl_vol.setToolTip("Volume") self.sl_vol.setToolTip("Volume")
self.sl_vol.valueChanged.connect(lambda v: self.ctx.player.set_volume(v / 100.0)) self.sl_vol.valueChanged.connect(lambda v: self.ctx.player.set_volume(v / 100.0))
self.lbl_time = QLabel("0:00.00") self.lbl_time = QLabel("0:00.00")
self.lbl_time.setObjectName("Muted") 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(QLabel("Original"), 0, 0)
gl.addWidget(self.wf_orig, 0, 1) gl.addWidget(self.wf_orig, 0, 1)
self.lbl_inst = QLabel("Installed") self.lbl_inst = QLabel("Installed")
gl.addWidget(self.lbl_inst, 1, 0) gl.addWidget(self.lbl_inst, 1, 0)
gl.addWidget(self.wf_inst, 1, 1) gl.addWidget(self.wf_inst, 1, 1)
row = QHBoxLayout() gl.setRowStretch(2, 1) # controls at the bottom, level with the other columns' actions
for w in (self.b_play_orig, self.b_play_inst, self.b_stop): gl.addLayout(self._fill((self.b_play_orig, 1), (self.b_play_inst, 1), (self.b_stop, 1)), 4, 0, 1, 2)
row.addWidget(w) vol = QHBoxLayout() # playback options: A/B, loop, volume, position
row.addStretch(1) vol.setSpacing(6)
gl.addLayout(row, 2, 0, 1, 2) vol.addWidget(self.b_ab)
row = QHBoxLayout() vol.addWidget(self.cb_loop)
for w in (self.b_ab, self.cb_loop, QLabel("Volume"), self.sl_vol, self.lbl_time): vol.addSpacing(6)
row.addWidget(w) vol.addWidget(QLabel("Volume"))
row.addStretch(1) vol.addWidget(self.sl_vol, 1)
gl.addLayout(row, 3, 0, 1, 2) vol.addWidget(self.lbl_time)
gl.addLayout(vol, 3, 0, 1, 2)
gl.setColumnStretch(1, 1) gl.setColumnStretch(1, 1)
gl.setRowStretch(4, 1)
self.wf_orig.seekRequested.connect(lambda t: self.play("orig", t)) self.wf_orig.seekRequested.connect(lambda t: self.play("orig", t))
self.wf_inst.seekRequested.connect(lambda t: self.play("inst", t)) self.wf_inst.seekRequested.connect(lambda t: self.play("inst", t))
listen = g listen = g
@@ -129,25 +144,29 @@ class EditorPanel(QWidget):
# replace --------------------------------------------------------------------------------- # replace ---------------------------------------------------------------------------------
g = QGroupBox("REPLACE") g = QGroupBox("REPLACE")
rl = QVBoxLayout(g) rl = QVBoxLayout(g)
rl.setSpacing(4) rl.setContentsMargins(8, 8, 8, 8)
rl.setSpacing(6)
src = QHBoxLayout() src = QHBoxLayout()
src.setSpacing(6)
self.b_load = self._btn("Load file…", "open", self.load_file_dialog, 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)") "WAV, FLAC, OGG, OPUS, MP3, AIFF (M4A/WMA need ffmpeg installed) (Ctrl+O)")
self.cmb_in = QComboBox() self.cmb_in = QComboBox()
self.cmb_in.setMinimumWidth(110) self.cmb_in.setMinimumWidth(110)
self.cmb_in.setMaximumWidth(200)
self.cmb_in.setToolTip("Recording device") self.cmb_in.setToolTip("Recording device")
self.b_rec = self._btn("Record", "record", self.toggle_record, "Record from the selected input") self.b_rec = self._btn("Record", "record", self.toggle_record, "Record from the selected input")
self.meter = QProgressBar() self.meter = QProgressBar()
self.meter.setRange(0, 100) self.meter.setRange(0, 100)
self.meter.setTextVisible(False) self.meter.setTextVisible(False)
self.meter.setMaximumWidth(60) self.meter.setFixedWidth(60)
self.meter.setToolTip("Input level") self.meter.setToolTip("Input level")
self.lbl_rec = QLabel("") self.lbl_rec = QLabel("")
self.lbl_rec.setObjectName("Muted") self.lbl_rec.setObjectName("Muted")
for w in (self.b_load, self.cmb_in, self.b_rec, self.meter, self.lbl_rec): src.addWidget(self.b_load, 1) # the row fills the column: file | microphone, level
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(w)
src.addStretch(1)
rl.addLayout(src) rl.addLayout(src)
self.wf_new = self._wave("new", selectable=True) 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.setToolTip("Click: play from here. Right-drag (or Shift+drag): select the part to keep.")
@@ -160,12 +179,12 @@ class EditorPanel(QWidget):
s_.setDecimals(2) s_.setDecimals(2)
s_.setSuffix(" s") s_.setSuffix(" s")
s_.setRange(0, 36000) s_.setRange(0, 36000)
s_.setMaximumWidth(92) s_.setMaximumWidth(80)
s_.valueChanged.connect(self._changed) s_.valueChanged.connect(self._changed)
self.sp_gain = QDoubleSpinBox() self.sp_gain = QDoubleSpinBox()
self.sp_gain.setRange(-40, 40) self.sp_gain.setRange(-40, 40)
self.sp_gain.setSuffix(" dB") self.sp_gain.setSuffix(" dB")
self.sp_gain.setMaximumWidth(92) self.sp_gain.setMaximumWidth(80)
self.sp_gain.valueChanged.connect(self._changed) self.sp_gain.valueChanged.connect(self._changed)
self.cb_norm = QCheckBox("Normalize") self.cb_norm = QCheckBox("Normalize")
self.cb_match = QCheckBox("Match loudness") self.cb_match = QCheckBox("Match loudness")
@@ -175,15 +194,24 @@ class EditorPanel(QWidget):
self.b_silence = self._btn("Trim silence", None, self.trim_silence, "Cut silence at the start and end") self.b_silence = self._btn("Trim silence", None, self.trim_silence, "Cut silence at the start and end")
self.b_play_new = self._btn("Replacement", "play", lambda: self.play("new"), "Play the processed replacement", self.b_play_new = self._btn("Replacement", "play", lambda: self.play("new"), "Play the processed replacement",
icons.ACCENT) icons.ACCENT)
proc = QHBoxLayout() # edit controls in a grid: labels in one column, the fields aligned, playback on the right
for w in (QLabel("Keep"), self.sp_a, QLabel("to"), self.sp_b, self.b_silence, self.b_play_new): proc = QGridLayout()
proc.addWidget(w) proc.setHorizontalSpacing(6)
proc.addStretch(1) proc.setVerticalSpacing(6)
rl.addLayout(proc) proc.addWidget(QLabel("Keep"), 0, 0)
proc = QHBoxLayout() proc.addWidget(self.sp_a, 0, 1)
for w in (QLabel("Gain"), self.sp_gain, self.cb_norm, self.cb_match): proc.addWidget(QLabel("to"), 0, 2, Qt.AlignmentFlag.AlignCenter)
proc.addWidget(w) proc.addWidget(self.sp_b, 0, 3)
proc.addStretch(1) proc.addLayout(self._fill((self.b_silence, 1), (self.b_play_new, 1)), 0, 4) # the rest of the width
proc.addWidget(QLabel("Gain"), 1, 0)
proc.addWidget(self.sp_gain, 1, 1)
checks = QHBoxLayout()
checks.setSpacing(12)
checks.addWidget(self.cb_norm)
checks.addWidget(self.cb_match)
checks.addStretch(1)
proc.addLayout(checks, 1, 2, 1, 3)
proc.setColumnStretch(4, 1)
rl.addLayout(proc) rl.addLayout(proc)
info = QHBoxLayout() info = QHBoxLayout()
self.lbl_new = QLabel() self.lbl_new = QLabel()
@@ -200,7 +228,6 @@ class EditorPanel(QWidget):
info.addWidget(self.lbl_warn, 1) info.addWidget(self.lbl_warn, 1)
rl.addLayout(info) rl.addLayout(info)
# apply (belongs to the replacement) ------------------------------------------------------ # apply (belongs to the replacement) ------------------------------------------------------
act = QHBoxLayout()
self.b_apply = self._btn("Apply to game", "apply", self.apply, self.b_apply = self._btn("Apply to game", "apply", self.apply,
"Encode the replacement and install it in the game files (Ctrl+S)", "#ffffff") "Encode the replacement and install it in the game files (Ctrl+S)", "#ffffff")
self.b_apply.setObjectName("Primary") self.b_apply.setObjectName("Primary")
@@ -209,9 +236,9 @@ class EditorPanel(QWidget):
"Save the original (or the replacement) as a WAV file") "Save the original (or the replacement) as a WAV file")
self.busy = Spinner(16) self.busy = Spinner(16)
self.lbl_busy = QLabel() self.lbl_busy = QLabel()
for w in (self.b_apply, self.b_revert, self.b_export, self.busy, self.lbl_busy): info.addWidget(self.busy, 0, Qt.AlignmentFlag.AlignTop) # encoding/writing: next to the status
act.addWidget(w) info.addWidget(self.lbl_busy, 0, Qt.AlignmentFlag.AlignTop)
act.addStretch(1) act = self._fill((self.b_apply, 2), (self.b_revert, 1), (self.b_export, 1))
rl.addStretch(1) rl.addStretch(1)
rl.addLayout(act) rl.addLayout(act)
replace = g replace = g
@@ -220,12 +247,13 @@ class EditorPanel(QWidget):
from .textpanel import SubtitleBox from .textpanel import SubtitleBox
gs = QGroupBox("SUBTITLE") gs = QGroupBox("SUBTITLE")
gsl = QVBoxLayout(gs) gsl = QVBoxLayout(gs)
gsl.setContentsMargins(8, 8, 8, 8)
self.subtitle = SubtitleBox(self.ctx, gs) self.subtitle = SubtitleBox(self.ctx, gs)
gsl.addWidget(self.subtitle) gsl.addWidget(self.subtitle, 1)
gsl.addStretch(1)
self.columns = QSplitter(Qt.Orientation.Horizontal) self.columns = QSplitter(Qt.Orientation.Horizontal)
self.columns.setChildrenCollapsible(False) self.columns.setChildrenCollapsible(False)
self.columns.setHandleWidth(4)
for w, stretch in ((listen, 3), (replace, 4), (gs, 3)): for w, stretch in ((listen, 3), (replace, 4), (gs, 3)):
self.columns.addWidget(w) self.columns.addWidget(w)
self.columns.setStretchFactor(self.columns.count() - 1, stretch) self.columns.setStretchFactor(self.columns.count() - 1, stretch)

View File

@@ -143,12 +143,16 @@ class SubtitleBox(QWidget):
super().__init__(parent) super().__init__(parent)
self.win = win self.win = win
self.keys = [] self.keys = []
self.lay = QFormLayout(self) outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(6)
self.lay = QFormLayout() # one line edit per subtitle line
self.lay.setContentsMargins(0, 0, 0, 0) self.lay.setContentsMargins(0, 0, 0, 0)
self.edits = [] self.edits = []
self.lbl = QLabel() self.lbl = QLabel()
self.lbl.setObjectName("Muted") self.lbl.setObjectName("Muted")
row = QHBoxLayout() row = QHBoxLayout()
row.setSpacing(6)
self.b_apply = QPushButton(icons.icon("apply"), "Apply") self.b_apply = QPushButton(icons.icon("apply"), "Apply")
self.b_revert = QPushButton(icons.icon("revert"), "Revert") self.b_revert = QPushButton(icons.icon("revert"), "Revert")
self.b_open = QPushButton(icons.icon("search"), "Show in list") self.b_open = QPushButton(icons.icon("search"), "Show in list")
@@ -158,16 +162,14 @@ class SubtitleBox(QWidget):
self.b_apply.clicked.connect(self.apply) self.b_apply.clicked.connect(self.apply)
self.b_revert.clicked.connect(self.revert) self.b_revert.clicked.connect(self.revert)
self.b_open.clicked.connect(lambda: self.keys and self.open_in_list.emit(self.keys[0])) self.b_open.clicked.connect(lambda: self.keys and self.open_in_list.emit(self.keys[0]))
for b in (self.b_apply, self.b_revert): for b in (self.b_apply, self.b_revert): # two halves of the column, like the sound's action bar
row.addWidget(b) row.addWidget(b, 1)
row.addStretch(1)
row2 = QHBoxLayout()
row2.addWidget(self.b_open)
row2.addStretch(1)
self.lbl.setWordWrap(True) self.lbl.setWordWrap(True)
self.lay.addRow(self.lbl) outer.addLayout(self.lay)
self.lay.addRow(row) outer.addWidget(self.lbl) # room counter
self.lay.addRow(row2) outer.addWidget(self.b_open) # the way to the Subtitles tab, across the column
outer.addStretch(1) # actions at the bottom, level with the sound's
outer.addLayout(row)
def set_sound(self, s): def set_sound(self, s):
for e in self.edits: for e in self.edits:

View File

@@ -149,8 +149,13 @@ copy per voice language) is filtered the same way, so the sound list never mixes
## 7. Editor UX choices ## 7. Editor UX choices
- One window with three tabs. *Sounds*: filters (left) and sound table on top, the editor of the selected - One window with 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) so nothing has sound below across the full width, in three resizable columns (Listen | Replace | Subtitle). Inside each
to scroll at usual window sizes. *Subtitles* and *Menu text*: search, filters, table, editor. The primary flow 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, (search -> select -> Space to listen -> Load file/Record -> Apply) needs no menus; power features (tags,
favorites, multi-select revert, mod packages, settings) are in the toolbar and context menu. favorites, multi-select revert, mod packages, settings) are in the toolbar and context menu.
- Long work (loading, encoding, writing 0.5 GB WADs) runs in background jobs with status messages; the - Long work (loading, encoding, writing 0.5 GB WADs) runs in background jobs with status messages; the

View File

@@ -91,6 +91,12 @@ and the game ties the recordings together itself:
identical, e.g. `Speech_STEEL_A1_CH2_Thug_behinddoormuffled` and `DIA_SteelA1Muffled`): the second place identical, e.g. `Speech_STEEL_A1_CH2_Thug_behinddoormuffled` and `DIA_SteelA1Muffled`): the second place
is a copy of the line (same `line`, keeps its position name) and is written and reverted with it, because is a copy of the line (same `line`, keeps its position name) and is written and reverted with it, because
the game plays whichever place the loaded package's table names. the game plays whichever place the loaded package's table names.
- Not every line has its own recording in every language; the game shares some itself: in each voice WAD
616-651 lines are byte-identical to the INT recording (ITA 651: efforts and emotes 509, cinematics 117,
boss fights 10, story 8, radio/TV 4, character lines 2), 167 in-memory voice lines are stored once for every
language (efforts, radio chatter, crowds) and 6 DLC intro lines are one recording shared by INT/FRA/ITA(/ESN).
So some lines play the English recording in every language: that is the game's own data, which the catalog
shows as it is.
- Independent checks (no BKPK involved): every WAD recording is named as a source by a Sound object of a bank - Independent checks (no BKPK involved): every WAD recording is named as a source by a Sound object of a bank
of its own language (54,055 of 54,055); every stored head or whole copy equals the start of the recording of its own language (54,055 of 54,055); every stored head or whole copy equals the start of the recording
its line gives it (2,185 of 2,185); a line's recordings have similar lengths across languages (11,761 of 12,434 its line gives it (2,185 of 2,185); a line's recordings have similar lengths across languages (11,761 of 12,434

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View File

@@ -25,6 +25,9 @@ game is set to (Steam > game properties > Language) and applies to everything yo
editor shows those and says *voices: INT* next to the list. editor shows those and says *voices: INT* next to the list.
- **Subtitles** and **Menu text**: the texts of that language. - **Subtitles** and **Menu text**: the texts of that language.
Changing it reloads the lists; the next start is again in the game's language. Changing it reloads the lists; the next start is again in the game's language.
Some voice lines sound English in every language: the game itself ships the English recording for most
efforts and grunts, many cinematics and a few other lines, and some in-memory lines exist only once for all
languages. The editor shows what each language's game files contain.
## Names ## Names
Every voice line has one name in every language, taken from the game itself (e.g. `Speech_TAPE_JOKER_1_04`, the Every voice line has one name in every language, taken from the game itself (e.g. `Speech_TAPE_JOKER_1_04`, the
@@ -56,10 +59,11 @@ the same line and are changed and reverted together.
## Listen ## Listen
The editor under the list shows the selected sound in three columns side by side (drag the dividers to 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** (or double-click) to resize them): **Listen**, **Replace** and **Subtitle**. Select a row and press **Space** (or double-click) to
play the original. In **Listen**: play the original. 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. Click on a - **Original** / **Installed** play the game's original and what is currently in the game; **Stop** (Esc).
waveform to play from that point. **Loop**, **Volume** and **Stop** (Esc) work for everything. Click on a waveform to play from that point.
- **A/B** switches between the original and your replacement at the same position, to compare. - Above them: **A/B** (switch between the original and your replacement at the same position, to compare),
**Loop**, **Volume** and the playback position. The icon buttons show their names when you point at them.
## Replace ## Replace
![Editor](img/editor.png) ![Editor](img/editor.png)

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "ackit" name = "ackit"
version = "3.2.2" version = "3.2.3"
description = "Batman: Arkham City (PC) audio toolkit and editor" description = "Batman: Arkham City (PC) audio toolkit and editor"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -137,6 +137,18 @@ def catalog_claims(cat):
claim("REPORT.md", "recordings stored twice (%s per language) are written together" % rng(copies.values(), str)) claim("REPORT.md", "recordings stored twice (%s per language) are written together" % rng(copies.values(), str))
claim("docs/formats/banks.md", "- %s recordings per language are stored twice" % rng(copies.values(), str).capitalize()) claim("docs/formats/banks.md", "- %s recordings per language are stored twice" % rng(copies.values(), str).capitalize())
claim("docs/categories.md", "Coverage of the %s WAD voice lines" % n(WL)) claim("docs/categories.md", "Coverage of the %s WAD voice lines" % n(WL))
# recordings the game shares between languages (the same bytes as the INT recording of the line)
intl = {s.line: s.sha1 for s in rows["INT"] if s.line}
same = {L: [s for s in {x.line: x for x in rows[L] if x.line}.values() if intl.get(s.line) == s.sha1]
for L in ("FRA", "ITA", "DEU", "ESN")}
ita = collections.Counter(s.category for s in same["ITA"])
shared = {s.target: s.language for s in bank if ";" in s.language}
claim("docs/formats/banks.md", "in each voice WAD %s lines are byte-identical to the INT recording (ITA %d: efforts "
"and emotes %d, cinematics %d, boss fights %d, story %d, radio/TV %d, character lines %d), %d in-memory voice "
"lines are stored once for every language (efforts, radio chatter, crowds) and %d DLC intro lines"
% (rng([len(v) for v in same.values()]), len(same["ITA"]), ita["Efforts & emotes"], ita["Cinematics"],
ita["Boss fights"], ita["Story missions"], ita["Radio & TV"], ita["Character lines"],
sum(1 for s in bank if s.line and not s.language), len(shared)))
claim("docs/decisions.md", "of the %s WAD voice lines" % n(WL)) claim("docs/decisions.md", "of the %s WAD voice lines" % n(WL))
# positions differ between languages # positions differ between languages