# Audio merge (crossfade) — Phase 2 design **Goal:** Let the user **easily assemble multiple audio clips into one output with crossfades at the joins** — directly from the Audio tab — with room to grow into fancier assembly (per-join curves, gain automation, effects). This is the "audio editor" the user asked for; its north star is *crossfade merging*. **Runs in:** Python/Qt client (`main.py`) + `core/ffmpeg.py`. No server/DB change. **Builds on:** Phase 1 (the Audio QToolBox tab, `build_audio_clip_command`, `audio_edit_filters`, the waveform strip, and ffplay audition). --- ## Priority / ordering The stated goal is crossfade merging, so that lands first. The interactive drag-select waveform (approved separately) is a *precision clip-picker* that feeds the merge — valuable but not required for a working merge (whole clips/files merge fine). Ordering: - **Phase 2 (this doc):** the **Merge pane** + crossfade engine. Clips are added from the current extract selection (rendered with its edits) or from files on disk. - **Phase 2-later:** interactive waveform selection (drag in/out handles, zoom, moving playhead) as the way to define each added clip precisely; then "fancier stuff" (per-join crossfade curves, per-clip gain, reorder-by-drag). --- ## Engine — `core/ffmpeg.py` `build_crossfade_merge_command(clips: list[str], crossfade: float, out_path: str) -> list[str]` - **0 clips** → `ValueError` (caller guards; never invoked empty). - **1 clip** → straight re-encode to `out_path`'s format: `-i c0 -vn out`. - **≥2 clips, crossfade > 0** → chain ffmpeg `acrossfade` (available; `N->A`): ``` -i c0 -i c1 ... -i c{n-1} -filter_complex "[0][1]acrossfade=d=D[a1];[a1][2]acrossfade=d=D[a2];…;[a{n-2}][{n-1}]acrossfade=d=D[out]" -map "[out]" out ``` (single global crossfade `D` on every join in v1; per-join comes later). For exactly 2 clips the label is `[out]` directly (no intermediate). - **≥2 clips, crossfade == 0** → butt-join via the `concat` filter (`concat=n=N:v=0:a=1`) so a zero-crossfade merge still works. Codec chosen by `out_path` extension via the existing `_AUDIO_CODEC_BY_EXT` (reuse — same formats as extract: wav/mp3/flac/m4a/ogg/opus). Note: acrossfade needs each pair to share a sample format; ffmpeg auto-negotiates via the filtergraph, but if mixed-rate inputs cause trouble we insert `aresample` before each input. Start without it; add only if a real mismatch shows up. Pure function → fully unit-tested (no ffmpeg run needed). --- ## UI — a third QToolBox pane: **Merge** Added to `_build_audio_tab` after "Extract & Edit" and "Scan / Classify". **Widgets (constructed in `__init__`):** - `_merge_list` — a `QListWidget` (reorderable) of clips to merge, in order. Each row shows the basename + duration (probed once on add). The clip's absolute path is stored on the item (`Qt.UserRole`). - `_spn_crossfade` — `QDoubleSpinBox`, 0.0–10.0 s, step 0.1, default 0.5 s, suffix " s". One global crossfade applied to every join in v1. - Buttons: **+ Selection** (add the current extract area, rendered with the edit chain, to a temp clip and append), **+ File…** (`QFileDialog` multi-select audio files), **▲ / ▼** (reorder selected row), **✕** (remove selected), **▶ Preview** (merge to a temp file and audition via the Phase-1 ffplay path), **Merge & Save…** (render + save-as). **Handlers:** - **+ Selection** — reuse `build_audio_clip_command(self._file_path, cursor, len, tmp, filters=self._current_edit_filters() or None)` → temp wav in the app temp dir (unique name per add) → append to `_merge_list`. Guards on a loaded file. - **+ File…** — append each chosen path. - **▲/▼/✕** — list reorder/remove. - **Merge & Save…** (`_on_merge_save`) — collect the ordered paths; if <1 clip, status + return; build via `build_crossfade_merge_command(paths, _spn_crossfade.value(), out)`; `QFileDialog.getSaveFileName` (same format filter as extract, remembered dir); `subprocess.run` under a wait cursor + status; report saved length via `probe_duration`, mirroring extract's success/ error reporting. - **▶ Preview** — same build to a temp file, then start the existing audition QProcess(ffplay) on it (reuse `_stop_audition`/teardown machinery, or a small shared `_play_file(path)`). **Temp files:** rendered selection-clips and preview output live in the system temp dir with unique names; a session set tracks them and `closeEvent` best-effort removes them (extends the Phase-1 audition teardown). --- ## Persistence & migration - QSettings gains `audio_crossfade` (last crossfade value). No DB/schema change. - The merge sequence is **session-only** (not persisted) in v1 — it's a scratch assembly surface, not a saved project. (Saved projects = "fancier stuff".) ## Testing - `tests/test_utils.py`: TDD `build_crossfade_merge_command` — 1-clip re-encode, 2-clip acrossfade (`-filter_complex` contains `acrossfade=d=0.5`, maps `[out]`), 3-clip chained (two acrossfade stages), crossfade==0 → `concat`, codec-by-ext, 0-clip → ValueError. - `tests/test_ui_structure.py`: Merge pane exists as a 3rd QToolBox page; `_merge_list`/`_spn_crossfade`/buttons present; add-file appends a row; remove/reorder mutate the list; `_on_merge_save` with an empty list is a safe no-op. ## What this does NOT do (v1) - No per-join crossfade durations or curve selection (single global value). - No interactive waveform drag-select yet (whole-clip/selection granularity). - No saved/reloadable merge projects (session-only sequence). - No multi-track mixing/overlap beyond the crossfade at joins. - No DB/dataset wiring (that's the deferred Phase 2 "dataset" work, separate).