diff --git a/.gitignore b/.gitignore index d2c680e..ea2d463 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ output/ preset.json *.log +combined_files.txt diff --git a/core/config.py b/core/config.py index aa66983..cab61a1 100644 --- a/core/config.py +++ b/core/config.py @@ -66,6 +66,9 @@ class LyricsConfig: @dataclass class LrcConfig: + enabled: bool = True # LRC generation stage + embed_enabled: bool = True # LRC embedding stage + skip_existing: bool = True # skip tracks with a ready text.lrc language: Optional[str] = "ru" align_model: Optional[str] = None device: str = "auto" diff --git a/core/lrc.py b/core/lrc.py index 9761481..29c3a45 100644 --- a/core/lrc.py +++ b/core/lrc.py @@ -37,6 +37,10 @@ def segments_to_lrc(segments: list[dict]) -> str: text = (seg.get("text") or "").strip() if not text: continue + # WhisperX can return multi-line text on long segments. An LRC line + # must be a single line: any newline would produce a broken line + # without a timestamp. Collapse all whitespace into single spaces. + text = re.sub(r"\s+", " ", text) start = float(seg.get("start", 0.0)) lines.append(f"{format_timestamp(start)}{text}") return "\n".join(lines) + "\n" diff --git a/core/lrc_pipeline.py b/core/lrc_pipeline.py index 25c83f2..cc6d406 100644 --- a/core/lrc_pipeline.py +++ b/core/lrc_pipeline.py @@ -11,7 +11,9 @@ from .config import PipelineConfig from .convert import convert_to_wav from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc from .metadata import read_metadata -from .pipeline import FileResult, _save_unrecognized, collect_audio_files +from .pipeline import ( + FileResult, _artifact_belongs_to, _save_unrecognized, collect_audio_files, +) from .transcribe import transcribe_aligned @@ -45,6 +47,20 @@ def _pick_audio_source( return target +def _ready_lrc(track_out: Path, source: Path) -> Optional[str]: + """Return existing valid LRC text for this exact source track, if any.""" + if not _artifact_belongs_to(track_out, source): + return None + lrc_path = track_out / "text.lrc" + if not lrc_path.is_file(): + return None + try: + content = lrc_path.read_text(encoding="utf-8") + except OSError: + return None + return content if content.strip() and is_lrc(content) else None + + def run_lrc_pipeline( config: PipelineConfig, log: LogFn = print, @@ -58,6 +74,10 @@ def run_lrc_pipeline( output_dir = Path(config.output_dir) output_dir.mkdir(parents=True, exist_ok=True) + if not config.lrc.enabled: + log("LRC: генерация отключена — пропуск этапа") + return [] + files = collect_audio_files(input_dir, config.recursive) if not files: log("Аудиофайлы не найдены.") @@ -69,6 +89,8 @@ def run_lrc_pipeline( results: list[FileResult] = [] total = len(files) + if on_progress: + on_progress("lrc", 0, total) if on_overall_progress: on_overall_progress(0, total) @@ -76,8 +98,6 @@ def run_lrc_pipeline( if stop_event is not None and stop_event.is_set(): log("⏹ Отменено") break - if on_progress: - on_progress("lrc", idx - 1, total) log(f"\n▶ [{idx}/{total}] {f.name}") track_out = output_dir / f.stem @@ -85,6 +105,17 @@ def run_lrc_pipeline( lrc_path = track_out / "text.lrc" error: Optional[str] = None + if config.lrc.skip_existing: + ready = _ready_lrc(track_out, f) + if ready is not None: + log(" ⊘ text.lrc уже готов — пропуск") + results.append(FileResult(f, None, track_out, None, "existing+lrc")) + if on_progress: + on_progress("lrc", idx, total) + if on_overall_progress: + on_overall_progress(idx, total) + continue + tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_")) try: embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None @@ -151,6 +182,10 @@ def run_embed_pipeline( output_dir = Path(config.output_dir) output_dir.mkdir(parents=True, exist_ok=True) + if not config.lrc.embed_enabled: + log("LRC embed: встраивание отключено — пропуск этапа") + return [] + files = collect_audio_files(input_dir, config.recursive) if not files: log("Аудиофайлы не найдены.") @@ -161,6 +196,8 @@ def run_embed_pipeline( results: list[FileResult] = [] total = len(files) + if on_progress: + on_progress("embed", 0, total) if on_overall_progress: on_overall_progress(0, total) @@ -168,8 +205,6 @@ def run_embed_pipeline( if stop_event is not None and stop_event.is_set(): log("⏹ Отменено") break - if on_progress: - on_progress("embed", idx - 1, total) lrc_path = output_dir / f.stem / "text.lrc" error: Optional[str] = None diff --git a/gui/main_window.py b/gui/main_window.py index 678fdb9..08f7414 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -6,12 +6,12 @@ from pathlib import Path from typing import Optional from PySide6.QtCore import Qt, QUrl -from PySide6.QtGui import QColor, QDesktopServices +from PySide6.QtGui import QColor, QDesktopServices, QKeySequence, QShortcut from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, - QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, - QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QTabWidget, - QVBoxLayout, QWidget, + QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, + QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSpinBox, + QTabWidget, QVBoxLayout, QWidget, ) from core.config import ( @@ -71,8 +71,17 @@ class MainWindow(QMainWindow): # ---------------- UI ---------------- def _build_ui(self) -> None: + # The whole page lives in a scroll area: on small screens the user can + # scroll down to the log instead of the console being cut off by the + # bottom edge of the window. + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setCentralWidget(scroll) + central = QWidget() - self.setCentralWidget(central) + scroll.setWidget(central) root = QVBoxLayout(central) root.setContentsMargins(18, 16, 18, 16) root.setSpacing(12) @@ -82,6 +91,10 @@ class MainWindow(QMainWindow): title.setObjectName("appTitle") header.addWidget(title) header.addStretch(1) + self.fullscreen_btn = QPushButton("⛶ На весь экран") + self.fullscreen_btn.setToolTip("Развернуть окно на весь экран (F11)") + self.fullscreen_btn.clicked.connect(self._toggle_fullscreen) + header.addWidget(self.fullscreen_btn) self.device_label = QLabel("") self.device_label.setObjectName("deviceInfo") header.addWidget(self.device_label) @@ -153,6 +166,7 @@ class MainWindow(QMainWindow): self.convert_enabled_cb.toggled.connect(self._sync_tab_indicators) self.separate_enabled_cb.toggled.connect(self._sync_tab_indicators) self.transcribe_enabled_cb.toggled.connect(self._sync_tab_indicators) + self.lrc_enabled_cb.toggled.connect(self._sync_tab_indicators) self.engine_cb.currentTextChanged.connect(self._sync_engine_fields) row = QHBoxLayout() @@ -191,9 +205,25 @@ class MainWindow(QMainWindow): self.log_view = QPlainTextEdit() self.log_view.setObjectName("log") self.log_view.setReadOnly(True) + self.log_view.setMinimumHeight(240) self.log_view.setPlaceholderText("Здесь появится лог выполнения…") root.addWidget(self.log_view, 1) + self._fs_shortcut = QShortcut(QKeySequence("F11"), self) + self._fs_shortcut.activated.connect(self._toggle_fullscreen) + self._esc_shortcut = QShortcut(QKeySequence("Escape"), self) + self._esc_shortcut.activated.connect(self._leave_fullscreen) + + def _toggle_fullscreen(self) -> None: + if self.isFullScreen(): + self.showNormal() + else: + self.showFullScreen() + + def _leave_fullscreen(self) -> None: + if self.isFullScreen(): + self.showNormal() + def _with_browse(self, edit: QLineEdit, picker) -> QWidget: w = QWidget() h = QHBoxLayout(w) @@ -374,6 +404,25 @@ class MainWindow(QMainWindow): info.setStyleSheet("color: #a6adc8;") form.addRow(info) + self.lrc_enabled_cb = QCheckBox("Включён (генерация LRC)") + self.lrc_enabled_cb.setToolTip( + "Отключите, чтобы пропустить этап генерации text.lrc.\n" + "Остальные этапы пайплайна продолжат работать." + ) + form.addRow("", self.lrc_enabled_cb) + self.lrc_embed_enabled_cb = QCheckBox("Включён (встраивание LRC в аудио)") + self.lrc_embed_enabled_cb.setToolTip( + "Отключите, чтобы пропустить этап встраивания LRC в теги аудио." + ) + form.addRow("", self.lrc_embed_enabled_cb) + self.lrc_skip_existing_cb = QCheckBox("Пропускать готовые text.lrc") + self.lrc_skip_existing_cb.setToolTip( + "Если //text.lrc уже существует и содержит корректный LRC,\n" + "файл не будет пересчитан. Папка с metadata.json от другого трека\n" + "готовой не считается." + ) + form.addRow("", self.lrc_skip_existing_cb) + self.lrc_audio_source_cb = QComboBox() self.lrc_audio_source_cb.addItems(["auto", "vocals", "convert", "input"]) self.lrc_audio_source_cb.setToolTip( @@ -433,6 +482,7 @@ class MainWindow(QMainWindow): bar.setTabTextColor(2, active if self.convert_enabled_cb.isChecked() else inactive) bar.setTabTextColor(3, active if self.separate_enabled_cb.isChecked() else inactive) bar.setTabTextColor(4, active if self.transcribe_enabled_cb.isChecked() else inactive) + bar.setTabTextColor(5, active if self.lrc_enabled_cb.isChecked() else inactive) def _sync_engine_fields(self) -> None: is_whisperx = self.engine_cb.currentText() == "whisperx" @@ -503,6 +553,9 @@ class MainWindow(QMainWindow): self.lrc_align_model_edit.setText(cfg.lrc.align_model or "") self.lrc_device_cb.setCurrentText(cfg.lrc.device) self.lrc_embed_in_place_cb.setChecked(cfg.lrc.embed_in_place) + self.lrc_enabled_cb.setChecked(cfg.lrc.enabled) + self.lrc_embed_enabled_cb.setChecked(cfg.lrc.embed_enabled) + self.lrc_skip_existing_cb.setChecked(cfg.lrc.skip_existing) self.chunk_size_spin.setValue(cfg.chunk_size) def _collect_config(self) -> PipelineConfig: @@ -562,6 +615,9 @@ class MainWindow(QMainWindow): ) lrc_lang = self.lrc_language_cb.currentText().strip() cfg.lrc = LrcConfig( + enabled=self.lrc_enabled_cb.isChecked(), + embed_enabled=self.lrc_embed_enabled_cb.isChecked(), + skip_existing=self.lrc_skip_existing_cb.isChecked(), language=None if lrc_lang in ("", "auto") else lrc_lang, align_model=self.lrc_align_model_edit.text().strip() or None, device=self.lrc_device_cb.currentText(), @@ -642,6 +698,18 @@ class MainWindow(QMainWindow): "Align-модель доступна только для WhisperX. Переключите движок на whisperx." ) return + if task == "lrc" and not cfg.lrc.enabled: + QMessageBox.warning( + self, "LRC отключён", + "Этап генерации LRC выключен. Включите его на вкладке LRC." + ) + return + if task == "embed" and not cfg.lrc.embed_enabled: + QMessageBox.warning( + self, "Встраивание LRC отключено", + "Этап встраивания LRC выключен. Включите его на вкладке LRC." + ) + return self.log_view.clear() self._log_device_info() diff --git a/gui/theme.py b/gui/theme.py index 9afeba0..8ccf7ce 100644 --- a/gui/theme.py +++ b/gui/theme.py @@ -170,29 +170,29 @@ QCheckBox::indicator:checked { } QProgressBar { - background-color: #181825; - border: 1px solid #313244; + background-color: #0b0b12; + border: 1px solid #7f849c; border-radius: 8px; text-align: center; - color: #cdd6f4; - min-height: 24px; - max-height: 24px; - font-weight: 500; + color: #ffffff; + min-height: 26px; + max-height: 26px; + font-weight: 700; } QProgressBar::chunk { - background-color: #89b4fa; + background-color: #1e66f5; border-radius: 7px; margin: 1px; } QProgressBar#overallProgress { - min-height: 28px; - max-height: 28px; - font-weight: 600; - color: #cdd6f4; - border-color: #45475a; + min-height: 30px; + max-height: 30px; + font-weight: 700; + color: #ffffff; + border: 1px solid #a6adc8; } QProgressBar#overallProgress::chunk { - background-color: #a6e3a1; + background-color: #2f9e44; border-radius: 8px; margin: 2px; }