9.3 KiB
Phase 3a — Interactive waveform selection — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Turn the read-only AudioWaveform strip into an interactive selection surface — drag in/out handles + click-drag to select + wheel zoom — whose selection drives the extract region (_cursor + _spn_audio_len), two-way synced with the length spinbox.
Architecture: Pure pixel↔time geometry in core/waveform.py (TDD); interactive state + painting + mouse/wheel on AudioWaveform (main.py); MainWindow wires the widget's selection_changed/view_changed to _cursor/_spn_audio_len and peak re-decode. Extract/audition/add-to-merge are unchanged (they already read _cursor+length).
Design doc: docs/plans/2026-07-02-audio-editor-fancier-design.md
Branch: audio-tab. UI tests: LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v. Never combine test_ui_structure.py + test_utils.py (segfault). 3 known pre-existing test_utils.py failures — leave them.
Task W1: Pure pixel↔time geometry (core/waveform.py, TDD)
Files: Modify core/waveform.py; test tests/test_utils.py.
Step 1 — failing tests:
def test_time_pixel_roundtrip():
from core.waveform import t_to_x, x_to_t
# window [10s, 10s+4s] across 400px
assert x_to_t(0, 400, 10.0, 4.0) == 10.0
assert x_to_t(400, 400, 10.0, 4.0) == 14.0
assert x_to_t(200, 400, 10.0, 4.0) == 12.0
assert t_to_x(12.0, 400, 10.0, 4.0) == 200
# round-trip
for x in (0, 37, 200, 399):
assert abs(t_to_x(x_to_t(x, 400, 10.0, 4.0), 400, 10.0, 4.0) - x) <= 1
def test_time_pixel_guards():
from core.waveform import t_to_x, x_to_t
assert x_to_t(50, 0, 10.0, 4.0) == 10.0 # zero width -> view_start
assert x_to_t(50, 400, 10.0, 0.0) == 10.0 # zero span -> view_start
assert t_to_x(12.0, 400, 10.0, 0.0) == 0 # zero span -> 0
Step 2 — run ...pytest tests/test_utils.py -k "time_pixel" -v → FAIL.
Step 3 — implement in core/waveform.py:
def x_to_t(x: float, width: float, view_start: float, view_dur: float) -> float:
"""Map a pixel x in [0,width] to a time in [view_start, view_start+view_dur]."""
if width <= 0 or view_dur <= 0:
return view_start
return view_start + (x / width) * view_dur
def t_to_x(t: float, width: float, view_start: float, view_dur: float) -> int:
"""Map a time to a pixel x in [0,width] (rounded)."""
if width <= 0 or view_dur <= 0:
return 0
return int(round((t - view_start) / view_dur * width))
Step 4 — ...pytest tests/test_utils.py -k "time_pixel or peaks or load_region" -v → PASS.
Step 5 — commit: feat: pixel<->time geometry helpers for the waveform
Task W2: AudioWaveform gains view + selection state + painting
Files: Modify main.py (AudioWaveform); test tests/test_ui_structure.py.
Step 1 — failing test:
def test_waveform_view_and_selection(win):
w = win._wave
w.set_view(10.0, 4.0)
w.set_selection(11.0, 12.5)
assert w._view_start == 10.0 and w._view_dur == 4.0
assert w.selection() == (11.0, 12.5)
# selection clamps into the view window
w.set_selection(9.0, 20.0)
s, e = w.selection()
assert s >= 10.0 and e <= 14.0 and s < e
w.set_peaks([0.1, 0.9, 0.3]) # still paints without crashing
Step 2 — run → FAIL.
Step 3 — implement on AudioWaveform (extend, keep set_peaks/clear/read-only fallback):
__init__: addself._view_start = 0.0,self._view_dur = 0.0,self._sel = None # (start,end) | None,self._playhead = None;setFixedHeight(96); keep tooltip.set_view(start, dur): store,update().set_selection(start, end): clamp to[view_start, view_start+view_dur], enforcestart<end; store inself._sel;update().selection() -> tuple|None: returnself._sel.set_playhead(t|None): store,update().paintEvent: keep peak bars; ifself._seland_view_dur>0, paint a translucent band (QColor(0,200,180,50)) betweent_to_x(sel_start)andt_to_x(sel_end), and two 2px handle lines (QColor(0,220,190)) at those x. If_playheadin-window, a 1px verticalQColor(255,255,255,160)line. Usefrom core.waveform import t_to_x(function-local import per file style).
Step 4 — full tests/test_ui_structure.py → new test + prior 37 pass.
Step 5 — commit: feat: waveform view/selection state + band/handle painting
Task W3: Mouse drag + wheel zoom → signals
Files: Modify main.py (AudioWaveform); test tests/test_ui_structure.py.
Step 1 — failing test (drive the handlers directly with synthetic positions rather than real Qt events where possible):
def test_waveform_drag_emits_selection(win):
from PyQt6.QtCore import QPointF
w = win._wave
w.resize(400, 96)
w.set_view(10.0, 4.0)
w.set_selection(11.0, 13.0)
got = []
w.selection_changed.connect(lambda s, e: got.append((s, e)))
# simulate: press in empty area near t=10.5 -> new selection anchor,
# drag to t=13.5, release
w._begin_drag_at_x(w._t_to_px(10.5))
w._drag_to_x(w._t_to_px(13.5))
w._end_drag()
assert got, "selection_changed should fire on release"
s, e = w.selection()
assert 10.0 <= s < e <= 14.0
def test_waveform_zoom_emits_view(win):
w = win._wave
w.resize(400, 96)
w.set_view(10.0, 4.0)
seen = []
w.view_changed.connect(lambda s, d: seen.append((s, d)))
w._zoom_at_x(200, 0.5) # zoom in (factor<1) about the middle
assert seen and seen[-1][1] < 4.0 # span shrank
Step 2 — run → FAIL.
Step 3 — implement:
- Add signals:
selection_changed = pyqtSignal(float, float),view_changed = pyqtSignal(float, float). - Helper
_t_to_px(t)/_px_to_t(x)wrapping the core helpers withself.width(),self._view_start,self._view_dur. - Refactor the drag into small methods the test can call:
_begin_drag_at_x(x)(pick handle if withinHANDLE_PX, else start new selection anchored at that time),_drag_to_x(x)(move active edge, clamp,update()),_end_drag()(finalize +emit selection_changed).mousePressEvent/mouseMoveEvent/mouseReleaseEventdelegate to these usingevent.position().x(). _zoom_at_x(x, factor):new_dur = clamp(self._view_dur*factor, MIN_SPAN, source-cap), keep the time under the pointer fixed, set_view_start/_view_dur,update(),emit view_changed.wheelEventcalls it withfactor=0.8(in)/1.25(out) using the wheel delta sign.
Step 4 — full UI file → new tests + prior pass.
Step 5 — commit: feat: waveform drag-select + wheel zoom (selection_changed/view_changed)
Task W4: MainWindow wiring (two-way sync + re-decode)
Files: Modify main.py; test tests/test_ui_structure.py.
Step 1 — failing test:
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
Step 2 — run → FAIL.
Step 3 — implement:
- Connect in
__init__(where_waveis built):self._wave.selection_changed.connect(self._on_wave_selection_changed)andself._wave.view_changed.connect(self._on_wave_view_changed). _on_wave_selection_changed(s, e): setself._cursor = s; set the length spinbox toe-s(block its signal or let it update the band); call_update_audio_region()and seek the video tosif desired (optional). Guard a re-entrancy flag so programmatic selection sets don't loop._on_wave_view_changed(vs, vd): re-decode peaks for[vs, vd]via the existing cappedload_region_samples+peaks(reuse_on_wave_refresh's decode, factored into a small_decode_wave(vs, vd)helper) andset_peaks.- Update
_on_wave_refreshto alsoset_view(self._cursor, view_dur)andset_selection(self._cursor, self._cursor+len)(view_dur = max(len, MIN_VIEW)). Two-way: when_spn_audio_lenchanges by typing, update the selection to[_cursor, _cursor+len](add to_on_audio_len_changed, guarded).
Step 4 — full UI file → new test + prior pass; manually confirm no signal feedback loop (the re-entrancy guard).
Step 5 — commit: feat: wire interactive waveform selection to cursor/length (two-way)
Task W5: Playhead during audition + verify + docs
Files: main.py, README.md; verify.
- Playhead: on audition start, start a
QTimer(~50ms) settingself._wave.set_playhead(sel_start + elapsed); stop +set_playhead(None)in_teardown_audition/_stop_audition. Elapsed via a stored start time (use a QElapsedTimer, nottime/Date). - Docs: README "Audio extraction & editing" gains an interactive-waveform bullet; changelog bump to a 1.5 entry ("Interactive waveform — drag to select the clip, zoom, playhead").
- Verify: each test file separately (expect 37+new passes; the 3 known failures only); import smoke.
- Commit:
feat: waveform playhead during audition + docs (v1.5)
Deferred to 3b / 3c
- Per-join crossfade + curves (3b); destructive clip editor (3c) — separate plans after 3a lands.