eq_panel.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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', '#ffffff')
  94. pg.setConfigOption('foreground', '#333333')
  95. self.plot_widget = pg.PlotWidget(title="Combined EQ Response")
  96. self.plot_widget.setLogMode(x=True, y=False)
  97. self.plot_widget.setXRange(np.log10(20), np.log10(20000))
  98. self.plot_widget.setYRange(-20, 20)
  99. self.plot_widget.setLabel('bottom', 'Frequency', units='Hz')
  100. self.plot_widget.setLabel('left', 'Gain', units='dB')
  101. self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
  102. self.curve = self.plot_widget.plot(pen=pg.mkPen('#ff7b00', width=2))
  103. self.plot_widget.setFixedHeight(250)
  104. layout.addWidget(self.plot_widget)
  105. # --- Middle: Band Controls ---
  106. scroll = QScrollArea()
  107. scroll.setWidgetResizable(True)
  108. scroll.setStyleSheet("border: none;")
  109. bands_container = QWidget()
  110. self.bands_layout = QHBoxLayout(bands_container)
  111. self.bands_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
  112. scroll.setWidget(bands_container)
  113. layout.addWidget(scroll)
  114. # --- Bottom: Presets & Tools & Tape Processing ---
  115. bottom_layout = QHBoxLayout()
  116. # Tools Group
  117. tools_grp = QGroupBox("EQ Tools")
  118. tools_layout = QVBoxLayout(tools_grp)
  119. row1 = QHBoxLayout()
  120. self.bypass_cb = QCheckBox("Bypass EQ")
  121. self.bypass_cb.setStyleSheet("font-weight: bold; color: #F44336;")
  122. self.bypass_cb.clicked.connect(self._on_bypass)
  123. row1.addWidget(self.bypass_cb)
  124. row1.addStretch()
  125. row1.addWidget(QLabel("Presets:"))
  126. self.preset_combo = QComboBox()
  127. self.preset_combo.addItems(["Custom", "Flat", "Type I (Ferric) Boost", "Type II (Chrome) Cut"])
  128. self.preset_combo.currentIndexChanged.connect(self._on_preset)
  129. row1.addWidget(self.preset_combo)
  130. reset_btn = QPushButton("Reset to Flat")
  131. reset_btn.clicked.connect(self._reset_flat)
  132. row1.addWidget(reset_btn)
  133. tools_layout.addLayout(row1)
  134. row2 = QHBoxLayout()
  135. save_btn = QPushButton("Save Profile...")
  136. save_btn.clicked.connect(self._save_profile)
  137. load_btn = QPushButton("Load Profile...")
  138. load_btn.clicked.connect(self._load_profile)
  139. row2.addWidget(save_btn)
  140. row2.addWidget(load_btn)
  141. row2.addStretch()
  142. tools_layout.addLayout(row2)
  143. bottom_layout.addWidget(tools_grp)
  144. # Tape Processing Group
  145. tape_grp = QGroupBox("Tape Processing (Record Mode)")
  146. tape_layout = QVBoxLayout(tape_grp)
  147. t_row1 = QHBoxLayout()
  148. self.tape_hp_cb = QCheckBox("High-Pass (Sub-Bass Cut)")
  149. self.tape_hp_cb.setChecked(self.tape_processor.enable_highpass)
  150. self.tape_hp_cb.clicked.connect(self._on_tape_proc_changed)
  151. t_row1.addWidget(self.tape_hp_cb)
  152. self.tape_lp_cb = QCheckBox("Low-Pass (Ultrasonic Cut)")
  153. self.tape_lp_cb.setChecked(self.tape_processor.enable_lowpass)
  154. self.tape_lp_cb.clicked.connect(self._on_tape_proc_changed)
  155. t_row1.addWidget(self.tape_lp_cb)
  156. tape_layout.addLayout(t_row1)
  157. t_row2 = QHBoxLayout()
  158. self.tape_sat_cb = QCheckBox("Soft Saturation (Limiter)")
  159. self.tape_sat_cb.setChecked(self.tape_processor.enable_saturation)
  160. self.tape_sat_cb.clicked.connect(self._on_tape_proc_changed)
  161. t_row2.addWidget(self.tape_sat_cb)
  162. self.sat_thresh_slider = QSlider(Qt.Orientation.Horizontal)
  163. self.sat_thresh_slider.setRange(10, 200) # 0.1 to 2.0
  164. self.sat_thresh_slider.setValue(int(self.tape_processor.saturation_threshold * 100))
  165. self.sat_thresh_slider.setToolTip("Saturation Threshold")
  166. self.sat_thresh_slider.valueChanged.connect(self._on_sat_slider)
  167. t_row2.addWidget(self.sat_thresh_slider)
  168. tape_layout.addLayout(t_row2)
  169. bottom_layout.addWidget(tape_grp)
  170. layout.addLayout(bottom_layout)
  171. def refresh_ui(self):
  172. """Rebuild the UI to match the EQEngine's current bands."""
  173. # Clear existing widgets
  174. for i in reversed(range(self.bands_layout.count())):
  175. self.bands_layout.itemAt(i).widget().setParent(None)
  176. self.band_widgets = []
  177. # Create new widgets
  178. for i, band in enumerate(self.eq_engine.bands):
  179. bw = EQBandWidget(band, i, self)
  180. self.bands_layout.addWidget(bw)
  181. self.band_widgets.append(bw)
  182. self.update_plot()
  183. def on_band_changed(self):
  184. """Called by a child BandWidget when a parameter changes."""
  185. self.preset_combo.blockSignals(True)
  186. self.preset_combo.setCurrentIndex(0) # Custom
  187. self.preset_combo.blockSignals(False)
  188. if not self.bypass_cb.isChecked():
  189. self.eq_engine.update_filters()
  190. self.update_plot()
  191. def _on_bypass(self):
  192. if self.bypass_cb.isChecked():
  193. # Clear filters in engine to bypass
  194. self.eq_engine._sos = None
  195. self.eq_engine._zi = None
  196. else:
  197. self.eq_engine.update_filters()
  198. self.update_plot()
  199. def _on_tape_proc_changed(self):
  200. self.tape_processor.enable_highpass = self.tape_hp_cb.isChecked()
  201. self.tape_processor.enable_lowpass = self.tape_lp_cb.isChecked()
  202. self.tape_processor.enable_saturation = self.tape_sat_cb.isChecked()
  203. self.tape_processor._update_filters()
  204. self.update_plot()
  205. def _on_sat_slider(self, val):
  206. self.tape_processor.saturation_threshold = val / 100.0
  207. def update_plot(self):
  208. """Recalculate and redraw the frequency response curve."""
  209. freqs = np.logspace(np.log10(20), np.log10(20000), 500)
  210. if self.bypass_cb.isChecked():
  211. response = np.zeros_like(freqs)
  212. else:
  213. # Need to temporarily generate SOS for the plot if bypassed, but here we just
  214. # Use the active SOS or calculate it from the bands.
  215. engine = EQEngine(self.eq_engine.sample_rate)
  216. all_bands = list(self.eq_engine.bands)
  217. if self.tape_processor.enable_highpass or self.tape_processor.enable_lowpass:
  218. all_bands.extend(self.tape_processor._filter_engine.bands)
  219. engine.bands = all_bands
  220. engine.update_filters()
  221. if engine._sos is None:
  222. response = np.zeros_like(freqs)
  223. else:
  224. w = 2 * np.pi * freqs / engine.sample_rate
  225. _, h = signal.sosfreqz(engine._sos, worN=w)
  226. response = 20 * np.log10(np.abs(h) + 1e-10)
  227. self.curve.setData(freqs, response)
  228. def _reset_flat(self):
  229. for band in self.eq_engine.bands:
  230. band.gain = 0.0
  231. for bw in self.band_widgets:
  232. bw._update_ui_from_band()
  233. self.on_band_changed()
  234. def _on_preset(self, index):
  235. if index == 1: # Flat
  236. self._reset_flat()
  237. elif index == 2: # Type I Boost (example)
  238. self._reset_flat()
  239. # Boost highs to compensate for tape roll-off
  240. if len(self.eq_engine.bands) > 0:
  241. self.eq_engine.bands[-1].gain = 6.0 # High shelf boost
  242. elif index == 3: # Type II Cut (example)
  243. self._reset_flat()
  244. # Cut highs if playing 70us tape on 120us setting
  245. if len(self.eq_engine.bands) > 0:
  246. self.eq_engine.bands[-1].gain = -4.5 # High shelf cut
  247. for bw in self.band_widgets:
  248. bw._update_ui_from_band()
  249. self.eq_engine.update_filters()
  250. self.update_plot()
  251. def _save_profile(self):
  252. path, _ = QFileDialog.getSaveFileName(self, "Save EQ Profile", "", "JSON Files (*.json)")
  253. if path:
  254. self.eq_engine.save_bands(path)
  255. self.settings.setValue("last_profile", path)
  256. def _load_profile(self):
  257. path, _ = QFileDialog.getOpenFileName(self, "Load EQ Profile", "", "JSON Files (*.json)")
  258. if path:
  259. try:
  260. self.eq_engine.load_bands(path)
  261. self.settings.setValue("last_profile", path)
  262. self.refresh_ui()
  263. except Exception as e:
  264. from PyQt6.QtWidgets import QMessageBox
  265. QMessageBox.warning(self, "Error", f"Failed to load profile:\n{e}")