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:
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()
|
||||
Reference in New Issue
Block a user