# Audio Merge (Crossfade) — Phase 2 Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** A "Merge" pane in the Audio tab that assembles an ordered list of audio clips into one output with a crossfade at every join, rendered via ffmpeg `acrossfade`. Clips come from the current extract selection (with its edits) or from files on disk. **Architecture:** A pure `core/ffmpeg.build_crossfade_merge_command` (TDD) + a third `QToolBox` page in `_build_audio_tab`. Reuses Phase-1 pieces: `build_audio_clip_command`, `_current_edit_filters`, `probe_duration`, and the ffplay audition machinery (`_audition_proc`/`_stop_audition`/`_teardown_audition`). **Tech Stack:** Python 3, PyQt6, ffmpeg (`acrossfade`/`concat` filters), pytest. **Design doc:** `docs/plans/2026-07-02-audio-merge-design.md` --- ## Conventions - Branch `audio-tab` (continues Phase 1). New commit per task; don't amend across tasks. - UI tests: `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v`. Run `test_ui_structure.py` and `test_utils.py` in SEPARATE processes (known segfault when combined). Pre-existing red in `test_utils.py`: `test_audio_extract_timing`, `test_ffmpeg_command_no_resize`, `test_db_get_markers_returns_sorted` — untouched by this work. --- ## Task M1: `build_crossfade_merge_command` (pure, TDD) **Files:** Modify `core/ffmpeg.py`; test `tests/test_utils.py`. **Step 1 — failing tests:** ```python def test_merge_single_clip_reencodes(): cmd = build_crossfade_merge_command(["/a.wav"], 0.5, "/o/out.mp3") assert cmd[0] == "ffmpeg" assert cmd.count("-i") == 1 assert "libmp3lame" in cmd # codec by out ext assert "acrossfade" not in " ".join(cmd) assert cmd[-1] == "/o/out.mp3" def test_merge_two_clips_acrossfade(): cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/out.wav") assert cmd.count("-i") == 2 fc = cmd[cmd.index("-filter_complex") + 1] assert "[0][1]acrossfade=d=0.5" in fc assert "[out]" in fc assert cmd[cmd.index("-map") + 1] == "[out]" def test_merge_three_clips_chains(): cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 1.0, "/o/o.wav") fc = cmd[cmd.index("-filter_complex") + 1] assert fc.count("acrossfade=d=1.0") == 2 # two joins assert cmd[cmd.index("-map") + 1] == "[out]" def test_merge_zero_crossfade_uses_concat(): cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.0, "/o/o.wav") fc = cmd[cmd.index("-filter_complex") + 1] assert "concat=n=2:v=0:a=1" in fc assert "acrossfade" not in fc def test_merge_empty_raises(): import pytest with pytest.raises(ValueError): build_crossfade_merge_command([], 0.5, "/o/o.wav") ``` **Step 2** — run `...pytest tests/test_utils.py -k merge -v` → FAIL. **Step 3 — implement** in `core/ffmpeg.py` (near `build_audio_clip_command`): ```python def build_crossfade_merge_command(clips: list[str], crossfade: float, out_path: str) -> list[str]: """ffmpeg command that concatenates *clips* in order into *out_path*, crossfading each join by *crossfade* seconds (0 = butt-join). Re-encoded per the output extension.""" if not clips: raise ValueError("no clips to merge") ext = os.path.splitext(out_path)[1].lower() codec = _AUDIO_CODEC_BY_EXT.get(ext, []) cmd = [_bin("ffmpeg"), "-y"] for c in clips: cmd += ["-i", c] if len(clips) == 1: return cmd + ["-vn", *codec, out_path] if crossfade > 0: # Chain acrossfade: [0][1]->[a1]; [a1][2]->[a2]; … last label = [out]. parts, prev = [], "0" for i in range(1, len(clips)): label = "out" if i == len(clips) - 1 else f"a{i}" parts.append(f"[{prev}][{i}]acrossfade=d={crossfade}[{label}]") prev = label fc = ";".join(parts) else: inputs = "".join(f"[{i}]" for i in range(len(clips))) fc = f"{inputs}concat=n={len(clips)}:v=0:a=1[out]" return cmd + ["-filter_complex", fc, "-map", "[out]", *codec, out_path] ``` Confirm `_bin` import in ffmpeg.py (it uses `_bin` already). **Step 4** — `...pytest tests/test_utils.py -k "merge or audio_clip" -v` → PASS (5 new + existing audio-clip tests). **Step 5 — commit:** `git add core/ffmpeg.py tests/test_utils.py && git commit -m "feat: build_crossfade_merge_command — chained acrossfade / concat"` --- ## Task M2: Merge pane widgets + 3rd QToolBox page **Files:** Modify `main.py`; test `tests/test_ui_structure.py`. **Step 1 — failing test:** ```python def test_merge_pane_present(win): from PyQt6.QtWidgets import QToolBox, QListWidget, QDoubleSpinBox tb = win._tab_audio.findChild(QToolBox) titles = [tb.itemText(i) for i in range(tb.count())] assert "Merge" in titles assert isinstance(win._merge_list, QListWidget) assert isinstance(win._spn_crossfade, QDoubleSpinBox) assert win._spn_crossfade.value() == 0.5 ``` **Step 2** — run → FAIL. **Step 3 — implement:** - In `__init__` (after the audio-edit widget block), construct: `self._merge_list = QListWidget()` (set `setSelectionMode(SingleSelection)`); `self._spn_crossfade = QDoubleSpinBox()` (range 0.0–10.0, decimals 2, step 0.1, suffix " s", value from QSettings `audio_crossfade` default 0.5, persist on change); the buttons `self._btn_merge_add_sel` ("+ Selection"), `self._btn_merge_add_file` ("+ File…"), `self._btn_merge_up` ("▲"), `self._btn_merge_down` ("▼"), `self._btn_merge_remove` ("✕"), `self._btn_merge_preview` ("▶ Preview"), `self._btn_merge_save` ("Merge & Save…"). Wire each `clicked` to its handler (defined in M3/M4; add stub methods now that `pass` — or define fully in later tasks and reference them). To keep TDD green, define minimal stub handlers now. - In `_build_audio_tab`, add a third page: a `QWidget` with a `QVBoxLayout` holding the list, a crossfade row (`QLabel("Crossfade:")` + `_spn_crossfade`), a button grid (add/remove/reorder), and a bottom row (Preview, Merge & Save). `tb.addItem(merge_page, "Merge")`. **Step 4** — run full `tests/test_ui_structure.py` → new test passes, prior 27 still pass. **Step 5 — commit:** `feat: Merge pane (3rd Audio QToolBox page) — list + crossfade + buttons` --- ## Task M3: Add-to-sequence + reorder/remove **Files:** `main.py`; test `tests/test_ui_structure.py`. **Step 1 — failing test** (drives the list ops without file dialogs): ```python def test_merge_list_add_remove_reorder(win, tmp_path): from PyQt6.QtCore import Qt a = tmp_path / "a.wav"; b = tmp_path / "b.wav" a.write_bytes(b""); b.write_bytes(b"") win._merge_add_paths([str(a), str(b)]) assert win._merge_list.count() == 2 assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(a) win._merge_list.setCurrentRow(1) win._merge_move(-1) # move b up assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(b) win._merge_list.setCurrentRow(0) win._merge_remove_selected() assert win._merge_list.count() == 1 ``` **Step 2** — run → FAIL. **Step 3 — implement** on MainWindow: - `_merge_add_paths(paths)` — for each path append a `QListWidgetItem` with text `basename (Ds)` (duration via `probe_duration`, blank if None) and `setData(UserRole, abspath)`. - `_on_merge_add_file` — `QFileDialog.getOpenFileNames` (audio filter, remembered dir) → `_merge_add_paths`. - `_on_merge_add_selection` — guard `_file_path`; render `[_cursor, _cursor+len]` with `_current_edit_filters()` to a unique temp wav (track it for cleanup) via `build_audio_clip_command` + `subprocess.run` under wait cursor; on success `_merge_add_paths([tmp])`; else status. - `_merge_move(delta)` and `_merge_remove_selected()` — standard list row moves/removal. - Replace the M2 stubs for add/file/selection/up/down/remove with these (up=`_merge_move(-1)`, down=`_merge_move(1)`). **Step 4** — run full UI file → new test + prior pass. **Step 5 — commit:** `feat: Merge sequence — add (file/selection), reorder, remove` --- ## Task M4: Merge & Save + Preview **Files:** `main.py`; test `tests/test_ui_structure.py`. **Step 1 — failing test** (patches the builder to capture args; sentinel-raises to avoid running ffmpeg): ```python def test_merge_save_builds_command(win, tmp_path, monkeypatch): import main as m, pytest from PyQt6.QtCore import Qt a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"") win._merge_add_paths([str(a), str(b)]) win._spn_crossfade.setValue(0.75) seen = {} class _Stop(Exception): pass def fake(clips, xf, out): seen["clips"] = clips; seen["xf"] = xf; seen["out"] = out raise _Stop monkeypatch.setattr(m, "build_crossfade_merge_command", fake) monkeypatch.setattr(m.QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), ""))) with pytest.raises(_Stop): win._on_merge_save() assert seen["clips"] == [str(a), str(b)] assert seen["xf"] == 0.75 assert seen["out"].endswith(".wav") def test_merge_save_empty_is_noop(win): win._merge_list.clear() win._on_merge_save() # must not raise / not open a dialog ``` **Step 2** — run → FAIL. **Step 3 — implement:** - `_merge_paths()` helper → list of UserRole paths in order. - `import build_crossfade_merge_command` at the top with the other ffmpeg imports. - `_on_merge_save` — if `_merge_paths()` empty → status + return; save dialog (reuse the extract save-filter builder / remembered dir); `cmd = build_crossfade_merge_command(paths, self._spn_crossfade.value(), out)`; run under wait cursor + status; on success report `probe_duration`, mirror extract's success/error handling. - `_play_file(path)` — extract the ffplay-start logic from `_on_audio_audition` into this shared helper (render→play stays in audition; `_play_file` just starts `QProcess(ffplay)` on an existing file and reuses `_stop_audition`/teardown). Have `_on_audio_audition` call `_play_file(tmp)` after its render. - `_on_merge_preview` — build to a unique temp wav, run, then `_play_file(tmp)`; guard empty list. **Step 4** — run full UI file → new tests + prior pass; also re-run the audition test to confirm the `_play_file` refactor didn't break it. **Step 5 — commit:** `feat: merge save + preview (crossfade render, ffplay preview)` --- ## Task M5: Temp cleanup, verify, docs **Files:** `main.py`, `README.md`; verify. - **Temp cleanup:** track rendered temp clips/preview outputs in a `self._merge_temps: set[str]`; best-effort `os.remove` them in `closeEvent` (add next to the audition teardown). Add `_stop_audition()` to `closeEvent` too (Phase-1 code-review follow-up). - **Docs:** bump `APP_VERSION` "1.3"→"1.4"; add a `("1.4", [...])` changelog entry describing the Merge pane (assemble clips, crossfade at joins, add from selection/file, preview, save). README: add a bullet group under "Audio extraction & editing" for **Merge / crossfade**. - **Verify:** run `tests/test_utils.py`, `tests/test_ui_structure.py`, `tests/test_audio_scan.py`, `tests/test_db.py` each in its own process; report counts; confirm the only failures are the 3 known pre-existing ones. `python -c "import main; print('OK')"` smoke. - **Commit:** `docs: Merge pane (Phase 2) — v1.4 changelog + README + temp cleanup` --- ## Deferred (Phase 2-later / "fancier stuff") - Interactive waveform drag-select (in/out handles, zoom, moving playhead) to define each added clip precisely. - Per-join crossfade durations + curve selection (`c1=/c2=`), per-clip gain, drag-to-reorder. - Saved/reloadable merge projects.