feat: double-click a merge clip to set its join crossfade/curve override + row labels

This commit is contained in:
2026-07-02 17:50:40 +02:00
parent 65200e482e
commit 68368b4ff6
2 changed files with 80 additions and 3 deletions
+59 -3
View File
@@ -4684,6 +4684,7 @@ class MainWindow(QMainWindow):
self._merge_list.setSelectionMode( self._merge_list.setSelectionMode(
QAbstractItemView.SelectionMode.SingleSelection) QAbstractItemView.SelectionMode.SingleSelection)
self._merge_list.setToolTip("Clips to merge, top to bottom") self._merge_list.setToolTip("Clips to merge, top to bottom")
self._merge_list.itemDoubleClicked.connect(self._on_merge_item_double_clicked)
self._merge_temps: set[str] = set() self._merge_temps: set[str] = set()
self._spn_crossfade = QDoubleSpinBox() self._spn_crossfade = QDoubleSpinBox()
self._spn_crossfade.setRange(0.0, 10.0) self._spn_crossfade.setRange(0.0, 10.0)
@@ -4695,9 +4696,7 @@ class MainWindow(QMainWindow):
self._spn_crossfade.valueChanged.connect( self._spn_crossfade.valueChanged.connect(
lambda v: self._settings.setValue("audio_crossfade", v)) lambda v: self._settings.setValue("audio_crossfade", v))
self._cmb_curve = QComboBox() self._cmb_curve = QComboBox()
for label, name in (("Triangular", "tri"), ("Exponential", "exp"), for label, name in self._MERGE_CURVES:
("Logarithmic", "log"), ("Quarter sine", "qsin"),
("Half sine", "hsin")):
self._cmb_curve.addItem(label, name) self._cmb_curve.addItem(label, name)
self._cmb_curve.setToolTip("Crossfade curve at each join") self._cmb_curve.setToolTip("Crossfade curve at each join")
self._btn_merge_add_sel = QPushButton(" Selection") self._btn_merge_add_sel = QPushButton(" Selection")
@@ -5181,6 +5180,9 @@ class MainWindow(QMainWindow):
APP_VERSION = "1.5" APP_VERSION = "1.5"
_SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync) _SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync)
_WAVE_MIN_VIEW = 3.0 # minimum waveform view window (seconds) _WAVE_MIN_VIEW = 3.0 # minimum waveform view window (seconds)
_MERGE_CURVES = (("Triangular", "tri"), ("Exponential", "exp"),
("Logarithmic", "log"), ("Quarter sine", "qsin"),
("Half sine", "hsin"))
CHANGELOG: list[tuple[str, list[str]]] = [ CHANGELOG: list[tuple[str, list[str]]] = [
("1.5", [ ("1.5", [
"<b>Interactive waveform</b> — the Audio tab's waveform is now a " "<b>Interactive waveform</b> — the Audio tab's waveform is now a "
@@ -6760,9 +6762,29 @@ class MainWindow(QMainWindow):
item.setData(Qt.ItemDataRole.UserRole + 2, None) # crossfade override (None = global) item.setData(Qt.ItemDataRole.UserRole + 2, None) # crossfade override (None = global)
item.setData(Qt.ItemDataRole.UserRole + 3, None) # curve override (None = global) item.setData(Qt.ItemDataRole.UserRole + 3, None) # curve override (None = global)
self._merge_list.addItem(item) self._merge_list.addItem(item)
self._refresh_merge_labels()
finally: finally:
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
def _merge_item_label(self, item, index: int, count: int) -> str:
name = os.path.basename(item.data(Qt.ItemDataRole.UserRole))
dur = item.data(Qt.ItemDataRole.UserRole + 1)
base = f"{name} ({dur:.2f}s)" if dur is not None else name
if index < count - 1: # has an outgoing join
xo = item.data(Qt.ItemDataRole.UserRole + 2)
co = item.data(Qt.ItemDataRole.UserRole + 3)
if xo is not None or co is not None:
xf = xo if xo is not None else self._spn_crossfade.value()
cv = co if co is not None else self._cmb_curve.currentData()
base += f"{xf:.2f}s {cv}" # ⤲ join marker
return base
def _refresh_merge_labels(self) -> None:
n = self._merge_list.count()
for i in range(n):
it = self._merge_list.item(i)
it.setText(self._merge_item_label(it, i, n))
def _on_merge_add_file(self) -> None: def _on_merge_add_file(self) -> None:
default_dir = (self._settings.value("audio_extract_dir", "") default_dir = (self._settings.value("audio_extract_dir", "")
or os.path.dirname(self._file_path or "")) or os.path.dirname(self._file_path or ""))
@@ -6811,11 +6833,45 @@ class MainWindow(QMainWindow):
item = self._merge_list.takeItem(row) item = self._merge_list.takeItem(row)
self._merge_list.insertItem(new, item) self._merge_list.insertItem(new, item)
self._merge_list.setCurrentRow(new) self._merge_list.setCurrentRow(new)
self._refresh_merge_labels()
def _merge_remove_selected(self) -> None: def _merge_remove_selected(self) -> None:
row = self._merge_list.currentRow() row = self._merge_list.currentRow()
if row >= 0: if row >= 0:
self._merge_list.takeItem(row) self._merge_list.takeItem(row)
self._refresh_merge_labels()
def _apply_merge_override(self, item, crossfade: float, curve: str) -> None:
item.setData(Qt.ItemDataRole.UserRole + 2, float(crossfade))
item.setData(Qt.ItemDataRole.UserRole + 3, curve)
self._refresh_merge_labels()
def _on_merge_item_double_clicked(self, item) -> None:
row = self._merge_list.row(item)
if row >= self._merge_list.count() - 1:
self._show_status("The last clip has no outgoing join to crossfade", 4000)
return
dlg = QDialog(self)
dlg.setWindowTitle("Join crossfade")
form = QFormLayout(dlg)
spn = QDoubleSpinBox()
spn.setRange(0.0, 10.0); spn.setDecimals(2); spn.setSingleStep(0.1); spn.setSuffix(" s")
xo = item.data(Qt.ItemDataRole.UserRole + 2)
spn.setValue(float(xo) if xo is not None else self._spn_crossfade.value())
cmb = QComboBox()
for label, name in self._MERGE_CURVES:
cmb.addItem(label, name)
co = item.data(Qt.ItemDataRole.UserRole + 3)
co = co if co is not None else self._cmb_curve.currentData()
idx = cmb.findData(co); cmb.setCurrentIndex(idx if idx >= 0 else 0)
form.addRow("Crossfade:", spn)
form.addRow("Curve:", cmb)
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel)
bb.accepted.connect(dlg.accept); bb.rejected.connect(dlg.reject)
form.addRow(bb)
if dlg.exec():
self._apply_merge_override(item, spn.value(), cmb.currentData())
def _merge_paths(self) -> list[str]: def _merge_paths(self) -> list[str]:
return [self._merge_list.item(i).data(Qt.ItemDataRole.UserRole) return [self._merge_list.item(i).data(Qt.ItemDataRole.UserRole)
+21
View File
@@ -616,3 +616,24 @@ def test_merge_preview_blocks_short_clip(win, tmp_path, monkeypatch):
def test_merge_preview_empty_is_noop(win): def test_merge_preview_empty_is_noop(win):
win._merge_list.clear() win._merge_list.clear()
win._on_merge_preview() win._on_merge_preview()
def test_merge_override_applies_and_labels(win, tmp_path):
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"")
win._merge_list.clear(); win._merge_add_paths([str(a), str(b)])
it0 = win._merge_list.item(0)
win._apply_merge_override(it0, 0.8, "exp")
assert it0.data(Qt.ItemDataRole.UserRole + 2) == 0.8
assert it0.data(Qt.ItemDataRole.UserRole + 3) == "exp"
assert "0.80s exp" in it0.text()
assert "" not in win._merge_list.item(1).text() # last item shows no join info
xfs, cvs = win._merge_join_values()
assert xfs == [0.8] and cvs == ["exp"]
def test_merge_double_click_last_is_noop(win, tmp_path):
a = tmp_path / "a.wav"; a.write_bytes(b"")
win._merge_list.clear(); win._merge_add_paths([str(a)])
win._on_merge_item_double_clicked(win._merge_list.item(0)) # only/last -> no dialog, no raise
assert win._merge_list.count() == 1