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)