spectrum_widget.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  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 buffer for FFT
  17. self.fft_size = 4096
  18. self.buffer = np.zeros(self.fft_size, dtype=np.float32)
  19. self.buffer_idx = 0
  20. # Window function for FFT (Hanning)
  21. self.window = np.hanning(self.fft_size)
  22. self._init_ui()
  23. # Setup timer for UI updates (to avoid overwhelming the GUI thread)
  24. self.update_timer = QTimer()
  25. self.update_timer.timeout.connect(self._update_plot)
  26. self.update_timer.start(self.update_interval_ms)
  27. self._new_data = False
  28. def _init_ui(self):
  29. layout = QVBoxLayout(self)
  30. layout.setContentsMargins(0, 0, 0, 0)
  31. # Configure pyqtgraph
  32. pg.setConfigOption('background', '#161625')
  33. pg.setConfigOption('foreground', '#a0a0b0')
  34. self.plot_widget = pg.PlotWidget()
  35. self.plot_widget.setLogMode(x=True, y=False)
  36. self.plot_widget.setLabel('bottom', 'Frequency', units='Hz')
  37. self.plot_widget.setLabel('left', 'Magnitude', units='dB')
  38. self.plot_widget.setYRange(-100, 0)
  39. self.plot_widget.setXRange(np.log10(20), np.log10(20000))
  40. self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
  41. # Plot curve (Amber color for analog feel)
  42. self.curve = self.plot_widget.plot(pen=pg.mkPen('#ff7b00', width=2))
  43. # Prepare frequency bins
  44. self.freqs = np.fft.rfftfreq(self.fft_size, d=1.0/self.sample_rate)
  45. # Avoid log(0) for the very first bin
  46. self.freqs[0] = 1e-10
  47. layout.addWidget(self.plot_widget)
  48. def set_sample_rate(self, sr: int):
  49. if self.sample_rate != sr:
  50. self.sample_rate = sr
  51. self.freqs = np.fft.rfftfreq(self.fft_size, d=1.0/self.sample_rate)
  52. self.freqs[0] = 1e-10
  53. def add_audio_data(self, data: np.ndarray):
  54. """
  55. Add new audio data to the buffer.
  56. Called from the audio callback thread.
  57. """
  58. if data.size == 0:
  59. return
  60. # Convert to mono if stereo
  61. if data.ndim > 1:
  62. chunk = np.mean(data, axis=1)
  63. else:
  64. chunk = data
  65. n = len(chunk)
  66. if n >= self.fft_size:
  67. # Take the latest fft_size samples
  68. self.buffer[:] = chunk[-self.fft_size:]
  69. self.buffer_idx = self.fft_size
  70. else:
  71. # Shift buffer left and append new data
  72. self.buffer[:-n] = self.buffer[n:]
  73. self.buffer[-n:] = chunk
  74. self.buffer_idx = min(self.buffer_idx + n, self.fft_size)
  75. self._new_data = True
  76. def _update_plot(self):
  77. """Calculate FFT and update the plot on the GUI thread."""
  78. if not self._new_data or self.buffer_idx < self.fft_size // 4:
  79. # Decay the plot if no new data
  80. if not self._new_data:
  81. current_data = self.curve.getData()[1]
  82. if current_data is not None:
  83. decayed = current_data - 2.0 # -2dB per frame decay
  84. self.curve.setData(self.freqs, decayed)
  85. return
  86. self._new_data = False
  87. # Apply window and calculate FFT
  88. windowed = self.buffer * self.window
  89. mag = np.abs(np.fft.rfft(windowed))
  90. # Normalize and convert to dB
  91. # Reference is amplitude 1.0 (0 dBFS)
  92. mag_db = 20 * np.log10(mag / (self.fft_size / 2) + 1e-10)
  93. # Smooth slightly in time domain (optional, but looks better)
  94. current_data = self.curve.getData()[1]
  95. if current_data is not None:
  96. alpha = 0.6
  97. mag_db = alpha * mag_db + (1 - alpha) * current_data
  98. # Update curve
  99. self.curve.setData(self.freqs, mag_db)
  100. def closeEvent(self, event):
  101. self.update_timer.stop()
  102. super().closeEvent(event)