Files
8-cut/docs/superpowers/plans/2026-07-04-audio-timeline-picker-heal-cut-implementation.md

22 KiB

Audio Timeline Picker And Heal Cut Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the existing audio timeline band practical for picking regions and add a one-click Heal Cut editor workflow for removing short artifacts and rejoining the audio smoothly.

Architecture: Keep the current single-lane TimelineWidget as the source-region picker and keep MainWindow as the synchronization owner between timeline, cursor, length spinbox, and waveform. Add a pure core.ffmpeg.build_audio_heal_delete_command() command builder, then wire it into AudioEditorDialog as the primary destructive operation using the existing rendered-version undo/redo stack.

Tech Stack: Python 3, PyQt6, ffmpeg filtergraphs, pytest. Run tests/test_ui_structure.py and tests/test_utils.py in separate processes with LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen.


Baseline

Run before implementation:

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -q
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -q

Expected baseline:

  • tests/test_ui_structure.py: 70 passed.
  • tests/test_utils.py: 90 passed, 3 known pre-existing failures:
    • test_ffmpeg_command_no_resize
    • test_db_get_markers_returns_sorted
    • test_audio_extract_timing

File Structure

  • core/ffmpeg.py: add pure Heal Cut command builder and small helper for automatic fade duration.
  • tests/test_utils.py: add pure tests for Heal Cut command generation.
  • main.py: import builder, improve TimelineWidget audio interactions, add editor Heal Cut UI, join preview range, loop playback, and Save to Library.
  • tests/test_ui_structure.py: add focused Qt structure/behavior tests for timeline picking and editor integration.

Task 1: Pure Heal Cut ffmpeg Builder

Files:

  • Modify: core/ffmpeg.py

  • Modify: tests/test_utils.py

  • Step 1: Write failing tests

Append these tests after test_audio_delete_empty_head in tests/test_utils.py:

def test_audio_heal_delete_command_crossfades_join():
    from core.ffmpeg import build_audio_heal_delete_command
    cmd = build_audio_heal_delete_command("/in.wav", 2.0, 4.0, "/o/o.wav", crossfade=0.1)
    assert cmd[0] == "ffmpeg"
    assert cmd.count("-i") == 1
    fc = cmd[cmd.index("-filter_complex") + 1]
    assert "atrim=end=2.0" in fc
    assert "atrim=start=4.0" in fc
    assert "acrossfade=d=0.1:c1=qsin:c2=qsin[out]" in fc
    assert cmd[cmd.index("-map") + 1] == "[out]"
    assert "pcm_s16le" in cmd
    assert cmd[-1] == "/o/o.wav"


def test_audio_heal_delete_command_auto_crossfade_clamped():
    from core.ffmpeg import build_audio_heal_delete_command
    cmd = build_audio_heal_delete_command("/in.wav", 10.0, 12.0, "/o/o.mp3")
    fc = cmd[cmd.index("-filter_complex") + 1]
    assert "acrossfade=d=0.25:c1=qsin:c2=qsin[out]" in fc
    assert "libmp3lame" in cmd


def test_audio_heal_delete_command_near_start_shortens_crossfade():
    from core.ffmpeg import build_audio_heal_delete_command
    cmd = build_audio_heal_delete_command("/in.wav", 0.03, 1.0, "/o/o.wav")
    fc = cmd[cmd.index("-filter_complex") + 1]
    assert "acrossfade=d=0.03:c1=qsin:c2=qsin[out]" in fc


def test_audio_heal_delete_command_rejects_invalid_region():
    import pytest
    from core.ffmpeg import build_audio_heal_delete_command
    with pytest.raises(ValueError):
        build_audio_heal_delete_command("/in.wav", 3.0, 3.0, "/o/o.wav")
  • Step 2: Verify RED

Run:

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -k "audio_heal_delete" -q

Expected: collection/import failure or test failure because build_audio_heal_delete_command does not exist.

  • Step 3: Implement minimal builder

In core/ffmpeg.py, add this helper and command builder after build_audio_delete_command:

