| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- """
- EQ Panel — Interactive UI for the Parametric EQ Engine.
- """
- from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
- QPushButton, QLabel, QSlider, QGroupBox,
- QCheckBox, QScrollArea, QComboBox, QFileDialog)
- from PyQt6.QtCore import Qt, QSettings
- import os
- import pyqtgraph as pg
- import numpy as np
- import scipy.signal as signal
- from core.eq_engine import EQEngine, EQBand
- from core.tape_processor import TapeProcessor
- class EQBandWidget(QGroupBox):
- """Widget for controlling a single EQ band."""
- def __init__(self, band: EQBand, index: int, parent_panel):
- super().__init__(f"Band {index + 1}")
- self.band = band
- self.parent_panel = parent_panel
-
- self.setFixedWidth(80)
- self._init_ui()
- self._update_ui_from_band()
-
- def _init_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(5, 10, 5, 5)
- layout.setSpacing(10)
-
- # Enabled toggle
- self.enabled_cb = QCheckBox("On")
- self.enabled_cb.clicked.connect(self._on_changed)
- layout.addWidget(self.enabled_cb, alignment=Qt.AlignmentFlag.AlignHCenter)
-
- # Gain slider (-15 to +15 dB)
- self.gain_slider = QSlider(Qt.Orientation.Vertical)
- self.gain_slider.setRange(-150, 150) # -15.0 to 15.0 dB
- self.gain_slider.setTickPosition(QSlider.TickPosition.TicksBothSides)
- self.gain_slider.setTickInterval(50)
- self.gain_slider.valueChanged.connect(self._on_gain_changed)
- layout.addWidget(self.gain_slider, alignment=Qt.AlignmentFlag.AlignHCenter)
-
- # Gain Label
- self.gain_lbl = QLabel("0.0 dB")
- self.gain_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(self.gain_lbl)
-
- # Freq
- freq_lbl = QLabel("Freq (Hz):")
- freq_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
- freq_lbl.setStyleSheet("font-size: 8pt; color: #808090;")
- layout.addWidget(freq_lbl)
-
- self.freq_val = QLabel(f"{self.band.freq:.0f}")
- self.freq_val.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(self.freq_val)
-
- # Q
- q_lbl = QLabel("Q:")
- q_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
- q_lbl.setStyleSheet("font-size: 8pt; color: #808090;")
- layout.addWidget(q_lbl)
-
- self.q_val = QLabel(f"{self.band.q:.2f}")
- self.q_val.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(self.q_val)
-
- def _update_ui_from_band(self):
- """Update widgets when the underlying band object changes (e.g. after calibration)."""
- self.enabled_cb.setChecked(self.band.enabled)
- # Block signals to avoid infinite loop
- self.gain_slider.blockSignals(True)
- self.gain_slider.setValue(int(self.band.gain * 10))
- self.gain_slider.blockSignals(False)
- self.gain_lbl.setText(f"{self.band.gain:.1f} dB")
- self.freq_val.setText(f"{self.band.freq:.0f}")
- self.q_val.setText(f"{self.band.q:.2f}")
-
- def _on_gain_changed(self, value):
- self.band.gain = value / 10.0
- self.gain_lbl.setText(f"{self.band.gain:.1f} dB")
- self.parent_panel.on_band_changed()
-
- def _on_changed(self):
- self.band.enabled = self.enabled_cb.isChecked()
- self.parent_panel.on_band_changed()
- class EQPanel(QWidget):
- def __init__(self, eq_engine: EQEngine, tape_processor: TapeProcessor):
- super().__init__()
- self.eq_engine = eq_engine
- self.tape_processor = tape_processor
- self.band_widgets = []
- self.settings = QSettings("TapeRecorder", "EQ")
- self._init_ui()
-
- last_profile = self.settings.value("last_profile", "")
- if last_profile and os.path.exists(last_profile):
- try:
- self.eq_engine.load_bands(last_profile)
- except Exception as e:
- print(f"Failed to load last profile: {e}")
-
- self.refresh_ui()
- def _init_ui(self):
- layout = QVBoxLayout(self)
-
- # --- Top: Response Plot ---
- pg.setConfigOption('background', '#ffffff')
- pg.setConfigOption('foreground', '#333333')
- self.plot_widget = pg.PlotWidget(title="Combined EQ Response")
- self.plot_widget.setLogMode(x=True, y=False)
- self.plot_widget.setXRange(np.log10(20), np.log10(20000))
- self.plot_widget.setYRange(-20, 20)
- self.plot_widget.setLabel('bottom', 'Frequency', units='Hz')
- self.plot_widget.setLabel('left', 'Gain', units='dB')
- self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
-
- self.curve = self.plot_widget.plot(pen=pg.mkPen('#ff7b00', width=2))
- self.plot_widget.setFixedHeight(250)
-
- layout.addWidget(self.plot_widget)
-
- # --- Middle: Band Controls ---
- scroll = QScrollArea()
- scroll.setWidgetResizable(True)
- scroll.setStyleSheet("border: none;")
-
- bands_container = QWidget()
- self.bands_layout = QHBoxLayout(bands_container)
- self.bands_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
-
- scroll.setWidget(bands_container)
- layout.addWidget(scroll)
-
- # --- Bottom: Presets & Tools & Tape Processing ---
- bottom_layout = QHBoxLayout()
-
- # Tools Group
- tools_grp = QGroupBox("EQ Tools")
- tools_layout = QVBoxLayout(tools_grp)
- row1 = QHBoxLayout()
-
- self.bypass_cb = QCheckBox("Bypass EQ")
- self.bypass_cb.setStyleSheet("font-weight: bold; color: #F44336;")
- self.bypass_cb.clicked.connect(self._on_bypass)
- row1.addWidget(self.bypass_cb)
- row1.addStretch()
-
- row1.addWidget(QLabel("Presets:"))
- self.preset_combo = QComboBox()
- self.preset_combo.addItems(["Custom", "Flat", "Type I (Ferric) Boost", "Type II (Chrome) Cut"])
- self.preset_combo.currentIndexChanged.connect(self._on_preset)
- row1.addWidget(self.preset_combo)
-
- reset_btn = QPushButton("Reset to Flat")
- reset_btn.clicked.connect(self._reset_flat)
- row1.addWidget(reset_btn)
- tools_layout.addLayout(row1)
-
- row2 = QHBoxLayout()
- save_btn = QPushButton("Save Profile...")
- save_btn.clicked.connect(self._save_profile)
- load_btn = QPushButton("Load Profile...")
- load_btn.clicked.connect(self._load_profile)
- row2.addWidget(save_btn)
- row2.addWidget(load_btn)
- row2.addStretch()
- tools_layout.addLayout(row2)
- bottom_layout.addWidget(tools_grp)
-
- # Tape Processing Group
- tape_grp = QGroupBox("Tape Processing (Record Mode)")
- tape_layout = QVBoxLayout(tape_grp)
-
- t_row1 = QHBoxLayout()
- self.tape_hp_cb = QCheckBox("High-Pass (Sub-Bass Cut)")
- self.tape_hp_cb.setChecked(self.tape_processor.enable_highpass)
- self.tape_hp_cb.clicked.connect(self._on_tape_proc_changed)
- t_row1.addWidget(self.tape_hp_cb)
-
- self.tape_lp_cb = QCheckBox("Low-Pass (Ultrasonic Cut)")
- self.tape_lp_cb.setChecked(self.tape_processor.enable_lowpass)
- self.tape_lp_cb.clicked.connect(self._on_tape_proc_changed)
- t_row1.addWidget(self.tape_lp_cb)
- tape_layout.addLayout(t_row1)
-
- t_row2 = QHBoxLayout()
- self.tape_sat_cb = QCheckBox("Soft Saturation (Limiter)")
- self.tape_sat_cb.setChecked(self.tape_processor.enable_saturation)
- self.tape_sat_cb.clicked.connect(self._on_tape_proc_changed)
- t_row2.addWidget(self.tape_sat_cb)
-
- self.sat_thresh_slider = QSlider(Qt.Orientation.Horizontal)
- self.sat_thresh_slider.setRange(10, 200) # 0.1 to 2.0
- self.sat_thresh_slider.setValue(int(self.tape_processor.saturation_threshold * 100))
- self.sat_thresh_slider.setToolTip("Saturation Threshold")
- self.sat_thresh_slider.valueChanged.connect(self._on_sat_slider)
- t_row2.addWidget(self.sat_thresh_slider)
- tape_layout.addLayout(t_row2)
-
- bottom_layout.addWidget(tape_grp)
-
- layout.addLayout(bottom_layout)
- def refresh_ui(self):
- """Rebuild the UI to match the EQEngine's current bands."""
- # Clear existing widgets
- for i in reversed(range(self.bands_layout.count())):
- self.bands_layout.itemAt(i).widget().setParent(None)
-
- self.band_widgets = []
-
- # Create new widgets
- for i, band in enumerate(self.eq_engine.bands):
- bw = EQBandWidget(band, i, self)
- self.bands_layout.addWidget(bw)
- self.band_widgets.append(bw)
-
- self.update_plot()
-
- def on_band_changed(self):
- """Called by a child BandWidget when a parameter changes."""
- self.preset_combo.blockSignals(True)
- self.preset_combo.setCurrentIndex(0) # Custom
- self.preset_combo.blockSignals(False)
-
- if not self.bypass_cb.isChecked():
- self.eq_engine.update_filters()
-
- self.update_plot()
-
- def _on_bypass(self):
- if self.bypass_cb.isChecked():
- # Clear filters in engine to bypass
- self.eq_engine._sos = None
- self.eq_engine._zi = None
- else:
- self.eq_engine.update_filters()
- self.update_plot()
- def _on_tape_proc_changed(self):
- self.tape_processor.enable_highpass = self.tape_hp_cb.isChecked()
- self.tape_processor.enable_lowpass = self.tape_lp_cb.isChecked()
- self.tape_processor.enable_saturation = self.tape_sat_cb.isChecked()
- self.tape_processor._update_filters()
- self.update_plot()
-
- def _on_sat_slider(self, val):
- self.tape_processor.saturation_threshold = val / 100.0
- def update_plot(self):
- """Recalculate and redraw the frequency response curve."""
- freqs = np.logspace(np.log10(20), np.log10(20000), 500)
-
- if self.bypass_cb.isChecked():
- response = np.zeros_like(freqs)
- else:
- # Need to temporarily generate SOS for the plot if bypassed, but here we just
- # Use the active SOS or calculate it from the bands.
- engine = EQEngine(self.eq_engine.sample_rate)
-
- all_bands = list(self.eq_engine.bands)
- if self.tape_processor.enable_highpass or self.tape_processor.enable_lowpass:
- all_bands.extend(self.tape_processor._filter_engine.bands)
-
- engine.bands = all_bands
- engine.update_filters()
-
- if engine._sos is None:
- response = np.zeros_like(freqs)
- else:
- w = 2 * np.pi * freqs / engine.sample_rate
- _, h = signal.sosfreqz(engine._sos, worN=w)
- response = 20 * np.log10(np.abs(h) + 1e-10)
-
- self.curve.setData(freqs, response)
- def _reset_flat(self):
- for band in self.eq_engine.bands:
- band.gain = 0.0
-
- for bw in self.band_widgets:
- bw._update_ui_from_band()
-
- self.on_band_changed()
-
- def _on_preset(self, index):
- if index == 1: # Flat
- self._reset_flat()
- elif index == 2: # Type I Boost (example)
- self._reset_flat()
- # Boost highs to compensate for tape roll-off
- if len(self.eq_engine.bands) > 0:
- self.eq_engine.bands[-1].gain = 6.0 # High shelf boost
- elif index == 3: # Type II Cut (example)
- self._reset_flat()
- # Cut highs if playing 70us tape on 120us setting
- if len(self.eq_engine.bands) > 0:
- self.eq_engine.bands[-1].gain = -4.5 # High shelf cut
-
- for bw in self.band_widgets:
- bw._update_ui_from_band()
-
- self.eq_engine.update_filters()
- self.update_plot()
-
- def _save_profile(self):
- path, _ = QFileDialog.getSaveFileName(self, "Save EQ Profile", "", "JSON Files (*.json)")
- if path:
- self.eq_engine.save_bands(path)
- self.settings.setValue("last_profile", path)
-
- def _load_profile(self):
- path, _ = QFileDialog.getOpenFileName(self, "Load EQ Profile", "", "JSON Files (*.json)")
- if path:
- try:
- self.eq_engine.load_bands(path)
- self.settings.setValue("last_profile", path)
- self.refresh_ui()
- except Exception as e:
- from PyQt6.QtWidgets import QMessageBox
- QMessageBox.warning(self, "Error", f"Failed to load profile:\n{e}")
|