calibration_panel.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. """
  2. Calibration Panel — Step-by-step wizard for frequency response measurement and EQ fitting.
  3. """
  4. from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
  5. QPushButton, QLabel, QComboBox,
  6. QProgressBar, QStackedWidget,
  7. QMessageBox, QGroupBox)
  8. from PyQt6.QtCore import Qt, QTimer, pyqtSignal
  9. import pyqtgraph as pg
  10. import numpy as np
  11. import threading
  12. import time
  13. from core.audio_device import DeviceManager
  14. from core.signal_generator import generate_log_sweep, generate_pink_noise
  15. from core.analyzer import calculate_transfer_function, smooth_curve
  16. from core.calibrator import calculate_correction_curve, fit_eq_bands
  17. from core.eq_engine import EQEngine
  18. from core.tape_processor import TapeProcessor
  19. import sounddevice as sd
  20. class CalibrationPanel(QWidget):
  21. # Signal emitted when calibration finishes and new EQ bands are ready
  22. calibration_complete = pyqtSignal(list) # list of EQBand
  23. # Internal signals for cross-thread UI updates
  24. _update_plots_signal = pyqtSignal(np.ndarray, np.ndarray, np.ndarray, np.ndarray)
  25. _fit_complete_signal = pyqtSignal()
  26. _set_play_btn_signal = pyqtSignal(bool, str)
  27. _set_prog_signal = pyqtSignal(bool)
  28. _set_analyze_btn_signal = pyqtSignal(str)
  29. _set_apply_btn_signal = pyqtSignal(str)
  30. def __init__(self, device_manager: DeviceManager, eq_engine: EQEngine, tape_processor: TapeProcessor):
  31. super().__init__()
  32. self.device_manager = device_manager
  33. self.eq_engine = eq_engine
  34. self.tape_processor = tape_processor
  35. # State
  36. self.reference_signal = None
  37. self.recorded_signal = None
  38. self.freqs = None
  39. self.response_db = None
  40. self.correction_db = None
  41. self._update_plots_signal.connect(self._update_plots_slot)
  42. self._fit_complete_signal.connect(self._fit_complete_slot)
  43. self._set_play_btn_signal.connect(self._set_play_btn_slot)
  44. self._set_prog_signal.connect(self._set_prog_slot)
  45. self._set_analyze_btn_signal.connect(self._set_analyze_btn_slot)
  46. self._set_apply_btn_signal.connect(self._set_apply_btn_slot)
  47. self._init_ui()
  48. def _init_ui(self):
  49. layout = QVBoxLayout(self)
  50. # --- Wizard Header ---
  51. header = QLabel("Calibration Wizard")
  52. header.setStyleSheet("font-size: 16pt; font-weight: bold; color: #ff7b00;")
  53. layout.addWidget(header)
  54. # --- Steps Stack ---
  55. self.stack = QStackedWidget()
  56. # Step 1: Generate & Play
  57. self.step1 = self._create_step1()
  58. self.stack.addWidget(self.step1)
  59. # Step 2: Record
  60. self.step2 = self._create_step2()
  61. self.stack.addWidget(self.step2)
  62. # Step 3: Analyze & Apply
  63. self.step3 = self._create_step3()
  64. self.stack.addWidget(self.step3)
  65. layout.addWidget(self.stack, 1)
  66. # --- Navigation ---
  67. nav_row = QHBoxLayout()
  68. self.prev_btn = QPushButton("← Back")
  69. self.prev_btn.clicked.connect(self._prev_step)
  70. self.prev_btn.setEnabled(False)
  71. self.next_btn = QPushButton("Next →")
  72. self.next_btn.setObjectName("primaryButton")
  73. self.next_btn.clicked.connect(self._next_step)
  74. nav_row.addWidget(self.prev_btn)
  75. nav_row.addStretch()
  76. nav_row.addWidget(self.next_btn)
  77. layout.addLayout(nav_row)
  78. def _create_step1(self) -> QWidget:
  79. w = QWidget()
  80. l = QVBoxLayout(w)
  81. info = QLabel(
  82. "Step 1: Generate Test Signal\n\n"
  83. "Prepare your cassette deck to record. "
  84. "A log sine sweep provides the best accuracy for calibration."
  85. )
  86. info.setWordWrap(True)
  87. l.addWidget(info)
  88. grp = QGroupBox("Signal & Tape Settings")
  89. gl = QVBoxLayout(grp)
  90. row0 = QHBoxLayout()
  91. row0.addWidget(QLabel("Tape Type:"))
  92. self.tape_type_cb = QComboBox()
  93. self.tape_type_cb.addItems(["Type I (Ferric)", "Type II (Chrome)", "Type IV (Metal)"])
  94. self.tape_type_cb.currentTextChanged.connect(self._on_tape_type_changed)
  95. row0.addWidget(self.tape_type_cb)
  96. row0.addStretch()
  97. gl.addLayout(row0)
  98. row1 = QHBoxLayout()
  99. row1.addWidget(QLabel("Signal Type:"))
  100. self.sig_type = QComboBox()
  101. self.sig_type.addItems(["Log Sine Sweep (Recommended)", "Pink Noise"])
  102. row1.addWidget(self.sig_type)
  103. row1.addStretch()
  104. gl.addLayout(row1)
  105. row2 = QHBoxLayout()
  106. row2.addWidget(QLabel("Duration:"))
  107. self.sig_dur = QComboBox()
  108. self.sig_dur.addItems(["10 Seconds", "20 Seconds", "30 Seconds"])
  109. row2.addWidget(self.sig_dur)
  110. row2.addStretch()
  111. gl.addLayout(row2)
  112. l.addWidget(grp)
  113. self.play_test_btn = QPushButton("Generate & Play Signal")
  114. self.play_test_btn.setObjectName("primaryButton")
  115. self.play_test_btn.clicked.connect(self._play_test_signal)
  116. l.addWidget(self.play_test_btn)
  117. self.play_prog = QProgressBar()
  118. self.play_prog.setTextVisible(False)
  119. self.play_prog.hide()
  120. l.addWidget(self.play_prog)
  121. l.addStretch()
  122. return w
  123. def _create_step2(self) -> QWidget:
  124. w = QWidget()
  125. l = QVBoxLayout(w)
  126. info = QLabel(
  127. "Step 2: Record Playback\n\n"
  128. "If you have a 3-head deck, you can record this immediately. "
  129. "For 2-head decks, rewind the tape and press play."
  130. )
  131. info.setWordWrap(True)
  132. l.addWidget(info)
  133. # Realtime view for recording
  134. pg.setConfigOption('background', '#ffffff')
  135. pg.setConfigOption('foreground', '#333333')
  136. self.rec_plot = pg.PlotWidget()
  137. self.rec_plot.setYRange(-1, 1)
  138. self.rec_curve = self.rec_plot.plot(pen='#ff7b00')
  139. l.addWidget(self.rec_plot, 1)
  140. self.rec_btn = QPushButton("Start Recording Capture")
  141. self.rec_btn.setObjectName("primaryButton")
  142. self.rec_btn.clicked.connect(self._toggle_recording)
  143. l.addWidget(self.rec_btn)
  144. return w
  145. def _create_step3(self) -> QWidget:
  146. w = QWidget()
  147. l = QVBoxLayout(w)
  148. self.analyze_btn = QPushButton("Analyze Recorded Signal")
  149. self.analyze_btn.setObjectName("primaryButton")
  150. self.analyze_btn.clicked.connect(self._analyze)
  151. l.addWidget(self.analyze_btn)
  152. # Result plot
  153. self.res_plot = pg.PlotWidget(title="Frequency Response & EQ Target")
  154. self.res_plot.setLogMode(x=True, y=False)
  155. self.res_plot.setXRange(np.log10(20), np.log10(20000))
  156. self.res_plot.setYRange(-20, 20)
  157. self.res_plot.showGrid(x=True, y=True, alpha=0.3)
  158. self.res_plot.addLegend()
  159. self.curve_raw = self.res_plot.plot(pen=pg.mkPen('#505050', width=1), name="Raw Response")
  160. self.curve_smooth = self.res_plot.plot(pen=pg.mkPen('#ff7b00', width=2), name="Smoothed Response")
  161. self.curve_target = self.res_plot.plot(pen=pg.mkPen('#4CAF50', width=2, style=Qt.PenStyle.DashLine), name="EQ Target")
  162. l.addWidget(self.res_plot, 1)
  163. self.apply_eq_btn = QPushButton("Auto-Fit EQ & Apply")
  164. self.apply_eq_btn.setObjectName("primaryButton")
  165. self.apply_eq_btn.setEnabled(False)
  166. self.apply_eq_btn.clicked.connect(self._apply_eq)
  167. l.addWidget(self.apply_eq_btn)
  168. return w
  169. def _set_play_btn_slot(self, enabled, text):
  170. self.play_test_btn.setEnabled(enabled)
  171. self.play_test_btn.setText(text)
  172. def _set_prog_slot(self, show):
  173. self.play_prog.setVisible(show)
  174. def _set_analyze_btn_slot(self, text):
  175. self.analyze_btn.setText(text)
  176. def _set_apply_btn_slot(self, text):
  177. self.apply_eq_btn.setText(text)
  178. def _on_tape_type_changed(self, tape_type: str):
  179. self.tape_processor.set_tape_type(tape_type)
  180. # --- Step 1 Logic ---
  181. def _play_test_signal(self):
  182. if self.device_manager.output_device_id is None:
  183. QMessageBox.warning(self, "Error", "No output device selected in Devices tab.")
  184. return
  185. dur = int(self.sig_dur.currentText().split()[0])
  186. sr = self.device_manager.sample_rate
  187. if "Sweep" in self.sig_type.currentText():
  188. self.reference_signal = generate_log_sweep(duration=dur, sample_rate=sr)
  189. else:
  190. self.reference_signal = generate_pink_noise(duration=dur, sample_rate=sr)
  191. # Play in background thread
  192. threading.Thread(target=self._play_thread, args=(self.reference_signal, sr), daemon=True).start()
  193. def _play_thread(self, signal_data, sr):
  194. # UI updates
  195. self._set_play_btn_signal.emit(False, "Playing...")
  196. self._set_prog_signal.emit(True)
  197. try:
  198. sd.play(signal_data, sr, device=self.device_manager.output_device_id)
  199. sd.wait()
  200. except Exception as e:
  201. print(f"Playback error: {e}")
  202. self._set_play_btn_signal.emit(True, "Generate & Play Signal")
  203. self._set_prog_signal.emit(False)
  204. # --- Step 2 Logic ---
  205. def _toggle_recording(self):
  206. if hasattr(self, 'is_recording') and self.is_recording:
  207. # Stop
  208. self.is_recording = False
  209. self.rec_btn.setText("Start Recording Capture")
  210. self.rec_btn.setStyleSheet("")
  211. if hasattr(self, 'rec_stream'):
  212. self.rec_stream.stop()
  213. self.rec_stream.close()
  214. self.recorded_signal = np.concatenate(self.rec_chunks, axis=0)
  215. # Take left channel if stereo
  216. if self.recorded_signal.ndim > 1:
  217. self.recorded_signal = self.recorded_signal[:, 0]
  218. else:
  219. # Start
  220. if self.device_manager.input_device_id is None:
  221. QMessageBox.warning(self, "Error", "No input device selected.")
  222. return
  223. self.is_recording = True
  224. self.rec_btn.setText("Stop Recording")
  225. self.rec_btn.setStyleSheet("background-color: #F44336; color: white;")
  226. self.rec_chunks = []
  227. kwargs = self.device_manager.get_sd_input_kwargs()
  228. self.rec_stream = sd.InputStream(callback=self._rec_callback, **kwargs)
  229. self.rec_stream.start()
  230. def _rec_callback(self, indata, frames, time, status):
  231. self.rec_chunks.append(indata.copy())
  232. # Update plot with decimated data for performance
  233. if len(self.rec_chunks) % 5 == 0:
  234. chunk = indata[:, 0]
  235. decimated = chunk[::max(1, len(chunk)//500)]
  236. from PyQt6.QtCore import QMetaObject, Q_ARG
  237. # Thread-safe plot update (simplified for UI)
  238. pass
  239. # --- Step 3 Logic ---
  240. def _analyze(self):
  241. if self.reference_signal is None or self.recorded_signal is None:
  242. QMessageBox.warning(self, "Error", "Missing reference or recorded signal.")
  243. return
  244. self.analyze_btn.setText("Analyzing...")
  245. self.analyze_btn.setEnabled(False)
  246. # Run in thread
  247. threading.Thread(target=self._analyze_thread, daemon=True).start()
  248. def _analyze_thread(self):
  249. try:
  250. sr = self.device_manager.sample_rate
  251. # 1. Transfer function
  252. freqs, resp = calculate_transfer_function(self.reference_signal, self.recorded_signal, sr)
  253. # 2. Smooth
  254. resp_smooth = smooth_curve(freqs, resp, fraction=1/3)
  255. # 3. Target correction
  256. correction = calculate_correction_curve(resp_smooth)
  257. self.freqs = freqs
  258. self.response_db = resp_smooth
  259. self.correction_db = correction
  260. # Update UI
  261. self._update_plots_signal.emit(freqs, resp, resp_smooth, correction)
  262. except Exception as e:
  263. print(f"Analysis error: {e}")
  264. self._set_analyze_btn_signal.emit("Analysis Failed")
  265. def _update_plots_slot(self, freqs, resp_raw, resp_smooth, correction):
  266. self.curve_raw.setData(freqs, resp_raw)
  267. self.curve_smooth.setData(freqs, resp_smooth)
  268. self.curve_target.setData(freqs, correction)
  269. self.analyze_btn.setText("Analysis Complete")
  270. self.apply_eq_btn.setEnabled(True)
  271. def _apply_eq(self):
  272. if self.freqs is None or self.correction_db is None:
  273. return
  274. self.apply_eq_btn.setText("Fitting EQ...")
  275. self.apply_eq_btn.setEnabled(False)
  276. # Fit in thread
  277. threading.Thread(target=self._fit_thread, daemon=True).start()
  278. def _fit_thread(self):
  279. sr = self.device_manager.sample_rate
  280. try:
  281. ttype = self.tape_type_cb.currentText()
  282. # Fit 10 peaking bands constrained by tape type
  283. bands = fit_eq_bands(self.freqs, self.correction_db, num_peaking_bands=10, sample_rate=sr, tape_type=ttype)
  284. # Apply to engine
  285. self.eq_engine.bands = bands
  286. self.eq_engine.update_filters()
  287. self._fit_complete_signal.emit()
  288. except Exception as e:
  289. print(f"Fitting error: {e}")
  290. self._set_apply_btn_signal.emit("Fit Failed")
  291. def _fit_complete_slot(self):
  292. self.apply_eq_btn.setText("EQ Applied ✓")
  293. self.calibration_complete.emit(self.eq_engine.bands)
  294. QMessageBox.information(self, "Success", "EQ calibrated and applied to engine!")
  295. # --- Navigation Logic ---
  296. def _prev_step(self):
  297. idx = self.stack.currentIndex()
  298. if idx > 0:
  299. self.stack.setCurrentIndex(idx - 1)
  300. self._update_nav_buttons()
  301. def _next_step(self):
  302. idx = self.stack.currentIndex()
  303. if idx < self.stack.count() - 1:
  304. self.stack.setCurrentIndex(idx + 1)
  305. self._update_nav_buttons()
  306. def _update_nav_buttons(self):
  307. idx = self.stack.currentIndex()
  308. self.prev_btn.setEnabled(idx > 0)
  309. self.next_btn.setEnabled(idx < self.stack.count() - 1)