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

119 lines
5.7 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 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).