Files
8-cut/docs/plans/2026-07-02-audio-editor-3a-waveform-implementation.md
T

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__: 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):

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:

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.