From 86980b38cfea9d78dde33a1d2af15be02afd6906 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 16:52:29 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Phase=203a=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=20interactive=20waveform=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...audio-editor-3a-waveform-implementation.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/plans/2026-07-02-audio-editor-3a-waveform-implementation.md diff --git a/docs/plans/2026-07-02-audio-editor-3a-waveform-implementation.md b/docs/plans/2026-07-02-audio-editor-3a-waveform-implementation.md new file mode 100644 index 0000000..4fdc7fa --- /dev/null +++ b/docs/plans/2026-07-02-audio-editor-3a-waveform-implementation.md @@ -0,0 +1,187 @@ +# 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.