Compare commits

..

2 Commits

Author SHA1 Message Date
jadssss
999934f174 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
2026-10-27 22:29:13 +03:00
jadssss
15c0ba5d66 add resume support, metadata paths, LRCLIB order and overall progress
- store source track path in metadata.json and validate it when skipping ready files
- add skip_existing/resume_partial options to config and GUI
- identify lyrics via embedded text, Shazam, then one LRCLIB author search
- keep post-ASR LRCLIB text matching and allow cancelling safely
- recover from CUDA OOM and free VRAM between batch stages
- add overall completed/total file progress bar to GUI and pipelines
2026-10-25 20:24:01 +03:00
10 changed files with 892 additions and 241 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"
@@ -81,6 +84,9 @@ class PipelineConfig:
keep_intermediate: bool = True keep_intermediate: bool = True
overwrite: bool = True overwrite: bool = True
mode: str = "sequential" mode: str = "sequential"
chunk_size: int = 64
skip_existing: bool = False # skip files that already have text.txt
resume_partial: bool = True # reuse existing vocals.wav / convert.wav
convert: ConvertConfig = field(default_factory=ConvertConfig) convert: ConvertConfig = field(default_factory=ConvertConfig)
separate: SeparateConfig = field(default_factory=SeparateConfig) separate: SeparateConfig = field(default_factory=SeparateConfig)

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,12 +11,15 @@ 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
LogFn = Callable[[str], None] LogFn = Callable[[str], None]
ProgressFn = Callable[[int, int, str], None] ProgressFn = Callable[[str, int, int], None] # stage, current, total
OverallFn = Callable[[int, int], None] # completed_files, total_files
def _pick_audio_source( def _pick_audio_source(
@@ -44,11 +47,26 @@ 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,
on_progress: Optional[ProgressFn] = None, on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None, stop_event: Optional[threading.Event] = None,
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]: ) -> list[FileResult]:
input_dir = Path(config.input_dir) input_dir = Path(config.input_dir)
if not input_dir.is_dir(): if not input_dir.is_dir():
@@ -56,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("Аудиофайлы не найдены.")
@@ -67,13 +89,15 @@ 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:
on_overall_progress(0, total)
for idx, f in enumerate(files, 1): for idx, f in enumerate(files, 1):
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(idx - 1, total, "lrc")
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
@@ -81,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
@@ -127,7 +162,9 @@ def run_lrc_pipeline(
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
if on_progress: if on_progress:
on_progress(idx, total, "lrc") on_progress("lrc", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results return results
@@ -137,6 +174,7 @@ def run_embed_pipeline(
log: LogFn = print, log: LogFn = print,
on_progress: Optional[ProgressFn] = None, on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None, stop_event: Optional[threading.Event] = None,
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]: ) -> list[FileResult]:
input_dir = Path(config.input_dir) input_dir = Path(config.input_dir)
if not input_dir.is_dir(): if not input_dir.is_dir():
@@ -144,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("Аудиофайлы не найдены.")
@@ -154,13 +196,15 @@ 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:
on_overall_progress(0, total)
for idx, f in enumerate(files, 1): for idx, f in enumerate(files, 1):
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(idx - 1, total, "embed")
lrc_path = output_dir / f.stem / "text.lrc" lrc_path = output_dir / f.stem / "text.lrc"
error: Optional[str] = None error: Optional[str] = None
@@ -190,6 +234,8 @@ def run_embed_pipeline(
results.append(FileResult(f, None, None, error, "embed")) results.append(FileResult(f, None, None, error, "embed"))
finally: finally:
if on_progress: if on_progress:
on_progress(idx, total, "embed") on_progress("embed", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results return results

File diff suppressed because it is too large Load Diff

View File

@@ -91,13 +91,10 @@ def separate_vocals_batch(
total = len(input_wavs) total = len(input_wavs)
processed = 0 processed = 0
while proc.poll() is None: while proc.poll() is None:
time.sleep(2.0)
if progress_callback and processed < total - 1: if progress_callback and processed < total - 1:
if proc.wait(2.0) is not None:
break
processed += 1 processed += 1
progress_callback(processed) progress_callback(processed)
else:
proc.wait(2.0)
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(f"unblend batch failed with exit code {proc.returncode}") raise RuntimeError(f"unblend batch failed with exit code {proc.returncode}")

View File

@@ -11,6 +11,31 @@ from pathlib import Path
from typing import Optional from typing import Optional
# Set permanently once the CUDA context has died (e.g. after an unrecoverable
# OOM). Every later CUDA transcription fails instantly instead of re-loading
# the model forever and hanging the pipeline.
_CUDA_BROKEN = False
def _is_cuda_error(exc: BaseException) -> bool:
err = str(exc).lower()
return (
"out of memory" in err
or "cuda" in err
or "device ordinal" in err
or "cublas" in err
or "cudnn" in err
)
def _check_cuda_usable(device: str) -> None:
if device == "cuda" and _CUDA_BROKEN:
raise RuntimeError(
"CUDA context is broken (previous OOM was unrecoverable). "
"Restart the app or switch device to CPU."
)
def _resolve_device(device: str) -> str: def _resolve_device(device: str) -> str:
if device in ("cuda", "cpu"): if device in ("cuda", "cpu"):
return device return device
@@ -39,7 +64,7 @@ def _free_vram() -> None:
import torch import torch
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.cuda.empty_cache() torch.cuda.empty_cache()
except ImportError: except Exception: # noqa: BLE001 — cleanup must never mask the original error
pass pass
@@ -124,7 +149,9 @@ def transcribe(
local_files_only: bool = False, local_files_only: bool = False,
vad_filter: bool = True, vad_filter: bool = True,
) -> str: ) -> str:
global _CUDA_BROKEN
device = _resolve_device(device) device = _resolve_device(device)
_check_cuda_usable(device)
if engine == "whisperx": if engine == "whisperx":
try: try:
@@ -133,16 +160,26 @@ def transcribe(
batch_size, local_files_only, vad_filter, batch_size, local_files_only, vad_filter,
) )
except Exception as e: except Exception as e:
err = str(e).lower() if not _is_cuda_error(e):
if "out of memory" in err or "cuda" in err or "device ordinal" in err: raise
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...") print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
_WhisperXModels._cache.clear() _WhisperXModels._cache.clear()
_free_vram() _free_vram()
try:
return _transcribe_whisperx( return _transcribe_whisperx(
audio_path, model, language, device, compute_type, audio_path, model, language, device, compute_type,
batch_size, local_files_only, vad_filter, batch_size, local_files_only, vad_filter,
) )
raise except Exception as retry_error:
if not _is_cuda_error(retry_error):
raise
_CUDA_BROKEN = True
print(" [ASR] CUDA context is unrecoverable — "
"GPU disabled for the rest of this run.")
raise RuntimeError(
"CUDA context is broken after OOM recovery failed. "
"Restart the app or switch device to CPU."
) from retry_error
if engine == "gigaam": if engine == "gigaam":
return _transcribe_gigaam(audio_path, gigaam_provider) return _transcribe_gigaam(audio_path, gigaam_provider)
@@ -191,6 +228,7 @@ def transcribe_aligned(
""" """
import whisperx import whisperx
device = _resolve_device(device) device = _resolve_device(device)
_check_cuda_usable(device)
model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only) model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only)
audio = whisperx.load_audio(str(audio_path)) audio = whisperx.load_audio(str(audio_path))
result = model_obj.transcribe(audio, batch_size=batch_size, language=language) result = model_obj.transcribe(audio, batch_size=batch_size, language=language)

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)
@@ -101,6 +114,16 @@ class MainWindow(QMainWindow):
self.mode_cb.addItem(label, key) self.mode_cb.addItem(label, key)
top_layout.addRow("Режим:", self.mode_cb) top_layout.addRow("Режим:", self.mode_cb)
self.chunk_size_spin = QSpinBox()
self.chunk_size_spin.setRange(1, 10000)
self.chunk_size_spin.setToolTip(
"Размер батча в staged-режиме.\n"
"После каждого батча: удаляются временные файлы, освобождается VRAM.\n"
"Модели WhisperX остаются в памяти — перезагрузки не будет.\n"
"64 — разумный компромисс для 32 ГБ RAM и 8 ГБ VRAM."
)
top_layout.addRow("Размер батча:", self.chunk_size_spin)
opts = QHBoxLayout() opts = QHBoxLayout()
self.recursive_cb = QCheckBox("Рекурсивный обход") self.recursive_cb = QCheckBox("Рекурсивный обход")
self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные") self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные")
@@ -110,6 +133,23 @@ class MainWindow(QMainWindow):
opts.addWidget(self.overwrite_cb) opts.addWidget(self.overwrite_cb)
opts.addStretch(1) opts.addStretch(1)
top_layout.addRow("Опции:", opts) top_layout.addRow("Опции:", opts)
resume_opts = QHBoxLayout()
self.skip_existing_cb = QCheckBox("Пропускать готовые (text.txt)")
self.skip_existing_cb.setToolTip(
"Не трогать файлы, у которых в <output>/<stem>/ уже есть text.txt.\n"
"Позволяет продолжить большой прогон после сбоя без пересчёта.\n"
"Если в metadata.json записан path другого файла — папка не считается готовой."
)
self.resume_partial_cb = QCheckBox("Подхватывать vocals.wav / convert.wav")
self.resume_partial_cb.setToolTip(
"Если text.txt ещё нет, но есть vocals.wav — сразу переходить к\n"
"транскрибации. Если есть только convert.wav — пропустить конвертацию."
)
resume_opts.addWidget(self.skip_existing_cb)
resume_opts.addWidget(self.resume_partial_cb)
resume_opts.addStretch(1)
top_layout.addRow("Resume:", resume_opts)
root.addWidget(top_box) root.addWidget(top_box)
self.tabs = QTabWidget() self.tabs = QTabWidget()
@@ -126,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()
@@ -156,12 +197,33 @@ class MainWindow(QMainWindow):
self.progress.setFormat("Ожидание") self.progress.setFormat("Ожидание")
root.addWidget(self.progress) root.addWidget(self.progress)
self.overall_progress = QProgressBar()
self.overall_progress.setObjectName("overallProgress")
self.overall_progress.setFormat("Общий прогресс: ожидание")
root.addWidget(self.overall_progress)
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)
@@ -209,8 +271,17 @@ class MainWindow(QMainWindow):
self.lyrics_user_agent = QLineEdit() self.lyrics_user_agent = QLineEdit()
form.addRow("User-Agent:", self.lyrics_user_agent) form.addRow("User-Agent:", self.lyrics_user_agent)
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам") self.lyrics_use_tags_cb = QCheckBox("Искать по тегам")
self.lyrics_use_tags_cb.setToolTip(
"Использовать artist/title из тегов файла для поиска на LRCLIB.\n"
"Срабатывает как fallback, если Shazam ничего не нашёл\n"
"или его результат отключён."
)
form.addRow("", self.lyrics_use_tags_cb) form.addRow("", self.lyrics_use_tags_cb)
self.lyrics_use_shazam_cb = QCheckBox("Искать по Shazam") self.lyrics_use_shazam_cb = QCheckBox("Искать по Shazam")
self.lyrics_use_shazam_cb.setToolTip(
"Приоритетный источник для поиска на LRCLIB: artist/title из Shazam.\n"
"Теги файла используются только если Shazam не дал результата."
)
form.addRow("", self.lyrics_use_shazam_cb) form.addRow("", self.lyrics_use_shazam_cb)
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy)") self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy)")
form.addRow("", self.lyrics_after_asr_cb) form.addRow("", self.lyrics_after_asr_cb)
@@ -333,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(
@@ -392,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"
@@ -410,6 +501,8 @@ class MainWindow(QMainWindow):
self.recursive_cb.setChecked(cfg.recursive) self.recursive_cb.setChecked(cfg.recursive)
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate) self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
self.overwrite_cb.setChecked(cfg.overwrite) self.overwrite_cb.setChecked(cfg.overwrite)
self.skip_existing_cb.setChecked(cfg.skip_existing)
self.resume_partial_cb.setChecked(cfg.resume_partial)
i = self.mode_cb.findData(cfg.mode) i = self.mode_cb.findData(cfg.mode)
if i >= 0: if i >= 0:
self.mode_cb.setCurrentIndex(i) self.mode_cb.setCurrentIndex(i)
@@ -460,6 +553,10 @@ 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)
def _collect_config(self) -> PipelineConfig: def _collect_config(self) -> PipelineConfig:
cfg = PipelineConfig() cfg = PipelineConfig()
@@ -468,6 +565,8 @@ class MainWindow(QMainWindow):
cfg.recursive = self.recursive_cb.isChecked() cfg.recursive = self.recursive_cb.isChecked()
cfg.keep_intermediate = self.keep_intermediate_cb.isChecked() cfg.keep_intermediate = self.keep_intermediate_cb.isChecked()
cfg.overwrite = self.overwrite_cb.isChecked() cfg.overwrite = self.overwrite_cb.isChecked()
cfg.skip_existing = self.skip_existing_cb.isChecked()
cfg.resume_partial = self.resume_partial_cb.isChecked()
cfg.mode = self.mode_cb.currentData() cfg.mode = self.mode_cb.currentData()
cfg.shazam = ShazamConfig( cfg.shazam = ShazamConfig(
@@ -516,12 +615,16 @@ 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(),
audio_source=self.lrc_audio_source_cb.currentText(), audio_source=self.lrc_audio_source_cb.currentText(),
embed_in_place=self.lrc_embed_in_place_cb.isChecked(), embed_in_place=self.lrc_embed_in_place_cb.isChecked(),
) )
cfg.chunk_size = self.chunk_size_spin.value()
return cfg return cfg
# ---------------- handlers ---------------- # ---------------- handlers ----------------
@@ -595,16 +698,32 @@ 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()
self.progress.setValue(0) self.progress.setValue(0)
self.progress.setFormat("Запуск…") self.progress.setFormat("Запуск…")
self.overall_progress.setMaximum(1)
self.overall_progress.setValue(0)
self.overall_progress.setFormat("Общий прогресс: запуск…")
self._set_busy(True) self._set_busy(True)
self._worker = PipelineWorker(cfg, task=task) self._worker = PipelineWorker(cfg, task=task)
self._worker.log.connect(self._append_log) self._worker.log.connect(self._append_log)
self._worker.progress.connect(self._on_progress) self._worker.progress.connect(self._on_progress)
self._worker.overall_progress.connect(self._on_overall_progress)
self._worker.finished_ok.connect(self._on_finished) self._worker.finished_ok.connect(self._on_finished)
self._worker.failed.connect(self._on_failed) self._worker.failed.connect(self._on_failed)
self._worker.start() self._worker.start()
@@ -619,21 +738,61 @@ class MainWindow(QMainWindow):
sb = self.log_view.verticalScrollBar() sb = self.log_view.verticalScrollBar()
sb.setValue(sb.maximum()) sb.setValue(sb.maximum())
def _on_progress(self, done: int, total: int, stage: str) -> None: def _on_progress(self, stage: str, done: int, total: int) -> None:
self.progress.setMaximum(total)
self.progress.setValue(done)
label = STAGE_LABELS.get(stage, stage) label = STAGE_LABELS.get(stage, stage)
self.progress.setFormat(f"{label} — %v / %m") if total > 0:
self.progress.setMaximum(total)
self.progress.setValue(min(done, total))
self.progress.setFormat(f"{label}: %v / %m")
else:
# Nothing to do for this stage — still show which stage we are on
# instead of leaving the previous stage's label frozen.
self.progress.setMaximum(1)
self.progress.setValue(0)
self.progress.setFormat(f"{label}: 0 / 0")
def _on_overall_progress(self, done: int, total: int) -> None:
"""Overall job progress: completed files / all files in the run."""
if total <= 0:
self.overall_progress.setMaximum(1)
self.overall_progress.setValue(0)
self.overall_progress.setFormat("Общий прогресс: 0 / 0")
return
done = max(0, min(done, total))
remaining = total - done
percent = int(done * 100 / total)
self.overall_progress.setMaximum(total)
self.overall_progress.setValue(done)
self.overall_progress.setFormat(
f"Общий прогресс: {done} / {total} ({percent}%) · осталось {remaining}"
)
def _on_finished(self, results: list) -> None: def _on_finished(self, results: list) -> None:
self._set_busy(False) self._set_busy(False)
ok = sum(1 for r in results if r.error is None) ok = sum(1 for r in results if r.error is None)
fail = len(results) - ok fail = len(results) - ok
self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}") self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}")
total = self.overall_progress.maximum()
done = self.overall_progress.value()
if total > 0 and done >= total:
self.overall_progress.setFormat(
f"Общий прогресс: {done} / {total} — завершено"
)
elif total > 0:
remaining = total - done
self.overall_progress.setFormat(
f"Общий прогресс: {done} / {total} — остановлено, осталось {remaining}"
)
self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.") self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
def _on_failed(self, message: str) -> None: def _on_failed(self, message: str) -> None:
self._set_busy(False) self._set_busy(False)
self.progress.setFormat("Ошибка") self.progress.setFormat("Ошибка")
if self.overall_progress.maximum() > 0:
self.overall_progress.setFormat(
f"Общий прогресс: {self.overall_progress.value()} / "
f"{self.overall_progress.maximum()} — ошибка"
)
self._append_log(f"\n✗ Ошибка пайплайна: {message}") self._append_log(f"\n✗ Ошибка пайплайна: {message}")
QMessageBox.critical(self, "Ошибка", message) QMessageBox.critical(self, "Ошибка", message)