def _auto_heal_crossfade(start: float, end: float,
                         requested: float | None = None) -> float:
    if end <= start:
        raise ValueError("heal delete end must be greater than start")
    if requested is not None:
        fade = max(0.0, float(requested))
    else:
        fade = min(0.25, max(0.04, (end - start) * 0.25))
    # Without knowing total duration, clamp only to available pre-roll.
    fade = min(fade, max(0.0, float(start)))
    return round(fade, 3)


def build_audio_heal_delete_command(input_path: str, start: float, end: float,
                                    out_path: str,
                                    crossfade: float | None = None) -> list[str]:
    """Remove [start, end] and heal the join with a short equal-power crossfade."""
    if end <= start:
        raise ValueError("heal delete end must be greater than start")
    s, e = round(start, 3), round(end, 3)
    xf = _auto_heal_crossfade(s, e, crossfade)
    ext = os.path.splitext(out_path)[1].lower()
    codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
    if xf <= 0:
        fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
              f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
              f"[a][b]concat=n=2:v=0:a=1[out]")
    else:
        fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
              f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
              f"[a][b]acrossfade=d={xf}:c1=qsin:c2=qsin[out]")
    return [_bin("ffmpeg"), "-y", "-i", input_path,
            "-filter_complex", fc, "-map", "[out]", *codec, out_path]
  • Step 4: Verify GREEN

Run:

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -k "audio_heal_delete or audio_delete or audio_silence or audio_reverse" -q

Expected: all selected tests pass.

  • Step 5: Commit
git add core/ffmpeg.py tests/test_utils.py
git commit -m "feat: add heal cut ffmpeg command"

Task 2: Timeline Audio Band Interaction Upgrade

Files:

  • Modify: main.py

  • Modify: tests/test_ui_structure.py

  • Step 1: Write failing tests

Add these tests after test_timeline_audio_band_resize_right in tests/test_ui_structure.py:

def test_timeline_audio_click_outside_moves_region_start(win):
    tl = win._timeline
    tl._duration = 20.0
    tl._view_start = 0.0
    tl._view_span = 20.0
    tl.resize(400, tl.height() or 80)
    tl.set_audio_mode(True)
    tl.set_audio_region(4.0, 8.0)
    got = []
    tl.audio_region_changed.connect(lambda s, e: got.append((s, e)))
    tl._audio_begin_drag_at_x(tl._time_to_x(12.0))
    tl._audio_end_drag()
    assert got[-1] == tl._audio_region
    s, e = tl._audio_region
    assert abs(s - 12.0) < 0.1
    assert abs((e - s) - 4.0) < 0.1


def test_timeline_audio_drag_empty_creates_region(win):
    tl = win._timeline
    tl._duration = 20.0
    tl._view_start = 0.0
    tl._view_span = 20.0
    tl.resize(400, tl.height() or 80)
    tl.set_audio_mode(True)
    tl.set_audio_region(4.0, 8.0)
    tl._audio_begin_drag_at_x(tl._time_to_x(12.0))
    tl._audio_drag_to_x(tl._time_to_x(15.0))
    tl._audio_end_drag()
    s, e = tl._audio_region
    assert abs(s - 12.0) < 0.1
    assert abs(e - 15.0) < 0.1


def test_timeline_audio_hover_cursor_state(win):
    tl = win._timeline
    tl._duration = 20.0
    tl._view_start = 0.0
    tl._view_span = 20.0
    tl.resize(400, tl.height() or 80)
    tl.set_audio_mode(True)
    tl.set_audio_region(4.0, 8.0)
    assert tl._audio_hit_at_x(tl._time_to_x(4.0)) == "left"
    assert tl._audio_hit_at_x(tl._time_to_x(8.0)) == "right"
    assert tl._audio_hit_at_x(tl._time_to_x(6.0)) == "move"
    assert tl._audio_hit_at_x(tl._time_to_x(12.0)) == "create"
  • Step 2: Verify RED

Run:

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "timeline_audio" -q

Expected: failures because _audio_hit_at_x does not exist and empty-space click/drag does not create/move the region.

  • Step 3: Implement minimal timeline changes

In TimelineWidget.__init__, change _AUDIO_EDGE_PX = 6 to _AUDIO_EDGE_PX = 10.

