Files
UnheardCity/app/waveform.py
2026-10-09 02:48:24 +02:00

124 lines
4.9 KiB
Python

"""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()))