329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""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()
|