Add a helper near _audio_begin_drag_at_x:

    def _audio_hit_at_x(self, x: float) -> str | None:
        if not self._audio_mode or self._audio_region is None:
            return None
        a0, a1 = self._audio_region
        ax0 = self._time_to_x(a0)
        ax1 = self._time_to_x(a1)
        if abs(x - ax0) <= self._AUDIO_EDGE_PX:
            return "left"
        if abs(x - ax1) <= self._AUDIO_EDGE_PX:
            return "right"
        if ax0 < x < ax1:
            return "move"
        return "create"

Update _audio_begin_drag_at_x so "create" preserves current width until the pointer moves:

    def _audio_begin_drag_at_x(self, x: float) -> None:
        if not self._audio_mode or self._audio_region is None:
            return
        hit = self._audio_hit_at_x(x)
        a0, a1 = self._audio_region
        t = max(0.0, min(self._pos_to_time(int(x)), self._duration))
        self._audio_drag = hit
        if hit == "move":
            self._audio_drag_anchor = t - a0
        elif hit == "create":
            width = max(self._AUDIO_MIN_W, a1 - a0)
            a0 = max(0.0, min(t, max(0.0, self._duration - width)))
            self._audio_region = (a0, a0 + width)
            self.update()
        if self._audio_drag is not None:
            self._audio_drag_orig = self._audio_region

Update _audio_drag_to_x with a create branch:

        elif self._audio_drag == "create":
            anchor = self._audio_drag_orig[0] if self._audio_drag_orig else a0
            a0 = min(anchor, t)
            a1 = max(anchor + self._AUDIO_MIN_W, max(anchor, t))

Update mouseMoveEvent hover cursor logic before marker hover:

        if self._audio_mode and self._audio_region is not None:
            hit = self._audio_hit_at_x(x)
            if hit in ("left", "right"):
                self.setCursor(Qt.CursorShape.SizeHorCursor)
            elif hit == "move":
                self.setCursor(Qt.CursorShape.OpenHandCursor)
            elif hit == "create":
                self.setCursor(Qt.CursorShape.CrossCursor)
            return

In paintEvent, draw handle rectangles in Audio mode after the audio band edge lines:

                    p.fillRect(ax1 - 4, rh, 8, th, QColor(0, 220, 190, 120))
                    p.fillRect(ax2 - 4, rh, 8, th, QColor(0, 220, 190, 120))
  • Step 4: Verify GREEN

Run:

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "timeline_audio" -q

Expected: all selected timeline audio tests pass.

  • Step 5: Commit
git add main.py tests/test_ui_structure.py
git commit -m "feat: improve audio region timeline picking"

Task 3: Editor Heal Cut UI And Undo Integration

Files:

  • Modify: main.py

  • Modify: tests/test_ui_structure.py

  • Step 1: Write failing tests

Update test_audio_editor_dialog_scaffold so the button list includes _btn_heal_cut:

    for name in ("_btn_heal_cut", "_btn_delete", "_btn_silence", "_btn_reverse",
                 "_btn_trim", "_btn_undo", "_btn_redo", "_btn_save_as"):
        assert isinstance(getattr(dlg, name), QPushButton)

Add this test after test_editor_delete_builds_command:

def test_editor_heal_cut_builds_command(win, tmp_path, monkeypatch):
    import main as m, pytest
    src = tmp_path / "v0.wav"
    src.write_bytes(b"")
    dlg = m.AudioEditorDialog(str(src), parent=win)
    dlg._clip_dur = 6.0
    dlg._wave.set_view(0.0, 6.0)
    dlg._wave.set_selection(2.0, 3.0)
    seen = {}
    class _Stop(Exception):
        pass
    def fake(inp, s, e, out):
        seen.update(inp=inp, s=s, e=e, out=out)
        raise _Stop
    monkeypatch.setattr(m, "build_audio_heal_delete_command", fake)
    with pytest.raises(_Stop):
        dlg._on_heal_cut()
    assert seen["inp"] == str(src)
    assert seen["s"] == 2.0 and seen["e"] == 3.0
    assert seen["out"].endswith(".wav")
  • Step 2: Verify RED

Run:

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "audio_editor_dialog_scaffold or editor_heal_cut" -q

