Initial commit
This commit is contained in:
123
app/waveform.py
Normal file
123
app/waveform.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""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()))
|
||||
Reference in New Issue
Block a user