Files
8-cut/docs/plans/2026-07-02-audio-merge-implementation.md
T

225 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.010.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.