migrate to mikroservices architecture, and add more features

This commit is contained in:
jadssss
2026-10-23 19:44:56 +03:00
parent 29a1f29b72
commit fcb9429c49
13 changed files with 981 additions and 2 deletions

1
gui/init.py Normal file
View File

@@ -0,0 +1 @@
"""Qt GUI package."""

399
gui/main_window.py Normal file
View File

@@ -0,0 +1,399 @@
"""Main application window."""
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.QtWidgets import (
QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QSplitter,
QTabWidget, QVBoxLayout, QWidget,
)
from core.config import ConvertConfig, PipelineConfig, SeparateConfig, TranscribeConfig
from gui.worker import PipelineWorker
LANGUAGES = ["auto", "ru", "en", "de", "fr", "es", "it", "uk", "ja", "zh", "ko"]
SAMPLE_RATES = [16000, 22050, 44100, 48000]
ENGINES = ["whisperx", "gigaam"]
def _detect_torch_cuda() -> bool:
try:
import torch
return bool(torch.cuda.is_available())
except Exception:
return False
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Song Transcriber v3")
self.resize(1080, 760)
self._worker: Optional[PipelineWorker] = None
self._build_ui()
self._apply_defaults()
# ---------------- UI ----------------
def _build_ui(self) -> None:
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
# --- top: paths + options ---
top_box = QGroupBox("Общие настройки")
top_layout = QFormLayout(top_box)
self.input_edit = QLineEdit()
top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir))
self.output_edit = QLineEdit()
top_layout.addRow("Выходная папка:", self._with_browse(self.output_edit, self._pick_output_dir))
opts_row = QHBoxLayout()
self.recursive_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)
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)
# --- run row ---
run_row = QHBoxLayout()
self.start_btn = QPushButton("▶ Запустить")
self.start_btn.clicked.connect(self._on_start)
self.stop_btn = QPushButton("⏹ Стоп")
self.stop_btn.setEnabled(False)
self.stop_btn.clicked.connect(self._on_stop)
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)
# --- progress ---
self.progress = QProgressBar()
self.progress.setFormat("%v / %m")
root.addWidget(self.progress)
# --- log ---
splitter = QSplitter(Qt.Vertical)
self.log_view = QPlainTextEdit()
self.log_view.setReadOnly(True)
self.log_view.setPlaceholderText("Здесь будет лог выполнения…")
splitter.addWidget(self.log_view)
splitter.setStretchFactor(0, 1)
root.addWidget(splitter, 1)
def _with_browse(self, line_edit: QLineEdit, picker) -> QWidget:
wrapper = QWidget()
h = QHBoxLayout(wrapper)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(line_edit)
btn = QPushButton("…")
btn.setFixedWidth(32)
btn.clicked.connect(picker)
h.addWidget(btn)
return wrapper
def _build_convert_tab(self) -> QWidget:
w = QWidget()
form = QFormLayout(w)
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)
form.addRow("Sample rate:", self.convert_sr)
self.convert_ch = QComboBox()
self.convert_ch.addItem("Моно", 1)
self.convert_ch.addItem("Стерео", 2)
form.addRow("Каналы:", self.convert_ch)
self.convert_ffmpeg = QLineEdit()
form.addRow("ffmpeg:", self.convert_ffmpeg)
return w
def _build_separate_tab(self) -> QWidget:
w = QWidget()
form = QFormLayout(w)
self.separate_enabled_cb = QCheckBox("Включён")
form.addRow("", self.separate_enabled_cb)
self.separate_model = QLineEdit()
form.addRow("Модель:", self.separate_model)
self.separate_stem = QLineEdit()
form.addRow("Isolate stem:", self.separate_stem)
self.separate_unblend = QLineEdit()
form.addRow("unblend CLI:", self.separate_unblend)
self.separate_kernels_cb = QCheckBox("Использовать кастомные ядра")
form.addRow("", self.separate_kernels_cb)
return w
def _build_transcribe_tab(self) -> QWidget:
w = QWidget()
form = QFormLayout(w)
self.transcribe_enabled_cb = QCheckBox("Включён")
form.addRow("", self.transcribe_enabled_cb)
self.engine_cb = QComboBox()
self.engine_cb.addItems(ENGINES)
form.addRow("Движок:", self.engine_cb)
self.model_edit = QLineEdit()
form.addRow("Модель:", self.model_edit)
self.lang_cb = QComboBox()
self.lang_cb.setEditable(True)
self.lang_cb.addItems(LANGUAGES)
form.addRow("Язык:", self.lang_cb)
self.device_cb = QComboBox()
self.device_cb.addItems(["auto", "cuda", "cpu"])
form.addRow("Device:", self.device_cb)
self.compute_type_cb = QComboBox()
self.compute_type_cb.addItems(["auto", "int8_float16", "int8", "float16", "float32"])
form.addRow("Compute type:", self.compute_type_cb)
self.batch_spin = QSpinBox()
self.batch_spin.setRange(1, 64)
form.addRow("Batch size:", self.batch_spin)
self.gigaam_provider_cb = QComboBox()
self.gigaam_provider_cb.addItems(["auto", "cuda", "cpu"])
form.addRow("GigaAM provider:", self.gigaam_provider_cb)
return w
# ---------------- defaults ----------------
def _apply_defaults(self) -> None:
cfg = PipelineConfig()
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)
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)
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)
# ---------------- config <-> ui ----------------
def _collect_config(self) -> PipelineConfig:
cfg = PipelineConfig()
cfg.input_dir = self.input_edit.text().strip()
cfg.output_dir = self.output_edit.text().strip() or "transcribed"
cfg.recursive = self.recursive_cb.isChecked()
cfg.keep_intermediate = self.keep_intermediate_cb.isChecked()
cfg.overwrite = self.overwrite_cb.isChecked()
cfg.convert = ConvertConfig(
enabled=self.convert_enabled_cb.isChecked(),
sample_rate=int(self.convert_sr.currentData()),
channels=int(self.convert_ch.currentData()),
ffmpeg_path=self.convert_ffmpeg.text().strip() or "ffmpeg",
)
cfg.separate = SeparateConfig(
enabled=self.separate_enabled_cb.isChecked(),
model=self.separate_model.text().strip(),
isolate_stem=self.separate_stem.text().strip() or "vocals",
unblend_path=self.separate_unblend.text().strip() or "unblend",
custom_kernels=self.separate_kernels_cb.isChecked(),
)
lang = self.lang_cb.currentText().strip()
compute = self.compute_type_cb.currentText().strip()
cfg.transcribe = TranscribeConfig(
enabled=self.transcribe_enabled_cb.isChecked(),
engine=self.engine_cb.currentText(),
model=self.model_edit.text().strip() or "large-v3",
language=None if lang in ("", "auto") else lang,
device=self.device_cb.currentText(),
compute_type=None if compute == "auto" else compute,
batch_size=self.batch_spin.value(),
gigaam_provider=self.gigaam_provider_cb.currentText(),
)
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:
self.input_edit.setText(d)
def _pick_output_dir(self) -> None:
d = QFileDialog.getExistingDirectory(self, "Выберите выходную папку")
if d:
self.output_edit.setText(d)
def _open_output_dir(self) -> None:
p = Path(self.output_edit.text().strip() or "transcribed").absolute()
p.mkdir(parents=True, exist_ok=True)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(p)))
def _save_preset(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, "Сохранить пресет", "preset.json", "JSON (*.json)"
)
if not path:
return
try:
self._collect_config().save(Path(path))
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Ошибка", str(exc))
def _load_preset(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self, "Загрузить пресет", "", "JSON (*.json)"
)
if not path:
return
try:
self._apply_config(PipelineConfig.load(Path(path)))
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Ошибка", str(exc))
def _on_start(self) -> None:
cfg = self._collect_config()
if not cfg.input_dir or not Path(cfg.input_dir).is_dir():
QMessageBox.warning(self, "Нет входной папки", "Выберите существующую входную папку.")
return
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.",
)
return
if cfg.separate.enabled and not shutil.which(cfg.separate.unblend_path):
QMessageBox.warning(
self, "unblend не найден",
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.",
)
return
self.log_view.clear()
self.progress.setValue(0)
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self._worker = PipelineWorker(cfg)
self._worker.log.connect(self._append_log)
self._worker.progress.connect(self._on_progress)
self._worker.finished_ok.connect(self._on_finished)
self._worker.failed.connect(self._on_failed)
self._worker.start()
def _on_stop(self) -> None:
if self._worker is not None:
self._worker.stop()
self._append_log("⏹ Запрошена остановка…")
def _append_log(self, text: str) -> None:
self.log_view.appendPlainText(text)
sb = self.log_view.verticalScrollBar()
sb.setValue(sb.maximum())
def _on_progress(self, done: int, total: int) -> None:
self.progress.setMaximum(total)
self.progress.setValue(done)
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._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
def _on_failed(self, message: str) -> None:
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self._append_log(f"\n✗ Ошибка пайплайна: {message}")
QMessageBox.critical(self, "Ошибка", message)

36
gui/worker.py Normal file
View File

@@ -0,0 +1,36 @@
"""QThread wrapper around core.pipeline.run_pipeline."""
from __future__ import annotations
import threading
from PySide6.QtCore import QThread, Signal
from core.config import PipelineConfig
from core.pipeline import run_pipeline
class PipelineWorker(QThread):
log = Signal(str)
progress = Signal(int, int) # done, total
finished_ok = Signal(list) # list[FileResult]
failed = Signal(str)
def __init__(self, config: PipelineConfig, parent=None):
super().__init__(parent)
self._config = config
self._stop_event = threading.Event()
def stop(self) -> None:
self._stop_event.set()
def run(self) -> None: # noqa: D401 — Qt override
try:
results = run_pipeline(
self._config,
log=self.log.emit,
on_progress=lambda d, t: self.progress.emit(d, t),
stop_event=self._stop_event,
)
self.finished_ok.emit(results)
except Exception as exc: # noqa: BLE001
self.failed.emit(f"{type(exc).__name__}: {exc}")