"""Waveform display: envelope, playhead, click-to-seek, drag-to-select (trim region).""" import numpy as np from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal from PySide6.QtGui import QColor, QPainter, QPalette, QPen from PySide6.QtWidgets import QSizePolicy, QWidget from . import spinner class Waveform(QWidget): seekRequested = Signal(float) # seconds selectionChanged = Signal(float, float) # seconds (start, end); (0, 0) = none def __init__(self, parent=None, selectable=False): super().__init__(parent) self.setMinimumHeight(70) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.selectable = selectable self.env = None # (mins, maxs) at a fixed resolution self.duration = 0.0 self.playhead = None self.sel = None self._drag = None self.placeholder = "no audio" self.accent = None self.bg = None # background colour (theme), else the palette's Base self.busy = False # spinner beside the placeholder (audio being loaded) self._angle = 0 self._timer = QTimer(self) self._timer.setInterval(spinner.STEP_MS) self._timer.timeout.connect(self._spin) def _spin(self): self._angle = (self._angle + spinner.STEP_DEG) % 360 self.update() def set_audio(self, x, rate, placeholder="no audio", busy=False): self.placeholder = placeholder self.busy = busy and (x is None or len(x) == 0) self._timer.start() if self.busy else self._timer.stop() self.sel = None self.playhead = None if x is None or len(x) == 0: self.env, self.duration = None, 0.0 else: m = x.mean(axis=1) if x.ndim == 2 else x cols = 2000 n = len(m) edges = np.linspace(0, n, cols + 1).astype(int) mins = np.array([m[a:b].min() if b > a else 0 for a, b in zip(edges[:-1], edges[1:])]) maxs = np.array([m[a:b].max() if b > a else 0 for a, b in zip(edges[:-1], edges[1:])]) self.env = (mins, maxs) self.duration = n / float(rate) self.update() def set_playhead(self, seconds): self.playhead = seconds self.update() def set_selection(self, a, b): self.sel = (min(a, b), max(a, b)) if b != a else None self.update() def _x2t(self, x): return max(0.0, min(self.duration, x / max(1, self.width()) * self.duration)) def _t2x(self, t): return t / self.duration * self.width() if self.duration else 0 def mousePressEvent(self, e): if not self.duration: return t = self._x2t(e.position().x()) if self.selectable and e.button() == Qt.MouseButton.LeftButton and e.modifiers() & Qt.KeyboardModifier.ShiftModifier: self._drag = t elif self.selectable and e.button() == Qt.MouseButton.RightButton: self._drag = t else: self.seekRequested.emit(t) def mouseMoveEvent(self, e): if self._drag is not None: self.set_selection(self._drag, self._x2t(e.position().x())) def mouseReleaseEvent(self, e): if self._drag is not None: self._drag = None a, b = self.sel if self.sel else (0.0, 0.0) self.selectionChanged.emit(a, b) def paintEvent(self, _): p = QPainter(self) pal = self.palette() r = self.rect() p.fillRect(r, self.bg or pal.color(QPalette.ColorRole.Base)) mid = r.height() / 2 fg = self.accent or pal.color(QPalette.ColorRole.Highlight) if self.env is None: p.setPen(pal.color(QPalette.ColorRole.PlaceholderText)) p.drawText(r, Qt.AlignmentFlag.AlignCenter, self.placeholder) if self.busy: tw = p.fontMetrics().horizontalAdvance(self.placeholder) d = 14 spinner.paint_arc(p, QRectF(r.center().x() - tw / 2 - d - 8, mid - d / 2, d, d), self._angle) return if self.sel: sc = QColor(fg) sc.setAlpha(50) p.fillRect(QRectF(self._t2x(self.sel[0]), 0, self._t2x(self.sel[1]) - self._t2x(self.sel[0]), r.height()), sc) mins, maxs = self.env w = max(1, r.width()) idx = (np.arange(w) * len(mins) / w).astype(int) p.setPen(QPen(fg, 1)) for x, i in enumerate(idx): j = min(len(mins), int((x + 1) * len(mins) / w) + 1) lo, hi = mins[i:j].min(), maxs[i:j].max() p.drawLine(QPointF(x, mid - hi * (mid - 2)), QPointF(x, mid - lo * (mid - 2))) p.setPen(QPen(pal.color(QPalette.ColorRole.Mid), 1)) p.drawLine(0, int(mid), w, int(mid)) if self.playhead is not None: p.setPen(QPen(pal.color(QPalette.ColorRole.Text), 1)) x = self._t2x(self.playhead) p.drawLine(QPointF(x, 0), QPointF(x, r.height()))