diff --git a/main.py b/main.py index b85a6cb..b7111f5 100755 --- a/main.py +++ b/main.py @@ -4684,6 +4684,7 @@ class MainWindow(QMainWindow): self._merge_list.setSelectionMode( QAbstractItemView.SelectionMode.SingleSelection) 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._spn_crossfade = QDoubleSpinBox() self._spn_crossfade.setRange(0.0, 10.0) @@ -4695,9 +4696,7 @@ class MainWindow(QMainWindow): 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")): + for label, name in self._MERGE_CURVES: self._cmb_curve.addItem(label, name) self._cmb_curve.setToolTip("Crossfade curve at each join") self._btn_merge_add_sel = QPushButton("+ Selection") @@ -5181,6 +5180,9 @@ class MainWindow(QMainWindow): APP_VERSION = "1.5" _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) + _MERGE_CURVES = (("Triangular", "tri"), ("Exponential", "exp"), + ("Logarithmic", "log"), ("Quarter sine", "qsin"), + ("Half sine", "hsin")) CHANGELOG: list[tuple[str, list[str]]] = [ ("1.5", [ "Interactive waveform — 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 + 3, None) # curve override (None = global) self._merge_list.addItem(item) + self._refresh_merge_labels() finally: 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: default_dir = (self._settings.value("audio_extract_dir", "") or os.path.dirname(self._file_path or "")) @@ -6811,11 +6833,45 @@ class MainWindow(QMainWindow): item = self._merge_list.takeItem(row) self._merge_list.insertItem(new, item) self._merge_list.setCurrentRow(new) + self._refresh_merge_labels() def _merge_remove_selected(self) -> None: row = self._merge_list.currentRow() if row >= 0: 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]: return [self._merge_list.item(i).data(Qt.ItemDataRole.UserRole) diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index d3c6b01..18e70a0 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -616,3 +616,24 @@ def test_merge_preview_blocks_short_clip(win, tmp_path, monkeypatch): def test_merge_preview_empty_is_noop(win): win._merge_list.clear() 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