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