fix: separate editor playhead from selection

This commit is contained in:
2026-07-04 21:13:20 +02:00
parent ccad721c98
commit d36c61a6d5
2 changed files with 177 additions and 25 deletions
+103 -21
View File
@@ -4084,9 +4084,11 @@ class AudioWaveform(QWidget):
decode).""" decode)."""
selection_changed = pyqtSignal(float, float) selection_changed = pyqtSignal(float, float)
playhead_changed = pyqtSignal(float)
view_changed = pyqtSignal(float, float) view_changed = pyqtSignal(float, float)
HANDLE_PX = 6 HANDLE_PX = 6
CLICK_DRAG_PX = 3
MIN_SPAN = 0.05 MIN_SPAN = 0.05
MIN_SEL = 0.01 MIN_SEL = 0.01
@@ -4097,7 +4099,9 @@ 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._markers: list[float] = []
self._drag_anchor: float | None = None self._drag_anchor: float | None = None
self._press_x: 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)")
@@ -4124,14 +4128,32 @@ class AudioWaveform(QWidget):
def selection(self) -> tuple[float, float] | None: def selection(self) -> tuple[float, float] | None:
return self._sel return self._sel
def clear_selection(self) -> None:
self._sel = None
self.update()
def set_playhead(self, t: float | None) -> None: 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._playhead = t
self.update() 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: def clear(self) -> None:
self._peaks = [] self._peaks = []
self._sel = None self._sel = None
self._playhead = None self._playhead = None
self._markers = []
self._drag_anchor = None
self._press_x = None
self.update() self.update()
def paintEvent(self, _ev): def paintEvent(self, _ev):
@@ -4153,6 +4175,12 @@ class AudioWaveform(QWidget):
p.setPen(QPen(QColor(0, 220, 190), 2)) p.setPen(QPen(QColor(0, 220, 190), 2))
p.drawLine(x1, 0, x1, h) p.drawLine(x1, 0, x1, h)
p.drawLine(x2, 0, x2, 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 if (self._playhead is not None and self._view_dur > 0
and self._view_start <= self._playhead <= self._view_start + self._view_dur): 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) 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) return t_to_x(t, self.width(), self._view_start, self._view_dur)
# --- drag-select / zoom ------------------------------------------------- # --- 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: def _begin_drag_at_x(self, x: float) -> None:
t = self._px_to_t(x) t = self._px_to_t(x)
anchor = t anchor = self._selection_handle_anchor(x)
if self._sel is not None and self._view_dur > 0: if anchor is None:
xs = self._t_to_px(self._sel[0]) anchor = t
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_anchor = anchor
self._drag_to_x(x) self._drag_to_x(x)
@@ -4205,6 +4239,12 @@ class AudioWaveform(QWidget):
if self._sel is not None: if self._sel is not None:
self.selection_changed.emit(self._sel[0], self._sel[1]) 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: def _zoom_at_x(self, x: float, factor: float) -> None:
if self._view_dur <= 0 or self.width() <= 0: if self._view_dur <= 0 or self.width() <= 0:
return return
@@ -4219,14 +4259,28 @@ class AudioWaveform(QWidget):
# --- Qt events ---------------------------------------------------------- # --- Qt events ----------------------------------------------------------
def mousePressEvent(self, e): def mousePressEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton: 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): 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): def mouseReleaseEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton: 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): def wheelEvent(self, e):
dy = e.angleDelta().y() dy = e.angleDelta().y()
@@ -4256,14 +4310,17 @@ class AudioEditorDialog(QDialog):
self._play_speed = 1.0 self._play_speed = 1.0
self._play_start = 0.0 self._play_start = 0.0
self._play_end: float | None = None self._play_end: float | None = None
self._play_loop = False
self._play_elapsed = QElapsedTimer() self._play_elapsed = QElapsedTimer()
self._playhead_timer = QTimer(self) self._playhead_timer = QTimer(self)
self._playhead_timer.setInterval(33) self._playhead_timer.setInterval(33)
self._playhead_timer.timeout.connect(self._tick_playhead) self._playhead_timer.timeout.connect(self._tick_playhead)
self._clip_dur = 0.0 # true duration of the current version self._clip_dur = 0.0 # true duration of the current version
self._join_preview: tuple[float, float] | None = None self._join_preview: tuple[float, float] | None = None
self._healed_seams: dict[str, list[float]] = {}
self._wave = AudioWaveform() self._wave = AudioWaveform()
self._wave.playhead_changed.connect(self._on_wave_playhead_changed)
self._btn_heal_cut = QPushButton("Heal Cut") self._btn_heal_cut = QPushButton("Heal Cut")
self._btn_delete = QPushButton("Delete") self._btn_delete = QPushButton("Delete")
self._btn_silence = QPushButton("Silence") 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)) samples = load_region_samples(path, 0.0, min(dur, self._EDIT_DECODE_CAP))
self._wave.set_view(0.0, dur) self._wave.set_view(0.0, dur)
self._wave.set_peaks(peaks(samples, buckets=self._EDITOR_WAVEFORM_BINS)) 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: else:
self._wave.set_view(0.0, 0.0) self._wave.set_view(0.0, 0.0)
self._wave.set_peaks([]) 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_undo.setEnabled(self._ver_idx > 0)
self._btn_redo.setEnabled(self._ver_idx < len(self._versions) - 1) 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: def _prepare_join_preview(self, seam_t: float) -> None:
self._join_preview = (max(0.0, seam_t - 1.0), seam_t + 1.0) 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): def _on_heal_cut(self):
self._apply_op(build_audio_heal_delete_command, whole_clip_ok=False, self._apply_op(build_audio_heal_delete_command, whole_clip_ok=False,
@@ -4443,15 +4508,20 @@ class AudioEditorDialog(QDialog):
if not checked: if not checked:
self._stop_play() self._stop_play()
return return
play_t = self._playhead_secs()
se = self._selection_secs() se = self._selection_secs()
if se is not None: if se is not None and se[0] <= play_t <= se[1]:
self._play_current(*se) self._play_current(se[0], se[1], loop=True)
else: 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, 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"] args = ["-autoexit", "-nodisp", "-loglevel", "error"]
if loop:
args += ["-loop", "0"]
if start is not None: if start is not None:
args += ["-ss", str(start)] args += ["-ss", str(start)]
if end is not None and start is not None and end > start: if end is not None and start is not None and end > start:
@@ -4466,14 +4536,16 @@ class AudioEditorDialog(QDialog):
return args return args
def _play_current(self, start: float | None = None, 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 from PyQt6.QtCore import QProcess
self._stop_play() self._stop_play()
self._play_proc = QProcess(self) self._play_proc = QProcess(self)
self._play_proc.finished.connect(self._on_play_finished) self._play_proc.finished.connect(self._on_play_finished)
self._play_proc.errorOccurred.connect(self._on_play_error) 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_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: if end is not None and end > self._play_start:
self._play_end = end self._play_end = end
elif self._clip_dur > 0: elif self._clip_dur > 0:
@@ -4496,6 +4568,18 @@ class AudioEditorDialog(QDialog):
return return
self._play_current(*self._join_preview) 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: def _set_playback_speed(self, speed: float) -> None:
if speed == 2.0 and self._btn_speed2.isChecked(): if speed == 2.0 and self._btn_speed2.isChecked():
self._btn_speed4.setChecked(False) self._btn_speed4.setChecked(False)
@@ -4511,7 +4595,7 @@ class AudioEditorDialog(QDialog):
return return
self._play_speed = eff self._play_speed = eff
if self._play_proc is not None: 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: def _tick_playhead(self) -> None:
elapsed = self._play_elapsed.elapsed() / 1000.0 elapsed = self._play_elapsed.elapsed() / 1000.0
@@ -4531,7 +4615,6 @@ class AudioEditorDialog(QDialog):
proc = self._play_proc proc = self._play_proc
self._play_proc = None self._play_proc = None
self._playhead_timer.stop() self._playhead_timer.stop()
self._wave.set_playhead(None)
if proc is not None: if proc is not None:
proc.deleteLater() proc.deleteLater()
self._btn_play.setText("▶ Play") self._btn_play.setText("▶ Play")
@@ -4544,7 +4627,6 @@ class AudioEditorDialog(QDialog):
proc = self._play_proc proc = self._play_proc
self._play_proc = None self._play_proc = None
self._playhead_timer.stop() self._playhead_timer.stop()
self._wave.set_playhead(None)
if proc is not None: if proc is not None:
try: try:
proc.finished.disconnect() proc.finished.disconnect()
+74 -4
View File
@@ -646,6 +646,31 @@ def test_waveform_view_and_selection(win):
assert w.selection() is None 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): def test_waveform_drag_emits_selection(win):
w = win._wave w = win._wave
w.resize(400, 96) w.resize(400, 96)
@@ -1003,19 +1028,52 @@ def test_editor_stop_play_unchecks_button(win, tmp_path):
assert not dlg._btn_play.isChecked() 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 import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"") src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win) dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 5.0 dlg._clip_dur = 5.0
dlg._wave.set_view(0.0, 5.0) dlg._wave.set_view(0.0, 5.0)
dlg._wave.set_selection(1.25, 2.75) dlg._wave.set_selection(1.25, 2.75)
dlg._wave.set_playhead(4.0)
seen = {} seen = {}
monkeypatch.setattr( monkeypatch.setattr(
dlg, "_play_current", 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) 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): 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"") src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win) dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._play_speed = 2.0 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 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 "-ss" in args and args[args.index("-ss") + 1] == "1.0"
assert "-t" in args and args[args.index("-t") + 1] == "2.5" 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" 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} 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): def test_editor_save_to_library_adds_current_version(win, tmp_path, monkeypatch):
import main as m import main as m
src = tmp_path / "v0.wav" src = tmp_path / "v0.wav"