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