improve GUI layout and LRC stage controls

- make the main page scrollable and keep the console reachable on small screens
- add fullscreen toggle (button + F11, Escape to exit)
- add enable switches for LRC generation/embedding and skip ready text.lrc
- sanitize multi-line ASR text before writing LRC timestamps
- emit LRC/embed progress once per file instead of twice
- increase progress-bar contrast
This commit is contained in:
jadssss
2026-10-27 22:29:13 +03:00
parent 15c0ba5d66
commit 999934f174
6 changed files with 135 additions and 24 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ output/
preset.json preset.json
*.log *.log
combined_files.txt

View File

@@ -66,6 +66,9 @@ class LyricsConfig:
@dataclass @dataclass
class LrcConfig: 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" language: Optional[str] = "ru"
align_model: Optional[str] = None align_model: Optional[str] = None
device: str = "auto" device: str = "auto"

View File

@@ -37,6 +37,10 @@ def segments_to_lrc(segments: list[dict]) -> str:
text = (seg.get("text") or "").strip() text = (seg.get("text") or "").strip()
if not text: if not text:
continue 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)) start = float(seg.get("start", 0.0))
lines.append(f"{format_timestamp(start)}{text}") lines.append(f"{format_timestamp(start)}{text}")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"

View File

@@ -11,7 +11,9 @@ from .config import PipelineConfig
from .convert import convert_to_wav from .convert import convert_to_wav
from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc
from .metadata import read_metadata 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 from .transcribe import transcribe_aligned
@@ -45,6 +47,20 @@ def _pick_audio_source(
return target 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( def run_lrc_pipeline(
config: PipelineConfig, config: PipelineConfig,
log: LogFn = print, log: LogFn = print,
@@ -58,6 +74,10 @@ def run_lrc_pipeline(
output_dir = Path(config.output_dir) output_dir = Path(config.output_dir)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
if not config.lrc.enabled:
log("LRC: генерация отключена — пропуск этапа")
return []
files = collect_audio_files(input_dir, config.recursive) files = collect_audio_files(input_dir, config.recursive)
if not files: if not files:
log("Аудиофайлы не найдены.") log("Аудиофайлы не найдены.")
@@ -69,6 +89,8 @@ def run_lrc_pipeline(
results: list[FileResult] = [] results: list[FileResult] = []
total = len(files) total = len(files)
if on_progress:
on_progress("lrc", 0, total)
if on_overall_progress: if on_overall_progress:
on_overall_progress(0, total) on_overall_progress(0, total)
@@ -76,8 +98,6 @@ def run_lrc_pipeline(
if stop_event is not None and stop_event.is_set(): if stop_event is not None and stop_event.is_set():
log("⏹ Отменено") log("⏹ Отменено")
break break
if on_progress:
on_progress("lrc", idx - 1, total)
log(f"\n▶ [{idx}/{total}] {f.name}") log(f"\n▶ [{idx}/{total}] {f.name}")
track_out = output_dir / f.stem track_out = output_dir / f.stem
@@ -85,6 +105,17 @@ def run_lrc_pipeline(
lrc_path = track_out / "text.lrc" lrc_path = track_out / "text.lrc"
error: Optional[str] = None 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}_")) tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_"))
try: try:
embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None 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 = Path(config.output_dir)
output_dir.mkdir(parents=True, exist_ok=True) 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) files = collect_audio_files(input_dir, config.recursive)
if not files: if not files:
log("Аудиофайлы не найдены.") log("Аудиофайлы не найдены.")
@@ -161,6 +196,8 @@ def run_embed_pipeline(
results: list[FileResult] = [] results: list[FileResult] = []
total = len(files) total = len(files)
if on_progress:
on_progress("embed", 0, total)
if on_overall_progress: if on_overall_progress:
on_overall_progress(0, total) on_overall_progress(0, total)
@@ -168,8 +205,6 @@ def run_embed_pipeline(
if stop_event is not None and stop_event.is_set(): if stop_event is not None and stop_event.is_set():
log("⏹ Отменено") log("⏹ Отменено")
break break
if on_progress:
on_progress("embed", idx - 1, total)
lrc_path = output_dir / f.stem / "text.lrc" lrc_path = output_dir / f.stem / "text.lrc"
error: Optional[str] = None error: Optional[str] = None

View File

@@ -6,12 +6,12 @@ from pathlib import Path
from typing import Optional from typing import Optional
from PySide6.QtCore import Qt, QUrl from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QColor, QDesktopServices from PySide6.QtGui import QColor, QDesktopServices, QKeySequence, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QTabWidget, QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSpinBox,
QVBoxLayout, QWidget, QTabWidget, QVBoxLayout, QWidget,
) )
from core.config import ( from core.config import (
@@ -71,8 +71,17 @@ class MainWindow(QMainWindow):
# ---------------- UI ---------------- # ---------------- UI ----------------
def _build_ui(self) -> None: 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() central = QWidget()
self.setCentralWidget(central) scroll.setWidget(central)
root = QVBoxLayout(central) root = QVBoxLayout(central)
root.setContentsMargins(18, 16, 18, 16) root.setContentsMargins(18, 16, 18, 16)
root.setSpacing(12) root.setSpacing(12)
@@ -82,6 +91,10 @@ class MainWindow(QMainWindow):
title.setObjectName("appTitle") title.setObjectName("appTitle")
header.addWidget(title) header.addWidget(title)
header.addStretch(1) 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 = QLabel("")
self.device_label.setObjectName("deviceInfo") self.device_label.setObjectName("deviceInfo")
header.addWidget(self.device_label) header.addWidget(self.device_label)
@@ -153,6 +166,7 @@ class MainWindow(QMainWindow):
self.convert_enabled_cb.toggled.connect(self._sync_tab_indicators) self.convert_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.separate_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.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) self.engine_cb.currentTextChanged.connect(self._sync_engine_fields)
row = QHBoxLayout() row = QHBoxLayout()
@@ -191,9 +205,25 @@ class MainWindow(QMainWindow):
self.log_view = QPlainTextEdit() self.log_view = QPlainTextEdit()
self.log_view.setObjectName("log") self.log_view.setObjectName("log")
self.log_view.setReadOnly(True) self.log_view.setReadOnly(True)
self.log_view.setMinimumHeight(240)
self.log_view.setPlaceholderText("Здесь появится лог выполнения…") self.log_view.setPlaceholderText("Здесь появится лог выполнения…")
root.addWidget(self.log_view, 1) 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: def _with_browse(self, edit: QLineEdit, picker) -> QWidget:
w = QWidget() w = QWidget()
h = QHBoxLayout(w) h = QHBoxLayout(w)
@@ -374,6 +404,25 @@ class MainWindow(QMainWindow):
info.setStyleSheet("color: #a6adc8;") info.setStyleSheet("color: #a6adc8;")
form.addRow(info) 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(
"Если <output>/<stem>/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 = QComboBox()
self.lrc_audio_source_cb.addItems(["auto", "vocals", "convert", "input"]) self.lrc_audio_source_cb.addItems(["auto", "vocals", "convert", "input"])
self.lrc_audio_source_cb.setToolTip( 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(2, active if self.convert_enabled_cb.isChecked() else inactive)
bar.setTabTextColor(3, active if self.separate_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(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: def _sync_engine_fields(self) -> None:
is_whisperx = self.engine_cb.currentText() == "whisperx" 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_align_model_edit.setText(cfg.lrc.align_model or "")
self.lrc_device_cb.setCurrentText(cfg.lrc.device) self.lrc_device_cb.setCurrentText(cfg.lrc.device)
self.lrc_embed_in_place_cb.setChecked(cfg.lrc.embed_in_place) 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) self.chunk_size_spin.setValue(cfg.chunk_size)
def _collect_config(self) -> PipelineConfig: def _collect_config(self) -> PipelineConfig:
@@ -562,6 +615,9 @@ class MainWindow(QMainWindow):
) )
lrc_lang = self.lrc_language_cb.currentText().strip() lrc_lang = self.lrc_language_cb.currentText().strip()
cfg.lrc = LrcConfig( 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, language=None if lrc_lang in ("", "auto") else lrc_lang,
align_model=self.lrc_align_model_edit.text().strip() or None, align_model=self.lrc_align_model_edit.text().strip() or None,
device=self.lrc_device_cb.currentText(), device=self.lrc_device_cb.currentText(),
@@ -642,6 +698,18 @@ class MainWindow(QMainWindow):
"Align-модель доступна только для WhisperX. Переключите движок на whisperx." "Align-модель доступна только для WhisperX. Переключите движок на whisperx."
) )
return 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_view.clear()
self._log_device_info() self._log_device_info()

View File

@@ -170,29 +170,29 @@ QCheckBox::indicator:checked {
} }
QProgressBar { QProgressBar {
background-color: #181825; background-color: #0b0b12;
border: 1px solid #313244; border: 1px solid #7f849c;
border-radius: 8px; border-radius: 8px;
text-align: center; text-align: center;
color: #cdd6f4; color: #ffffff;
min-height: 24px; min-height: 26px;
max-height: 24px; max-height: 26px;
font-weight: 500; font-weight: 700;
} }
QProgressBar::chunk { QProgressBar::chunk {
background-color: #89b4fa; background-color: #1e66f5;
border-radius: 7px; border-radius: 7px;
margin: 1px; margin: 1px;
} }
QProgressBar#overallProgress { QProgressBar#overallProgress {
min-height: 28px; min-height: 30px;
max-height: 28px; max-height: 30px;
font-weight: 600; font-weight: 700;
color: #cdd6f4; color: #ffffff;
border-color: #45475a; border: 1px solid #a6adc8;
} }
QProgressBar#overallProgress::chunk { QProgressBar#overallProgress::chunk {
background-color: #a6e3a1; background-color: #2f9e44;
border-radius: 8px; border-radius: 8px;
margin: 2px; margin: 2px;
} }