fix: separate editor playhead from selection
This commit is contained in:
@@ -4084,9 +4084,11 @@ class AudioWaveform(QWidget):
|
||||
decode)."""
|
||||
|
||||
selection_changed = pyqtSignal(float, float)
|
||||
playhead_changed = pyqtSignal(float)
|
||||
view_changed = pyqtSignal(float, float)
|
||||
|
||||
HANDLE_PX = 6
|
||||
CLICK_DRAG_PX = 3
|
||||
MIN_SPAN = 0.05
|
||||
MIN_SEL = 0.01
|
||||
|
||||
@@ -4097,7 +4099,9 @@ class AudioWaveform(QWidget):
|
||||
self._view_dur = 0.0
|
||||
self._sel: tuple[float, float] | None = None
|
||||
self._playhead: float | None = None
|
||||
self._markers: list[float] = []
|
||||
self._drag_anchor: float | None = None
|
||||
self._press_x: float | None = None
|
||||
self.setFixedHeight(96)
|
||||
self.setToolTip("Waveform of the current audio area (↻ to refresh)")
|
||||
|
||||
@@ -4124,14 +4128,32 @@ class AudioWaveform(QWidget):
|
||||
def selection(self) -> tuple[float, float] | None:
|
||||
return self._sel
|
||||
|
||||
def clear_selection(self) -> None:
|
||||
self._sel = None
|
||||
self.update()
|
||||
|
||||
def set_playhead(self, t: float | None) -> None:
|
||||
if t is not None and self._view_dur > 0:
|
||||
lo = self._view_start
|
||||
hi = self._view_start + self._view_dur
|
||||
t = min(max(t, lo), hi)
|
||||
self._playhead = t
|
||||
self.update()
|
||||
|
||||
def playhead(self) -> float | None:
|
||||
return self._playhead
|
||||
|
||||
def set_markers(self, markers: list[float] | None) -> None:
|
||||
self._markers = list(markers or [])
|
||||
self.update()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._peaks = []
|
||||
self._sel = None
|
||||
self._playhead = None
|
||||
self._markers = []
|
||||
self._drag_anchor = None
|
||||
self._press_x = None
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _ev):
|
||||
@@ -4153,6 +4175,12 @@ class AudioWaveform(QWidget):
|
||||
p.setPen(QPen(QColor(0, 220, 190), 2))
|
||||
p.drawLine(x1, 0, x1, h)
|
||||
p.drawLine(x2, 0, x2, h)
|
||||
if self._markers and self._view_dur > 0:
|
||||
p.setPen(QPen(QColor(255, 120, 0), 2))
|
||||
for marker in self._markers:
|
||||
if self._view_start <= marker <= self._view_start + self._view_dur:
|
||||
mx = t_to_x(marker, w, self._view_start, self._view_dur)
|
||||
p.drawLine(mx, 0, mx, h)
|
||||
if (self._playhead is not None and self._view_dur > 0
|
||||
and self._view_start <= self._playhead <= self._view_start + self._view_dur):
|
||||
px = t_to_x(self._playhead, w, self._view_start, self._view_dur)
|
||||
@@ -4169,16 +4197,22 @@ class AudioWaveform(QWidget):
|
||||
return t_to_x(t, self.width(), self._view_start, self._view_dur)
|
||||
|
||||
# --- drag-select / zoom -------------------------------------------------
|
||||
def _selection_handle_anchor(self, x: float) -> float | None:
|
||||
if self._sel is None or self._view_dur <= 0:
|
||||
return None
|
||||
xs = self._t_to_px(self._sel[0])
|
||||
xe = self._t_to_px(self._sel[1])
|
||||
if abs(x - xs) <= self.HANDLE_PX:
|
||||
return self._sel[1] # drag start; pivot on end
|
||||
if abs(x - xe) <= self.HANDLE_PX:
|
||||
return self._sel[0] # drag end; pivot on start
|
||||
return None
|
||||
|
||||
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
|
||||
anchor = self._selection_handle_anchor(x)
|
||||
if anchor is None:
|
||||
anchor = t
|
||||
self._drag_anchor = anchor
|
||||
self._drag_to_x(x)
|
||||
|
||||
@@ -4205,6 +4239,12 @@ class AudioWaveform(QWidget):
|
||||
if self._sel is not None:
|
||||
self.selection_changed.emit(self._sel[0], self._sel[1])
|
||||
|
||||
def _set_playhead_from_x(self, x: float, emit: bool = True) -> None:
|
||||
t = self._px_to_t(x)
|
||||
self.set_playhead(t)
|
||||
if emit and self._playhead is not None:
|
||||
self.playhead_changed.emit(self._playhead)
|
||||
|
||||
def _zoom_at_x(self, x: float, factor: float) -> None:
|
||||
if self._view_dur <= 0 or self.width() <= 0:
|
||||
return
|
||||
@@ -4219,14 +4259,28 @@ class AudioWaveform(QWidget):
|
||||
# --- Qt events ----------------------------------------------------------
|
||||
def mousePressEvent(self, e):
|
||||
if e.button() == Qt.MouseButton.LeftButton:
|
||||
self._begin_drag_at_x(e.position().x())
|
||||
x = e.position().x()
|
||||
self._press_x = x
|
||||
if self._selection_handle_anchor(x) is not None:
|
||||
self._begin_drag_at_x(x)
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
self._drag_to_x(e.position().x())
|
||||
x = e.position().x()
|
||||
if self._drag_anchor is not None:
|
||||
self._drag_to_x(x)
|
||||
elif (e.buttons() & Qt.MouseButton.LeftButton
|
||||
and self._press_x is not None
|
||||
and abs(x - self._press_x) >= self.CLICK_DRAG_PX):
|
||||
self._begin_drag_at_x(self._press_x)
|
||||
self._drag_to_x(x)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
if e.button() == Qt.MouseButton.LeftButton:
|
||||
self._end_drag()
|
||||
if self._drag_anchor is not None:
|
||||
self._end_drag()
|
||||
elif self._press_x is not None:
|
||||
self._set_playhead_from_x(e.position().x())
|
||||
self._press_x = None
|
||||
|
||||
def wheelEvent(self, e):
|
||||
dy = e.angleDelta().y()
|
||||
@@ -4256,14 +4310,17 @@ class AudioEditorDialog(QDialog):
|
||||
self._play_speed = 1.0
|
||||
self._play_start = 0.0
|
||||
self._play_end: float | None = None
|
||||
self._play_loop = False
|
||||
self._play_elapsed = QElapsedTimer()
|
||||
self._playhead_timer = QTimer(self)
|
||||
self._playhead_timer.setInterval(33)
|
||||
self._playhead_timer.timeout.connect(self._tick_playhead)
|
||||
self._clip_dur = 0.0 # true duration of the current version
|
||||
self._join_preview: tuple[float, float] | None = None
|
||||
self._healed_seams: dict[str, list[float]] = {}
|
||||
|
||||
self._wave = AudioWaveform()
|
||||
self._wave.playhead_changed.connect(self._on_wave_playhead_changed)
|
||||
self._btn_heal_cut = QPushButton("Heal Cut")
|
||||
self._btn_delete = QPushButton("Delete")
|
||||
self._btn_silence = QPushButton("Silence")
|
||||
@@ -4347,10 +4404,15 @@ class AudioEditorDialog(QDialog):
|
||||
samples = load_region_samples(path, 0.0, min(dur, self._EDIT_DECODE_CAP))
|
||||
self._wave.set_view(0.0, dur)
|
||||
self._wave.set_peaks(peaks(samples, buckets=self._EDITOR_WAVEFORM_BINS))
|
||||
self._wave.set_selection(0.0, dur)
|
||||
self._wave.clear_selection()
|
||||
playhead = self._wave.playhead()
|
||||
self._wave.set_playhead(0.0 if playhead is None else min(playhead, dur))
|
||||
else:
|
||||
self._wave.set_view(0.0, 0.0)
|
||||
self._wave.set_peaks([])
|
||||
self._wave.clear_selection()
|
||||
self._wave.set_playhead(None)
|
||||
self._wave.set_markers(self._healed_seams.get(path, []))
|
||||
self._btn_undo.setEnabled(self._ver_idx > 0)
|
||||
self._btn_redo.setEnabled(self._ver_idx < len(self._versions) - 1)
|
||||
|
||||
@@ -4411,6 +4473,9 @@ class AudioEditorDialog(QDialog):
|
||||
|
||||
def _prepare_join_preview(self, seam_t: float) -> None:
|
||||
self._join_preview = (max(0.0, seam_t - 1.0), seam_t + 1.0)
|
||||
self._healed_seams[self._current()] = [seam_t]
|
||||
self._wave.set_markers([seam_t])
|
||||
self._set_status(f"Healed join marked at {seam_t:.2f}s")
|
||||
|
||||
def _on_heal_cut(self):
|
||||
self._apply_op(build_audio_heal_delete_command, whole_clip_ok=False,
|
||||
@@ -4443,15 +4508,20 @@ class AudioEditorDialog(QDialog):
|
||||
if not checked:
|
||||
self._stop_play()
|
||||
return
|
||||
play_t = self._playhead_secs()
|
||||
se = self._selection_secs()
|
||||
if se is not None:
|
||||
self._play_current(*se)
|
||||
if se is not None and se[0] <= play_t <= se[1]:
|
||||
self._play_current(se[0], se[1], loop=True)
|
||||
else:
|
||||
self._play_current()
|
||||
end = self._clip_dur if self._clip_dur > play_t else None
|
||||
self._play_current(play_t, end, loop=False)
|
||||
|
||||
def _play_args(self, start: float | None = None,
|
||||
end: float | None = None) -> list[str]:
|
||||
end: float | None = None,
|
||||
loop: bool = False) -> list[str]:
|
||||
args = ["-autoexit", "-nodisp", "-loglevel", "error"]
|
||||
if loop:
|
||||
args += ["-loop", "0"]
|
||||
if start is not None:
|
||||
args += ["-ss", str(start)]
|
||||
if end is not None and start is not None and end > start:
|
||||
@@ -4466,14 +4536,16 @@ class AudioEditorDialog(QDialog):
|
||||
return args
|
||||
|
||||
def _play_current(self, start: float | None = None,
|
||||
end: float | None = None) -> None:
|
||||
end: float | None = None,
|
||||
loop: bool = False) -> None:
|
||||
from PyQt6.QtCore import QProcess
|
||||
self._stop_play()
|
||||
self._play_proc = QProcess(self)
|
||||
self._play_proc.finished.connect(self._on_play_finished)
|
||||
self._play_proc.errorOccurred.connect(self._on_play_error)
|
||||
args = self._play_args(start, end)
|
||||
args = self._play_args(start, end, loop=loop)
|
||||
self._play_start = max(0.0, start if start is not None else 0.0)
|
||||
self._play_loop = loop
|
||||
if end is not None and end > self._play_start:
|
||||
self._play_end = end
|
||||
elif self._clip_dur > 0:
|
||||
@@ -4496,6 +4568,18 @@ class AudioEditorDialog(QDialog):
|
||||
return
|
||||
self._play_current(*self._join_preview)
|
||||
|
||||
def _on_wave_playhead_changed(self, t: float) -> None:
|
||||
if self._play_proc is not None:
|
||||
self._stop_play()
|
||||
|
||||
def _playhead_secs(self) -> float:
|
||||
t = self._wave.playhead()
|
||||
if t is None:
|
||||
t = 0.0
|
||||
if self._clip_dur > 0:
|
||||
t = max(0.0, min(t, self._clip_dur))
|
||||
return t
|
||||
|
||||
def _set_playback_speed(self, speed: float) -> None:
|
||||
if speed == 2.0 and self._btn_speed2.isChecked():
|
||||
self._btn_speed4.setChecked(False)
|
||||
@@ -4511,7 +4595,7 @@ class AudioEditorDialog(QDialog):
|
||||
return
|
||||
self._play_speed = eff
|
||||
if self._play_proc is not None:
|
||||
self._play_current(self._play_start, self._play_end)
|
||||
self._play_current(self._play_start, self._play_end, loop=self._play_loop)
|
||||
|
||||
def _tick_playhead(self) -> None:
|
||||
elapsed = self._play_elapsed.elapsed() / 1000.0
|
||||
@@ -4531,7 +4615,6 @@ class AudioEditorDialog(QDialog):
|
||||
proc = self._play_proc
|
||||
self._play_proc = None
|
||||
self._playhead_timer.stop()
|
||||
self._wave.set_playhead(None)
|
||||
if proc is not None:
|
||||
proc.deleteLater()
|
||||
self._btn_play.setText("▶ Play")
|
||||
@@ -4544,7 +4627,6 @@ class AudioEditorDialog(QDialog):
|
||||
proc = self._play_proc
|
||||
self._play_proc = None
|
||||
self._playhead_timer.stop()
|
||||
self._wave.set_playhead(None)
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.finished.disconnect()
|
||||
|
||||
@@ -646,6 +646,31 @@ def test_waveform_view_and_selection(win):
|
||||
assert w.selection() is None
|
||||
|
||||
|
||||
def test_waveform_click_sets_playhead_without_changing_selection(win):
|
||||
from PyQt6.QtCore import QEvent, QPointF, Qt
|
||||
from PyQt6.QtGui import QMouseEvent
|
||||
w = win._wave
|
||||
w.resize(400, 96)
|
||||
w.set_view(0.0, 10.0)
|
||||
w.set_selection(2.0, 4.0)
|
||||
got = []
|
||||
w.playhead_changed.connect(lambda t: got.append(t))
|
||||
x = w._t_to_px(7.0)
|
||||
press = QMouseEvent(
|
||||
QEvent.Type.MouseButtonPress, QPointF(x, 20),
|
||||
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
|
||||
Qt.KeyboardModifier.NoModifier)
|
||||
release = QMouseEvent(
|
||||
QEvent.Type.MouseButtonRelease, QPointF(x, 20),
|
||||
Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier)
|
||||
w.mousePressEvent(press)
|
||||
w.mouseReleaseEvent(release)
|
||||
assert w.selection() == (2.0, 4.0)
|
||||
assert got and abs(got[-1] - 7.0) < 0.05
|
||||
assert abs((w.playhead() or 0.0) - 7.0) < 0.05
|
||||
|
||||
|
||||
def test_waveform_drag_emits_selection(win):
|
||||
w = win._wave
|
||||
w.resize(400, 96)
|
||||
@@ -1003,19 +1028,52 @@ def test_editor_stop_play_unchecks_button(win, tmp_path):
|
||||
assert not dlg._btn_play.isChecked()
|
||||
|
||||
|
||||
def test_editor_play_button_auditions_selection(win, tmp_path, monkeypatch):
|
||||
def test_editor_reload_starts_with_playhead_and_no_selection(win, tmp_path, monkeypatch):
|
||||
import main as m
|
||||
import core.waveform as wf
|
||||
import numpy as np
|
||||
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||
monkeypatch.setattr(m, "probe_duration", lambda _p: 4.0)
|
||||
monkeypatch.setattr(
|
||||
wf, "load_region_samples",
|
||||
lambda path, start, dur: np.ones(32000, dtype="float32"))
|
||||
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||
assert dlg._wave.selection() is None
|
||||
assert dlg._wave.playhead() == 0.0
|
||||
|
||||
|
||||
def test_editor_play_button_starts_from_playhead_outside_selection(win, tmp_path, monkeypatch):
|
||||
import main as m
|
||||
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||
dlg._clip_dur = 5.0
|
||||
dlg._wave.set_view(0.0, 5.0)
|
||||
dlg._wave.set_selection(1.25, 2.75)
|
||||
dlg._wave.set_playhead(4.0)
|
||||
seen = {}
|
||||
monkeypatch.setattr(
|
||||
dlg, "_play_current",
|
||||
lambda start=None, end=None: seen.update(start=start, end=end))
|
||||
lambda start=None, end=None, loop=False:
|
||||
seen.update(start=start, end=end, loop=loop))
|
||||
dlg._on_play(True)
|
||||
assert seen == {"start": 1.25, "end": 2.75}
|
||||
assert seen == {"start": 4.0, "end": 5.0, "loop": False}
|
||||
|
||||
|
||||
def test_editor_play_button_loops_selection_when_playhead_inside(win, tmp_path, monkeypatch):
|
||||
import main as m
|
||||
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||
dlg._clip_dur = 5.0
|
||||
dlg._wave.set_view(0.0, 5.0)
|
||||
dlg._wave.set_selection(1.25, 2.75)
|
||||
dlg._wave.set_playhead(2.0)
|
||||
seen = {}
|
||||
monkeypatch.setattr(
|
||||
dlg, "_play_current",
|
||||
lambda start=None, end=None, loop=False:
|
||||
seen.update(start=start, end=end, loop=loop))
|
||||
dlg._on_play(True)
|
||||
assert seen == {"start": 1.25, "end": 2.75, "loop": True}
|
||||
|
||||
|
||||
def test_editor_has_local_speed_controls(win, tmp_path):
|
||||
@@ -1048,8 +1106,9 @@ def test_editor_play_args_include_selection_and_speed(win, tmp_path):
|
||||
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||
dlg._play_speed = 2.0
|
||||
args = dlg._play_args(start=1.0, end=3.5)
|
||||
args = dlg._play_args(start=1.0, end=3.5, loop=True)
|
||||
assert args[:3] == ["-autoexit", "-nodisp", "-loglevel"]
|
||||
assert "-loop" in args and args[args.index("-loop") + 1] == "0"
|
||||
assert "-ss" in args and args[args.index("-ss") + 1] == "1.0"
|
||||
assert "-t" in args and args[args.index("-t") + 1] == "2.5"
|
||||
assert "-af" in args and args[args.index("-af") + 1] == "atempo=2.0"
|
||||
@@ -1104,6 +1163,17 @@ def test_editor_loop_join_plays_preview_range(win, tmp_path, monkeypatch):
|
||||
assert seen == {"start": 1.0, "end": 3.0}
|
||||
|
||||
|
||||
def test_editor_heal_cut_marks_healed_seam(win, tmp_path):
|
||||
import main as m
|
||||
src = tmp_path / "v0.wav"
|
||||
src.write_bytes(b"")
|
||||
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||
dlg._prepare_join_preview(2.0)
|
||||
assert dlg._join_preview == (1.0, 3.0)
|
||||
assert dlg._wave._markers == [2.0]
|
||||
assert "2.00s" in dlg._status.text()
|
||||
|
||||
|
||||
def test_editor_save_to_library_adds_current_version(win, tmp_path, monkeypatch):
|
||||
import main as m
|
||||
src = tmp_path / "v0.wav"
|
||||
|
||||
Reference in New Issue
Block a user