feat: waveform drag-select + wheel zoom (selection_changed/view_changed)
This commit is contained in:
@@ -3943,6 +3943,13 @@ class AudioWaveform(QWidget):
|
|||||||
audio region. Populated on demand via manual refresh (no implicit UI-thread
|
audio region. Populated on demand via manual refresh (no implicit UI-thread
|
||||||
decode)."""
|
decode)."""
|
||||||
|
|
||||||
|
selection_changed = pyqtSignal(float, float)
|
||||||
|
view_changed = pyqtSignal(float, float)
|
||||||
|
|
||||||
|
HANDLE_PX = 6
|
||||||
|
MIN_SPAN = 0.05
|
||||||
|
MIN_SEL = 0.01
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._peaks: list[float] = []
|
self._peaks: list[float] = []
|
||||||
@@ -3950,6 +3957,7 @@ class AudioWaveform(QWidget):
|
|||||||
self._view_dur = 0.0
|
self._view_dur = 0.0
|
||||||
self._sel: tuple[float, float] | None = None
|
self._sel: tuple[float, float] | None = None
|
||||||
self._playhead: float | None = None
|
self._playhead: float | None = None
|
||||||
|
self._drag_anchor: float | None = None
|
||||||
self.setFixedHeight(96)
|
self.setFixedHeight(96)
|
||||||
self.setToolTip("Waveform of the current audio area (↻ to refresh)")
|
self.setToolTip("Waveform of the current audio area (↻ to refresh)")
|
||||||
|
|
||||||
@@ -4011,6 +4019,82 @@ class AudioWaveform(QWidget):
|
|||||||
p.setPen(QPen(QColor(255, 255, 255, 160), 1))
|
p.setPen(QPen(QColor(255, 255, 255, 160), 1))
|
||||||
p.drawLine(px, 0, px, h)
|
p.drawLine(px, 0, px, h)
|
||||||
|
|
||||||
|
# --- pixel/time helpers -------------------------------------------------
|
||||||
|
def _px_to_t(self, x: float) -> float:
|
||||||
|
from core.waveform import x_to_t
|
||||||
|
return x_to_t(x, self.width(), self._view_start, self._view_dur)
|
||||||
|
|
||||||
|
def _t_to_px(self, t: float) -> int:
|
||||||
|
from core.waveform import t_to_x
|
||||||
|
return t_to_x(t, self.width(), self._view_start, self._view_dur)
|
||||||
|
|
||||||
|
# --- drag-select / zoom -------------------------------------------------
|
||||||
|
def _begin_drag_at_x(self, x: float) -> None:
|
||||||
|
t = self._px_to_t(x)
|
||||||
|
anchor = t
|
||||||
|
if self._sel is not None and self._view_dur > 0:
|
||||||
|
xs = self._t_to_px(self._sel[0])
|
||||||
|
xe = self._t_to_px(self._sel[1])
|
||||||
|
if abs(x - xs) <= self.HANDLE_PX:
|
||||||
|
anchor = self._sel[1] # drag start; pivot on end
|
||||||
|
elif abs(x - xe) <= self.HANDLE_PX:
|
||||||
|
anchor = self._sel[0] # drag end; pivot on start
|
||||||
|
self._drag_anchor = anchor
|
||||||
|
self._drag_to_x(x)
|
||||||
|
|
||||||
|
def _drag_to_x(self, x: float) -> None:
|
||||||
|
if self._drag_anchor is None:
|
||||||
|
return
|
||||||
|
t = self._px_to_t(x)
|
||||||
|
lo, hi = min(self._drag_anchor, t), max(self._drag_anchor, t)
|
||||||
|
vs, ve = self._view_start, self._view_start + self._view_dur
|
||||||
|
if self._view_dur > 0:
|
||||||
|
lo = max(vs, min(lo, ve))
|
||||||
|
hi = max(vs, min(hi, ve))
|
||||||
|
if hi - lo < self.MIN_SEL:
|
||||||
|
if hi + self.MIN_SEL <= ve or self._view_dur <= 0:
|
||||||
|
hi = lo + self.MIN_SEL # room above
|
||||||
|
else:
|
||||||
|
lo = hi - self.MIN_SEL # at the ceiling -> grow downward
|
||||||
|
self.set_selection(lo, hi)
|
||||||
|
|
||||||
|
def _end_drag(self) -> None:
|
||||||
|
if self._drag_anchor is None:
|
||||||
|
return
|
||||||
|
self._drag_anchor = None
|
||||||
|
if self._sel is not None:
|
||||||
|
self.selection_changed.emit(self._sel[0], self._sel[1])
|
||||||
|
|
||||||
|
def _zoom_at_x(self, x: float, factor: float) -> None:
|
||||||
|
if self._view_dur <= 0 or self.width() <= 0:
|
||||||
|
return
|
||||||
|
t = self._px_to_t(x)
|
||||||
|
new_dur = max(self.MIN_SPAN, self._view_dur * factor)
|
||||||
|
frac = x / self.width()
|
||||||
|
self._view_start = t - frac * new_dur
|
||||||
|
self._view_dur = new_dur
|
||||||
|
self.update()
|
||||||
|
self.view_changed.emit(self._view_start, self._view_dur)
|
||||||
|
|
||||||
|
# --- Qt events ----------------------------------------------------------
|
||||||
|
def mousePressEvent(self, e):
|
||||||
|
if e.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self._begin_drag_at_x(e.position().x())
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, e):
|
||||||
|
self._drag_to_x(e.position().x())
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, e):
|
||||||
|
if e.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self._end_drag()
|
||||||
|
|
||||||
|
def wheelEvent(self, e):
|
||||||
|
dy = e.angleDelta().y()
|
||||||
|
if dy == 0:
|
||||||
|
return
|
||||||
|
factor = 0.8 if dy > 0 else 1.25
|
||||||
|
self._zoom_at_x(e.position().x(), factor)
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
@@ -414,6 +414,32 @@ def test_waveform_view_and_selection(win):
|
|||||||
assert w.selection() is None
|
assert w.selection() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_waveform_drag_emits_selection(win):
|
||||||
|
w = win._wave
|
||||||
|
w.resize(400, 96)
|
||||||
|
w.set_view(10.0, 4.0)
|
||||||
|
w.set_selection(11.0, 13.0)
|
||||||
|
got = []
|
||||||
|
w.selection_changed.connect(lambda s, e: got.append((s, e)))
|
||||||
|
# new selection: press near t=10.5 (not on a handle), drag to t=13.5, release
|
||||||
|
w._begin_drag_at_x(w._t_to_px(10.5))
|
||||||
|
w._drag_to_x(w._t_to_px(13.5))
|
||||||
|
w._end_drag()
|
||||||
|
assert got, "selection_changed should fire on release"
|
||||||
|
s, e = w.selection()
|
||||||
|
assert 10.0 <= s < e <= 14.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_waveform_zoom_emits_view(win):
|
||||||
|
w = win._wave
|
||||||
|
w.resize(400, 96)
|
||||||
|
w.set_view(10.0, 4.0)
|
||||||
|
seen = []
|
||||||
|
w.view_changed.connect(lambda s, d: seen.append((s, d)))
|
||||||
|
w._zoom_at_x(200, 0.5) # zoom in about the middle
|
||||||
|
assert seen and seen[-1][1] < 4.0
|
||||||
|
|
||||||
|
|
||||||
def test_audition_button_present_and_safe(win):
|
def test_audition_button_present_and_safe(win):
|
||||||
from PyQt6.QtWidgets import QPushButton
|
from PyQt6.QtWidgets import QPushButton
|
||||||
assert isinstance(win._btn_audio_play, QPushButton)
|
assert isinstance(win._btn_audio_play, QPushButton)
|
||||||
|
|||||||
Reference in New Issue
Block a user