Files
AudioTranscriberCombine/gui/main_window.py

400 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)