188 lines
9.3 KiB
Markdown
188 lines
9.3 KiB
Markdown
# 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<end`; store in `self._sel`; `update()`.
|
|
- `selection() -> 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.
|