# 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: ```bash 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`: ```python 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: ```bash 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`: ```python 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: ```bash 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** ```bash 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`: ```python 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: ```bash 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`: ```python 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: ```python 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: ```python 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: ```python 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: ```python 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: ```bash 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** ```bash 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`: ```python 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`: ```python 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: ```bash 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: ```python build_audio_delete_command, build_audio_silence_command, build_audio_reverse_command, ``` becomes: ```python 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`: ```python self._btn_heal_cut = QPushButton("Heal Cut") ``` Wire it in the button/slot tuple: ```python (self._btn_heal_cut, self._on_heal_cut), ``` Add it first in the ops row: ```python 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`: ```python 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: ```python def _apply_op(self, build_fn, whole_clip_ok: bool = True, after_success=None) -> None: ``` The successful-render block becomes: ```python 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: ```python 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()`: ```python self._join_preview: tuple[float, float] | None = None ``` - [ ] **Step 4: Verify GREEN** Run: ```bash 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** ```bash 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`: ```python 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: ```python 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: ```bash 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: ```python self._btn_loop_join = QPushButton("Loop Join") self._btn_save_library = QPushButton("Save to Library") ``` Wire buttons: ```python (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: ```python 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: ```python 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: ```python 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: ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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.