From 65200e482e7a34437d84366b5668686d0493e9cc Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 17:40:01 +0200 Subject: [PATCH] feat: global crossfade curve combo + per-join render/guard plumbing Co-Authored-By: Claude Opus 4.8 --- main.py | 62 ++++++++++++++++++++++++++------------ tests/test_ui_structure.py | 36 ++++++++++++++++++++-- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/main.py b/main.py index af9fb9a..b85a6cb 100755 --- a/main.py +++ b/main.py @@ -4694,6 +4694,12 @@ class MainWindow(QMainWindow): self._spn_crossfade.setValue(float(self._settings.value("audio_crossfade", 0.5))) self._spn_crossfade.valueChanged.connect( lambda v: self._settings.setValue("audio_crossfade", v)) + self._cmb_curve = QComboBox() + for label, name in (("Triangular", "tri"), ("Exponential", "exp"), + ("Logarithmic", "log"), ("Quarter sine", "qsin"), + ("Half sine", "hsin")): + self._cmb_curve.addItem(label, name) + self._cmb_curve.setToolTip("Crossfade curve at each join") self._btn_merge_add_sel = QPushButton("+ Selection") self._btn_merge_add_sel.setToolTip( "Add the current audio area (with edits) as a clip") @@ -5050,6 +5056,8 @@ class MainWindow(QMainWindow): xrow = QHBoxLayout() xrow.addWidget(QLabel("Crossfade:")) xrow.addWidget(self._spn_crossfade) + xrow.addWidget(QLabel("Curve:")) + xrow.addWidget(self._cmb_curve) xrow.addStretch() mv.addLayout(xrow) brow = QHBoxLayout() @@ -6749,6 +6757,8 @@ class MainWindow(QMainWindow): item = QListWidgetItem(text) item.setData(Qt.ItemDataRole.UserRole, ap) item.setData(Qt.ItemDataRole.UserRole + 1, dur) + item.setData(Qt.ItemDataRole.UserRole + 2, None) # crossfade override (None = global) + item.setData(Qt.ItemDataRole.UserRole + 3, None) # curve override (None = global) self._merge_list.addItem(item) finally: QApplication.restoreOverrideCursor() @@ -6811,22 +6821,33 @@ class MainWindow(QMainWindow): return [self._merge_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self._merge_list.count())] - def _merge_guard_ok(self, crossfade: float) -> bool: - """False (+ status) if a clip with a KNOWN duration is <= the crossfade - (acrossfade needs each clip longer than the crossfade).""" - if crossfade <= 0: - return True - # Clips whose duration couldn't be probed (None) are not flagged here; - # if genuinely too short they surface as an ffmpeg error at render. - for i in range(self._merge_list.count()): - it = self._merge_list.item(i) - d = it.data(Qt.ItemDataRole.UserRole + 1) - if d is not None and d <= crossfade: - name = os.path.basename(it.data(Qt.ItemDataRole.UserRole)) - self._show_status( - f"'{name}' ({d:.2f}s) is shorter than the " - f"{crossfade:.2f}s crossfade", 6000) - return False + def _merge_join_values(self) -> tuple[list[float], list[str]]: + n = self._merge_list.count() + xfs, cvs = [], [] + for j in range(n - 1): + it = self._merge_list.item(j) + xo = it.data(Qt.ItemDataRole.UserRole + 2) + co = it.data(Qt.ItemDataRole.UserRole + 3) + xfs.append(float(xo) if xo is not None else self._spn_crossfade.value()) + cvs.append(co if co is not None else self._cmb_curve.currentData()) + return xfs, cvs + + def _merge_guard_ok(self) -> bool: + """False (+ status) if a clip with a KNOWN duration is <= its join's + crossfade (acrossfade needs both sides longer than the crossfade).""" + xfs, _cvs = self._merge_join_values() + for j, xf in enumerate(xfs): + if xf <= 0: + continue + for idx in (j, j + 1): + it = self._merge_list.item(idx) + d = it.data(Qt.ItemDataRole.UserRole + 1) + if d is not None and d <= xf: + name = os.path.basename(it.data(Qt.ItemDataRole.UserRole)) + self._show_status( + f"'{name}' ({d:.2f}s) is shorter than its " + f"{xf:.2f}s crossfade", 6000) + return False return True def _play_file(self, path: str) -> None: @@ -6841,8 +6862,9 @@ class MainWindow(QMainWindow): def _render_merge(self, out_path: str) -> tuple[bool, str]: """Render the merge sequence to out_path. Returns (ok, last_stderr_line).""" - cmd = build_crossfade_merge_command( - self._merge_paths(), self._spn_crossfade.value(), out_path) + xfs, cvs = self._merge_join_values() + cmd = build_crossfade_merge_command(self._merge_paths(), xfs, out_path, + curves=cvs) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) err = "" try: @@ -6862,7 +6884,7 @@ class MainWindow(QMainWindow): if not paths: self._show_status("Add clips to merge first", 3000) return - if not self._merge_guard_ok(self._spn_crossfade.value()): + if not self._merge_guard_ok(): return default_dir = (self._settings.value("audio_extract_dir", "") or os.path.dirname(self._file_path or "")) @@ -6890,7 +6912,7 @@ class MainWindow(QMainWindow): if not paths: self._show_status("Add clips to merge first", 3000) return - if not self._merge_guard_ok(self._spn_crossfade.value()): + if not self._merge_guard_ok(): return import tempfile fd, tmp = tempfile.mkstemp(suffix=".wav", prefix="8cut_merge_prev_") diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 65164e4..d3c6b01 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -532,18 +532,48 @@ def test_merge_save_builds_command(win, tmp_path, monkeypatch): win._spn_crossfade.setValue(0.75) seen = {} class _Stop(Exception): pass - def fake(clips, xf, out): - seen.update(clips=clips, xf=xf, out=out); raise _Stop + def fake(clips, xf, out, curves=None): + seen.update(clips=clips, xf=xf, out=out, curves=curves); raise _Stop monkeypatch.setattr(m, "build_crossfade_merge_command", fake) monkeypatch.setattr(m.QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), ""))) with pytest.raises(_Stop): win._on_merge_save() assert seen["clips"] == [str(a), str(b)] - assert seen["xf"] == 0.75 + assert seen["xf"] == [0.75] # 1 join for 2 clips + assert seen["curves"] == ["tri"] assert seen["out"].endswith(".wav") +def test_merge_curve_combo_present(win): + from PyQt6.QtWidgets import QComboBox + assert isinstance(win._cmb_curve, QComboBox) + assert win._cmb_curve.currentData() == "tri" # default + + +def test_merge_render_builds_per_join_lists(win, tmp_path, monkeypatch): + import main as m, pytest + from PyQt6.QtCore import Qt + a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; c = tmp_path / "c.wav" + for f in (a, b, c): f.write_bytes(b"") + win._merge_list.clear() + win._merge_add_paths([str(a), str(b), str(c)]) + for i in range(3): + win._merge_list.item(i).setData(Qt.ItemDataRole.UserRole + 1, 5.0) # long enough + win._spn_crossfade.setValue(0.5) + seen = {} + class _Stop(Exception): pass + def fake(clips, xf, out, curves=None): + seen.update(clips=clips, xf=xf, out=out, curves=curves); raise _Stop + monkeypatch.setattr(m, "build_crossfade_merge_command", fake) + monkeypatch.setattr(m.QFileDialog, "getSaveFileName", + staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), ""))) + with pytest.raises(_Stop): + win._on_merge_save() + assert seen["xf"] == [0.5, 0.5] # 2 joins for 3 clips, effective = global + assert seen["curves"] == ["tri", "tri"] + + def test_merge_save_blocks_short_clip(win, tmp_path, monkeypatch): import main as m from PyQt6.QtCore import Qt