Expected: failures because _btn_heal_cut, _on_heal_cut, and the import do not exist.

  • Step 3: Implement minimal editor wiring

In main.py, update the from core.ffmpeg import block so this line:

    build_audio_delete_command, build_audio_silence_command,
    build_audio_reverse_command,

becomes:

    build_audio_delete_command, build_audio_silence_command,
    build_audio_reverse_command, build_audio_heal_delete_command,

In AudioEditorDialog.__init__, create the button before _btn_delete:

        self._btn_heal_cut = QPushButton("Heal Cut")

Wire it in the button/slot tuple:

                        (self._btn_heal_cut, self._on_heal_cut),

Add it first in the ops row:

        ops.addWidget(self._btn_heal_cut)
        for b in (self._btn_delete, self._btn_silence, self._btn_reverse,
                  self._btn_trim):
            ops.addWidget(b)

Add the handler next to _on_delete:

    def _on_heal_cut(self):
        self._apply_op(build_audio_heal_delete_command, whole_clip_ok=False,
                       after_success=self._prepare_join_preview)

Update _apply_op signature and its successful-render block. The function header becomes:

    def _apply_op(self, build_fn, whole_clip_ok: bool = True,
                  after_success=None) -> None:

The successful-render block becomes:

        if (proc is not None and proc.returncode == 0
                and os.path.exists(tmp) and os.path.getsize(tmp) > 0):
            del self._versions[self._ver_idx + 1:]
            self._versions.append(tmp)
            self._ver_idx += 1
            self._temps.add(tmp)
            self._reload()
            if after_success is not None:
                after_success(s)
            self._set_status("")

Add a no-op preview method for this task:

    def _prepare_join_preview(self, seam_t: float) -> None:
        self._join_preview = (max(0.0, seam_t - 1.0), seam_t + 1.0)

Initialize before _reload():

        self._join_preview: tuple[float, float] | None = None
  • Step 4: Verify GREEN

Run:

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "audio_editor_dialog_scaffold or editor_heal_cut or editor_delete" -q

Expected: selected editor tests pass.

  • Step 5: Commit
git add main.py tests/test_ui_structure.py
git commit -m "feat: add heal cut editor action"

Task 4: Join Loop Preview And Save To Library

Files:

  • Modify: main.py

  • Modify: tests/test_ui_structure.py

  • Step 1: Write failing tests

Add this test after test_editor_play_stop_safe:

def test_editor_loop_join_plays_preview_range(win, tmp_path, monkeypatch):
    import main as m
    src = tmp_path / "v0.wav"
    src.write_bytes(b"")
    dlg = m.AudioEditorDialog(str(src), parent=win)
    dlg._join_preview = (1.0, 3.0)
    seen = {}
    monkeypatch.setattr(dlg, "_play_current", lambda start=None, end=None: seen.update(start=start, end=end))
    dlg._on_loop_join()
    assert seen == {"start": 1.0, "end": 3.0}

Add this test after the library tests:

def test_editor_save_to_library_adds_current_version(win, tmp_path, monkeypatch):
    import main as m
    src = tmp_path / "v0.wav"
    src.write_bytes(b"")
    out_dir = tmp_path / "library"
    win._settings.setValue("audio_library_dir", str(out_dir))
    dlg = m.AudioEditorDialog(str(src), parent=win)
    dlg._clip_dur = 2.0
    saved = []
    monkeypatch.setattr(m, "build_audio_clip_command",
                        lambda inp, start, dur, out: ["ffmpeg", out])
    def fake_run(cmd, capture_output=True, timeout=300):
        target = cmd[-1]
        os.makedirs(os.path.dirname(target), exist_ok=True)
        with open(target, "wb") as f:
            f.write(b"x")
        class Proc:
            returncode = 0
        return Proc()
    monkeypatch.setattr(m.subprocess, "run", fake_run)
    win._scan_panel._library.add_clip = lambda p: saved.append(p)
    dlg._on_save_to_library()
    assert saved
    assert saved[0].startswith(str(out_dir))
  • Step 2: Verify RED

Run:

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "loop_join or save_to_library" -q

Expected: failures because _on_loop_join, _play_current, _btn_loop_join, _btn_save_library, and _on_save_to_library do not exist.

  • Step 3: Implement playback helper and save button

