456 lines
19 KiB
Python
456 lines
19 KiB
Python
"""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)
|