feat: global crossfade curve combo + per-join render/guard plumbing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4694,6 +4694,12 @@ class MainWindow(QMainWindow):
|
|||||||
self._spn_crossfade.setValue(float(self._settings.value("audio_crossfade", 0.5)))
|
self._spn_crossfade.setValue(float(self._settings.value("audio_crossfade", 0.5)))
|
||||||
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()
|
||||||
|
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 = QPushButton("+ Selection")
|
||||||
self._btn_merge_add_sel.setToolTip(
|
self._btn_merge_add_sel.setToolTip(
|
||||||
"Add the current audio area (with edits) as a clip")
|
"Add the current audio area (with edits) as a clip")
|
||||||
@@ -5050,6 +5056,8 @@ class MainWindow(QMainWindow):
|
|||||||
xrow = QHBoxLayout()
|
xrow = QHBoxLayout()
|
||||||
xrow.addWidget(QLabel("Crossfade:"))
|
xrow.addWidget(QLabel("Crossfade:"))
|
||||||
xrow.addWidget(self._spn_crossfade)
|
xrow.addWidget(self._spn_crossfade)
|
||||||
|
xrow.addWidget(QLabel("Curve:"))
|
||||||
|
xrow.addWidget(self._cmb_curve)
|
||||||
xrow.addStretch()
|
xrow.addStretch()
|
||||||
mv.addLayout(xrow)
|
mv.addLayout(xrow)
|
||||||
brow = QHBoxLayout()
|
brow = QHBoxLayout()
|
||||||
@@ -6749,6 +6757,8 @@ class MainWindow(QMainWindow):
|
|||||||
item = QListWidgetItem(text)
|
item = QListWidgetItem(text)
|
||||||
item.setData(Qt.ItemDataRole.UserRole, ap)
|
item.setData(Qt.ItemDataRole.UserRole, ap)
|
||||||
item.setData(Qt.ItemDataRole.UserRole + 1, dur)
|
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)
|
self._merge_list.addItem(item)
|
||||||
finally:
|
finally:
|
||||||
QApplication.restoreOverrideCursor()
|
QApplication.restoreOverrideCursor()
|
||||||
@@ -6811,22 +6821,33 @@ class MainWindow(QMainWindow):
|
|||||||
return [self._merge_list.item(i).data(Qt.ItemDataRole.UserRole)
|
return [self._merge_list.item(i).data(Qt.ItemDataRole.UserRole)
|
||||||
for i in range(self._merge_list.count())]
|
for i in range(self._merge_list.count())]
|
||||||
|
|
||||||
def _merge_guard_ok(self, crossfade: float) -> bool:
|
def _merge_join_values(self) -> tuple[list[float], list[str]]:
|
||||||
"""False (+ status) if a clip with a KNOWN duration is <= the crossfade
|
n = self._merge_list.count()
|
||||||
(acrossfade needs each clip longer than the crossfade)."""
|
xfs, cvs = [], []
|
||||||
if crossfade <= 0:
|
for j in range(n - 1):
|
||||||
return True
|
it = self._merge_list.item(j)
|
||||||
# Clips whose duration couldn't be probed (None) are not flagged here;
|
xo = it.data(Qt.ItemDataRole.UserRole + 2)
|
||||||
# if genuinely too short they surface as an ffmpeg error at render.
|
co = it.data(Qt.ItemDataRole.UserRole + 3)
|
||||||
for i in range(self._merge_list.count()):
|
xfs.append(float(xo) if xo is not None else self._spn_crossfade.value())
|
||||||
it = self._merge_list.item(i)
|
cvs.append(co if co is not None else self._cmb_curve.currentData())
|
||||||
d = it.data(Qt.ItemDataRole.UserRole + 1)
|
return xfs, cvs
|
||||||
if d is not None and d <= crossfade:
|
|
||||||
name = os.path.basename(it.data(Qt.ItemDataRole.UserRole))
|
def _merge_guard_ok(self) -> bool:
|
||||||
self._show_status(
|
"""False (+ status) if a clip with a KNOWN duration is <= its join's
|
||||||
f"'{name}' ({d:.2f}s) is shorter than the "
|
crossfade (acrossfade needs both sides longer than the crossfade)."""
|
||||||
f"{crossfade:.2f}s crossfade", 6000)
|
xfs, _cvs = self._merge_join_values()
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
def _play_file(self, path: str) -> None:
|
def _play_file(self, path: str) -> None:
|
||||||
@@ -6841,8 +6862,9 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _render_merge(self, out_path: str) -> tuple[bool, str]:
|
def _render_merge(self, out_path: str) -> tuple[bool, str]:
|
||||||
"""Render the merge sequence to out_path. Returns (ok, last_stderr_line)."""
|
"""Render the merge sequence to out_path. Returns (ok, last_stderr_line)."""
|
||||||
cmd = build_crossfade_merge_command(
|
xfs, cvs = self._merge_join_values()
|
||||||
self._merge_paths(), self._spn_crossfade.value(), out_path)
|
cmd = build_crossfade_merge_command(self._merge_paths(), xfs, out_path,
|
||||||
|
curves=cvs)
|
||||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||||
err = ""
|
err = ""
|
||||||
try:
|
try:
|
||||||
@@ -6862,7 +6884,7 @@ class MainWindow(QMainWindow):
|
|||||||
if not paths:
|
if not paths:
|
||||||
self._show_status("Add clips to merge first", 3000)
|
self._show_status("Add clips to merge first", 3000)
|
||||||
return
|
return
|
||||||
if not self._merge_guard_ok(self._spn_crossfade.value()):
|
if not self._merge_guard_ok():
|
||||||
return
|
return
|
||||||
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 ""))
|
||||||
@@ -6890,7 +6912,7 @@ class MainWindow(QMainWindow):
|
|||||||
if not paths:
|
if not paths:
|
||||||
self._show_status("Add clips to merge first", 3000)
|
self._show_status("Add clips to merge first", 3000)
|
||||||
return
|
return
|
||||||
if not self._merge_guard_ok(self._spn_crossfade.value()):
|
if not self._merge_guard_ok():
|
||||||
return
|
return
|
||||||
import tempfile
|
import tempfile
|
||||||
fd, tmp = tempfile.mkstemp(suffix=".wav", prefix="8cut_merge_prev_")
|
fd, tmp = tempfile.mkstemp(suffix=".wav", prefix="8cut_merge_prev_")
|
||||||
|
|||||||
@@ -532,18 +532,48 @@ def test_merge_save_builds_command(win, tmp_path, monkeypatch):
|
|||||||
win._spn_crossfade.setValue(0.75)
|
win._spn_crossfade.setValue(0.75)
|
||||||
seen = {}
|
seen = {}
|
||||||
class _Stop(Exception): pass
|
class _Stop(Exception): pass
|
||||||
def fake(clips, xf, out):
|
def fake(clips, xf, out, curves=None):
|
||||||
seen.update(clips=clips, xf=xf, out=out); raise _Stop
|
seen.update(clips=clips, xf=xf, out=out, curves=curves); raise _Stop
|
||||||
monkeypatch.setattr(m, "build_crossfade_merge_command", fake)
|
monkeypatch.setattr(m, "build_crossfade_merge_command", fake)
|
||||||
monkeypatch.setattr(m.QFileDialog, "getSaveFileName",
|
monkeypatch.setattr(m.QFileDialog, "getSaveFileName",
|
||||||
staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), "")))
|
staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), "")))
|
||||||
with pytest.raises(_Stop):
|
with pytest.raises(_Stop):
|
||||||
win._on_merge_save()
|
win._on_merge_save()
|
||||||
assert seen["clips"] == [str(a), str(b)]
|
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")
|
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):
|
def test_merge_save_blocks_short_clip(win, tmp_path, monkeypatch):
|
||||||
import main as m
|
import main as m
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
|
|||||||
Reference in New Issue
Block a user