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:
2026-10-28 13:54:12 +01:00
parent a706dae419
commit 6de470174e
36 changed files with 2872 additions and 278 deletions

455
app/edit.py Normal file
View 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)