feat: wire interactive waveform selection to cursor/length (two-way, clamped view)

This commit is contained in:
2026-07-02 17:18:26 +02:00
parent 25fbf8142f
commit 356cfcc7d7
2 changed files with 76 additions and 10 deletions
+65 -10
View File
@@ -4111,6 +4111,7 @@ class MainWindow(QMainWindow):
# State # State
self._file_path: str = "" self._file_path: str = ""
self._cursor: float = 0.0 self._cursor: float = 0.0
self._wave_syncing = False
self._export_counter: int = 1 self._export_counter: int = 1
self._export_worker: ExportWorker | None = None self._export_worker: ExportWorker | None = None
self._export_queue: list[dict] = [] self._export_queue: list[dict] = []
@@ -4658,6 +4659,13 @@ class MainWindow(QMainWindow):
self._btn_wave_refresh.setToolTip( self._btn_wave_refresh.setToolTip(
"Decode the current audio area and draw its waveform") "Decode the current audio area and draw its waveform")
self._btn_wave_refresh.clicked.connect(self._on_wave_refresh) 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 = QPushButton("▶ Play")
self._btn_audio_play.setCheckable(True) self._btn_audio_play.setCheckable(True)
self._btn_audio_play.setToolTip( self._btn_audio_play.setToolTip(
@@ -5158,6 +5166,7 @@ class MainWindow(QMainWindow):
APP_VERSION = "1.4" APP_VERSION = "1.4"
_SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync) _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]]] = [ CHANGELOG: list[tuple[str, list[str]]] = [
("1.4", [ ("1.4", [
"<b>Merge (crossfade)</b> — a new <b>Merge</b> pane in the Audio tab " "<b>Merge (crossfade)</b> — a new <b>Merge</b> pane in the Audio tab "
@@ -6704,6 +6713,8 @@ class MainWindow(QMainWindow):
def _on_audio_len_changed(self, value: float) -> None: def _on_audio_len_changed(self, value: float) -> None:
self._settings.setValue("audio_extract_len", value) self._settings.setValue("audio_extract_len", value)
self._update_audio_region() 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: def _update_audio_region(self) -> None:
"""Keep the timeline's audio-area band in sync with the playhead and """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", QMessageBox.warning(self, "Preview failed",
f"Could not render preview:\n\n{err}") f"Could not render preview:\n\n{err}")
def _on_wave_refresh(self) -> None: def _decode_wave(self, view_start: float, view_dur: float) -> bool:
"""Decode the current audio area and repaint the waveform strip.""" """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: if not self._file_path:
self._wave.clear() self._wave.clear()
return return False
from core.waveform import load_region_samples, peaks from core.waveform import load_region_samples, peaks
start = self._cursor preview = min(view_dur, 120.0)
dur = self._spn_audio_len.value()
preview = min(dur, 120.0) # cap decode; the strip is 128 bars regardless
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try: try:
samples = load_region_samples(self._file_path, start, preview) samples = load_region_samples(self._file_path, view_start, preview)
finally: finally:
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
self._wave.set_peaks(peaks(samples)) 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) self._show_status("Waveform: no audio decoded", 3000)
elif preview < dur: elif view_dur > 120.0:
self._show_status(f"Waveform shows first {preview:.0f}s of {dur:.0f}s", 4000) self._show_status(f"Waveform shows first 120s of the {view_dur:.0f}s view", 4000)
def _current_edit_filters(self) -> list[str]: def _current_edit_filters(self) -> list[str]:
"""ffmpeg -af chain for the current edit controls (shared by extract + """ffmpeg -af chain for the current edit controls (shared by extract +
+11
View File
@@ -440,6 +440,17 @@ def test_waveform_zoom_emits_view(win):
assert seen and seen[-1][1] < 4.0 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): def test_audition_button_present_and_safe(win):
from PyQt6.QtWidgets import QPushButton from PyQt6.QtWidgets import QPushButton
assert isinstance(win._btn_audio_play, QPushButton) assert isinstance(win._btn_audio_play, QPushButton)