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:
455
app/edit.py
Normal file
455
app/edit.py
Normal 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)
|
||||
258
app/editor.py
258
app/editor.py
@@ -1,14 +1,16 @@
|
||||
"""Single-sound editor: listen, substitute (file or recording), process, apply / revert."""
|
||||
"""Single-sound editor of the Edit tab: listen, replace (file or recording), process with effects (`effects`,
|
||||
ackit/fx.py, between the trim and the loudness steps), apply / revert."""
|
||||
import json
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QGridLayout, QGroupBox, QSplitter,
|
||||
QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, QSlider,
|
||||
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QGridLayout, QGroupBox,
|
||||
QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, QSizePolicy, QSlider,
|
||||
QVBoxLayout, QWidget)
|
||||
|
||||
from ackit import audio, workspace as W
|
||||
from ackit import audio, fx, workspace as W
|
||||
from . import icons, theme
|
||||
from .audioio import Recorder, input_devices, find_device
|
||||
from .spinner import Spinner
|
||||
@@ -29,6 +31,8 @@ class EditorPanel(QWidget):
|
||||
applied = Signal(str) # sound key
|
||||
reverted = Signal(str)
|
||||
message = Signal(str)
|
||||
source_changed = Signal() # a new replacement source (file, recording)
|
||||
edited = Signal() # trim / gain / normalize / loudness changed
|
||||
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -39,6 +43,10 @@ class EditorPanel(QWidget):
|
||||
self.src = None # replacement source (x, rate) before processing
|
||||
self.src_name = ""
|
||||
self.playing_what = None
|
||||
self.effects = None # effects settings (ackit.fx) or None
|
||||
self._src_gen = 0 # identity of the current source (effects cache key)
|
||||
self._fx_cache = (None, None) # (key, rendered audio)
|
||||
self._fx_pending = None
|
||||
self.recorder = Recorder(self)
|
||||
self._build()
|
||||
self.set_sound(None)
|
||||
@@ -66,14 +74,17 @@ class EditorPanel(QWidget):
|
||||
def _wave(self, kind, selectable=False):
|
||||
w = Waveform(selectable=selectable)
|
||||
w.accent, w.bg = theme.WAVE[kind], theme.WAVE_BG
|
||||
w.setMinimumHeight(44) # three rows + the rest fit the Edit tab without scrolling
|
||||
w.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # and grow with the window
|
||||
return w
|
||||
|
||||
def _build(self):
|
||||
"""Header on top; below, side by side (resizable): LISTEN | REPLACE (with the apply buttons) | SUBTITLE."""
|
||||
"""Top to bottom: header, SUBTITLE (when the sound has one), LISTEN (original, installed and replacement,
|
||||
each with its play button), REPLACEMENT (record / load, trim, level), then the action bar."""
|
||||
self.setObjectName("EditorPanel")
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(8, 6, 8, 6)
|
||||
lay.setSpacing(4)
|
||||
lay.setContentsMargins(8, 4, 8, 2)
|
||||
lay.setSpacing(3)
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("EditorTitle")
|
||||
self.title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
@@ -93,25 +104,41 @@ class EditorPanel(QWidget):
|
||||
self.meta.setObjectName("Muted")
|
||||
lay.addWidget(self.meta)
|
||||
|
||||
# listen ----------------------------------------------------------------------------------
|
||||
# the subtitle, large ------------------------------------------------------------------------
|
||||
from .textpanel import SubtitlePanel
|
||||
self.subtitle_box = QGroupBox("SUBTITLE")
|
||||
sl = QVBoxLayout(self.subtitle_box)
|
||||
sl.setContentsMargins(8, 8, 8, 8)
|
||||
self.subtitle = SubtitlePanel(self.ctx, self.subtitle_box)
|
||||
sl.addWidget(self.subtitle)
|
||||
lay.addWidget(self.subtitle_box)
|
||||
|
||||
# listen: one row per version, its play button on the left -----------------------------------
|
||||
g = QGroupBox("LISTEN")
|
||||
gl = QGridLayout(g)
|
||||
gl.setContentsMargins(8, 8, 8, 8)
|
||||
gl.setVerticalSpacing(4)
|
||||
gl.setHorizontalSpacing(8)
|
||||
self.wf_orig = self._wave("orig")
|
||||
self.wf_inst = self._wave("inst")
|
||||
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.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"),
|
||||
"Play the original (double-click the row; Space until a replacement is loaded)",
|
||||
icons.STEEL)
|
||||
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e")
|
||||
"Play the game's original (Space plays the replacement once there is one)", icons.STEEL)
|
||||
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now",
|
||||
"#8f4a4e")
|
||||
self.b_play_new = self._btn("Replacement", "play", lambda: self.play("new"),
|
||||
"Play the replacement as it will be applied (trim, effects, level)", icons.ACCENT)
|
||||
for i, (b, w) in enumerate(((self.b_play_orig, self.wf_orig), (self.b_play_inst, self.wf_inst),
|
||||
(self.b_play_new, self.wf_new))):
|
||||
b.setMinimumWidth(110)
|
||||
gl.addWidget(b, i, 0)
|
||||
gl.addWidget(w, i, 1)
|
||||
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
|
||||
# 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.b_ab = self._btn("A/B", "ab", self.ab_switch, "Switch between the original and the replacement at the same "
|
||||
"position, to compare", icons.ACCENT)
|
||||
self.cb_loop = self._btn("Loop", "loop", None, "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)
|
||||
@@ -122,71 +149,65 @@ class EditorPanel(QWidget):
|
||||
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)
|
||||
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)
|
||||
tr = QHBoxLayout()
|
||||
tr.setSpacing(6)
|
||||
for w in (self.b_stop, self.b_ab, self.cb_loop):
|
||||
tr.addWidget(w)
|
||||
tr.addSpacing(10)
|
||||
tr.addWidget(QLabel("Volume"))
|
||||
tr.addWidget(self.sl_vol, 1)
|
||||
tr.addWidget(self.lbl_time)
|
||||
gl.addLayout(tr, 3, 0, 1, 2)
|
||||
gl.setColumnStretch(1, 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
|
||||
self.wf_new.seekRequested.connect(lambda t: self.play("new", t))
|
||||
self.wf_new.selectionChanged.connect(self._selection)
|
||||
for r in range(3):
|
||||
gl.setRowStretch(r, 1) # the waveforms take the free height of the window
|
||||
lay.addWidget(g, 1)
|
||||
|
||||
# replace ---------------------------------------------------------------------------------
|
||||
g = QGroupBox("REPLACE")
|
||||
# replacement: source, trim, level -----------------------------------------------------------
|
||||
g = QGroupBox("REPLACEMENT")
|
||||
rl = QVBoxLayout(g)
|
||||
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.b_rec = self._btn("Record", "record", self.toggle_record, "Record from the selected input")
|
||||
self.b_rec.setObjectName("Primary")
|
||||
self.b_rec.setMinimumWidth(150)
|
||||
self.cmb_in = QComboBox()
|
||||
self.cmb_in.setMinimumWidth(110)
|
||||
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.setFixedWidth(60)
|
||||
self.meter.setFixedWidth(80)
|
||||
self.meter.setToolTip("Input level")
|
||||
self.lbl_rec = QLabel("")
|
||||
self.lbl_rec.setObjectName("Muted")
|
||||
src.addWidget(self.b_load, 1) # the row fills the column: file | microphone, level
|
||||
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)")
|
||||
src.addWidget(self.b_rec)
|
||||
src.addWidget(self.cmb_in, 1)
|
||||
src.addWidget(self.meter)
|
||||
src.addWidget(self.lbl_rec)
|
||||
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(self.b_load)
|
||||
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.")
|
||||
self.wf_new.seekRequested.connect(lambda t: self.play("new", t))
|
||||
self.wf_new.selectionChanged.connect(self._selection)
|
||||
rl.addWidget(self.wf_new)
|
||||
self.sp_a = QDoubleSpinBox()
|
||||
self.sp_b = QDoubleSpinBox()
|
||||
for s_ in (self.sp_a, self.sp_b):
|
||||
s_.setDecimals(2)
|
||||
s_.setSuffix(" s")
|
||||
s_.setRange(0, 36000)
|
||||
s_.setMaximumWidth(80)
|
||||
s_.setMaximumWidth(90)
|
||||
s_.valueChanged.connect(self._changed)
|
||||
self.sp_gain = QDoubleSpinBox()
|
||||
self.sp_gain.setRange(-40, 40)
|
||||
self.sp_gain.setSuffix(" dB")
|
||||
self.sp_gain.setMaximumWidth(80)
|
||||
self.sp_gain.setMaximumWidth(90)
|
||||
self.sp_gain.valueChanged.connect(self._changed)
|
||||
self.cb_norm = QCheckBox("Normalize")
|
||||
self.cb_match = QCheckBox("Match loudness")
|
||||
@@ -194,17 +215,14 @@ class EditorPanel(QWidget):
|
||||
self.cb_norm.toggled.connect(self._changed)
|
||||
self.cb_match.toggled.connect(self._changed)
|
||||
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)
|
||||
# edit controls in a grid: labels in one column, the fields aligned, playback on the right
|
||||
proc = QGridLayout()
|
||||
proc = QGridLayout() # labels in one column, the fields aligned
|
||||
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(self.b_silence, 0, 4)
|
||||
proc.addWidget(QLabel("Gain"), 1, 0)
|
||||
proc.addWidget(self.sp_gain, 1, 1)
|
||||
checks = QHBoxLayout()
|
||||
@@ -213,7 +231,7 @@ class EditorPanel(QWidget):
|
||||
checks.addWidget(self.cb_match)
|
||||
checks.addStretch(1)
|
||||
proc.addLayout(checks, 1, 2, 1, 3)
|
||||
proc.setColumnStretch(4, 1)
|
||||
proc.setColumnStretch(5, 1)
|
||||
rl.addLayout(proc)
|
||||
info = QHBoxLayout()
|
||||
self.lbl_new = QLabel()
|
||||
@@ -229,37 +247,33 @@ class EditorPanel(QWidget):
|
||||
info.addWidget(self.warn_icon, 0, Qt.AlignmentFlag.AlignTop)
|
||||
info.addWidget(self.lbl_warn, 1)
|
||||
rl.addLayout(info)
|
||||
# apply (belongs to the replacement) ------------------------------------------------------
|
||||
lay.addWidget(g)
|
||||
|
||||
# actions -----------------------------------------------------------------------------------
|
||||
self.b_prev = self._btn("Previous", "prev", None, "Previous sound of the edit list (Alt+Left)")
|
||||
self.b_next = self._btn("Next", "next", None, "Next sound of the edit list (Alt+Right)")
|
||||
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")
|
||||
self.b_revert = self._btn("Revert", "revert", self.revert, "Put the game's original back")
|
||||
self.b_export = self._btn("Save WAV…", "save", self.export_wav,
|
||||
"Save the original (or the replacement) as a WAV file")
|
||||
"Save the original (or the replacement as applied) as a WAV file")
|
||||
self.busy = Spinner(16)
|
||||
self.lbl_busy = QLabel()
|
||||
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
|
||||
|
||||
# subtitle of this sound (edited side by side with the audio) -------------------------------
|
||||
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, 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)
|
||||
lay.addWidget(self.columns, 1)
|
||||
self.action_bar = QWidget() # the Edit tab keeps it below its scroll area (always visible)
|
||||
act = QHBoxLayout(self.action_bar)
|
||||
act.setContentsMargins(8, 4, 8, 6)
|
||||
act.setSpacing(6)
|
||||
act.addWidget(self.b_prev, 1)
|
||||
act.addSpacing(10)
|
||||
act.addWidget(self.b_apply, 3)
|
||||
act.addWidget(self.b_revert, 1)
|
||||
act.addWidget(self.b_export, 1)
|
||||
act.addWidget(self.busy)
|
||||
act.addWidget(self.lbl_busy)
|
||||
act.addSpacing(10)
|
||||
act.addWidget(self.b_next, 1)
|
||||
lay.addWidget(self.action_bar)
|
||||
|
||||
self.recorder.level.connect(lambda v: self.meter.setValue(int(min(1.0, v) * 100)))
|
||||
self.recorder.elapsed.connect(lambda t: self.lbl_rec.setText("recording %s" % fmt_s(t)))
|
||||
@@ -305,13 +319,16 @@ class EditorPanel(QWidget):
|
||||
self.recorder.stop()
|
||||
self.sound, self.orig, self.installed, self.src = s, None, None, None
|
||||
enabled = s is not None
|
||||
for w in self.findChildren(QPushButton) + [self.sp_a, self.sp_b, self.sp_gain, self.cb_norm, self.cb_match]:
|
||||
# the action bar may live outside this widget (the Edit tab keeps it below its scroll area)
|
||||
buttons = set(self.findChildren(QPushButton)) | set(self.action_bar.findChildren(QPushButton))
|
||||
for w in list(buttons) + [self.sp_a, self.sp_b, self.sp_gain, self.cb_norm, self.cb_match]:
|
||||
w.setEnabled(enabled)
|
||||
self.wf_orig.set_audio(None, 1, "select a sound" if s is None else "loading…", busy=s is not None)
|
||||
self.wf_inst.set_audio(None, 1, "")
|
||||
self.wf_new.set_audio(None, 1, "load a file or record a replacement")
|
||||
self.lbl_new.setText("")
|
||||
self.subtitle.set_sound(s)
|
||||
self.subtitle_box.setVisible(bool(self.subtitle.keys))
|
||||
if s is None:
|
||||
self.title.setText("No sound selected")
|
||||
self.meta.setText("Search and select a sound in the list.")
|
||||
@@ -380,8 +397,9 @@ class EditorPanel(QWidget):
|
||||
self.ctx.runner.start(lambda job: audio.load(path), done, failed)
|
||||
return result[0] if result else True
|
||||
|
||||
def set_source(self, x, sr, name):
|
||||
def set_source(self, x, sr, name, restoring=False):
|
||||
self.src, self.src_name = (np.asarray(x, np.float32), sr), name
|
||||
self._src_gen += 1
|
||||
dur = audio.duration(x, sr)
|
||||
self.sp_a.blockSignals(True)
|
||||
self.sp_b.blockSignals(True)
|
||||
@@ -392,6 +410,8 @@ class EditorPanel(QWidget):
|
||||
self.sp_a.blockSignals(False)
|
||||
self.sp_b.blockSignals(False)
|
||||
self._update_new()
|
||||
if not restoring:
|
||||
self.source_changed.emit()
|
||||
|
||||
def toggle_record(self):
|
||||
if self.recorder.is_recording():
|
||||
@@ -427,15 +447,65 @@ class EditorPanel(QWidget):
|
||||
|
||||
def _changed(self, *_):
|
||||
self._reproc.start()
|
||||
self.edited.emit()
|
||||
|
||||
def processed(self):
|
||||
"""Replacement audio after trim / gain / normalize / loudness matching -> (x, rate) or None."""
|
||||
if not self.src:
|
||||
return None
|
||||
def set_effects(self, settings):
|
||||
self.effects = settings
|
||||
self._changed()
|
||||
|
||||
def _fx_key(self):
|
||||
return (self._src_gen, self.sp_a.value(), self.sp_b.value(), json.dumps(self.effects, sort_keys=True),
|
||||
self.sound.channels if self.sound else None)
|
||||
|
||||
def _effected(self, render=True):
|
||||
"""Trimmed replacement with the effects (cached). With render=False: None while not rendered yet."""
|
||||
x, sr = self.src
|
||||
a, b = self.sp_a.value(), self.sp_b.value()
|
||||
if b > a:
|
||||
x = audio.trim(x, sr, a, b)
|
||||
if not fx.active(self.effects):
|
||||
return x
|
||||
key = self._fx_key()
|
||||
if self._fx_cache[0] == key:
|
||||
return self._fx_cache[1]
|
||||
if not render:
|
||||
return None
|
||||
y = fx.render(x, sr, self.effects, key[-1])
|
||||
self._fx_cache = (key, y)
|
||||
return y
|
||||
|
||||
def _render_effects(self):
|
||||
"""Render the effects in the background (spinner in the replacement waveform), then show the result."""
|
||||
key = self._fx_key()
|
||||
if self._fx_pending == key:
|
||||
return
|
||||
self._fx_pending = key
|
||||
x, sr = self.src
|
||||
a, b = self.sp_a.value(), self.sp_b.value()
|
||||
if b > a:
|
||||
x = audio.trim(x, sr, a, b)
|
||||
settings = self.effects
|
||||
self.wf_new.set_audio(None, 1, "applying effects…", busy=True)
|
||||
|
||||
def done(y):
|
||||
if self._fx_pending == key:
|
||||
self._fx_pending = None
|
||||
self._fx_cache = (key, y)
|
||||
if self.src and self._fx_key() == key:
|
||||
self._update_new()
|
||||
|
||||
def failed(e, tb):
|
||||
self._fx_pending = None
|
||||
self._failed("The effects failed", e)
|
||||
|
||||
self.ctx.runner.start(lambda job: fx.render(x, sr, settings, key[-1]), done, failed)
|
||||
|
||||
def processed(self):
|
||||
"""Replacement audio after trim / effects / gain / normalize / loudness matching -> (x, rate) or None."""
|
||||
if not self.src:
|
||||
return None
|
||||
sr = self.src[1]
|
||||
x = self._effected()
|
||||
if self.cb_match.isChecked() and self.orig is not None:
|
||||
x = audio.match_loudness(x, self.orig[0])
|
||||
if self.cb_norm.isChecked():
|
||||
@@ -445,6 +515,9 @@ class EditorPanel(QWidget):
|
||||
return x, sr
|
||||
|
||||
def _update_new(self):
|
||||
if self.src and self._effected(render=False) is None:
|
||||
self._render_effects()
|
||||
return
|
||||
p = self.processed()
|
||||
if p is None:
|
||||
self.wf_new.set_audio(None, 1, "load a file or record a replacement")
|
||||
@@ -455,7 +528,9 @@ class EditorPanel(QWidget):
|
||||
self.wf_new.set_audio(x, sr)
|
||||
dur = audio.duration(x, sr)
|
||||
s = self.sound
|
||||
self.lbl_new.setText("%s: %.2f s, peak %.1f dBFS" % (self.src_name, dur, audio.peak_db(x)))
|
||||
on = fx.summary(self.effects)
|
||||
self.lbl_new.setText("%s%s: %.2f s, peak %.1f dBFS" % (self.src_name, " + " + on if on else "", dur,
|
||||
audio.peak_db(x)))
|
||||
warnings = []
|
||||
if s and not s.is_pack_media and dur > s.seconds * 1.15 + 0.25:
|
||||
warnings.append("Longer than the original (%.2f s): it will be squeezed into the original's size "
|
||||
@@ -477,6 +552,7 @@ class EditorPanel(QWidget):
|
||||
if not a:
|
||||
return
|
||||
self.playing_what = what
|
||||
self.ctx.player.owner = self # two editors share the player: only this one shows playheads
|
||||
self.ctx.player.play(a[0], a[1], start, loop=self.cb_loop.isChecked(), label=what)
|
||||
|
||||
def toggle_play(self):
|
||||
@@ -500,6 +576,8 @@ class EditorPanel(QWidget):
|
||||
w.set_playhead(None)
|
||||
|
||||
def _pos(self, t):
|
||||
if getattr(self.ctx.player, "owner", self) is not self:
|
||||
return
|
||||
self.lbl_time.setText(fmt_s(t))
|
||||
wf = {"orig": self.wf_orig, "inst": self.wf_inst, "new": self.wf_new}.get(self.playing_what)
|
||||
if wf:
|
||||
@@ -535,6 +613,8 @@ class EditorPanel(QWidget):
|
||||
return
|
||||
ws = self.ctx.ws
|
||||
meta = {"source": self.src_name}
|
||||
if fx.active(self.effects):
|
||||
meta["effects"] = fx.summary(self.effects)
|
||||
|
||||
def work(job):
|
||||
job.report("encoding…")
|
||||
|
||||
328
app/effects.py
Normal file
328
app/effects.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""Effects panel of the Edit tab, built from ackit.fx (BLOCKS, PRESETS, LOOKS), sized to fit without scrolling:
|
||||
a preset list (several effects at once), the effect chain (one line per effect: tick = on, click = show its
|
||||
settings) and the settings of the selected effect (its preset buttons, one slider per parameter with a readable
|
||||
value: kHz, ms, dB, %; logarithmic for frequencies and times). Double-click a slider to reset it; moving a
|
||||
slider or pressing a preset turns its effect on."""
|
||||
import json
|
||||
import math
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import (QAbstractItemView, QApplication, QCheckBox, QComboBox, QGridLayout, QHBoxLayout,
|
||||
QLabel, QListWidget, QListWidgetItem, QPushButton, QSlider, QStackedWidget,
|
||||
QVBoxLayout, QWidget)
|
||||
|
||||
from ackit import fx
|
||||
|
||||
STEPS = 1000
|
||||
|
||||
|
||||
def is_log(p):
|
||||
return p.kind != "bool" and p.lo > 0 and p.hi / p.lo >= 20
|
||||
|
||||
|
||||
def fmt(p, v):
|
||||
"""Value as people read it: 3.4 kHz, 160 ms, -6.0 dB, +4.0 st, 35 %."""
|
||||
if p.kind == "bool":
|
||||
return "on" if v else "off"
|
||||
if p.unit == "Hz":
|
||||
return "%.1f kHz" % (v / 1000) if v >= 1000 else ("%.2f Hz" % v if v < 10 else "%d Hz" % round(v))
|
||||
if p.unit in ("st", "dB"):
|
||||
return "%+.1f %s" % (v, p.unit) if v else "0 %s" % p.unit
|
||||
if p.unit == "ms":
|
||||
return "%.1f ms" % v if v < 10 else "%d ms" % round(v)
|
||||
if p.unit == "s":
|
||||
return "%.2f s" % v
|
||||
if not p.unit and p.kind == "float" and p.hi <= 1:
|
||||
return "%d %%" % round(v * 100)
|
||||
if p.kind == "int":
|
||||
return "%d%s" % (v, " " + p.unit if p.unit else "")
|
||||
return "%.1f %s" % (v, p.unit) if p.unit else "%.2f" % v
|
||||
|
||||
|
||||
class Slider(QSlider):
|
||||
"""Horizontal slider that ignores the mouse wheel unless focused (scrolling the panel never changes values)
|
||||
and resets to its default on double-click."""
|
||||
reset = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Qt.Orientation.Horizontal)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
def wheelEvent(self, e):
|
||||
if self.hasFocus():
|
||||
super().wheelEvent(e)
|
||||
else:
|
||||
e.ignore()
|
||||
|
||||
def mouseDoubleClickEvent(self, e):
|
||||
self.reset.emit()
|
||||
|
||||
|
||||
class ParamControl(QWidget):
|
||||
"""One parameter: name | slider | value (or a check box for on/off parameters)."""
|
||||
changed = Signal()
|
||||
|
||||
def __init__(self, p, parent=None):
|
||||
super().__init__(parent)
|
||||
self.p = p
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(8)
|
||||
self.name = QLabel(p.label)
|
||||
self.name.setMinimumWidth(92)
|
||||
self.name.setToolTip(p.tip)
|
||||
lay.addWidget(self.name)
|
||||
if p.kind == "bool":
|
||||
self.check = QCheckBox()
|
||||
self.check.setToolTip(p.tip)
|
||||
self.check.toggled.connect(lambda _: self.changed.emit())
|
||||
lay.addWidget(self.check, 1)
|
||||
self.slider = self.value_lbl = None
|
||||
return
|
||||
self.check = None
|
||||
self.slider = Slider()
|
||||
self.slider.setRange(0, self.steps())
|
||||
self.slider.setToolTip("%s\nDouble-click: back to %s" % (p.tip, fmt(p, p.default)))
|
||||
self.value_lbl = QLabel()
|
||||
self.value_lbl.setMinimumWidth(self.value_lbl.fontMetrics().horizontalAdvance("-10.0 kHz") + 4)
|
||||
self.value_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.slider.valueChanged.connect(self._moved)
|
||||
self.slider.reset.connect(lambda: (self.set_value(p.default), self.changed.emit()))
|
||||
lay.addWidget(self.slider, 1)
|
||||
lay.addWidget(self.value_lbl)
|
||||
|
||||
def steps(self):
|
||||
p = self.p
|
||||
return int(round((p.hi - p.lo) / p.step)) if p.kind == "int" else STEPS
|
||||
|
||||
def to_pos(self, v):
|
||||
p = self.p
|
||||
if p.kind == "int":
|
||||
return int(round((v - p.lo) / p.step))
|
||||
f = math.log(v / p.lo) / math.log(p.hi / p.lo) if is_log(p) else (v - p.lo) / (p.hi - p.lo)
|
||||
return int(round(min(max(f, 0.0), 1.0) * STEPS))
|
||||
|
||||
def from_pos(self, i):
|
||||
p = self.p
|
||||
if p.kind == "int":
|
||||
return int(p.lo + i * p.step)
|
||||
f = i / STEPS
|
||||
v = p.lo * (p.hi / p.lo) ** f if is_log(p) else p.lo + f * (p.hi - p.lo)
|
||||
q = p.step or 0.01 # keep values tidy: rounded to the parameter's step (finer when low)
|
||||
if is_log(p) and v < 10 * q:
|
||||
q = q / 10
|
||||
return round(round(v / q) * q, 6)
|
||||
|
||||
def _moved(self, i):
|
||||
self._value = self.from_pos(i)
|
||||
self.value_lbl.setText(fmt(self.p, self._value))
|
||||
self.changed.emit()
|
||||
|
||||
def value(self):
|
||||
if self.check is not None:
|
||||
return self.check.isChecked()
|
||||
return self._value
|
||||
|
||||
def set_value(self, v):
|
||||
if self.check is not None:
|
||||
self.check.blockSignals(True)
|
||||
self.check.setChecked(bool(v))
|
||||
self.check.blockSignals(False)
|
||||
return
|
||||
self._value = v
|
||||
self.slider.blockSignals(True)
|
||||
self.slider.setValue(self.to_pos(v))
|
||||
self.slider.blockSignals(False)
|
||||
self.value_lbl.setText(fmt(self.p, v))
|
||||
|
||||
|
||||
class EffectsRack(QWidget):
|
||||
changed = Signal(dict)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.controls, self.pages, self.items = {}, {}, {}
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(6, 6, 6, 6)
|
||||
lay.setSpacing(6)
|
||||
head = QHBoxLayout()
|
||||
title = QLabel("EFFECTS")
|
||||
f = title.font()
|
||||
f.setBold(True)
|
||||
title.setFont(f)
|
||||
head.addWidget(title, 1)
|
||||
self.b_reset = QPushButton("Reset")
|
||||
self.b_reset.setToolTip("Every effect off, every value back to its default")
|
||||
self.b_copy = QPushButton("Copy")
|
||||
self.b_copy.setToolTip("Copy these settings (to paste them on another sound)")
|
||||
self.b_paste = QPushButton("Paste")
|
||||
self.b_paste.setToolTip("Use copied settings on this sound")
|
||||
for b in (self.b_reset, self.b_copy, self.b_paste):
|
||||
head.addWidget(b)
|
||||
lay.addLayout(head)
|
||||
self.cmb_preset = QComboBox()
|
||||
self.cmb_preset.addItem("Start from a preset…")
|
||||
for name, blocks in fx.LOOKS:
|
||||
self.cmb_preset.addItem(name)
|
||||
self.cmb_preset.setItemData(self.cmb_preset.count() - 1, "Turns on: " + ", ".join(
|
||||
fx.BY_KEY[k].label for k in blocks) + " (every other effect off)", Qt.ItemDataRole.ToolTipRole)
|
||||
self.cmb_preset.setToolTip("Sets several effects at once, like sounds heard in the game")
|
||||
self.cmb_preset.activated.connect(self._preset_chosen)
|
||||
lay.addWidget(self.cmb_preset)
|
||||
|
||||
self.chain = QListWidget() # the effects in their order: tick = on, select = settings
|
||||
self.chain.setStyleSheet("QListWidget::item { padding: 3px 2px; } " # selected: grey, so a ticked
|
||||
"QListWidget::item:selected { background: #3a3a3a; color: #ffffff; }") # box shows
|
||||
self.chain.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.chain.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
for blk in fx.BLOCKS:
|
||||
it = QListWidgetItem(blk.label)
|
||||
it.setFlags(it.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
it.setCheckState(Qt.CheckState.Unchecked)
|
||||
it.setToolTip(blk.tip)
|
||||
it.setData(Qt.ItemDataRole.UserRole, blk.key)
|
||||
self.chain.addItem(it)
|
||||
self.items[blk.key] = it
|
||||
rows = self.chain.sizeHintForRow(0) * len(fx.BLOCKS)
|
||||
self.chain.setFixedHeight(rows + 2 * self.chain.frameWidth() + 2)
|
||||
self.chain.itemChanged.connect(self._item_changed)
|
||||
self.chain.currentItemChanged.connect(lambda cur, _: cur and self.show_block(
|
||||
cur.data(Qt.ItemDataRole.UserRole)))
|
||||
lay.addWidget(self.chain)
|
||||
hint = QLabel("Tick an effect to use it, click its name for its settings. They run top to bottom.")
|
||||
hint.setObjectName("Muted")
|
||||
hint.setWordWrap(True)
|
||||
lay.addWidget(hint)
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
for blk in fx.BLOCKS:
|
||||
page = self._page(blk)
|
||||
self.pages[blk.key] = page
|
||||
self.stack.addWidget(page)
|
||||
lay.addWidget(self.stack)
|
||||
lay.addStretch(1)
|
||||
self.b_reset.clicked.connect(lambda: (self.set_settings(fx.defaults()), self._emit()))
|
||||
self.b_copy.clicked.connect(lambda: QApplication.clipboard().setText(json.dumps(self.settings())))
|
||||
self.b_paste.clicked.connect(self.paste)
|
||||
self.chain.setCurrentRow(0)
|
||||
|
||||
def _page(self, blk):
|
||||
page = QWidget()
|
||||
v = QVBoxLayout(page)
|
||||
v.setContentsMargins(0, 4, 0, 0)
|
||||
v.setSpacing(3)
|
||||
name = QLabel(blk.label)
|
||||
f = name.font()
|
||||
f.setBold(True)
|
||||
name.setFont(f)
|
||||
v.addWidget(name)
|
||||
about = QLabel(blk.tip)
|
||||
about.setObjectName("Muted")
|
||||
about.setWordWrap(True)
|
||||
v.addWidget(about)
|
||||
if fx.PRESETS.get(blk.key):
|
||||
pg = QGridLayout()
|
||||
pg.setSpacing(4)
|
||||
for i, (pname, _) in enumerate(fx.PRESETS[blk.key]):
|
||||
b = QPushButton(pname)
|
||||
b.setToolTip("Turn %s on with the values '%s' (then adjust by ear)" % (blk.label, pname))
|
||||
b.clicked.connect(lambda _=False, k=blk.key, n=pname: self.apply_preset(k, n))
|
||||
pg.addWidget(b, i // 2, i % 2)
|
||||
v.addLayout(pg)
|
||||
for p in blk.params:
|
||||
c = ParamControl(p)
|
||||
c.set_value(p.default)
|
||||
c.changed.connect(lambda k=blk.key: self._param_changed(k))
|
||||
v.addWidget(c)
|
||||
self.controls[(blk.key, p.key)] = c
|
||||
v.addStretch(1)
|
||||
return page
|
||||
|
||||
# --- on / off and the selected effect ----------------------------------------------------------
|
||||
def is_on(self, key):
|
||||
return self.items[key].checkState() == Qt.CheckState.Checked
|
||||
|
||||
def set_on(self, key, on):
|
||||
self.items[key].setCheckState(Qt.CheckState.Checked if on else Qt.CheckState.Unchecked)
|
||||
|
||||
def show_block(self, key):
|
||||
self.stack.setCurrentWidget(self.pages[key])
|
||||
if self.chain.currentItem() is not self.items[key]:
|
||||
self.chain.setCurrentItem(self.items[key])
|
||||
|
||||
def _style(self, key):
|
||||
f = QFont(self.chain.font())
|
||||
f.setBold(self.is_on(key)) # effects that are on stand out in the chain
|
||||
self.items[key].setFont(f)
|
||||
|
||||
def _item_changed(self, it):
|
||||
key = it.data(Qt.ItemDataRole.UserRole)
|
||||
self.chain.blockSignals(True)
|
||||
self._style(key)
|
||||
self.chain.blockSignals(False)
|
||||
self._emit()
|
||||
|
||||
def _param_changed(self, key):
|
||||
if not self.is_on(key):
|
||||
self.set_on(key, True) # emits through _item_changed
|
||||
else:
|
||||
self._emit()
|
||||
|
||||
def set_channels(self, n):
|
||||
layout = fx.LAYOUTS.get(n, "")
|
||||
self.chain.blockSignals(True)
|
||||
self.items["channels"].setText("Channels (%s)" % ("mono: no effect" if n <= 1 else "%d ch, %s" % (n, layout)))
|
||||
self.chain.blockSignals(False)
|
||||
|
||||
# --- settings ----------------------------------------------------------------------------------
|
||||
def _emit(self):
|
||||
if not self.signalsBlocked():
|
||||
self.changed.emit(self.settings())
|
||||
|
||||
def settings(self):
|
||||
out = {}
|
||||
for b in fx.BLOCKS:
|
||||
d = {"on": self.is_on(b.key)}
|
||||
for p in b.params:
|
||||
d[p.key] = self.controls[(b.key, p.key)].value()
|
||||
out[b.key] = d
|
||||
return fx.normalize(out)
|
||||
|
||||
def set_settings(self, settings):
|
||||
s = fx.normalize(settings)
|
||||
self.blockSignals(True)
|
||||
self.chain.blockSignals(True)
|
||||
for b in fx.BLOCKS:
|
||||
self.set_on(b.key, s[b.key]["on"])
|
||||
self._style(b.key)
|
||||
for p in b.params:
|
||||
self.controls[(b.key, p.key)].set_value(s[b.key][p.key])
|
||||
self.chain.blockSignals(False)
|
||||
self.blockSignals(False)
|
||||
|
||||
def apply_preset(self, block, name):
|
||||
self.set_settings(fx.with_preset(self.settings(), block, name))
|
||||
self.show_block(block)
|
||||
self._emit()
|
||||
|
||||
def apply_look(self, name):
|
||||
self.set_settings(fx.look(name))
|
||||
on = [b.key for b in fx.BLOCKS if self.is_on(b.key)]
|
||||
if on:
|
||||
self.show_block(on[0])
|
||||
self._emit()
|
||||
|
||||
def _preset_chosen(self, i):
|
||||
if i > 0:
|
||||
self.apply_look(self.cmb_preset.itemText(i))
|
||||
self.cmb_preset.setCurrentIndex(0)
|
||||
|
||||
def paste(self):
|
||||
try:
|
||||
s = json.loads(QApplication.clipboard().text())
|
||||
except ValueError:
|
||||
return
|
||||
if isinstance(s, dict) and any(k in s for k in fx.BY_KEY):
|
||||
self.set_settings(s)
|
||||
self._emit()
|
||||
19
app/icons.py
19
app/icons.py
@@ -146,6 +146,25 @@ def _draw(p, name, c, s):
|
||||
p.drawPath(half)
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.drawEllipse(rect)
|
||||
elif name in ("prev", "next"): # chevron
|
||||
d = -1 if name == "prev" else 1
|
||||
x0 = s / 2 - d * s * 0.12
|
||||
p.drawLine(QPointF(x0, s * 0.24), QPointF(x0 + d * s * 0.24, s / 2))
|
||||
p.drawLine(QPointF(x0 + d * s * 0.24, s / 2), QPointF(x0, s * 0.76))
|
||||
elif name == "add": # plus
|
||||
p.drawLine(QPointF(s / 2, s * 0.22), QPointF(s / 2, s * 0.78))
|
||||
p.drawLine(QPointF(s * 0.22, s / 2), QPointF(s * 0.78, s / 2))
|
||||
elif name == "edit": # pencil
|
||||
p.translate(s / 2, s / 2)
|
||||
p.rotate(-45)
|
||||
p.drawRect(QRectF(-s * 0.30, -s * 0.09, s * 0.48, s * 0.18))
|
||||
path = QPainterPath()
|
||||
path.moveTo(s * 0.18, -s * 0.09)
|
||||
path.lineTo(s * 0.36, 0)
|
||||
path.lineTo(s * 0.18, s * 0.09)
|
||||
p.drawPath(path)
|
||||
p.rotate(45)
|
||||
p.translate(-s / 2, -s / 2)
|
||||
elif name == "search":
|
||||
p.drawEllipse(QRectF(s * 0.18, s * 0.18, s * 0.46, s * 0.46))
|
||||
p.drawLine(QPointF(s * 0.58, s * 0.58), QPointF(s * 0.84, s * 0.84))
|
||||
|
||||
10
app/main.py
10
app/main.py
@@ -44,7 +44,15 @@ def smoke_test(game_dir=None, report=None):
|
||||
ws = Workspace(Game(gd), store_root=os.path.join(td, "store"))
|
||||
s = next(x for x in ws.catalog.sounds if x.group == "Speech" and 1.0 < x.seconds < 4.0)
|
||||
x, sr = audio.decode_wem(ws.original_bytes(s))
|
||||
wem, info = ws.prepare(s, x[::-1].copy(), sr)
|
||||
from ackit import fx # effects (pedalboard) on the way, as the Edit tab does
|
||||
st = fx.defaults()
|
||||
for k in ("pitch", "eq", "reverb"):
|
||||
st[k]["on"] = True
|
||||
st["pitch"]["shift"] = -3.0
|
||||
y = fx.render(x[::-1].copy(), sr, st, s.channels)
|
||||
print("effects OK: %s (pedalboard %s), %.2f s -> %.2f s"
|
||||
% (fx.summary(st), __import__("pedalboard").__version__, len(x) / sr, len(y) / sr))
|
||||
wem, info = ws.prepare(s, y, sr)
|
||||
out = os.path.join(td, os.path.basename(ws.wad_path(s)))
|
||||
inject(ws.wad_path(s), {s.target: wem}, {s.target: ws.limit(s)}, out_path=out)
|
||||
assert read_target(out, s.target) == wem
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Main window. Tabs: Sounds (filters + sound table on top, single-sound editor below, with the sound's
|
||||
subtitle), Subtitles and Menu text (app/textpanel.py)."""
|
||||
"""Main window. Tabs: Sounds (browse: filters + sound table, a preview below; app/preview.py), Edit (the edit list,
|
||||
the editor and the effects: app/edit.py; the only place where sounds are replaced), Subtitles and Menu text
|
||||
(app/textpanel.py)."""
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import QByteArray, QEvent, QItemSelectionModel, QSize, Qt, QTimer
|
||||
from PySide6.QtCore import QByteArray, QItemSelectionModel, QSize, Qt, QTimer
|
||||
from PySide6.QtGui import QFont, QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout,
|
||||
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox,
|
||||
QPushButton, QScrollArea, QSplitter, QTableView, QTabWidget, QToolBar, QVBoxLayout,
|
||||
QPushButton, QSplitter, QTableView, QTabWidget, QToolBar, QVBoxLayout,
|
||||
QWidget)
|
||||
|
||||
from ackit import settings as settings_mod, workspace as W
|
||||
@@ -17,15 +18,15 @@ from ackit.texts import TextConflictError
|
||||
from . import icons, theme
|
||||
from .audioio import Player, find_device, output_devices
|
||||
from .dialogs import ExportModDialog, ImportModDialog, SettingsDialog, pick_game_dir
|
||||
from .editor import EditorPanel
|
||||
from .edit import EditPanel
|
||||
from .models import C_FAV, C_NAME, COLS, SoundFilter, SoundModel
|
||||
from .preview import PreviewPanel
|
||||
from .spinner import BusyOverlay, Spinner
|
||||
from .textpanel import TextPanel
|
||||
from .userdata import UserData
|
||||
from .workers import Runner
|
||||
|
||||
SIDEBAR_MIN, SIDEBAR_MAX = 220, 320
|
||||
TABLE_MIN = 160 # the sound list never gets smaller than this when fitting the editor
|
||||
|
||||
ANY = "Any"
|
||||
|
||||
@@ -196,20 +197,16 @@ class MainWindow(QMainWindow):
|
||||
self.table.sortByColumn(C_NAME, Qt.SortOrder.AscendingOrder)
|
||||
self.table.selectionModel().currentRowChanged.connect(self._current_changed)
|
||||
self.table.clicked.connect(self._clicked)
|
||||
self.table.doubleClicked.connect(lambda _: self.editor.play("orig"))
|
||||
self.table.doubleClicked.connect(lambda _: self.preview.play("orig"))
|
||||
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.table.customContextMenuRequested.connect(self._context_menu)
|
||||
|
||||
# editor
|
||||
self.editor = EditorPanel(self)
|
||||
self.editor.applied.connect(self._after_change)
|
||||
self.editor.reverted.connect(self._after_change)
|
||||
self.editor.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidget(self.editor)
|
||||
scroll.setWidgetResizable(True)
|
||||
# preview of the selected sound (no editing here)
|
||||
self.preview = PreviewPanel(self)
|
||||
self.preview.edit_requested.connect(self.edit_selected)
|
||||
self.preview.add_requested.connect(self.add_selected_to_edit)
|
||||
|
||||
# sidebar | sound list on top, the editor below across the whole width
|
||||
# sidebar | sound list on top, the preview below across the whole width
|
||||
hs = QSplitter(Qt.Orientation.Horizontal)
|
||||
hs.addWidget(side)
|
||||
hs.addWidget(self.table)
|
||||
@@ -217,18 +214,19 @@ class MainWindow(QMainWindow):
|
||||
hs.setSizes([SIDEBAR_MAX, 100000])
|
||||
self.vsplit = QSplitter(Qt.Orientation.Vertical)
|
||||
self.vsplit.addWidget(hs)
|
||||
self.vsplit.addWidget(scroll)
|
||||
self.vsplit.setStretchFactor(0, 1) # extra height goes to the list; the editor keeps its fitted height
|
||||
self.vsplit.addWidget(self.preview)
|
||||
self.vsplit.setStretchFactor(0, 1) # extra height goes to the list
|
||||
self.vsplit.setStretchFactor(1, 0)
|
||||
self.scroll = scroll
|
||||
self._fitted = False
|
||||
self._user_sized = False # once the divider is dragged, keep the user's choice
|
||||
self.vsplit.splitterMoved.connect(lambda *_: setattr(self, "_user_sized", True))
|
||||
self.editor.installEventFilter(self)
|
||||
# sections: sounds, subtitles, menu text
|
||||
self.vsplit.setChildrenCollapsible(False)
|
||||
# sections: sounds (browse), edit, subtitles, menu text
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setDocumentMode(True)
|
||||
self.tabs.addTab(self.vsplit, icons.icon("wave"), "Sounds")
|
||||
self.edit = EditPanel(self)
|
||||
self.editor = self.edit.editor # the one sound editor
|
||||
self.tabs.addTab(self.edit, icons.icon("edit"), "Edit")
|
||||
self.edit.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
|
||||
self.editor.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
|
||||
self.subs = TextPanel(self, "subs")
|
||||
self.menu = TextPanel(self, "menu")
|
||||
self.tabs.addTab(self.subs, icons.icon("ab"), "Subtitles")
|
||||
@@ -255,12 +253,15 @@ class MainWindow(QMainWindow):
|
||||
|
||||
sc = Qt.ShortcutContext.WindowShortcut
|
||||
QShortcut(QKeySequence("Ctrl+F"), self, self.search.setFocus, context=sc)
|
||||
QShortcut(QKeySequence("Ctrl+S"), self, lambda: self.editor.apply(), context=sc)
|
||||
QShortcut(QKeySequence("Ctrl+O"), self, lambda: self.editor.load_file_dialog(), context=sc)
|
||||
QShortcut(QKeySequence("Escape"), self, self.editor.stop, context=sc)
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Space), self.table, self.editor.toggle_play,
|
||||
context=Qt.ShortcutContext.WidgetShortcut)
|
||||
QShortcut(QKeySequence(Qt.Key.Key_F2), self.table, self.edit_notes, context=Qt.ShortcutContext.WidgetShortcut)
|
||||
edit = Qt.ShortcutContext.WidgetWithChildrenShortcut # editing shortcuts: in the Edit tab only
|
||||
QShortcut(QKeySequence("Ctrl+S"), self.edit, lambda: self.editor.apply(), context=edit)
|
||||
QShortcut(QKeySequence("Ctrl+O"), self.edit, lambda: self.editor.load_file_dialog(), context=edit)
|
||||
QShortcut(QKeySequence("Escape"), self, self.player.stop, context=sc)
|
||||
wsc = Qt.ShortcutContext.WidgetShortcut
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Space), self.table, self.preview.toggle_play, context=wsc)
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Return), self.table, self.edit_selected, context=wsc)
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Enter), self.table, self.edit_selected, context=wsc)
|
||||
QShortcut(QKeySequence(Qt.Key.Key_F2), self.table, self.edit_notes, context=wsc)
|
||||
|
||||
# --- dialogs (overridable in tests) -----------------------------------------------------------
|
||||
def confirm(self, title, text):
|
||||
@@ -355,6 +356,7 @@ class MainWindow(QMainWindow):
|
||||
fill(self.cmb_arch, list(ws.catalog.archives))
|
||||
fill(self.cmb_tag, self.userdata.all_tags())
|
||||
self._filters_changed()
|
||||
self.edit.populate(ws)
|
||||
|
||||
def _fill_categories(self):
|
||||
g = self.cmb_group.currentText()
|
||||
@@ -405,7 +407,22 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _current_changed(self, cur, prev):
|
||||
if cur.isValid():
|
||||
self.editor.set_sound(self.proxy.sound_at(cur))
|
||||
self.preview.set_sound(self.proxy.sound_at(cur))
|
||||
|
||||
def edit_selected(self):
|
||||
"""Selected sounds onto the edit list; the Edit tab opens on the first."""
|
||||
sel = self.selected_sounds() or ([self.preview.sound] if self.preview.sound else [])
|
||||
if sel:
|
||||
self.edit_sounds(sel)
|
||||
|
||||
def edit_sounds(self, sounds):
|
||||
self.edit.add(sounds, open_first=True)
|
||||
self.tabs.setCurrentWidget(self.edit)
|
||||
|
||||
def add_selected_to_edit(self):
|
||||
sel = self.selected_sounds()
|
||||
self.edit.add(sel)
|
||||
self.statusBar().showMessage("%d sound(s) on the edit list (Edit tab)" % len(sel), 5000)
|
||||
|
||||
def _clicked(self, index):
|
||||
if index.column() == C_FAV:
|
||||
@@ -429,7 +446,10 @@ class MainWindow(QMainWindow):
|
||||
if not sel:
|
||||
return
|
||||
m = QMenu(self)
|
||||
m.addAction("Play original", lambda: self.editor.play("orig"))
|
||||
m.addAction(icons.icon("edit"), "Edit (Enter)", self.edit_selected)
|
||||
m.addAction(icons.icon("add"), "Add to edit list", self.add_selected_to_edit)
|
||||
m.addSeparator()
|
||||
m.addAction("Play original (Space)", lambda: self.preview.play("orig"))
|
||||
m.addAction("Notes && tags… (F2)", self.edit_notes)
|
||||
m.addAction("Toggle favorite", lambda: self._toggle_fav(sel))
|
||||
m.addSeparator()
|
||||
@@ -492,6 +512,8 @@ class MainWindow(QMainWindow):
|
||||
if panel.kind in self._text_loaded:
|
||||
panel.refresh()
|
||||
self.editor.subtitle.set_sound(self.editor.sound)
|
||||
if self.preview.sound:
|
||||
self.preview.set_sound(self.preview.sound)
|
||||
|
||||
def show_sound(self, key):
|
||||
"""Sounds tab with `key` selected (from a subtitle)."""
|
||||
@@ -515,8 +537,12 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# --- changes ----------------------------------------------------------------------------------
|
||||
def _after_change(self, key=None):
|
||||
self.model.refresh_keys(modified=self.ws.modified_keys()) # the view refilters/re-sorts itself
|
||||
modified = self.ws.modified_keys()
|
||||
self.model.refresh_keys(modified=modified) # the view refilters/re-sorts itself
|
||||
self._update_count()
|
||||
self.edit.refresh(modified)
|
||||
if self.preview.sound:
|
||||
self.preview.set_sound(self.preview.sound)
|
||||
|
||||
def revert_sounds(self, sounds):
|
||||
sounds = [s for s in sounds if self.ws.store.get(s.key)]
|
||||
@@ -572,7 +598,7 @@ class MainWindow(QMainWindow):
|
||||
self._after_change()
|
||||
self._texts_changed()
|
||||
if self.editor.sound:
|
||||
self.editor.set_sound(self.editor.sound)
|
||||
self.edit.load(self.editor.sound)
|
||||
|
||||
# --- changed underneath -----------------------------------------------------------------------
|
||||
def check_problems(self):
|
||||
@@ -744,31 +770,8 @@ class MainWindow(QMainWindow):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def fit_editor(self):
|
||||
"""Give the editor panel the height of its contents (everything visible), the list gets the rest."""
|
||||
total = sum(self.vsplit.sizes()) or self.vsplit.height()
|
||||
lay = self.editor.layout()
|
||||
need = self.editor.sizeHint().height()
|
||||
if lay.hasHeightForWidth(): # wrapped labels: height at the actual width
|
||||
need = max(need, lay.heightForWidth(self.scroll.viewport().width()))
|
||||
need += 2 * self.scroll.frameWidth()
|
||||
if self.editor.minimumSizeHint().width() > self.scroll.viewport().width(): # narrow window: room for the h-bar
|
||||
need += self.scroll.horizontalScrollBar().sizeHint().height()
|
||||
editor_h = min(need, max(0, total - TABLE_MIN))
|
||||
self.vsplit.setSizes([total - editor_h, editor_h])
|
||||
|
||||
def eventFilter(self, obj, e):
|
||||
if obj is self.editor and e.type() == QEvent.Type.LayoutRequest and self._fitted and not self._user_sized:
|
||||
QTimer.singleShot(0, self.fit_editor)
|
||||
return super().eventFilter(obj, e)
|
||||
|
||||
def showEvent(self, e):
|
||||
super().showEvent(e)
|
||||
if not self._fitted:
|
||||
self._fitted = True
|
||||
QTimer.singleShot(0, self.fit_editor)
|
||||
|
||||
def closeEvent(self, e):
|
||||
self.player.stop()
|
||||
self.edit.flush()
|
||||
self.save_settings()
|
||||
super().closeEvent(e)
|
||||
|
||||
181
app/preview.py
Normal file
181
app/preview.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Sounds tab: preview of the selected sound (listen, read its subtitle) with the way into the Edit tab. No
|
||||
editing here: editing is the Edit tab's job."""
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from ackit import audio, subtitles, workspace as W
|
||||
from . import icons, theme
|
||||
from .editor import STATE_TEXT
|
||||
from .waveform import Waveform
|
||||
|
||||
|
||||
class PreviewPanel(QWidget):
|
||||
edit_requested = Signal() # open the selected sounds in the Edit tab
|
||||
add_requested = Signal() # add them to the edit list (stay here)
|
||||
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx # main window: .ws, .runner, .player, .language
|
||||
self.sound = None
|
||||
self.orig = self.installed = None
|
||||
self.playing_what = None
|
||||
self._build()
|
||||
self.set_sound(None)
|
||||
|
||||
def _build(self):
|
||||
self.setObjectName("EditorPanel")
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(8, 6, 8, 6)
|
||||
lay.setSpacing(12)
|
||||
info = QVBoxLayout()
|
||||
info.setSpacing(3)
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("EditorTitle")
|
||||
self.title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
f = self.title.font()
|
||||
f.setPointSizeF(f.pointSizeF() * 1.2)
|
||||
f.setBold(True)
|
||||
self.title.setFont(f)
|
||||
self.meta = QLabel()
|
||||
self.meta.setObjectName("Muted")
|
||||
self.meta.setWordWrap(True)
|
||||
self.state = QLabel()
|
||||
self.sub = QLabel()
|
||||
self.sub.setWordWrap(True)
|
||||
self.sub.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
for w in (self.title, self.meta, self.state, self.sub):
|
||||
info.addWidget(w)
|
||||
info.addStretch(1)
|
||||
lay.addLayout(info, 3)
|
||||
|
||||
# player: the transport buttons together, the waveforms under them (click = play from there)
|
||||
player = QVBoxLayout()
|
||||
player.setSpacing(4)
|
||||
tr = QHBoxLayout()
|
||||
tr.setSpacing(6)
|
||||
self.b_orig = self._btn("Play original", "play", lambda: self.play("orig"), "Play the game's original (Space)",
|
||||
icons.STEEL)
|
||||
self.b_inst = self._btn("Play installed", "play", lambda: self.play("inst"),
|
||||
"Play what the game has now (your replacement)", "#8f4a4e")
|
||||
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
|
||||
for b in (self.b_orig, self.b_inst, self.b_stop):
|
||||
tr.addWidget(b)
|
||||
tr.addStretch(1)
|
||||
player.addLayout(tr)
|
||||
waves = QGridLayout()
|
||||
waves.setVerticalSpacing(3)
|
||||
waves.setHorizontalSpacing(6)
|
||||
self.wf_orig, self.wf_inst = Waveform(), Waveform()
|
||||
for i, (w, kind, name) in enumerate(((self.wf_orig, "orig", "Original"), (self.wf_inst, "inst", "Installed"))):
|
||||
w.accent, w.bg = theme.WAVE[kind], theme.WAVE_BG
|
||||
w.setMinimumHeight(40)
|
||||
lab = QLabel(name)
|
||||
lab.setObjectName("Muted")
|
||||
waves.addWidget(lab, i, 0)
|
||||
waves.addWidget(w, i, 1)
|
||||
waves.setColumnStretch(1, 1)
|
||||
player.addLayout(waves)
|
||||
self.wf_orig.seekRequested.connect(lambda t: self.play("orig", t))
|
||||
self.wf_inst.seekRequested.connect(lambda t: self.play("inst", t))
|
||||
lay.addLayout(player, 4)
|
||||
|
||||
# editing: into the Edit tab (on the right, apart from the player)
|
||||
acts = QVBoxLayout()
|
||||
acts.setSpacing(6)
|
||||
self.b_edit = self._btn("Edit", "edit", self.edit_requested.emit,
|
||||
"Open the selected sound(s) in the Edit tab (Enter)", "#ffffff")
|
||||
self.b_edit.setObjectName("Primary")
|
||||
self.b_add = self._btn("Add to edit list", "add", self.add_requested.emit,
|
||||
"Put the selected sounds on the Edit tab's list and stay here")
|
||||
for b in (self.b_edit, self.b_add):
|
||||
acts.addWidget(b)
|
||||
acts.addStretch(1)
|
||||
lay.addSpacing(8)
|
||||
lay.addLayout(acts, 1)
|
||||
self.ctx.player.position.connect(self._pos)
|
||||
self.ctx.player.playing.connect(lambda on: None if on else self._clear_playheads())
|
||||
|
||||
@staticmethod
|
||||
def _btn(text, icon, slot, tip, color=None):
|
||||
b = QPushButton(text)
|
||||
b.setIcon(icons.icon(icon, color or icons.TEXT))
|
||||
b.clicked.connect(lambda _=False: slot())
|
||||
b.setToolTip(tip)
|
||||
return b
|
||||
|
||||
def set_sound(self, s):
|
||||
self.stop()
|
||||
self.sound, self.orig, self.installed = s, None, None
|
||||
for b in (self.b_orig, self.b_inst, self.b_edit, self.b_add, self.b_stop):
|
||||
b.setEnabled(s is not None)
|
||||
self.wf_orig.set_audio(None, 1, "select a sound" if s is None else "loading…", busy=s is not None)
|
||||
self.wf_inst.set_audio(None, 1, "")
|
||||
if s is None:
|
||||
self.title.setText("No sound selected")
|
||||
self.meta.setText("Select a sound in the list to listen to it; Edit opens it for changing.")
|
||||
self.state.setText("")
|
||||
self.sub.setText("")
|
||||
return
|
||||
self.title.setText(s.name)
|
||||
who = s.character or (("mentions " + s.characters_inferred) if s.characters_inferred else "—")
|
||||
self.meta.setText("%s / %s • %s • %.2f s, %d Hz, %s • %s"
|
||||
% (s.group, s.category, who, s.seconds, s.rate,
|
||||
{1: "mono", 2: "stereo"}.get(s.channels, "%d ch" % s.channels), s.key))
|
||||
ws = self.ctx.ws
|
||||
subs = ws.texts.for_sound(s, self.ctx.language) if ws else []
|
||||
self.sub.setText("“%s”" % " / ".join(ws.texts.current(_key(x)) or x.text for x in subs) if subs else "")
|
||||
key = s.key
|
||||
|
||||
def work(job):
|
||||
cur = ws.current_bytes(s)
|
||||
st = ws.state(s, cur)
|
||||
o = audio.decode_wem(ws.original_bytes(s))
|
||||
return key, st, o, audio.decode_wem(cur) if st != W.ORIGINAL else None
|
||||
|
||||
def failed(e, tb):
|
||||
self.wf_orig.set_audio(None, 1, "could not load: %s" % e)
|
||||
|
||||
self.ctx.runner.start(work, self._loaded, failed)
|
||||
|
||||
def _loaded(self, r):
|
||||
key, st, o, inst = r
|
||||
if not self.sound or self.sound.key != key:
|
||||
return
|
||||
self.orig, self.installed = o, inst
|
||||
self.wf_orig.set_audio(*o)
|
||||
self.wf_inst.set_audio(*(inst or (None, 1)), placeholder="same as the original")
|
||||
self.b_inst.setEnabled(inst is not None)
|
||||
self.state.setText(STATE_TEXT.get(st, st))
|
||||
|
||||
def play(self, what, start=0.0):
|
||||
a = {"orig": self.orig, "inst": self.installed}.get(what)
|
||||
if not a:
|
||||
return
|
||||
self.playing_what = what
|
||||
self.ctx.player.owner = self
|
||||
self.ctx.player.play(a[0], a[1], start, label=what)
|
||||
|
||||
def toggle_play(self):
|
||||
if self.ctx.player.is_playing():
|
||||
self.stop()
|
||||
else:
|
||||
self.play("orig")
|
||||
|
||||
def stop(self):
|
||||
self.ctx.player.stop()
|
||||
self._clear_playheads()
|
||||
|
||||
def _clear_playheads(self):
|
||||
for w in (self.wf_orig, self.wf_inst):
|
||||
w.set_playhead(None)
|
||||
|
||||
def _pos(self, t):
|
||||
if getattr(self.ctx.player, "owner", None) is not self:
|
||||
return
|
||||
wf = {"orig": self.wf_orig, "inst": self.wf_inst}.get(self.playing_what)
|
||||
if wf:
|
||||
wf.set_playhead(t)
|
||||
|
||||
|
||||
def _key(sub):
|
||||
return subtitles.key(sub.lang, sub.speech, sub.line)
|
||||
100
app/textpanel.py
100
app/textpanel.py
@@ -6,8 +6,8 @@ length), menu texts into the localization files. Revert per text or all; changed
|
||||
"""
|
||||
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout,
|
||||
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, QTableView,
|
||||
QVBoxLayout, QWidget)
|
||||
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QSplitter,
|
||||
QTableView, QVBoxLayout, QWidget)
|
||||
|
||||
from ackit import subtitles
|
||||
from ackit.texts import CHANGED, FOREIGN, LOST, MODIFIED, ORIGINAL, TextConflictError
|
||||
@@ -240,6 +240,102 @@ class SubtitleBox(QWidget):
|
||||
self.win.runner.start(lambda job: fn(job, False), done, failed)
|
||||
|
||||
|
||||
class SubtitleEdit(QPlainTextEdit):
|
||||
"""A subtitle line as a large wrapping text box (one line of text in the game: Enter is ignored and pasted
|
||||
line breaks become spaces). Has the QLineEdit calls SubtitleBox uses: text(), textChanged."""
|
||||
|
||||
def __init__(self, text, parent=None):
|
||||
super().__init__(parent)
|
||||
f = self.font()
|
||||
f.setPointSizeF(f.pointSizeF() * 1.3)
|
||||
self.setFont(f)
|
||||
self.setPlainText(text)
|
||||
self.setTabChangesFocus(True)
|
||||
self.setFixedHeight(self.fontMetrics().lineSpacing() * 2 + 14) # 2 lines at the Edit tab's width
|
||||
|
||||
def text(self):
|
||||
return self.toPlainText()
|
||||
|
||||
def setText(self, text):
|
||||
self.setPlainText(text)
|
||||
|
||||
def keyPressEvent(self, e):
|
||||
if e.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
e.ignore()
|
||||
return
|
||||
super().keyPressEvent(e)
|
||||
|
||||
def insertFromMimeData(self, source):
|
||||
self.insertPlainText(" ".join(source.text().split()))
|
||||
|
||||
|
||||
class SubtitlePanel(SubtitleBox):
|
||||
"""The subtitle of the sound in the Edit tab: large text, the game's original under it (and the
|
||||
English line when editing another language), room counter, Apply / Revert / Show in list."""
|
||||
|
||||
def __init__(self, win, parent=None):
|
||||
super().__init__(win, parent)
|
||||
self.lines = QVBoxLayout()
|
||||
self.lines.setContentsMargins(0, 0, 0, 0)
|
||||
self.lines.setSpacing(4)
|
||||
outer = self.layout()
|
||||
outer.insertLayout(0, self.lines)
|
||||
outer.setSpacing(4)
|
||||
row = outer.itemAt(outer.count() - 1).layout() # one row: room counter | Apply | Revert | Show in list
|
||||
for w in (self.lbl, self.b_open):
|
||||
outer.removeWidget(w)
|
||||
row.insertWidget(0, self.lbl, 3)
|
||||
row.addWidget(self.b_open, 1)
|
||||
self._parts = []
|
||||
self._english = {}
|
||||
|
||||
def _english_text(self, ln):
|
||||
if ln.lang == "INT":
|
||||
return None
|
||||
if not self._english:
|
||||
try:
|
||||
self._english = {(x.speech, x.line): x.text for x in self.win.ws.texts.subtitles("INT")}
|
||||
except Exception: # noqa: BLE001 - only a reference text
|
||||
self._english = {None: None}
|
||||
return self._english.get((ln.speech, ln.line))
|
||||
|
||||
def set_sound(self, s):
|
||||
for w in self._parts:
|
||||
self.lines.removeWidget(w)
|
||||
w.deleteLater()
|
||||
self._parts, self.edits, self.keys = [], [], []
|
||||
ws = self.win.ws
|
||||
lines = ws.texts.for_sound(s, self.win.language) if ws and s else []
|
||||
if not lines:
|
||||
self.lbl.setText("No subtitle for this sound." if s else "")
|
||||
for b in (self.b_apply, self.b_revert, self.b_open):
|
||||
b.setEnabled(False)
|
||||
return
|
||||
tx = ws.texts
|
||||
for i, ln in enumerate(lines):
|
||||
k = subtitles.key(ln.lang, ln.speech, ln.line)
|
||||
e = SubtitleEdit(tx.records().get(k, {}).get("applied") or ln.text)
|
||||
e.setToolTip("Room: %d characters" % ln.room)
|
||||
e.textChanged.connect(self._check)
|
||||
self.lines.addWidget(e)
|
||||
part = " (part %d of %d)" % (i + 1, len(lines)) if len(lines) > 1 else ""
|
||||
ref = "Original%s: %s" % (part, ln.text)
|
||||
en = self._english_text(ln)
|
||||
if en and en != ln.text:
|
||||
ref += "\nEnglish: " + en
|
||||
r = QLabel(ref)
|
||||
r.setObjectName("Muted")
|
||||
r.setWordWrap(True)
|
||||
r.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.lines.addWidget(r)
|
||||
self._parts += [e, r]
|
||||
self.edits.append(e)
|
||||
self.keys.append(k)
|
||||
self.b_open.setEnabled(True)
|
||||
self.b_revert.setEnabled(any(k in tx.records() for k in self.keys))
|
||||
self._check()
|
||||
|
||||
|
||||
class TextPanel(QWidget):
|
||||
"""kind 'subs' (subtitles) or 'menu' (menu text)."""
|
||||
changed = Signal()
|
||||
|
||||
@@ -58,8 +58,9 @@ QLineEdit, QComboBox, QDoubleSpinBox, QPlainTextEdit, QListWidget, QTreeWidget {
|
||||
QLineEdit:focus, QComboBox:focus, QDoubleSpinBox:focus, QPlainTextEdit:focus { border-color: %(ACCENT)s; }
|
||||
QComboBox QAbstractItemView { background: #1b1b1b; border: 1px solid %(CONTROL)s;
|
||||
selection-background-color: rgba(176,36,44,90); }
|
||||
QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #555555; border-radius: 3px; background: #101010; }
|
||||
QCheckBox::indicator:checked { background: %(ACCENT)s; border-color: %(ACCENT)s; }
|
||||
QCheckBox::indicator, QGroupBox::indicator, QListView::indicator { width: 14px; height: 14px; border: 1px solid #555555; border-radius: 3px;
|
||||
background: #101010; }
|
||||
QCheckBox::indicator:checked, QGroupBox::indicator:checked, QListView::indicator:checked { background: %(ACCENT)s; border-color: %(ACCENT)s; }
|
||||
QProgressBar { background: #101010; border: 1px solid %(CONTROL)s; border-radius: 3px; height: 10px; }
|
||||
QProgressBar::chunk { background: %(ACCENT)s; border-radius: 2px; }
|
||||
QSlider::groove:horizontal { height: 4px; background: #333333; border-radius: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user