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

12 KiB
Raw Blame History

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:

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() (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):

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_fileQFileDialog.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):

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.