waveform_widget.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. """
  2. Waveform Widget — Overview display of an entire audio file.
  3. """
  4. import numpy as np
  5. import pyqtgraph as pg
  6. from PyQt6.QtWidgets import QWidget, QVBoxLayout
  7. from PyQt6.QtCore import pyqtSignal
  8. class WaveformWidget(QWidget):
  9. """
  10. Displays the static waveform of a loaded audio file
  11. and an interactive playhead cursor.
  12. """
  13. # Emitted when user clicks on the waveform to seek
  14. seek_requested = pyqtSignal(float) # position in seconds
  15. def __init__(self):
  16. super().__init__()
  17. self._init_ui()
  18. self.duration_sec = 0.0
  19. def _init_ui(self):
  20. layout = QVBoxLayout(self)
  21. layout.setContentsMargins(0, 0, 0, 0)
  22. # Configure pyqtgraph
  23. pg.setConfigOption('background', '#161625')
  24. pg.setConfigOption('foreground', '#a0a0b0')
  25. self.plot_widget = pg.PlotWidget()
  26. self.plot_widget.setMouseEnabled(x=False, y=False)
  27. self.plot_widget.hideAxis('left')
  28. self.plot_widget.hideAxis('bottom')
  29. self.plot_widget.setYRange(-1.1, 1.1)
  30. # Waveform curves (L/R)
  31. self.curve_l = self.plot_widget.plot(pen=pg.mkPen('#3a3a5c', width=1))
  32. self.curve_r = self.plot_widget.plot(pen=pg.mkPen('#4a4a7a', width=1))
  33. # Playhead line
  34. self.playhead = pg.InfiniteLine(pos=0, angle=90, pen=pg.mkPen('#ff7b00', width=2))
  35. self.plot_widget.addItem(self.playhead)
  36. # Mouse click handling for seek
  37. self.plot_widget.scene().sigMouseClicked.connect(self._on_mouse_clicked)
  38. layout.addWidget(self.plot_widget)
  39. def load_audio(self, audio_data):
  40. """Generate waveform overview from AudioData."""
  41. self.duration_sec = audio_data.duration
  42. self.plot_widget.setXRange(0, self.duration_sec)
  43. # Decimate data for display (we don't need millions of points)
  44. # Target ~2000 points for the view
  45. target_points = 2000
  46. n_samples = audio_data.num_samples
  47. step = max(1, n_samples // target_points)
  48. # Instead of just picking every Nth sample, get min/max over blocks
  49. # to correctly represent the envelope
  50. blocks = n_samples // step
  51. # Time axis
  52. t = np.linspace(0, self.duration_sec, blocks)
  53. samples = audio_data.samples[:blocks * step]
  54. if audio_data.channels >= 2:
  55. left = samples[:, 0].reshape(-1, step)
  56. right = samples[:, 1].reshape(-1, step)
  57. l_max = np.max(left, axis=1)
  58. l_min = np.min(left, axis=1)
  59. r_max = np.max(right, axis=1)
  60. r_min = np.min(right, axis=1)
  61. # Interleave min/max for shaded look, or just use max
  62. # For simplicity, we just plot the max envelope
  63. self.curve_l.setData(t, l_max)
  64. # Offset right channel negative for classic display
  65. self.curve_r.setData(t, -r_max)
  66. else:
  67. mono = samples.reshape(-1, step)
  68. m_max = np.max(mono, axis=1)
  69. m_min = np.min(mono, axis=1)
  70. self.curve_l.setData(t, m_max)
  71. self.curve_r.setData(t, m_min)
  72. self.set_position(0)
  73. def set_position(self, pos_sec: float):
  74. """Update playhead position."""
  75. if 0 <= pos_sec <= self.duration_sec:
  76. self.playhead.setValue(pos_sec)
  77. def _on_mouse_clicked(self, event):
  78. """Handle click to seek."""
  79. if event.button() == 1: # Left click
  80. # Map scene coordinates to plot coordinates
  81. pos = self.plot_widget.plotItem.vb.mapSceneToView(event.scenePos())
  82. x_sec = pos.x()
  83. if 0 <= x_sec <= self.duration_sec:
  84. self.seek_requested.emit(x_sec)
  85. self.set_position(x_sec)