diff --git a/NOTES.md b/NOTES.md index 4d89c40..32f0e8f 100644 --- a/NOTES.md +++ b/NOTES.md @@ -121,6 +121,11 @@ 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. 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). - 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). diff --git a/REPORT.md b/REPORT.md index 64ba4c3..84129f8 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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 (`ackit status`). The catalog builder refuses to run on a modified game, so the catalog always describes the 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: 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). diff --git a/ackit/__init__.py b/ackit/__init__.py index 469f2ef..f0fa0b5 100644 --- a/ackit/__init__.py +++ b/ackit/__init__.py @@ -8,4 +8,4 @@ Library layers (low -> high): 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.2.2" +__version__ = "3.2.3" diff --git a/app/editor.py b/app/editor.py index 722015b..7650556 100644 --- a/app/editor.py +++ b/app/editor.py @@ -54,6 +54,15 @@ class EditorPanel(QWidget): b.setToolTip(tip) 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): w = Waveform(selectable=selectable) 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.""" self.setObjectName("EditorPanel") lay = QVBoxLayout(self) - lay.setContentsMargins(10, 6, 10, 6) + lay.setContentsMargins(8, 6, 8, 6) lay.setSpacing(4) self.title = QLabel() self.title.setObjectName("EditorTitle") @@ -87,41 +96,47 @@ class EditorPanel(QWidget): # listen ---------------------------------------------------------------------------------- g = QGroupBox("LISTEN") gl = QGridLayout(g) + gl.setContentsMargins(8, 8, 8, 8) gl.setVerticalSpacing(4) self.wf_orig = self._wave("orig") 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_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_ab = self._btn("A/B", "ab", self.ab_switch, "Switch between original and replacement at the same position", - icons.ACCENT) - self.cb_loop = self._btn("Loop", "loop", None, "Repeat playback") + # 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.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) - self.sl_vol.setMaximumWidth(120) + self.sl_vol.setMinimumWidth(80) self.sl_vol.setToolTip("Volume") 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.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) - row = QHBoxLayout() - for w in (self.b_play_orig, self.b_play_inst, self.b_stop): - row.addWidget(w) - row.addStretch(1) - gl.addLayout(row, 2, 0, 1, 2) - row = QHBoxLayout() - for w in (self.b_ab, self.cb_loop, QLabel("Volume"), self.sl_vol, self.lbl_time): - row.addWidget(w) - row.addStretch(1) - gl.addLayout(row, 3, 0, 1, 2) + 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) gl.setColumnStretch(1, 1) - gl.setRowStretch(4, 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 @@ -129,25 +144,29 @@ class EditorPanel(QWidget): # replace --------------------------------------------------------------------------------- g = QGroupBox("REPLACE") rl = QVBoxLayout(g) - rl.setSpacing(4) + 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.cmb_in = QComboBox() self.cmb_in.setMinimumWidth(110) - self.cmb_in.setMaximumWidth(200) 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.setMaximumWidth(60) + self.meter.setFixedWidth(60) self.meter.setToolTip("Input level") self.lbl_rec = QLabel("") 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.addStretch(1) 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.") @@ -160,12 +179,12 @@ class EditorPanel(QWidget): s_.setDecimals(2) s_.setSuffix(" s") s_.setRange(0, 36000) - s_.setMaximumWidth(92) + s_.setMaximumWidth(80) s_.valueChanged.connect(self._changed) self.sp_gain = QDoubleSpinBox() self.sp_gain.setRange(-40, 40) self.sp_gain.setSuffix(" dB") - self.sp_gain.setMaximumWidth(92) + self.sp_gain.setMaximumWidth(80) self.sp_gain.valueChanged.connect(self._changed) self.cb_norm = QCheckBox("Normalize") 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_play_new = self._btn("Replacement", "play", lambda: self.play("new"), "Play the processed replacement", icons.ACCENT) - proc = QHBoxLayout() - for w in (QLabel("Keep"), self.sp_a, QLabel("to"), self.sp_b, self.b_silence, self.b_play_new): - proc.addWidget(w) - proc.addStretch(1) - rl.addLayout(proc) - proc = QHBoxLayout() - for w in (QLabel("Gain"), self.sp_gain, self.cb_norm, self.cb_match): - proc.addWidget(w) - proc.addStretch(1) + # edit controls in a grid: labels in one column, the fields aligned, playback on the right + proc = QGridLayout() + 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(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) info = QHBoxLayout() self.lbl_new = QLabel() @@ -200,7 +228,6 @@ class EditorPanel(QWidget): info.addWidget(self.lbl_warn, 1) rl.addLayout(info) # apply (belongs to the replacement) ------------------------------------------------------ - act = QHBoxLayout() 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") @@ -209,9 +236,9 @@ class EditorPanel(QWidget): "Save the original (or the replacement) as a WAV file") self.busy = Spinner(16) self.lbl_busy = QLabel() - for w in (self.b_apply, self.b_revert, self.b_export, self.busy, self.lbl_busy): - act.addWidget(w) - act.addStretch(1) + 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 @@ -220,12 +247,13 @@ class EditorPanel(QWidget): 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) - gsl.addStretch(1) + 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) diff --git a/app/textpanel.py b/app/textpanel.py index 87e9af0..76012ab 100644 --- a/app/textpanel.py +++ b/app/textpanel.py @@ -143,12 +143,16 @@ class SubtitleBox(QWidget): super().__init__(parent) self.win = win 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.edits = [] self.lbl = QLabel() self.lbl.setObjectName("Muted") row = QHBoxLayout() + row.setSpacing(6) self.b_apply = QPushButton(icons.icon("apply"), "Apply") self.b_revert = QPushButton(icons.icon("revert"), "Revert") 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_revert.clicked.connect(self.revert) 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): - row.addWidget(b) - row.addStretch(1) - row2 = QHBoxLayout() - row2.addWidget(self.b_open) - row2.addStretch(1) + for b in (self.b_apply, self.b_revert): # two halves of the column, like the sound's action bar + row.addWidget(b, 1) self.lbl.setWordWrap(True) - self.lay.addRow(self.lbl) - self.lay.addRow(row) - self.lay.addRow(row2) + outer.addLayout(self.lay) + outer.addWidget(self.lbl) # room counter + 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): for e in self.edits: diff --git a/docs/decisions.md b/docs/decisions.md index 52abd6f..4b77b27 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -149,8 +149,13 @@ copy per voice language) is filtered the same way, so the sound list never mixes ## 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) so nothing has - to scroll at usual window sizes. *Subtitles* and *Menu text*: search, filters, table, editor. The primary flow + 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, 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 diff --git a/docs/formats/banks.md b/docs/formats/banks.md index 0f94e47..cd22e80 100644 --- a/docs/formats/banks.md +++ b/docs/formats/banks.md @@ -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 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. +- 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 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 diff --git a/docs/img/browse.png b/docs/img/browse.png index 3291e6e..1dfffaf 100644 Binary files a/docs/img/browse.png and b/docs/img/browse.png differ diff --git a/docs/img/editor.png b/docs/img/editor.png index fa41785..5299e4e 100644 Binary files a/docs/img/editor.png and b/docs/img/editor.png differ diff --git a/docs/img/main.png b/docs/img/main.png index d7a3647..c3acdec 100644 Binary files a/docs/img/main.png and b/docs/img/main.png differ diff --git a/docs/user-guide.md b/docs/user-guide.md index 8012e49..a903e9d 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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. - **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 +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 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 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 -play the original. In **Listen**: -- **Original** / **Installed** play the game's original and what is currently in the game. Click on a - waveform to play from that point. **Loop**, **Volume** and **Stop** (Esc) work for everything. -- **A/B** switches between the original and your replacement at the same position, to compare. +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; **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. ## Replace ![Editor](img/editor.png) diff --git a/pyproject.toml b/pyproject.toml index 416abf1..5dbfc5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ackit" -version = "3.2.2" +version = "3.2.3" description = "Batman: Arkham City (PC) audio toolkit and editor" readme = "README.md" requires-python = ">=3.10" diff --git a/tools/check_docs.py b/tools/check_docs.py index 16d54cd..a043cfe 100644 --- a/tools/check_docs.py +++ b/tools/check_docs.py @@ -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("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)) + # 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)) # positions differ between languages