In AudioEditorDialog.__init__, add:

        self._btn_loop_join = QPushButton("Loop Join")
        self._btn_save_library = QPushButton("Save to Library")

Wire buttons:

                        (self._btn_loop_join, self._on_loop_join),
                        (self._btn_save_library, self._on_save_to_library),

Add _btn_loop_join beside play and _btn_save_library before Save As.

Replace _on_play playback start with a helper:

    def _on_play(self, checked: bool) -> None:
        if not checked:
            self._stop_play()
            return
        self._play_current()

    def _play_current(self, start: float | None = None,
                      end: float | None = None) -> None:
        from PyQt6.QtCore import QProcess
        self._stop_play()
        self._play_proc = QProcess(self)
        self._play_proc.finished.connect(self._on_play_finished)
        self._play_proc.errorOccurred.connect(self._on_play_error)
        args = ["-autoexit", "-nodisp", "-loglevel", "error"]
        if start is not None:
            args += ["-ss", str(start)]
        if end is not None and start is not None and end > start:
            args += ["-t", str(end - start)]
        args.append(self._current())
        self._play_proc.start(_bin("ffplay"), args)
        self._btn_play.setText("■ Stop")
        if not self._btn_play.isChecked():
            self._btn_play.blockSignals(True)
            self._btn_play.setChecked(True)
            self._btn_play.blockSignals(False)

Add loop handler:

    def _on_loop_join(self) -> None:
        if not self._join_preview:
            self._set_status("No healed join to preview")
            return
        self._play_current(*self._join_preview)

Add save-to-library handler:

    def _on_save_to_library(self) -> None:
        parent = self.parent()
        settings = getattr(parent, "_settings", None)
        scan_panel = getattr(parent, "_scan_panel", None)
        if settings is None or scan_panel is None:
            self._set_status("Library unavailable")
            return
        base = settings.value("audio_library_dir", "")
        if not base:
            base = os.path.join(str(Path.home()), "8cut_audio_library")
            settings.setValue("audio_library_dir", base)
        try:
            os.makedirs(base, exist_ok=True)
        except OSError:
            self._set_status("Could not create library folder")
            return
        stem = os.path.splitext(os.path.basename(self._current()))[0] or "clip"
        out = os.path.join(base, f"{stem}_edited.wav")
        i = 1
        while os.path.exists(out):
            out = os.path.join(base, f"{stem}_edited_{i}.wav")
            i += 1
        dur = probe_duration(self._current()) or self._clip_dur or 0.0
        cmd = build_audio_clip_command(self._current(), 0.0, dur, out)
        QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
        try:
            proc = subprocess.run(cmd, capture_output=True, timeout=300)
        except Exception:
            proc = None
        finally:
            QApplication.restoreOverrideCursor()
        if proc is not None and proc.returncode == 0 and os.path.exists(out):
            scan_panel._library.add_clip(out)
            self._last_saved = out
            self._set_status(f"Saved to library: {os.path.basename(out)}")
        else:
            self._set_status("Save to library failed")
  • Step 4: Verify GREEN

Run:

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "audio_editor_dialog_scaffold or editor_play_stop_safe or loop_join or save_to_library" -q

Expected: selected tests pass.

  • Step 5: Commit
git add main.py tests/test_ui_structure.py
git commit -m "feat: preview healed joins and save editor clips to library"

Task 5: Final Verification

Files:

  • Verify only; no file changes expected.

  • Step 1: Run full UI tests

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -q

Expected: all tests pass.

  • Step 2: Run utility tests
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -q

Expected: new Heal Cut tests pass; the same three baseline failures remain unless unrelated baseline fixes were made:

  • test_ffmpeg_command_no_resize

  • test_db_get_markers_returns_sorted

  • test_audio_extract_timing

  • Step 3: Import smoke

LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -c "import main; print('OK')"

Expected: prints OK.

  • Step 4: Review git diff
git status --short
git diff --stat HEAD~4..HEAD

Expected: commits touch only core/ffmpeg.py, main.py, tests/test_utils.py, and tests/test_ui_structure.py, plus this plan commit if it was committed separately.