Files
UnheardCity/app/effects.py

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