spectrum_widget.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. """
  2. Spectrum Widget — Real-time FFT spectrum display using pyqtgraph.
  3. """
  4. import numpy as np
  5. import pyqtgraph as pg
  6. from PyQt6.QtWidgets import QWidget, QVBoxLayout
  7. from PyQt6.QtCore import QTimer
  8. class SpectrumWidget(QWidget):
  9. """
  10. Displays a real-time frequency spectrum.
  11. """
  12. def __init__(self, sample_rate: int = 44100, update_interval_ms: int = 50):
  13. super().__init__()
  14. self.sample_rate = sample_rate
  15. self.update_interval_ms = update_interval_ms
  16. # Audio buffers for FFT
  17. self.fft_size = 4096
  18. self.buffer_raw = np.zeros(self.fft_size, dtype=np.float32)
  19. self.buffer_processed = np.zeros(self.fft_size, dtype=np.float32)
  20. self.buffer_idx_raw = 0
  21. self.buffer_idx_processed = 0
  22. # Window function for FFT (Hanning)
  23. self.window = np.hanning(self.fft_size)
  24. self._init_ui()
  25. # Setup timer for UI updates (to avoid overwhelming the GUI thread)
  26. self.update_timer = QTimer()
  27. self.update_timer.timeout.connect(self._update_plot)
  28. self.update_timer.start(self.update_interval_ms)
  29. self._new_data = False
  30. def _init_ui(self):
  31. layout = QVBoxLayout(self)
  32. layout.setContentsMargins(0, 0, 0, 0)
  33. # Configure pyqtgraph
  34. pg.setConfigOption('background', '#ffffff')
  35. pg.setConfigOption('foreground', '#333333')
  36. self.plot_widget = pg.PlotWidget()
  37. self.plot_widget.setLogMode(x=True, y=False)
  38. self.plot_widget.setLabel('bottom', 'Frequency', units='Hz')
  39. self.plot_widget.setLabel('left', 'Magnitude', units='dB')
  40. self.plot_widget.setYRange(-100, 0)
  41. self.plot_widget.setXRange(np.log10(20), np.log10(20000))
  42. self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
  43. from PyQt6.QtCore import Qt
  44. # Plot curves
  45. self.curve_raw = self.plot_widget.plot(pen=pg.mkPen('#a0a0a0', width=1, style=Qt.PenStyle.DashLine), name="Actual")
  46. self.curve_processed = self.plot_widget.plot(pen=pg.mkPen('#ff7b00', width=2), name="Corrected")
  47. self.plot_widget.addLegend()
  48. # Prepare frequency bins
  49. self.freqs = np.fft.rfftfreq(self.fft_size, d=1.0/self.sample_rate)
  50. # Avoid log(0) for the very first bin
  51. self.freqs[0] = 1e-10
  52. layout.addWidget(self.plot_widget)
  53. def set_sample_rate(self, sr: int):
  54. if self.sample_rate != sr:
  55. self.sample_rate = sr
  56. self.freqs = np.fft.rfftfreq(self.fft_size, d=1.0/self.sample_rate)
  57. self.freqs[0] = 1e-10
  58. def add_audio_data(self, raw_data: np.ndarray, processed_data: np.ndarray):
  59. """
  60. Add new audio data to the buffers.
  61. Called from the GUI thread on poll.
  62. """
  63. def _add(data, buffer, idx):
  64. if data.size == 0:
  65. return idx
  66. if data.ndim > 1:
  67. chunk = np.mean(data, axis=1)
  68. else:
  69. chunk = data
  70. n = len(chunk)
  71. if n >= self.fft_size:
  72. buffer[:] = chunk[-self.fft_size:]
  73. return self.fft_size
  74. else:
  75. buffer[:-n] = buffer[n:]
  76. buffer[-n:] = chunk
  77. return min(idx + n, self.fft_size)
  78. self.buffer_idx_raw = _add(raw_data, self.buffer_raw, self.buffer_idx_raw)
  79. self.buffer_idx_processed = _add(processed_data, self.buffer_processed, self.buffer_idx_processed)
  80. self._new_data = True
  81. def _update_plot(self):
  82. """Calculate FFT and update the plots on the GUI thread."""
  83. if not self._new_data or self.buffer_idx_processed < self.fft_size // 4:
  84. # Decay the plots if no new data
  85. if not self._new_data:
  86. for curve in (self.curve_raw, self.curve_processed):
  87. current_data = curve.getData()[1]
  88. if current_data is not None:
  89. decayed = current_data - 2.0 # -2dB per frame decay
  90. curve.setData(self.freqs, decayed)
  91. return
  92. self._new_data = False
  93. def _calc_fft(buffer, curve):
  94. windowed = buffer * self.window
  95. mag = np.abs(np.fft.rfft(windowed))
  96. mag_db = 20 * np.log10(mag / (self.fft_size / 2) + 1e-10)
  97. current_data = curve.getData()[1]
  98. if current_data is not None:
  99. alpha = 0.6
  100. mag_db = alpha * mag_db + (1 - alpha) * current_data
  101. curve.setData(self.freqs, mag_db)
  102. _calc_fft(self.buffer_raw, self.curve_raw)
  103. _calc_fft(self.buffer_processed, self.curve_processed)
  104. def closeEvent(self, event):
  105. self.update_timer.stop()
  106. super().closeEvent(event)