12 KiB
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. Runtest_ui_structure.pyandtest_utils.pyin SEPARATE processes (known segfault when combined). Pre-existing red intest_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:
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):
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:
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()(setsetSelectionMode(SingleSelection));self._spn_crossfade = QDoubleSpinBox()(range 0.0–10.0, decimals 2, step 0.1, suffix " s", value from QSettingsaudio_crossfadedefault 0.5, persist on change); the buttonsself._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 eachclickedto its handler (defined in M3/M4; add stub methods now thatpass— 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: aQWidgetwith aQVBoxLayoutholding 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):
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 aQListWidgetItemwith textbasename (Ds)(duration viaprobe_duration, blank if None) andsetData(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) viabuild_audio_clip_command+subprocess.rununder 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):
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_commandat 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 reportprobe_duration, mirror extract's success/error handling._play_file(path)— extract the ffplay-start logic from_on_audio_auditioninto this shared helper (render→play stays in audition;_play_filejust startsQProcess(ffplay)on an existing file and reuses_stop_audition/teardown). Have_on_audio_auditioncall_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-effortos.removethem incloseEvent(add next to the audition teardown). Add_stop_audition()tocloseEventtoo (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.pyeach 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.