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

5.7 KiB
Raw Blame History

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 clipsValueError (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_crossfadeQDoubleSpinBox, 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).