staged/sequential pipeline modes + dark themed Qt GUI

This commit is contained in:
jadssss
2026-10-23 23:57:43 +03:00
parent fcb9429c49
commit e1a42ec1df
7 changed files with 721 additions and 280 deletions

View File

@@ -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)

View File

@@ -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
]

View File

@@ -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}")

View File

@@ -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.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.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.clicked.connect(self._save_preset)
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)

215
gui/theme.py Normal file
View File

@@ -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;
}
"""

View File

@@ -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)

26
main.py
View File

@@ -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()