From 356cfcc7d7ec6dd7bdbb8dd3b9a2e36071968d53 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 17:12:04 +0200 Subject: [PATCH] feat: wire interactive waveform selection to cursor/length (two-way, clamped view) --- main.py | 75 +++++++++++++++++++++++++++++++++----- tests/test_ui_structure.py | 11 ++++++ 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index 24ff2d2..140fc37 100755 --- a/main.py +++ b/main.py @@ -4111,6 +4111,7 @@ class MainWindow(QMainWindow): # State self._file_path: str = "" self._cursor: float = 0.0 + self._wave_syncing = False self._export_counter: int = 1 self._export_worker: ExportWorker | None = None self._export_queue: list[dict] = [] @@ -4658,6 +4659,13 @@ class MainWindow(QMainWindow): self._btn_wave_refresh.setToolTip( "Decode the current audio area and draw its waveform") self._btn_wave_refresh.clicked.connect(self._on_wave_refresh) + self._wave.selection_changed.connect(self._on_wave_selection_changed) + self._wave.view_changed.connect(self._on_wave_view_changed) + self._wave_pending_view = None + self._wave_decode_timer = QTimer(self) + self._wave_decode_timer.setSingleShot(True) + self._wave_decode_timer.setInterval(150) + self._wave_decode_timer.timeout.connect(self._decode_pending_wave) self._btn_audio_play = QPushButton("▶ Play") self._btn_audio_play.setCheckable(True) self._btn_audio_play.setToolTip( @@ -5158,6 +5166,7 @@ class MainWindow(QMainWindow): APP_VERSION = "1.4" _SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync) + _WAVE_MIN_VIEW = 3.0 # minimum waveform view window (seconds) CHANGELOG: list[tuple[str, list[str]]] = [ ("1.4", [ "Merge (crossfade) — a new Merge pane in the Audio tab " @@ -6704,6 +6713,8 @@ class MainWindow(QMainWindow): def _on_audio_len_changed(self, value: float) -> None: self._settings.setValue("audio_extract_len", value) self._update_audio_region() + if not self._wave_syncing and self._wave.selection() is not None: + self._wave.set_selection(self._cursor, self._cursor + value) def _update_audio_region(self) -> None: """Keep the timeline's audio-area band in sync with the playhead and @@ -6881,25 +6892,69 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "Preview failed", f"Could not render preview:\n\n{err}") - def _on_wave_refresh(self) -> None: - """Decode the current audio area and repaint the waveform strip.""" + def _decode_wave(self, view_start: float, view_dur: float) -> bool: + """Decode + paint the waveform for [view_start, view_start+view_dur] (capped). + Returns True if audio was decoded, False otherwise.""" if not self._file_path: self._wave.clear() - return + return False from core.waveform import load_region_samples, peaks - start = self._cursor - dur = self._spn_audio_len.value() - preview = min(dur, 120.0) # cap decode; the strip is 128 bars regardless + preview = min(view_dur, 120.0) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: - samples = load_region_samples(self._file_path, start, preview) + samples = load_region_samples(self._file_path, view_start, preview) finally: QApplication.restoreOverrideCursor() self._wave.set_peaks(peaks(samples)) - if samples.size == 0: + return samples.size > 0 + + def _on_wave_selection_changed(self, s: float, e: float) -> None: + if self._wave_syncing: + return + self._wave_syncing = True + try: + self._cursor = s + self._timeline.set_cursor(s) + if self._file_path: + try: + self._mpv.seek(s) + except Exception: + pass + self._spn_audio_len.setValue(e - s) # fires _on_audio_len_changed (guarded) + self._update_audio_region() + finally: + self._wave_syncing = False + + def _on_wave_view_changed(self, view_start: float, view_dur: float) -> None: + src = self._timeline._duration + if src and src > 0: + view_dur = min(view_dur, src) + view_start = max(0.0, min(view_start, max(0.0, src - view_dur))) + self._wave.set_view(view_start, view_dur) # re-apply clamped window (cheap repaint) + self._wave_pending_view = (view_start, view_dur) + self._wave_decode_timer.start() # coalesce rapid wheel zoom + + def _decode_pending_wave(self) -> None: + if self._wave_pending_view is None: + return + vs, vd = self._wave_pending_view + self._wave_pending_view = None + self._decode_wave(vs, vd) + + def _on_wave_refresh(self) -> None: + if not self._file_path: + self._wave.clear() + return + start = self._cursor + length = self._spn_audio_len.value() + view_dur = max(length, self._WAVE_MIN_VIEW) + self._wave.set_view(start, view_dur) + ok = self._decode_wave(start, view_dur) + self._wave.set_selection(start, start + length) + if not ok: self._show_status("Waveform: no audio decoded", 3000) - elif preview < dur: - self._show_status(f"Waveform shows first {preview:.0f}s of {dur:.0f}s", 4000) + elif view_dur > 120.0: + self._show_status(f"Waveform shows first 120s of the {view_dur:.0f}s view", 4000) def _current_edit_filters(self) -> list[str]: """ffmpeg -af chain for the current edit controls (shared by extract + diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 63006d0..c71c0d3 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -440,6 +440,17 @@ def test_waveform_zoom_emits_view(win): assert seen and seen[-1][1] < 4.0 +def test_waveform_selection_syncs_cursor_and_length(win): + win._file_path = "/x/video.mp4" + win._cursor = 10.0 + win._spn_audio_len.setValue(3.0) + win._wave.set_view(10.0, 6.0) + win._wave.set_selection(11.0, 14.0) + win._on_wave_selection_changed(11.0, 14.0) + assert win._cursor == 11.0 + assert abs(win._spn_audio_len.value() - 3.0) < 1e-6 + + def test_audition_button_present_and_safe(win): from PyQt6.QtWidgets import QPushButton assert isinstance(win._btn_audio_play, QPushButton)