docs: audio merge (crossfade) — Phase 2 design + implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.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).
|
||||
Reference in New Issue
Block a user