From e1a42ec1dfc9b12619c371f148673cc0caf5f486 Mon Sep 17 00:00:00 2001 From: jadssss Date: Fri, 23 Oct 2026 23:57:43 +0300 Subject: [PATCH] staged/sequential pipeline modes + dark themed Qt GUI --- core/config.py | 11 +- core/pipeline.py | 411 +++++++++++++++++++++++++++++---------------- core/transcribe.py | 37 ++-- gui/main_window.py | 293 ++++++++++++++++++++------------ gui/theme.py | 215 ++++++++++++++++++++++++ gui/worker.py | 8 +- main.py | 26 ++- 7 files changed, 721 insertions(+), 280 deletions(-) create mode 100644 gui/theme.py diff --git a/core/config.py b/core/config.py index 6e5f7af..1772d40 100644 --- a/core/config.py +++ b/core/config.py @@ -7,6 +7,9 @@ from pathlib import Path from typing import Optional +MODES = ("sequential", "staged") + + @dataclass class ConvertConfig: enabled: bool = True @@ -31,7 +34,7 @@ class TranscribeConfig: model: str = "large-v3" language: Optional[str] = "ru" # None = autodetect device: str = "auto" # "cuda" | "cpu" | "auto" - compute_type: Optional[str] = None # None = auto (int8_float16 / int8) + compute_type: Optional[str] = None # None = auto batch_size: int = 8 gigaam_provider: str = "auto" # "auto" | "cuda" | "cpu" @@ -43,12 +46,12 @@ class PipelineConfig: recursive: bool = False keep_intermediate: bool = True overwrite: bool = True + mode: str = "sequential" # "sequential" | "staged" convert: ConvertConfig = field(default_factory=ConvertConfig) separate: SeparateConfig = field(default_factory=SeparateConfig) transcribe: TranscribeConfig = field(default_factory=TranscribeConfig) - # --- serialization --- def to_dict(self) -> dict: return asdict(self) @@ -65,9 +68,9 @@ class PipelineConfig: value = data[name] current = getattr(cfg, name) if hasattr(current, "__dataclass_fields__"): - sub_fields = {f.name for f in fields(current)} + sub = {f.name for f in fields(current)} for k, v in value.items(): - if k in sub_fields: + if k in sub: setattr(current, k, v) else: setattr(cfg, name, value) diff --git a/core/pipeline.py b/core/pipeline.py index 1fb7a03..4aeff02 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -1,4 +1,9 @@ -"""Orchestrate convert -> separate -> transcribe over a directory of audio files.""" +"""Orchestrate convert -> separate -> transcribe. + +Two modes: + - sequential: for each file, run all enabled stages before moving to the next. + - staged: run stage 1 over all files, then stage 2 over all files, then stage 3. +""" from __future__ import annotations import shutil @@ -19,8 +24,7 @@ AUDIO_EXTENSIONS = { } LogFn = Callable[[str], None] -StageFn = Callable[[str, str], None] -ProgressFn = Callable[[int, int], None] +ProgressFn = Callable[[int, int, str], None] # done, total, stage @dataclass @@ -32,24 +36,24 @@ class FileResult: class Cancelled(Exception): - """Raised internally when the stop event is set.""" + """Raised when the stop event is set.""" + + +# ---------------------------------------------------------------- helpers + +_ARTIFACT_NAMES = { + "convert": "convert.wav", + "separate": "vocals.wav", + "transcribe": "text.txt", +} def collect_audio_files(input_dir: Path, recursive: bool) -> list[Path]: it = input_dir.rglob("*") if recursive else input_dir.iterdir() - return sorted( - p for p in it - if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS - ) - - -def _check_cancel(stop_event: Optional[threading.Event]) -> None: - if stop_event is not None and stop_event.is_set(): - raise Cancelled() + return sorted(p for p in it if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS) def _final_stages(config: PipelineConfig) -> set[str]: - """Which stage produces the final artifact that must survive cleanup.""" if config.transcribe.enabled: return {"transcribe"} if config.separate.enabled: @@ -59,137 +63,9 @@ def _final_stages(config: PipelineConfig) -> set[str]: return set() -_ARTIFACT_NAMES = { - "convert": "convert.wav", - "separate": "vocals.wav", - "transcribe": "text.txt", -} - - -def run_pipeline( - config: PipelineConfig, - log: LogFn = print, - on_stage: Optional[StageFn] = None, - on_progress: Optional[ProgressFn] = None, - stop_event: Optional[threading.Event] = None, -) -> list[FileResult]: - input_dir = Path(config.input_dir) - if not input_dir.is_dir(): - raise FileNotFoundError(f"Input directory not found: {input_dir}") - - output_dir = Path(config.output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - files = collect_audio_files(input_dir, config.recursive) - total = len(files) - results: list[FileResult] = [] - - if total == 0: - log("Аудиофайлы не найдены.") - return results - - log(f"Найдено треков: {total}") - - for idx, input_path in enumerate(files, start=1): - _check_cancel(stop_event) - on_progress and on_progress(idx - 1, total) - log(f"\n▶ [{idx}/{total}] {input_path.name}") - try: - results.append(_process_one(input_path, config, log, on_stage, stop_event)) - except Cancelled: - log("⏹ Отменено пользователем") - break - except Exception as exc: # noqa: BLE001 — we log per-file and continue - log(f" ✗ Ошибка: {exc}") - results.append(FileResult(input_path, None, None, str(exc))) - finally: - on_progress and on_progress(idx, total) - - return results - - -def _process_one( - input_path: Path, - config: PipelineConfig, - log: LogFn, - on_stage: Optional[StageFn], - stop_event: Optional[threading.Event], -) -> FileResult: - track_stem = input_path.stem - track_out = Path(config.output_dir) / track_stem - track_out.mkdir(parents=True, exist_ok=True) - - tmp_work = Path(tempfile.mkdtemp(prefix=f"at_{track_stem}_")) - finals = _final_stages(config) - - try: - # ---------- 1. convert ---------- - source_wav: Path = input_path - if config.convert.enabled: - _check_cancel(stop_event) - on_stage and on_stage("convert", input_path.name) - log(" [1] Конвертация в WAV...") - target = ( - track_out / "convert.wav" - if ("convert" in finals or config.keep_intermediate) - else tmp_work / "convert.wav" - ) - if config.overwrite or not target.exists(): - convert_to_wav( - input_path, target, - sample_rate=config.convert.sample_rate, - channels=config.convert.channels, - ffmpeg_path=config.convert.ffmpeg_path, - ) - source_wav = target - log(f" ✓ {target.name}") - - # ---------- 2. separate ---------- - vocal_path: Path = source_wav - if config.separate.enabled: - _check_cancel(stop_event) - on_stage and on_stage("separate", input_path.name) - log(" [2] Отделение вокала...") - raw_vocal = separate_vocals( - source_wav, tmp_work, - model=config.separate.model, - isolate_stem=config.separate.isolate_stem, - unblend_path=config.separate.unblend_path, - custom_kernels=config.separate.custom_kernels, - ) - if "separate" in finals or config.keep_intermediate: - vocal_path = track_out / "vocals.wav" - shutil.move(str(raw_vocal), str(vocal_path)) - else: - vocal_path = raw_vocal # stays in tmp_work - log(f" ✓ {vocal_path.name}") - - # ---------- 3. transcribe ---------- - text: Optional[str] = None - if config.transcribe.enabled: - _check_cancel(stop_event) - on_stage and on_stage("transcribe", input_path.name) - log(" [3] Транскрибация...") - text = transcribe( - vocal_path, - engine=config.transcribe.engine, - model=config.transcribe.model, - language=config.transcribe.language, - device=config.transcribe.device, - compute_type=config.transcribe.compute_type, - batch_size=config.transcribe.batch_size, - gigaam_provider=config.transcribe.gigaam_provider, - ) - (track_out / "text.txt").write_text(text, encoding="utf-8") - log(f" ✓ text.txt ({len(text)} символов)") - - if not config.keep_intermediate: - _cleanup_keeping(track_out, finals) - - return FileResult(input_path, text, track_out) - - finally: - shutil.rmtree(tmp_work, ignore_errors=True) +def _check_cancel(stop_event: Optional[threading.Event]) -> None: + if stop_event is not None and stop_event.is_set(): + raise Cancelled() def _cleanup_keeping(track_out: Path, finals: set[str]) -> None: @@ -206,3 +82,248 @@ def _cleanup_keeping(track_out: Path, finals: set[str]) -> None: shutil.rmtree(p, ignore_errors=True) except OSError: pass + + +# ---------------------------------------------------------------- entry + +def run_pipeline( + config: PipelineConfig, + log: LogFn = print, + on_progress: Optional[ProgressFn] = None, + stop_event: Optional[threading.Event] = None, +) -> list[FileResult]: + input_dir = Path(config.input_dir) + if not input_dir.is_dir(): + raise FileNotFoundError(f"Input directory not found: {input_dir}") + + output_dir = Path(config.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + files = collect_audio_files(input_dir, config.recursive) + if not files: + log("Аудиофайлы не найдены.") + return [] + + mode = (config.mode or "sequential").lower() + log(f"Найдено треков: {len(files)} | режим: {mode}") + + if mode == "staged": + return _run_staged(config, files, log, on_progress, stop_event) + return _run_sequential(config, files, log, on_progress, stop_event) + + +# ---------------------------------------------------------------- sequential + +def _run_sequential( + config: PipelineConfig, + files: list[Path], + log: LogFn, + on_progress: Optional[ProgressFn], + stop_event: Optional[threading.Event], +) -> list[FileResult]: + total = len(files) + finals = _final_stages(config) + results: list[FileResult] = [] + + for idx, f in enumerate(files, 1): + if stop_event is not None and stop_event.is_set(): + log("⏹ Отменено") + break + if on_progress: + on_progress(idx - 1, total, "file") + log(f"\n▶ [{idx}/{total}] {f.name}") + + track_out = Path(config.output_dir) / f.stem + track_out.mkdir(parents=True, exist_ok=True) + + audio_path: Path = f + text: Optional[str] = None + error: Optional[str] = None + + try: + if config.convert.enabled: + _check_cancel(stop_event) + log(" [1] Конвертация в WAV...") + target = track_out / "convert.wav" + if config.overwrite or not target.exists(): + convert_to_wav( + f, target, + sample_rate=config.convert.sample_rate, + channels=config.convert.channels, + ffmpeg_path=config.convert.ffmpeg_path, + ) + audio_path = target + log(" ✓ convert.wav") + + if config.separate.enabled: + _check_cancel(stop_event) + log(" [2] Отделение вокала...") + target = track_out / "vocals.wav" + with tempfile.TemporaryDirectory() as tmp: + raw = separate_vocals( + audio_path, Path(tmp), + model=config.separate.model, + isolate_stem=config.separate.isolate_stem, + unblend_path=config.separate.unblend_path, + custom_kernels=config.separate.custom_kernels, + ) + shutil.move(str(raw), str(target)) + audio_path = target + log(" ✓ vocals.wav") + + if config.transcribe.enabled: + _check_cancel(stop_event) + log(" [3] Транскрибация...") + text = transcribe( + audio_path, + engine=config.transcribe.engine, + model=config.transcribe.model, + language=config.transcribe.language, + device=config.transcribe.device, + compute_type=config.transcribe.compute_type, + batch_size=config.transcribe.batch_size, + gigaam_provider=config.transcribe.gigaam_provider, + ) + (track_out / "text.txt").write_text(text, encoding="utf-8") + log(f" ✓ text.txt ({len(text)} символов)") + + if not config.keep_intermediate: + _cleanup_keeping(track_out, finals) + + except Cancelled: + log("⏹ Отменено") + break + except Exception as e: # noqa: BLE001 — per-file isolation + error = f"{type(e).__name__}: {e}" + log(f" ✗ Ошибка: {error}") + finally: + if on_progress: + on_progress(idx, total, "file") + results.append(FileResult(f, text, track_out if not error else None, error)) + + return results + + +# ---------------------------------------------------------------- staged + +def _run_staged( + config: PipelineConfig, + files: list[Path], + log: LogFn, + on_progress: Optional[ProgressFn], + stop_event: Optional[threading.Event], +) -> list[FileResult]: + output_root = Path(config.output_dir) + total = len(files) + + # per-file state + state: dict[Path, dict] = { + f: {"audio_path": f, "track_out": None, "text": None, "error": None} + for f in files + } + + def track_out_for(f: Path) -> Path: + s = state[f] + if s["track_out"] is None: + p = output_root / f.stem + p.mkdir(parents=True, exist_ok=True) + s["track_out"] = p + return s["track_out"] + + def active() -> list[Path]: + return [f for f in files if state[f]["error"] is None] + + def cancelled() -> bool: + return stop_event is not None and stop_event.is_set() + + # ---- Stage 1: convert ---- + if config.convert.enabled: + log(f"\n══ Этап 1/3 · Конвертация ({total} файлов) ══") + for i, f in enumerate(files, 1): + if cancelled(): + log("⏹ Отменено") + break + if on_progress: + on_progress(i, total, "convert") + try: + target = track_out_for(f) / "convert.wav" + if config.overwrite or not target.exists(): + convert_to_wav( + f, target, + sample_rate=config.convert.sample_rate, + channels=config.convert.channels, + ffmpeg_path=config.convert.ffmpeg_path, + ) + state[f]["audio_path"] = target + log(f" ✓ {f.name}") + except Exception as e: # noqa: BLE001 + state[f]["error"] = f"{type(e).__name__}: {e}" + log(f" ✗ {f.name}: {e}") + + # ---- Stage 2: separate ---- + if config.separate.enabled and not cancelled(): + todo = active() + log(f"\n══ Этап 2/3 · Отделение вокала ({len(todo)} файлов) ══") + for i, f in enumerate(todo, 1): + if cancelled(): + log("⏹ Отменено") + break + if on_progress: + on_progress(i, len(todo), "separate") + try: + target = track_out_for(f) / "vocals.wav" + with tempfile.TemporaryDirectory() as tmp: + raw = separate_vocals( + state[f]["audio_path"], Path(tmp), + model=config.separate.model, + isolate_stem=config.separate.isolate_stem, + unblend_path=config.separate.unblend_path, + custom_kernels=config.separate.custom_kernels, + ) + shutil.move(str(raw), str(target)) + state[f]["audio_path"] = target + log(f" ✓ {f.name}") + except Exception as e: # noqa: BLE001 + state[f]["error"] = f"{type(e).__name__}: {e}" + log(f" ✗ {f.name}: {e}") + + # ---- Stage 3: transcribe ---- + if config.transcribe.enabled and not cancelled(): + todo = active() + log(f"\n══ Этап 3/3 · Транскрибация ({len(todo)} файлов) ══") + for i, f in enumerate(todo, 1): + if cancelled(): + log("⏹ Отменено") + break + if on_progress: + on_progress(i, len(todo), "transcribe") + try: + text = transcribe( + state[f]["audio_path"], + engine=config.transcribe.engine, + model=config.transcribe.model, + language=config.transcribe.language, + device=config.transcribe.device, + compute_type=config.transcribe.compute_type, + batch_size=config.transcribe.batch_size, + gigaam_provider=config.transcribe.gigaam_provider, + ) + (track_out_for(f) / "text.txt").write_text(text, encoding="utf-8") + state[f]["text"] = text + log(f" ✓ {f.name} ({len(text)} символов)") + except Exception as e: # noqa: BLE001 + state[f]["error"] = f"{type(e).__name__}: {e}" + log(f" ✗ {f.name}: {e}") + + # ---- Cleanup ---- + if not config.keep_intermediate: + finals = _final_stages(config) + for f in files: + s = state[f] + if s["error"] is None and s["track_out"] is not None: + _cleanup_keeping(s["track_out"], finals) + + return [ + FileResult(f, state[f]["text"], state[f]["track_out"], state[f]["error"]) + for f in files + ] diff --git a/core/transcribe.py b/core/transcribe.py index d669789..0350fb1 100644 --- a/core/transcribe.py +++ b/core/transcribe.py @@ -1,10 +1,12 @@ """Speech recognition via WhisperX or GigaAM (onnx-asr). Models are cached per (name, device, compute_type) so loading happens once -per process, not once per file. +per process, not once per file. Loading is retried a few times to survive +transient HuggingFace / SSL hiccups. """ from __future__ import annotations +import time from pathlib import Path from typing import Optional @@ -19,6 +21,18 @@ def _resolve_device(device: str) -> str: return "cpu" +def _retry(fn, attempts: int = 3, base_delay: float = 2.0): + last = None + for i in range(attempts): + try: + return fn() + except Exception as e: # noqa: BLE001 — HF download / SSL are broad + last = e + if i < attempts - 1: + time.sleep(base_delay * (i + 1)) + raise last + + class _WhisperXModels: _cache: dict = {} @@ -29,9 +43,11 @@ class _WhisperXModels: key = (model_name, device, compute_type) if key not in cls._cache: import whisperx - cls._cache[key] = whisperx.load_model( - model_name, device, compute_type=compute_type - ) + + def load(): + return whisperx.load_model(model_name, device, compute_type=compute_type) + + cls._cache[key] = _retry(load) return cls._cache[key] @@ -40,8 +56,6 @@ class _GigaAMModels: @classmethod def get(cls, provider: str): - # onnx-asr accepts a list; CUDAExecutionProvider silently falls back to CPU - # if CUDA isn't available at runtime. providers = { "cuda": ["CUDAExecutionProvider"], "cpu": ["CPUExecutionProvider"], @@ -50,7 +64,11 @@ class _GigaAMModels: key = tuple(providers) if key not in cls._cache: import onnx_asr - cls._cache[key] = onnx_asr.load_model("gigaam-v3-ctc", providers=list(providers)) + + def load(): + return onnx_asr.load_model("gigaam-v3-ctc", providers=list(providers)) + + cls._cache[key] = _retry(load) return cls._cache[key] @@ -65,11 +83,8 @@ def transcribe( gigaam_provider: str = "auto", ) -> str: device = _resolve_device(device) - if engine == "whisperx": - return _transcribe_whisperx( - audio_path, model, language, device, compute_type, batch_size - ) + return _transcribe_whisperx(audio_path, model, language, device, compute_type, batch_size) if engine == "gigaam": return _transcribe_gigaam(audio_path, gigaam_provider) raise ValueError(f"Unknown ASR engine: {engine!r}") diff --git a/gui/main_window.py b/gui/main_window.py index 3ac27a6..3d470a2 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -1,19 +1,17 @@ -"""Main application window.""" +"""Main application window — dark theme, mode selector, staged/sequential pipelines.""" from __future__ import annotations import shutil -import subprocess -import sys from pathlib import Path from typing import Optional from PySide6.QtCore import Qt, QUrl -from PySide6.QtGui import QDesktopServices +from PySide6.QtGui import QColor, QDesktopServices from PySide6.QtWidgets import ( QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, - QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QSplitter, - QTabWidget, QVBoxLayout, QWidget, + QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QTabWidget, + QVBoxLayout, QWidget, ) from core.config import ConvertConfig, PipelineConfig, SeparateConfig, TranscribeConfig @@ -24,33 +22,74 @@ LANGUAGES = ["auto", "ru", "en", "de", "fr", "es", "it", "uk", "ja", "zh", "ko"] SAMPLE_RATES = [16000, 22050, 44100, 48000] ENGINES = ["whisperx", "gigaam"] +STAGE_LABELS = { + "file": "Файлы", + "convert": "Конвертация", + "separate": "Отделение", + "transcribe": "Транскрибация", +} -def _detect_torch_cuda() -> bool: +MODE_LABELS = { + "sequential": "Последовательно (файл за файлом)", + "staged": "Пакетно (по этапам)", +} + + +def _detect_device_info() -> tuple[str, str]: + """Return (display_text, color_hex) describing the compute device.""" try: import torch - return bool(torch.cuda.is_available()) - except Exception: - return False + except ImportError: + return "⚠ PyTorch не установлен", "#f38ba8" + + if torch.cuda.is_available(): + try: + name = torch.cuda.get_device_name(0) + vram = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3) + return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1" + except Exception: # noqa: BLE001 + return "✓ CUDA доступна", "#a6e3a1" + + return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af" class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() - self.setWindowTitle("Song Transcriber v3") - self.resize(1080, 760) + self.setWindowTitle("Song Transcriber") + self.resize(1100, 850) self._worker: Optional[PipelineWorker] = None self._build_ui() self._apply_defaults() + self._sync_tab_indicators() + self._sync_engine_fields() + self._log_device_info() # ---------------- UI ---------------- + def _build_ui(self) -> None: central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) + root.setContentsMargins(18, 16, 18, 16) + root.setSpacing(12) - # --- top: paths + options --- + # --- header row: title + device badge --- + header = QHBoxLayout() + title = QLabel("🎵 Song Transcriber") + title.setObjectName("appTitle") + header.addWidget(title) + header.addStretch(1) + + self.device_label = QLabel("") + self.device_label.setObjectName("deviceInfo") + header.addWidget(self.device_label) + root.addLayout(header) + + # --- top settings --- top_box = QGroupBox("Общие настройки") top_layout = QFormLayout(top_box) + top_layout.setSpacing(10) self.input_edit = QLineEdit() top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir)) @@ -58,81 +97,97 @@ class MainWindow(QMainWindow): self.output_edit = QLineEdit() top_layout.addRow("Выходная папка:", self._with_browse(self.output_edit, self._pick_output_dir)) - opts_row = QHBoxLayout() + self.mode_cb = QComboBox() + for key, label in MODE_LABELS.items(): + self.mode_cb.addItem(label, key) + top_layout.addRow("Режим:", self.mode_cb) + + opts = QHBoxLayout() self.recursive_cb = QCheckBox("Рекурсивный обход") - self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные файлы") + self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные") self.overwrite_cb = QCheckBox("Перезаписывать") - opts_row.addWidget(self.recursive_cb) - opts_row.addWidget(self.keep_intermediate_cb) - opts_row.addWidget(self.overwrite_cb) - opts_row.addStretch(1) - top_layout.addRow("Опции:", opts_row) + opts.addWidget(self.recursive_cb) + opts.addWidget(self.keep_intermediate_cb) + opts.addWidget(self.overwrite_cb) + opts.addStretch(1) + top_layout.addRow("Опции:", opts) root.addWidget(top_box) - # --- stages tabs --- - tabs = QTabWidget() - tabs.addTab(self._build_convert_tab(), "Конвертация") - tabs.addTab(self._build_separate_tab(), "Отделение вокала") - tabs.addTab(self._build_transcribe_tab(), "Транскрибация") - root.addWidget(tabs) + # --- stages --- + self.tabs = QTabWidget() + self.tabs.addTab(self._build_convert_tab(), "Конвертация") + self.tabs.addTab(self._build_separate_tab(), "Отделение вокала") + self.tabs.addTab(self._build_transcribe_tab(), "Транскрибация") + root.addWidget(self.tabs) - # --- run row --- - run_row = QHBoxLayout() - self.start_btn = QPushButton("▶ Запустить") + 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.engine_cb.currentTextChanged.connect(self._sync_engine_fields) + + # --- controls row --- + row = QHBoxLayout() + row.setSpacing(10) + self.start_btn = QPushButton("▶ Запустить") + self.start_btn.setObjectName("primary") self.start_btn.clicked.connect(self._on_start) - self.stop_btn = QPushButton("⏹ Стоп") + self.stop_btn = QPushButton("⏹ Стоп") + self.stop_btn.setObjectName("danger") self.stop_btn.setEnabled(False) self.stop_btn.clicked.connect(self._on_stop) - self.open_out_btn = QPushButton("📂 Открыть выходную папку") + + self.open_out_btn = QPushButton("📂 Открыть результат") self.open_out_btn.clicked.connect(self._open_output_dir) - self.save_preset_btn = QPushButton("💾 Сохранить пресет") + self.save_preset_btn = QPushButton("💾 Сохранить пресет") self.save_preset_btn.clicked.connect(self._save_preset) - self.load_preset_btn = QPushButton("📁 Загрузить пресет") + self.load_preset_btn = QPushButton("📁 Загрузить пресет") self.load_preset_btn.clicked.connect(self._load_preset) - run_row.addWidget(self.start_btn) - run_row.addWidget(self.stop_btn) - run_row.addStretch(1) - run_row.addWidget(self.open_out_btn) - run_row.addWidget(self.save_preset_btn) - run_row.addWidget(self.load_preset_btn) - root.addLayout(run_row) + row.addWidget(self.start_btn) + row.addWidget(self.stop_btn) + row.addStretch(1) + row.addWidget(self.open_out_btn) + row.addWidget(self.save_preset_btn) + row.addWidget(self.load_preset_btn) + root.addLayout(row) # --- progress --- self.progress = QProgressBar() - self.progress.setFormat("%v / %m") + self.progress.setFormat("Ожидание") root.addWidget(self.progress) # --- log --- - splitter = QSplitter(Qt.Vertical) self.log_view = QPlainTextEdit() + self.log_view.setObjectName("log") self.log_view.setReadOnly(True) - self.log_view.setPlaceholderText("Здесь будет лог выполнения…") - splitter.addWidget(self.log_view) - splitter.setStretchFactor(0, 1) - root.addWidget(splitter, 1) + self.log_view.setPlaceholderText("Здесь появится лог выполнения…") + root.addWidget(self.log_view, 1) - def _with_browse(self, line_edit: QLineEdit, picker) -> QWidget: - wrapper = QWidget() - h = QHBoxLayout(wrapper) + def _with_browse(self, edit: QLineEdit, picker) -> QWidget: + w = QWidget() + h = QHBoxLayout(w) h.setContentsMargins(0, 0, 0, 0) - h.addWidget(line_edit) + h.setSpacing(6) + h.addWidget(edit) btn = QPushButton("…") - btn.setFixedWidth(32) + btn.setFixedWidth(36) btn.clicked.connect(picker) h.addWidget(btn) - return wrapper + return w def _build_convert_tab(self) -> QWidget: w = QWidget() form = QFormLayout(w) + form.setContentsMargins(16, 16, 16, 16) + form.setSpacing(10) + self.convert_enabled_cb = QCheckBox("Включён") form.addRow("", self.convert_enabled_cb) self.convert_sr = QComboBox() for sr in SAMPLE_RATES: - self.convert_sr.addItem(str(sr), sr) + self.convert_sr.addItem(f"{sr} Hz", sr) form.addRow("Sample rate:", self.convert_sr) self.convert_ch = QComboBox() @@ -147,6 +202,9 @@ class MainWindow(QMainWindow): def _build_separate_tab(self) -> QWidget: w = QWidget() form = QFormLayout(w) + form.setContentsMargins(16, 16, 16, 16) + form.setSpacing(10) + self.separate_enabled_cb = QCheckBox("Включён") form.addRow("", self.separate_enabled_cb) @@ -166,6 +224,9 @@ class MainWindow(QMainWindow): def _build_transcribe_tab(self) -> QWidget: w = QWidget() form = QFormLayout(w) + form.setContentsMargins(16, 16, 16, 16) + form.setSpacing(10) + self.transcribe_enabled_cb = QCheckBox("Включён") form.addRow("", self.transcribe_enabled_cb) @@ -179,6 +240,10 @@ class MainWindow(QMainWindow): self.lang_cb = QComboBox() self.lang_cb.setEditable(True) self.lang_cb.addItems(LANGUAGES) + self.lang_cb.setToolTip( + "'auto' — WhisperX определяет язык на каждом файле отдельно.\n" + "Это заметно медленнее. Если знаешь язык — выбери его явно." + ) form.addRow("Язык:", self.lang_cb) self.device_cb = QComboBox() @@ -190,7 +255,13 @@ class MainWindow(QMainWindow): form.addRow("Compute type:", self.compute_type_cb) self.batch_spin = QSpinBox() - self.batch_spin.setRange(1, 64) + self.batch_spin.setRange(1, 512) + self.batch_spin.setToolTip( + "Размер батча сегментов ВНУТРИ одного файла (не файлов!).\n" + "Больше ≠ всегда быстрее: упирается в VRAM.\n" + "8–16 для 8 ГБ VRAM, 32–64 для 24 ГБ.\n" + "При 'CUDA out of memory' — уменьшайте." + ) form.addRow("Batch size:", self.batch_spin) self.gigaam_provider_cb = QComboBox() @@ -198,21 +269,51 @@ class MainWindow(QMainWindow): form.addRow("GigaAM provider:", self.gigaam_provider_cb) return w - # ---------------- defaults ---------------- + # ---------------- visual sync ---------------- + + def _log_device_info(self) -> None: + text, color = _detect_device_info() + self.device_label.setText(text) + self.device_label.setStyleSheet(f"color: {color};") + self._append_log(text) + + def _sync_tab_indicators(self) -> None: + active = QColor("#89b4fa") + inactive = QColor("#585b70") + bar = self.tabs.tabBar() + bar.setTabTextColor(0, active if self.convert_enabled_cb.isChecked() else inactive) + bar.setTabTextColor(1, active if self.separate_enabled_cb.isChecked() else inactive) + bar.setTabTextColor(2, active if self.transcribe_enabled_cb.isChecked() else inactive) + + def _sync_engine_fields(self) -> None: + is_whisperx = self.engine_cb.currentText() == "whisperx" + for w in (self.lang_cb, self.device_cb, self.compute_type_cb, self.batch_spin): + w.setEnabled(is_whisperx) + self.gigaam_provider_cb.setEnabled(not is_whisperx) + + # ---------------- defaults & config <-> UI ---------------- + def _apply_defaults(self) -> None: - cfg = PipelineConfig() + self._apply_config(PipelineConfig()) + + def _apply_config(self, cfg: PipelineConfig) -> None: + self.input_edit.setText(cfg.input_dir) self.output_edit.setText(str(Path(cfg.output_dir).absolute())) self.recursive_cb.setChecked(cfg.recursive) self.keep_intermediate_cb.setChecked(cfg.keep_intermediate) self.overwrite_cb.setChecked(cfg.overwrite) + i = self.mode_cb.findData(cfg.mode) + if i >= 0: + self.mode_cb.setCurrentIndex(i) + self.convert_enabled_cb.setChecked(cfg.convert.enabled) - idx = self.convert_sr.findData(cfg.convert.sample_rate) - if idx >= 0: - self.convert_sr.setCurrentIndex(idx) - idx = self.convert_ch.findData(cfg.convert.channels) - if idx >= 0: - self.convert_ch.setCurrentIndex(idx) + i = self.convert_sr.findData(cfg.convert.sample_rate) + if i >= 0: + self.convert_sr.setCurrentIndex(i) + i = self.convert_ch.findData(cfg.convert.channels) + if i >= 0: + self.convert_ch.setCurrentIndex(i) self.convert_ffmpeg.setText(cfg.convert.ffmpeg_path) self.separate_enabled_cb.setChecked(cfg.separate.enabled) @@ -230,7 +331,6 @@ class MainWindow(QMainWindow): self.batch_spin.setValue(cfg.transcribe.batch_size) self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider) - # ---------------- config <-> ui ---------------- def _collect_config(self) -> PipelineConfig: cfg = PipelineConfig() cfg.input_dir = self.input_edit.text().strip() @@ -238,6 +338,7 @@ class MainWindow(QMainWindow): cfg.recursive = self.recursive_cb.isChecked() cfg.keep_intermediate = self.keep_intermediate_cb.isChecked() cfg.overwrite = self.overwrite_cb.isChecked() + cfg.mode = self.mode_cb.currentData() cfg.convert = ConvertConfig( enabled=self.convert_enabled_cb.isChecked(), @@ -267,38 +368,8 @@ class MainWindow(QMainWindow): ) return cfg - def _apply_config(self, cfg: PipelineConfig) -> None: - self.input_edit.setText(cfg.input_dir) - self.output_edit.setText(cfg.output_dir) - self.recursive_cb.setChecked(cfg.recursive) - self.keep_intermediate_cb.setChecked(cfg.keep_intermediate) - self.overwrite_cb.setChecked(cfg.overwrite) - - self.convert_enabled_cb.setChecked(cfg.convert.enabled) - i = self.convert_sr.findData(cfg.convert.sample_rate) - if i >= 0: - self.convert_sr.setCurrentIndex(i) - i = self.convert_ch.findData(cfg.convert.channels) - if i >= 0: - self.convert_ch.setCurrentIndex(i) - self.convert_ffmpeg.setText(cfg.convert.ffmpeg_path) - - self.separate_enabled_cb.setChecked(cfg.separate.enabled) - self.separate_model.setText(cfg.separate.model) - self.separate_stem.setText(cfg.separate.isolate_stem) - self.separate_unblend.setText(cfg.separate.unblend_path) - self.separate_kernels_cb.setChecked(cfg.separate.custom_kernels) - - self.transcribe_enabled_cb.setChecked(cfg.transcribe.enabled) - self.engine_cb.setCurrentText(cfg.transcribe.engine) - self.model_edit.setText(cfg.transcribe.model) - self.lang_cb.setCurrentText(cfg.transcribe.language or "auto") - self.device_cb.setCurrentText(cfg.transcribe.device) - self.compute_type_cb.setCurrentText(cfg.transcribe.compute_type or "auto") - self.batch_spin.setValue(cfg.transcribe.batch_size) - self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider) - # ---------------- handlers ---------------- + def _pick_input_dir(self) -> None: d = QFileDialog.getExistingDirectory(self, "Выберите входную папку") if d: @@ -315,9 +386,7 @@ class MainWindow(QMainWindow): QDesktopServices.openUrl(QUrl.fromLocalFile(str(p))) def _save_preset(self) -> None: - path, _ = QFileDialog.getSaveFileName( - self, "Сохранить пресет", "preset.json", "JSON (*.json)" - ) + path, _ = QFileDialog.getSaveFileName(self, "Сохранить пресет", "preset.json", "JSON (*.json)") if not path: return try: @@ -326,9 +395,7 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Ошибка", str(exc)) def _load_preset(self) -> None: - path, _ = QFileDialog.getOpenFileName( - self, "Загрузить пресет", "", "JSON (*.json)" - ) + path, _ = QFileDialog.getOpenFileName(self, "Загрузить пресет", "", "JSON (*.json)") if not path: return try: @@ -344,23 +411,23 @@ class MainWindow(QMainWindow): if not (cfg.convert.enabled or cfg.separate.enabled or cfg.transcribe.enabled): QMessageBox.warning(self, "Нет этапов", "Включите хотя бы один этап.") return - - # sanity: ffmpeg / unblend availability if cfg.convert.enabled and not shutil.which(cfg.convert.ffmpeg_path): - QMessageBox.warning( - self, "ffmpeg не найден", - f"Команда '{cfg.convert.ffmpeg_path}' не найдена в PATH.", - ) + QMessageBox.warning(self, "ffmpeg не найден", + f"Команда '{cfg.convert.ffmpeg_path}' не найдена в PATH.") return if cfg.separate.enabled and not shutil.which(cfg.separate.unblend_path): - QMessageBox.warning( - self, "unblend не найден", - f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.", - ) + QMessageBox.warning(self, "unblend не найден", + f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.") return self.log_view.clear() + self._log_device_info() + if cfg.transcribe.enabled and cfg.transcribe.language is None: + self._append_log("⚠ Язык = auto: WhisperX определит язык на каждом файле. " + "Если язык известен — задайте его явно, будет быстрее.") + self.progress.setValue(0) + self.progress.setFormat("Запуск…") self.start_btn.setEnabled(False) self.stop_btn.setEnabled(True) @@ -381,19 +448,23 @@ class MainWindow(QMainWindow): sb = self.log_view.verticalScrollBar() sb.setValue(sb.maximum()) - def _on_progress(self, done: int, total: int) -> None: + def _on_progress(self, done: int, total: int, stage: str) -> None: self.progress.setMaximum(total) self.progress.setValue(done) + label = STAGE_LABELS.get(stage, stage) + self.progress.setFormat(f"{label} — %v / %m") def _on_finished(self, results: list) -> None: self.start_btn.setEnabled(True) self.stop_btn.setEnabled(False) ok = sum(1 for r in results if r.error is None) fail = len(results) - ok + self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}") self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.") def _on_failed(self, message: str) -> None: self.start_btn.setEnabled(True) self.stop_btn.setEnabled(False) + self.progress.setFormat("Ошибка") self._append_log(f"\n✗ Ошибка пайплайна: {message}") QMessageBox.critical(self, "Ошибка", message) diff --git a/gui/theme.py b/gui/theme.py new file mode 100644 index 0000000..974c77b --- /dev/null +++ b/gui/theme.py @@ -0,0 +1,215 @@ +"""Dark theme for the whole application (Catppuccin Macchiato-inspired).""" +from __future__ import annotations + +DARK_STYLESHEET = """ +QWidget { + background-color: #1e1e2e; + color: #cdd6f4; + font-family: 'Inter', 'Segoe UI', 'Noto Sans', sans-serif; + font-size: 10pt; +} +QMainWindow { background-color: #181825; } + +QLabel { background: transparent; } +QLabel#appTitle { + font-size: 18pt; + font-weight: 700; + color: #89b4fa; + padding: 4px 0 10px 0; +} +QLabel#appSubtitle { + color: #7f849c; + font-size: 9pt; + padding-bottom: 12px; +} +QLabel#deviceInfo { + color: #a6adc8; + font-size: 9pt; + padding: 4px 8px; + background-color: #181825; + border: 1px solid #313244; + border-radius: 6px; +} + +QGroupBox { + border: 1px solid #313244; + border-radius: 10px; + margin-top: 14px; + padding: 16px 12px 12px 12px; + background-color: #1e1e2e; + font-weight: 600; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 12px; + padding: 0 6px; + color: #89b4fa; +} + +QLineEdit, QSpinBox, QComboBox, QPlainTextEdit { + background-color: #181825; + border: 1px solid #313244; + border-radius: 6px; + padding: 6px 10px; + color: #cdd6f4; + selection-background-color: #89b4fa; + selection-color: #1e1e2e; + min-height: 22px; +} +QLineEdit:focus, QSpinBox:focus, QComboBox:focus, QPlainTextEdit:focus { + border: 1px solid #89b4fa; +} +QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled { + color: #585b70; + background-color: #11111b; +} + +QComboBox::drop-down { border: none; width: 22px; } +QComboBox::down-arrow { + width: 0; height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #89b4fa; + margin-right: 8px; +} +QComboBox QAbstractItemView { + background-color: #181825; + border: 1px solid #313244; + selection-background-color: #313244; + selection-color: #89b4fa; + outline: none; +} + +/* ─── buttons: higher contrast ─────────────────────────────────── */ +QPushButton { + background-color: #3d4159; + border: 1px solid #5c6080; + border-radius: 6px; + padding: 8px 16px; + color: #ffffff; + font-weight: 600; + min-height: 22px; +} +QPushButton:hover { background-color: #4a4e6b; border-color: #7a7fa0; } +QPushButton:pressed { background-color: #585b70; } +QPushButton:focus { border: 1px solid #a6c8ff; outline: none; } +QPushButton:disabled { + background-color: #2a2d42; + color: #6c7086; + border-color: #3d4055; +} + +QPushButton#primary { + background-color: #89b4fa; + color: #11111b; + font-weight: 700; + border: none; + padding: 8px 20px; +} +QPushButton#primary:hover { background-color: #a6c8ff; } +QPushButton#primary:pressed { background-color: #74a0e0; } +QPushButton#primary:disabled { + background-color: #45475a; + color: #6c7086; +} + +QPushButton#danger { + background-color: #f38ba8; + color: #11111b; + font-weight: 700; + border: none; + padding: 8px 20px; +} +QPushButton#danger:hover { background-color: #f5a4b8; } +QPushButton#danger:pressed { background-color: #e07898; } +QPushButton#danger:disabled { + background-color: #45475a; + color: #6c7086; +} + +QTabWidget::pane { + border: 1px solid #313244; + border-radius: 10px; + background-color: #1e1e2e; + top: -1px; +} +QTabBar::tab { + background-color: #181825; + color: #7f849c; + padding: 8px 20px; + border: 1px solid #313244; + border-bottom: none; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + margin-right: 3px; + font-weight: 500; +} +QTabBar::tab:selected { + background-color: #1e1e2e; + color: #89b4fa; + border-color: #313244; +} +QTabBar::tab:hover:!selected { background-color: #252535; } + +QCheckBox { + spacing: 8px; + background: transparent; +} +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 1px solid #5c6080; + border-radius: 4px; + background-color: #181825; +} +QCheckBox::indicator:hover { border-color: #89b4fa; } +QCheckBox::indicator:checked { + background-color: #89b4fa; + border-color: #89b4fa; +} + +QProgressBar { + background-color: #181825; + border: 1px solid #313244; + border-radius: 8px; + text-align: center; + color: #cdd6f4; + min-height: 24px; + max-height: 24px; + font-weight: 500; +} +QProgressBar::chunk { + background-color: #89b4fa; + border-radius: 7px; + margin: 1px; +} + +QScrollBar:vertical { + background: #181825; + width: 12px; + border-radius: 6px; + margin: 0; +} +QScrollBar::handle:vertical { + background: #45475a; + border-radius: 6px; + min-height: 24px; + margin: 2px; +} +QScrollBar::handle:vertical:hover { background: #585b70; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } + +QSplitter::handle { background-color: #313244; } + +QPlainTextEdit#log { + background-color: #11111b; + border: 1px solid #313244; + border-radius: 10px; + padding: 10px; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 9.5pt; + color: #cdd6f4; +} +""" diff --git a/gui/worker.py b/gui/worker.py index d9b3ad7..28aa423 100644 --- a/gui/worker.py +++ b/gui/worker.py @@ -11,8 +11,8 @@ from core.pipeline import run_pipeline class PipelineWorker(QThread): log = Signal(str) - progress = Signal(int, int) # done, total - finished_ok = Signal(list) # list[FileResult] + progress = Signal(int, int, str) # done, total, stage + finished_ok = Signal(list) failed = Signal(str) def __init__(self, config: PipelineConfig, parent=None): @@ -23,12 +23,12 @@ class PipelineWorker(QThread): def stop(self) -> None: self._stop_event.set() - def run(self) -> None: # noqa: D401 — Qt override + def run(self) -> None: try: results = run_pipeline( self._config, log=self.log.emit, - on_progress=lambda d, t: self.progress.emit(d, t), + on_progress=lambda d, t, s: self.progress.emit(d, t, s), stop_event=self._stop_event, ) self.finished_ok.emit(results) diff --git a/main.py b/main.py index 84e3570..7e99abe 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,16 @@ #!/usr/bin/env python3 -"""Song Transcriber v3 — Qt GUI entry point. - -Run: python main.py -""" +"""Song Transcriber — Qt GUI entry point.""" from __future__ import annotations import ctypes import os import sys +# Must be set BEFORE PySide6 is imported. +os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false") + def preload_cudnn() -> None: - """Preload cuDNN shared libraries so torch / onnxruntime can find them.""" try: import nvidia.cudnn except ImportError: @@ -27,13 +26,30 @@ def preload_cudnn() -> None: pass +def enable_tf32() -> None: + """Enable TF32 for faster matmul on Ampere+ GPUs (RTX 30/40 series).""" + try: + import torch + if torch.cuda.is_available(): + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + except ImportError: + pass + + def main() -> int: preload_cudnn() + enable_tf32() + from PySide6.QtWidgets import QApplication + from gui.theme import DARK_STYLESHEET from gui.main_window import MainWindow app = QApplication(sys.argv) app.setApplicationName("Audio Transcriber") + app.setStyle("Fusion") + app.setStyleSheet(DARK_STYLESHEET) + window = MainWindow() window.show() return app.exec()