eq_panel.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. """
  2. EQ Panel — Interactive UI for the Parametric EQ Engine.
  3. """
  4. from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
  5. QPushButton, QLabel, QSlider, QGroupBox,
  6. QCheckBox, QScrollArea, QComboBox, QFileDialog)
  7. from PyQt6.QtCore import Qt, QSettings
  8. import os
  9. import pyqtgraph as pg
  10. import numpy as np
  11. import scipy.signal as signal
  12. from core.eq_engine import EQEngine, EQBand
  13. from core.tape_processor import TapeProcessor
  14. class EQBandWidget(QGroupBox):
  15. """Widget for controlling a single EQ band."""
  16. def __init__(self, band: EQBand, index: int, parent_panel):
  17. super().__init__(f"Band {index + 1}")
  18. self.band = band
  19. self.parent_panel = parent_panel
  20. self.setFixedWidth(80)
  21. self._init_ui()
  22. self._update_ui_from_band()
  23. def _init_ui(self):
  24. layout = QVBoxLayout(self)
  25. layout.setContentsMargins(5, 10, 5, 5)
  26. layout.setSpacing(10)
  27. # Enabled toggle
  28. self.enabled_cb = QCheckBox("On")
  29. self.enabled_cb.clicked.connect(self._on_changed)
  30. layout.addWidget(self.enabled_cb, alignment=Qt.AlignmentFlag.AlignHCenter)
  31. # Gain slider (-15 to +15 dB)
  32. self.gain_slider = QSlider(Qt.Orientation.Vertical)
  33. self.gain_slider.setRange(-150, 150) # -15.0 to 15.0 dB
  34. self.gain_slider.setTickPosition(QSlider.TickPosition.TicksBothSides)
  35. self.gain_slider.setTickInterval(50)
  36. self.gain_slider.valueChanged.connect(self._on_gain_changed)
  37. layout.addWidget(self.gain_slider, alignment=Qt.AlignmentFlag.AlignHCenter)
  38. # Gain Label
  39. self.gain_lbl = QLabel("0.0 dB")
  40. self.gain_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  41. layout.addWidget(self.gain_lbl)
  42. # Freq
  43. freq_lbl = QLabel("Freq (Hz):")
  44. freq_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  45. freq_lbl.setStyleSheet("font-size: 8pt; color: #808090;")
  46. layout.addWidget(freq_lbl)
  47. self.freq_val = QLabel(f"{self.band.freq:.0f}")
  48. self.freq_val.setAlignment(Qt.AlignmentFlag.AlignCenter)
  49. layout.addWidget(self.freq_val)
  50. # Q
  51. q_lbl = QLabel("Q:")
  52. q_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  53. q_lbl.setStyleSheet("font-size: 8pt; color: #808090;")
  54. layout.addWidget(q_lbl)
  55. self.q_val = QLabel(f"{self.band.q:.2f}")
  56. self.q_val.setAlignment(Qt.AlignmentFlag.AlignCenter)
  57. layout.addWidget(self.q_val)
  58. def _update_ui_from_band(self):
  59. """Update widgets when the underlying band object changes (e.g. after calibration)."""
  60. self.enabled_cb.setChecked(self.band.enabled)
  61. # Block signals to avoid infinite loop
  62. self.gain_slider.blockSignals(True)
  63. self.gain_slider.setValue(int(self.band.gain * 10))
  64. self.gain_slider.blockSignals(False)
  65. self.gain_lbl.setText(f"{self.band.gain:.1f} dB")
  66. self.freq_val.setText(f"{self.band.freq:.0f}")
  67. self.q_val.setText(f"{self.band.q:.2f}")
  68. def _on_gain_changed(self, value):
  69. self.band.gain = value / 10.0
  70. self.gain_lbl.setText(f"{self.band.gain:.1f} dB")
  71. self.parent_panel.on_band_changed()
  72. def _on_changed(self):
  73. self.band.enabled = self.enabled_cb.isChecked()
  74. self.parent_panel.on_band_changed()
  75. class EQPanel(QWidget):
  76. def __init__(self, eq_engine: EQEngine, tape_processor: TapeProcessor):
  77. super().__init__()
  78. self.eq_engine = eq_engine
  79. self.tape_processor = tape_processor
  80. self.band_widgets = []
  81. self.settings = QSettings("TapeRecorder", "EQ")
  82. self._init_ui()
  83. last_profile = self.settings.value("last_profile", "")
  84. if last_profile and os.path.exists(last_profile):
  85. try:
  86. self.eq_engine.load_bands(last_profile)
  87. except Exception as e:
  88. print(f"Failed to load last profile: {e}")
  89. self.refresh_ui()
  90. def _init_ui(self):
  91. layout = QVBoxLayout(self)
  92. # --- Top: Response Plot ---
  93. pg.setConfigOption('background', '#161625')
  94. self.plot_widget = pg.PlotWidget(title="Combined EQ Response")
  95. self.plot_widget.setLogMode(x=True, y=False)
  96. self.plot_widget.setXRange(np.log10(20), np.log10(20000))
  97. self.plot_widget.setYRange(-20, 20)
  98. self.plot_widget.setLabel('bottom', 'Frequency', units='Hz')
  99. self.plot_widget.setLabel('left', 'Gain', units='dB')
  100. self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
  101. self.curve = self.plot_widget.plot(pen=pg.mkPen('#ff7b00', width=2))
  102. self.plot_widget.setFixedHeight(250)
  103. layout.addWidget(self.plot_widget)
  104. # --- Middle: Band Controls ---
  105. scroll = QScrollArea()
  106. scroll.setWidgetResizable(True)
  107. scroll.setStyleSheet("border: none;")
  108. bands_container = QWidget()
  109. self.bands_layout = QHBoxLayout(bands_container)
  110. self.bands_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
  111. scroll.setWidget(bands_container)
  112. layout.addWidget(scroll)
  113. # --- Bottom: Presets & Tools & Tape Processing ---
  114. bottom_layout = QHBoxLayout()
  115. # Tools Group
  116. tools_grp = QGroupBox("EQ Tools")
  117. tools_layout = QVBoxLayout(tools_grp)
  118. row1 = QHBoxLayout()
  119. self.bypass_cb = QCheckBox("Bypass EQ")
  120. self.bypass_cb.setStyleSheet("font-weight: bold; color: #F44336;")
  121. self.bypass_cb.clicked.connect(self._on_bypass)
  122. row1.addWidget(self.bypass_cb)
  123. row1.addStretch()
  124. row1.addWidget(QLabel("Presets:"))
  125. self.preset_combo = QComboBox()
  126. self.preset_combo.addItems(["Custom", "Flat", "Type I (Ferric) Boost", "Type II (Chrome) Cut"])
  127. self.preset_combo.currentIndexChanged.connect(self._on_preset)
  128. row1.addWidget(self.preset_combo)
  129. reset_btn = QPushButton("Reset to Flat")
  130. reset_btn.clicked.connect(self._reset_flat)
  131. row1.addWidget(reset_btn)
  132. tools_layout.addLayout(row1)
  133. row2 = QHBoxLayout()
  134. save_btn = QPushButton("Save Profile...")
  135. save_btn.clicked.connect(self._save_profile)
  136. load_btn = QPushButton("Load Profile...")
  137. load_btn.clicked.connect(self._load_profile)
  138. row2.addWidget(save_btn)
  139. row2.addWidget(load_btn)
  140. row2.addStretch()
  141. tools_layout.addLayout(row2)
  142. bottom_layout.addWidget(tools_grp)
  143. # Tape Processing Group
  144. tape_grp = QGroupBox("Tape Processing (Record Mode)")
  145. tape_layout = QVBoxLayout(tape_grp)
  146. t_row1 = QHBoxLayout()
  147. self.tape_hp_cb = QCheckBox("High-Pass (Sub-Bass Cut)")
  148. self.tape_hp_cb.setChecked(self.tape_processor.enable_highpass)
  149. self.tape_hp_cb.clicked.connect(self._on_tape_proc_changed)
  150. t_row1.addWidget(self.tape_hp_cb)
  151. self.tape_lp_cb = QCheckBox("Low-Pass (Ultrasonic Cut)")
  152. self.tape_lp_cb.setChecked(self.tape_processor.enable_lowpass)
  153. self.tape_lp_cb.clicked.connect(self._on_tape_proc_changed)
  154. t_row1.addWidget(self.tape_lp_cb)
  155. tape_layout.addLayout(t_row1)
  156. t_row2 = QHBoxLayout()
  157. self.tape_sat_cb = QCheckBox("Soft Saturation (Limiter)")
  158. self.tape_sat_cb.setChecked(self.tape_processor.enable_saturation)
  159. self.tape_sat_cb.clicked.connect(self._on_tape_proc_changed)
  160. t_row2.addWidget(self.tape_sat_cb)
  161. self.sat_thresh_slider = QSlider(Qt.Orientation.Horizontal)
  162. self.sat_thresh_slider.setRange(10, 200) # 0.1 to 2.0
  163. self.sat_thresh_slider.setValue(int(self.tape_processor.saturation_threshold * 100))
  164. self.sat_thresh_slider.setToolTip("Saturation Threshold")
  165. self.sat_thresh_slider.valueChanged.connect(self._on_sat_slider)
  166. t_row2.addWidget(self.sat_thresh_slider)
  167. tape_layout.addLayout(t_row2)
  168. bottom_layout.addWidget(tape_grp)
  169. layout.addLayout(bottom_layout)
  170. def refresh_ui(self):
  171. """Rebuild the UI to match the EQEngine's current bands."""
  172. # Clear existing widgets
  173. for i in reversed(range(self.bands_layout.count())):
  174. self.bands_layout.itemAt(i).widget().setParent(None)
  175. self.band_widgets = []
  176. # Create new widgets
  177. for i, band in enumerate(self.eq_engine.bands):
  178. bw = EQBandWidget(band, i, self)
  179. self.bands_layout.addWidget(bw)
  180. self.band_widgets.append(bw)
  181. self.update_plot()
  182. def on_band_changed(self):
  183. """Called by a child BandWidget when a parameter changes."""
  184. self.preset_combo.blockSignals(True)
  185. self.preset_combo.setCurrentIndex(0) # Custom
  186. self.preset_combo.blockSignals(False)
  187. if not self.bypass_cb.isChecked():
  188. self.eq_engine.update_filters()
  189. self.update_plot()
  190. def _on_bypass(self):
  191. if self.bypass_cb.isChecked():
  192. # Clear filters in engine to bypass
  193. self.eq_engine._sos = None
  194. self.eq_engine._zi = None
  195. else:
  196. self.eq_engine.update_filters()
  197. self.update_plot()
  198. def _on_tape_proc_changed(self):
  199. self.tape_processor.enable_highpass = self.tape_hp_cb.isChecked()
  200. self.tape_processor.enable_lowpass = self.tape_lp_cb.isChecked()
  201. self.tape_processor.enable_saturation = self.tape_sat_cb.isChecked()
  202. self.tape_processor._update_filters()
  203. self.update_plot()
  204. def _on_sat_slider(self, val):
  205. self.tape_processor.saturation_threshold = val / 100.0
  206. def update_plot(self):
  207. """Recalculate and redraw the frequency response curve."""
  208. freqs = np.logspace(np.log10(20), np.log10(20000), 500)
  209. if self.bypass_cb.isChecked():
  210. response = np.zeros_like(freqs)
  211. else:
  212. # Need to temporarily generate SOS for the plot if bypassed, but here we just
  213. # Use the active SOS or calculate it from the bands.
  214. engine = EQEngine(self.eq_engine.sample_rate)
  215. all_bands = list(self.eq_engine.bands)
  216. if self.tape_processor.enable_highpass or self.tape_processor.enable_lowpass:
  217. all_bands.extend(self.tape_processor._filter_engine.bands)
  218. engine.bands = all_bands
  219. engine.update_filters()
  220. if engine._sos is None:
  221. response = np.zeros_like(freqs)
  222. else:
  223. w = 2 * np.pi * freqs / engine.sample_rate
  224. _, h = signal.sosfreqz(engine._sos, worN=w)
  225. response = 20 * np.log10(np.abs(h) + 1e-10)
  226. self.curve.setData(freqs, response)
  227. def _reset_flat(self):
  228. for band in self.eq_engine.bands:
  229. band.gain = 0.0
  230. for bw in self.band_widgets:
  231. bw._update_ui_from_band()
  232. self.on_band_changed()
  233. def _on_preset(self, index):
  234. if index == 1: # Flat
  235. self._reset_flat()
  236. elif index == 2: # Type I Boost (example)
  237. self._reset_flat()
  238. # Boost highs to compensate for tape roll-off
  239. if len(self.eq_engine.bands) > 0:
  240. self.eq_engine.bands[-1].gain = 6.0 # High shelf boost
  241. elif index == 3: # Type II Cut (example)
  242. self._reset_flat()
  243. # Cut highs if playing 70us tape on 120us setting
  244. if len(self.eq_engine.bands) > 0:
  245. self.eq_engine.bands[-1].gain = -4.5 # High shelf cut
  246. for bw in self.band_widgets:
  247. bw._update_ui_from_band()
  248. self.eq_engine.update_filters()
  249. self.update_plot()
  250. def _save_profile(self):
  251. path, _ = QFileDialog.getSaveFileName(self, "Save EQ Profile", "", "JSON Files (*.json)")
  252. if path:
  253. self.eq_engine.save_bands(path)
  254. self.settings.setValue("last_profile", path)
  255. def _load_profile(self):
  256. path, _ = QFileDialog.getOpenFileName(self, "Load EQ Profile", "", "JSON Files (*.json)")
  257. if path:
  258. try:
  259. self.eq_engine.load_bands(path)
  260. self.settings.setValue("last_profile", path)
  261. self.refresh_ui()
  262. except Exception as e:
  263. from PyQt6.QtWidgets import QMessageBox
  264. QMessageBox.warning(self, "Error", f"Failed to load profile:\n{e}")