docs: audio merge (crossfade) — Phase 2 design + implementation plan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:37:31 +02:00
co-authored by Claude Opus 4.8
parent 578ad87b1a
commit 7573277a03
2 changed files with 342 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
# 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 <codec> 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]" <codec> 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.010.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).