From d36c61a6d539d0a7d9dd5b5c70a5b84947a2d7b1 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Jul 2026 21:13:20 +0200 Subject: [PATCH] fix: separate editor playhead from selection --- main.py | 124 ++++++++++++++++++++++++++++++------- tests/test_ui_structure.py | 78 +++++++++++++++++++++-- 2 files changed, 177 insertions(+), 25 deletions(-) diff --git a/main.py b/main.py index 429a02c..c5aa35c 100755 --- a/main.py +++ b/main.py @@ -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() diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 70adac7..d4ad6a0 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -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"