"""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: /edits/index.json + replacements/*.wav. (Data of the first versions, /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)