# 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:** ```python 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`: ```python 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:** ```python 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__`: add `self._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]`, enforce `start tuple|None`: return `self._sel`. - `set_playhead(t|None)`: store, `update()`. - `paintEvent`: keep peak bars; if `self._sel` and `_view_dur>0`, paint a translucent band (`QColor(0,200,180,50)`) between `t_to_x(sel_start)` and `t_to_x(sel_end)`, and two 2px handle lines (`QColor(0,220,190)`) at those x. If `_playhead` in-window, a 1px vertical `QColor(255,255,255,160)` line. Use `from 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): ```python 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 with `self.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 within `HANDLE_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`/`mouseReleaseEvent` delegate to these using `event.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`. `wheelEvent` calls it with `factor=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:** ```python 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 `_wave` is built): `self._wave.selection_changed.connect(self._on_wave_selection_changed)` and `self._wave.view_changed.connect(self._on_wave_view_changed)`. - `_on_wave_selection_changed(s, e)`: set `self._cursor = s`; set the length spinbox to `e-s` (block its signal or let it update the band); call `_update_audio_region()` and seek the video to `s` if 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 capped `load_region_samples`+`peaks` (reuse `_on_wave_refresh`'s decode, factored into a small `_decode_wave(vs, vd)` helper) and `set_peaks`. - Update `_on_wave_refresh` to also `set_view(self._cursor, view_dur)` and `set_selection(self._cursor, self._cursor+len)` (view_dur = max(len, MIN_VIEW)). Two-way: when `_spn_audio_len` changes 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) setting `self._wave.set_playhead(sel_start + elapsed)`; stop + `set_playhead(None)` in `_teardown_audition`/`_stop_audition`. Elapsed via a stored start time (use a QElapsedTimer, not `time`/`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.