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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,3 +14,4 @@ output/
|
|||||||
|
|
||||||
preset.json
|
preset.json
|
||||||
*.log
|
*.log
|
||||||
|
combined_files.txt
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
26
gui/theme.py
26
gui/theme.py
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user