Version 3.4.0: Edit tab (edit list, large subtitle, replacements with effects and presets), Sounds tab only browses, ackit fx (pedalboard + own reverb) and replace --fx, first Linux build, catalog without a mod package

This commit is contained in:
2026-10-28 13:54:12 +01:00
parent a706dae419
commit 6de470174e
36 changed files with 2872 additions and 278 deletions

455
app/edit.py Normal file
View File

@@ -0,0 +1,455 @@
"""Edit tab: the one place where sounds are replaced. The sounds come from the Sounds tab (Edit / Add to edit
list) into an edit list; each keeps its replacement (recorded or loaded), its trim/level values and its effects
(ackit/fx.py) in the user data folder until you apply it to the game. Layout: edit list | editor (subtitle,
listen, replacement, actions) | effects. Plan: docs/history/mission4_dubbing_plan.md."""
import json
import os
import re
import shutil
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal
from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (QAbstractItemView, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton,
QScrollArea, QSplitter, QTableView, QVBoxLayout, QWidget)
from ackit import audio, fx
from ackit.paths import user_dir
from . import icons
from .editor import EditorPanel
from .effects import EffectsRack
class EditStore:
"""The edit list and, per sound key, its replacement audio (WAV) and settings: <user data>/edits/index.json +
replacements/*.wav. (Data of the first versions, <user data>/dubs/ and edits/takes/, is moved here once.)"""
def __init__(self, root=None):
self.root = root or os.path.join(user_dir("data"), "edits")
old = os.path.join(os.path.dirname(self.root), "dubs")
if root is None and not os.path.exists(self.root) and os.path.isdir(old):
shutil.move(old, self.root)
takes = os.path.join(self.root, "takes")
if os.path.isdir(takes) and not os.path.exists(os.path.join(self.root, "replacements")):
os.rename(takes, os.path.join(self.root, "replacements"))
os.makedirs(os.path.join(self.root, "replacements"), exist_ok=True)
self.path = os.path.join(self.root, "index.json")
try:
data = json.load(open(self.path, encoding="utf-8"))
except (OSError, ValueError):
data = {}
if "items" not in data: # first version: {key: item}, no list
data = {"list": [k for k, v in data.items() if v.get("take")], "items": data}
self.list, self.items = list(data.get("list", [])), dict(data.get("items", {}))
for v in self.items.values(): # first versions: "take" = the source's name
if "take" in v:
v.setdefault("source", v.pop("take"))
def save(self):
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"list": self.list, "items": self.items}, f, indent=1, ensure_ascii=False)
os.replace(tmp, self.path)
def get(self, key):
return self.items.get(key, {})
def audio_path(self, key):
return os.path.join(self.root, "replacements", re.sub(r"[^A-Za-z0-9_.-]", "_", key) + ".wav")
def has_replacement(self, key):
return bool(self.get(key).get("source")) and os.path.exists(self.audio_path(key))
def load_replacement(self, key):
return audio.load(self.audio_path(key)) if self.has_replacement(key) else None
def save_replacement(self, key, x, rate, name):
audio.save_wav(self.audio_path(key), x, rate)
self.update(key, source=name)
def update(self, key, **fields):
self.items[key] = dict(self.get(key), **fields)
self.save()
def add(self, keys):
new = [k for k in keys if k not in self.list]
self.list += new
if new:
self.save()
return new
def remove(self, keys):
"""Off the list; the replacements and settings stay (adding the sound again brings them back)."""
self.list = [k for k in self.list if k not in set(keys)]
self.save()
# --- edit list -----------------------------------------------------------------------------------
LCOLS = ["Sound", "", "FX", ""] # Saved and Game have icons in the header
L_NAME, L_SAVED, L_FX, L_GAME = range(len(LCOLS))
class EditListModel(QAbstractTableModel):
def __init__(self, store, parent=None):
super().__init__(parent)
self.store = store
self.sounds = []
self.modified = set()
def reset(self, sounds, modified):
self.beginResetModel()
self.sounds, self.modified = list(sounds), set(modified)
self.endResetModel()
def refresh(self, modified=None):
if modified is not None:
self.modified = set(modified)
if self.sounds:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.sounds) - 1, len(LCOLS) - 1))
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.sounds)
def columnCount(self, parent=QModelIndex()):
return len(LCOLS)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if orientation != Qt.Orientation.Horizontal:
return None
if role == Qt.ItemDataRole.DisplayRole:
return LCOLS[section]
if role == Qt.ItemDataRole.DecorationRole:
return {L_SAVED: icons.icon("record"), L_GAME: icons.icon("apply", icons.ACCENT)}.get(section)
if role == Qt.ItemDataRole.ToolTipRole:
return {L_SAVED: "A replacement is saved (user data folder)", L_FX: "Number of effects on",
L_GAME: "Applied: the replacement is in the game"}.get(section)
return None
def state(self, s):
if s.key in self.modified:
return "Applied"
return "Replacement saved" if self.store.has_replacement(s.key) else "Not started"
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
s, c = self.sounds[index.row()], index.column()
rec = self.store.get(s.key)
n_fx = sum(1 for b in fx.BLOCKS if (rec.get("fx") or {}).get(b.key, {}).get("on"))
if role == Qt.ItemDataRole.DisplayRole:
if c == L_NAME:
return s.name
if c == L_FX:
return str(n_fx) if n_fx else ""
return ""
if role == Qt.ItemDataRole.DecorationRole:
if c == L_SAVED and self.store.has_replacement(s.key):
return icons.icon("record")
if c == L_GAME and s.key in self.modified:
return icons.icon("apply", icons.ACCENT)
if role == Qt.ItemDataRole.FontRole and c == L_NAME and s.key in self.modified:
f = QFont()
f.setBold(True)
return f
if role == Qt.ItemDataRole.ToolTipRole:
tip = "%s\n%s • %s\n%s" % (s.name, s.group + " / " + s.category, s.character or "—", self.state(s))
if n_fx:
tip += "\nEffects: " + fx.summary(rec.get("fx"))
return tip
return None
# --- the tab -------------------------------------------------------------------------------------
class EditPanel(QWidget):
message = Signal(str)
def __init__(self, ctx, parent=None, store=None):
super().__init__(parent)
self.ctx = ctx # main window: .ws, .runner, .player, .settings, ._after_change
self.store = store or EditStore()
self._restoring = False
self._build()
def _build(self):
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
left = QWidget()
ll = QVBoxLayout(left)
ll.setContentsMargins(6, 6, 0, 6)
ll.setSpacing(6)
title = QLabel("EDIT LIST")
f = title.font()
f.setBold(True)
title.setFont(f)
ll.addWidget(title)
self.filter = QLineEdit()
self.filter.setPlaceholderText("Filter the list…")
self.filter.setClearButtonEnabled(True)
self.filter.textChanged.connect(lambda _: self._apply_filter())
ll.addWidget(self.filter)
self.model = EditListModel(self.store, self)
self.table = QTableView()
self.table.setModel(self.model)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.table.verticalHeader().hide()
self.table.verticalHeader().setDefaultSectionSize(22)
self.table.setAlternatingRowColors(True)
self.table.setWordWrap(False)
self.table.setTextElideMode(Qt.TextElideMode.ElideLeft) # names differ at their end
h = self.table.horizontalHeader()
h.setSectionResizeMode(L_NAME, QHeaderView.ResizeMode.Stretch)
for c in (L_SAVED, L_FX, L_GAME):
h.setSectionResizeMode(c, QHeaderView.ResizeMode.ResizeToContents)
self.table.selectionModel().currentRowChanged.connect(
lambda cur, _: self.load(self.model.sounds[cur.row()]) if cur.isValid() else None)
ll.addWidget(self.table, 1)
self.lbl_count = QLabel()
self.lbl_count.setObjectName("Muted")
self.lbl_count.setWordWrap(True)
ll.addWidget(self.lbl_count)
row = QHBoxLayout()
self.b_remove = QPushButton("Remove")
self.b_remove.setToolTip("Take the selected sounds off the list (their replacements and settings are kept)")
self.b_remove.clicked.connect(self.remove_selected)
self.b_clear = QPushButton("Remove applied")
self.b_clear.setToolTip("Take every sound whose replacement is in the game off the list")
self.b_clear.clicked.connect(self.remove_applied)
row.addWidget(self.b_remove, 1)
row.addWidget(self.b_clear, 1)
ll.addLayout(row)
self.b_use = QPushButton("Use these effects for the selected")
self.b_use.setToolTip("Give every selected sound of the list the effects shown on the right (their "
"replacements stay)")
self.b_use.clicked.connect(self.use_for_selected)
ll.addWidget(self.b_use)
self.editor = EditorPanel(self.ctx)
self.editor.message.connect(self.message)
self.editor.source_changed.connect(self._source_changed)
self.editor.edited.connect(lambda: self._save_timer.start())
self.editor.applied.connect(self._after_write)
self.editor.reverted.connect(self._after_write)
self.editor.b_prev.clicked.connect(lambda: self.step(-1))
self.editor.b_next.clicked.connect(lambda: self.step(1))
es = QScrollArea()
es.setWidget(self.editor)
es.setWidgetResizable(True)
mid = QWidget() # the editor scrolls; its action bar stays visible
ml = QVBoxLayout(mid)
ml.setContentsMargins(0, 0, 0, 0)
ml.setSpacing(0)
ml.addWidget(es, 1)
self.editor.layout().removeWidget(self.editor.action_bar)
ml.addWidget(self.editor.action_bar)
self.rack = EffectsRack()
self.rack.changed.connect(self._effects_changed)
rs = QScrollArea()
rs.setWidget(self.rack)
rs.setWidgetResizable(True)
rs.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
rs.setMinimumWidth(self.rack.minimumSizeHint().width() + rs.verticalScrollBar().sizeHint().width() + 4)
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(left)
split.addWidget(mid)
split.addWidget(rs)
split.setStretchFactor(1, 1)
split.setSizes([250, 100000, 360])
split.setChildrenCollapsible(False)
lay.addWidget(split)
self.split = split
ctx = Qt.ShortcutContext.WidgetWithChildrenShortcut
QShortcut(QKeySequence("Alt+Left"), self, lambda: self.step(-1), context=ctx)
QShortcut(QKeySequence("Alt+Right"), self, lambda: self.step(1), context=ctx)
QShortcut(QKeySequence(Qt.Key.Key_Space), self.table, self.editor.toggle_play,
context=Qt.ShortcutContext.WidgetShortcut)
QShortcut(QKeySequence(Qt.Key.Key_Delete), self.table, self.remove_selected,
context=Qt.ShortcutContext.WidgetShortcut)
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(300)
self._save_timer.timeout.connect(self._save_settings)
self._nav()
# --- list ------------------------------------------------------------------------------------
def populate(self, ws):
"""The list's sounds that the loaded catalog has (sounds of another language stay on the list)."""
cur = self.editor.sound
sounds = [ws.catalog.by_key[k] for k in self.store.list if k in ws.catalog.by_key]
self.model.reset(sounds, ws.modified_keys())
self.editor.refresh_devices()
self._apply_filter()
if cur and cur.key in {s.key for s in sounds}:
self._select(ws.catalog.by_key[cur.key])
elif cur:
self.editor.set_sound(None)
self._nav()
def refresh(self, modified=None):
self.model.refresh(modified)
self._count()
def _count(self):
n, applied = len(self.model.sounds), sum(1 for s in self.model.sounds if s.key in self.model.modified)
other = len(self.store.list) - n
self.lbl_count.setText("%d sound(s), %d applied%s" % (n, applied, "; %d of another language hidden" % other
if other else "") if n or other else
"Empty: select sounds in the Sounds tab and press Edit.")
def _apply_filter(self):
words = self.filter.text().lower().split()
for r, s in enumerate(self.model.sounds):
hay = (s.name + " " + s.character + " " + s.category).lower()
self.table.setRowHidden(r, not all(w in hay for w in words))
self._count()
def add(self, sounds, open_first=False):
"""Put sounds on the list (once); with open_first, show the first of them."""
ws = self.ctx.ws
self.store.add([s.key for s in sounds])
known = {s.key for s in self.model.sounds}
if any(s.key not in known for s in sounds):
self.model.reset([ws.catalog.by_key[k] for k in self.store.list if k in ws.catalog.by_key],
ws.modified_keys())
self._apply_filter()
if open_first and sounds:
self._select(sounds[0])
self._nav()
def _row(self, s):
return next((i for i, x in enumerate(self.model.sounds) if x.key == s.key), -1)
def _select(self, s):
r = self._row(s)
if r < 0:
return False
idx = self.model.index(r, L_NAME)
if self.table.isRowHidden(r):
self.filter.clear()
if self.table.currentIndex().row() == r and self.editor.sound and self.editor.sound.key == s.key:
return True
self.table.setCurrentIndex(idx)
self.table.scrollTo(idx)
return True
def select_name(self, name):
s = next((x for x in self.model.sounds if x.name == name), None)
return bool(s) and self._select(s)
def step(self, d):
"""Previous / next visible sound of the list."""
rows = [r for r in range(len(self.model.sounds)) if not self.table.isRowHidden(r)]
if not rows:
return
cur = self.table.currentIndex().row()
nxt = [r for r in rows if (r > cur if d > 0 else r < cur)]
if nxt:
self._select(self.model.sounds[nxt[0] if d > 0 else nxt[-1]])
def _nav(self):
rows = [r for r in range(len(self.model.sounds)) if not self.table.isRowHidden(r)]
cur = self.table.currentIndex().row()
self.editor.b_prev.setEnabled(any(r < cur for r in rows))
self.editor.b_next.setEnabled(any(r > cur for r in rows))
def selected_sounds(self):
return [self.model.sounds[i.row()] for i in self.table.selectionModel().selectedRows()]
def remove_selected(self):
self._remove(self.selected_sounds())
def remove_applied(self):
self._remove([s for s in self.model.sounds if s.key in self.model.modified])
def _remove(self, sounds):
if not sounds:
return
self.store.remove([s.key for s in sounds])
gone = {s.key for s in sounds}
if self.editor.sound and self.editor.sound.key in gone:
self.editor.set_sound(None)
self.model.reset([s for s in self.model.sounds if s.key not in gone], self.model.modified)
self._apply_filter()
self._nav()
def use_for_selected(self):
settings = self.rack.settings()
sounds = self.selected_sounds()
for s in sounds:
self.store.update(s.key, fx=settings)
self.refresh()
self.message.emit("Effects set for %d sound(s)" % len(sounds))
# --- one sound -------------------------------------------------------------------------------
def flush(self):
"""Write pending edit values now (switching sounds, window closing)."""
if self._save_timer.isActive():
self._save_timer.stop()
self._save_settings()
def load(self, s):
"""Show `s` with its saved replacement, edit values and effects."""
self.flush()
self._restoring = True
try:
rec = self.store.get(s.key) if s else {}
settings = fx.normalize(rec.get("fx"))
self.rack.set_settings(settings)
self.rack.set_channels(s.channels if s else 1)
self.editor.effects = settings
self.editor.set_sound(s)
ed = self.editor
saved = self.store.load_replacement(s.key) if s else None
if saved:
ed.set_source(saved[0], saved[1], rec.get("source") or "replacement", restoring=True)
a, b = rec.get("trim") or (0, 0)
if b > a:
ed.sp_a.setValue(a)
ed.sp_b.setValue(b)
ed.sp_gain.setValue(rec.get("gain", 0.0))
ed.cb_norm.setChecked(rec.get("normalize", False))
ed.cb_match.setChecked(rec.get("match", s.group == "Speech"))
else:
ed.cb_match.setChecked(bool(s) and s.group == "Speech") # voices: as loud as the original
finally:
self._restoring = False
self._save_timer.stop()
self._nav()
def _source_changed(self):
s, ed = self.editor.sound, self.editor
if self._restoring or not s or not ed.src:
return
self.store.save_replacement(s.key, ed.src[0], ed.src[1], ed.src_name)
self._save_settings()
self.refresh()
def _effects_changed(self, settings):
if self._restoring:
return
self.editor.set_effects(settings)
self._save_timer.start()
def _save_settings(self):
s, ed = self.editor.sound, self.editor
if self._restoring or not s:
return
rec = {"fx": self.rack.settings(), "gain": ed.sp_gain.value(), "normalize": ed.cb_norm.isChecked(),
"match": ed.cb_match.isChecked()}
if ed.src:
rec["trim"] = [ed.sp_a.value(), ed.sp_b.value()]
old = self.store.get(s.key)
if not ed.src and not fx.active(rec["fx"]) and not old:
return # nothing to keep for an untouched sound
if any(old.get(k) != v for k, v in rec.items()):
self.store.update(s.key, **rec)
self.refresh()
def _after_write(self, key):
self.ctx._after_change(key)
s = self.editor.sound
if s and s.key == key:
QTimer.singleShot(0, lambda: self.load(s) if self.editor.sound is s else None)

View File

@@ -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
View 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()

View File

@@ -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))

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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()

View File

@@ -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; }