View File

@@ -170,20 +170,32 @@ 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 {
min-height: 30px;
max-height: 30px;
font-weight: 700;
color: #ffffff;
border: 1px solid #a6adc8;
}
QProgressBar#overallProgress::chunk {
background-color: #2f9e44;
border-radius: 8px;
margin: 2px;
}
QScrollBar:vertical { QScrollBar:vertical {
background: #181825; background: #181825;

View File

@@ -10,7 +10,8 @@ from core.config import PipelineConfig
class PipelineWorker(QThread): class PipelineWorker(QThread):
log = Signal(str) log = Signal(str)
progress = Signal(int, int, str) # done, total, stage progress = Signal(str, int, int) # stage, current, total_for_stage
overall_progress = Signal(int, int) # completed_files, total_files
finished_ok = Signal(list) finished_ok = Signal(list)
failed = Signal(str) failed = Signal(str)
@@ -35,8 +36,9 @@ class PipelineWorker(QThread):
results = runner( results = runner(
self._config, self._config,
log=self.log.emit, log=self.log.emit,
on_progress=lambda d, t, s: self.progress.emit(d, t, s), on_progress=lambda s, d, t: self.progress.emit(s, d, t),
stop_event=self._stop_event, stop_event=self._stop_event,
on_overall_progress=lambda d, t: self.overall_progress.emit(d, t),
) )
self.finished_ok.emit(results) self.finished_ok.emit(results)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001