diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2c680e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env + +transcribed/ +output/ +*.wav +*.flac +*.mp3 +!tests/fixtures/* + +preset.json +*.log diff --git a/README.md b/README.md index 74e70bc..25de24b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ -# audio_transcriber_combine +# Audio Transcriber Combine -Комбайн по вытаскиванию текста из аудио \ No newline at end of file +Локальное десктоп-приложение для извлечения текста из аудио. + +## Этапы + +1. **convert** — любой аудиоформат → WAV через `ffmpeg` (настраиваемый sample rate / каналы). +2. **separate** — отделение вокала через `unblend` (Mel-Band RoFormer). +3. **transcribe** — ASR через **WhisperX** или **GigaAM** (onnx-asr). + +Каждый этап можно включать/выключать отдельно. Промежуточные файлы +можно сохранять (по умолчанию — да) или удалять, оставляя только финальный +результат последнего включённого этапа. + +## Раскладка результатов diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..6e5f7af --- /dev/null +++ b/core/config.py @@ -0,0 +1,85 @@ +"""Pipeline configuration dataclasses with JSON save/load.""" +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field, fields +from pathlib import Path +from typing import Optional + + +@dataclass +class ConvertConfig: + enabled: bool = True + sample_rate: int = 44100 + channels: int = 2 + ffmpeg_path: str = "ffmpeg" + + +@dataclass +class SeparateConfig: + enabled: bool = True + model: str = "bs_roformer_anvuew" + isolate_stem: str = "vocals" + unblend_path: str = "unblend" + custom_kernels: bool = False + + +@dataclass +class TranscribeConfig: + enabled: bool = True + engine: str = "whisperx" # "whisperx" | "gigaam" + 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) + batch_size: int = 8 + gigaam_provider: str = "auto" # "auto" | "cuda" | "cpu" + + +@dataclass +class PipelineConfig: + input_dir: str = "" + output_dir: str = "transcribed" + recursive: bool = False + keep_intermediate: bool = True + overwrite: bool = True + + 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) + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=2, ensure_ascii=False) + + @classmethod + def from_dict(cls, data: dict) -> "PipelineConfig": + cfg = cls() + for top in fields(cls): + name = top.name + if name not in data: + continue + value = data[name] + current = getattr(cfg, name) + if hasattr(current, "__dataclass_fields__"): + sub_fields = {f.name for f in fields(current)} + for k, v in value.items(): + if k in sub_fields: + setattr(current, k, v) + else: + setattr(cfg, name, value) + return cfg + + @classmethod + def from_json(cls, text: str) -> "PipelineConfig": + return cls.from_dict(json.loads(text)) + + def save(self, path: Path) -> None: + Path(path).write_text(self.to_json(), encoding="utf-8") + + @classmethod + def load(cls, path: Path) -> "PipelineConfig": + return cls.from_json(Path(path).read_text(encoding="utf-8")) diff --git a/core/convert.py b/core/convert.py new file mode 100644 index 0000000..234599b --- /dev/null +++ b/core/convert.py @@ -0,0 +1,28 @@ +"""Convert any audio file to a WAV with chosen sample rate and channels via ffmpeg.""" +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def convert_to_wav( + input_path: Path, + output_wav: Path, + sample_rate: int = 44100, + channels: int = 2, + ffmpeg_path: str = "ffmpeg", +) -> None: + """Run ffmpeg to produce a WAV file. Raises RuntimeError on ffmpeg failure.""" + output_wav.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + ffmpeg_path, "-y", "-hide_banner", "-loglevel", "error", + "-i", str(input_path), + "-ar", str(sample_rate), + "-ac", str(channels), + str(output_wav), + ] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + f"ffmpeg failed for {input_path.name}:\n{result.stderr.strip()[:800]}" + ) diff --git a/core/init.py b/core/init.py new file mode 100644 index 0000000..7438566 --- /dev/null +++ b/core/init.py @@ -0,0 +1 @@ +"""Core pipeline: convert, separate, transcribe.""" diff --git a/core/pipeline.py b/core/pipeline.py new file mode 100644 index 0000000..1fb7a03 --- /dev/null +++ b/core/pipeline.py @@ -0,0 +1,208 @@ +"""Orchestrate convert -> separate -> transcribe over a directory of audio files.""" +from __future__ import annotations + +import shutil +import tempfile +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from .config import PipelineConfig +from .convert import convert_to_wav +from .separate import separate_vocals +from .transcribe import transcribe + + +AUDIO_EXTENSIONS = { + ".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus", ".wma", +} + +LogFn = Callable[[str], None] +StageFn = Callable[[str, str], None] +ProgressFn = Callable[[int, int], None] + + +@dataclass +class FileResult: + input_path: Path + text: Optional[str] + output_dir: Optional[Path] + error: Optional[str] = None + + +class Cancelled(Exception): + """Raised internally when the stop event is set.""" + + +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() + + +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: + return {"separate"} + if config.convert.enabled: + return {"convert"} + 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 _cleanup_keeping(track_out: Path, finals: set[str]) -> None: + if not track_out.exists(): + return + keep = {_ARTIFACT_NAMES[s] for s in finals if s in _ARTIFACT_NAMES} + for p in track_out.iterdir(): + if p.name in keep: + continue + try: + if p.is_file(): + p.unlink() + elif p.is_dir(): + shutil.rmtree(p, ignore_errors=True) + except OSError: + pass diff --git a/core/separate.py b/core/separate.py new file mode 100644 index 0000000..c76e66a --- /dev/null +++ b/core/separate.py @@ -0,0 +1,40 @@ +"""Vocal separation via unblend (Mel-Band RoFormer).""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +def separate_vocals( + input_wav: Path, + work_dir: Path, + model: str = "bs_roformer_anvuew", + isolate_stem: str = "vocals", + unblend_path: str = "unblend", + custom_kernels: bool = False, +) -> Path: + """Separate the isolated stem from input_wav. Returns path to the stem WAV.""" + work_dir.mkdir(parents=True, exist_ok=True) + output_pattern = work_dir / "{track}" / "{stem}.wav" + cmd = [ + unblend_path, "separate", str(input_wav), + "-m", model, + "-o", str(output_pattern), + "--isolate-stem", isolate_stem, + ] + env = os.environ.copy() + env["UNBLEND_CUSTOM_KERNELS"] = "1" if custom_kernels else "0" + + result = subprocess.run(cmd, capture_output=True, text=True, env=env) + if result.returncode != 0: + raise RuntimeError( + f"unblend failed for {input_wav.name}:\n{result.stderr.strip()[:800]}" + ) + + candidates = sorted(work_dir.rglob(f"{isolate_stem}.wav")) + if not candidates: + raise RuntimeError( + f"unblend finished but stem '{isolate_stem}.wav' was not found in {work_dir}" + ) + return candidates[0] diff --git a/core/transcribe.py b/core/transcribe.py new file mode 100644 index 0000000..d669789 --- /dev/null +++ b/core/transcribe.py @@ -0,0 +1,95 @@ +"""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. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + + +def _resolve_device(device: str) -> str: + if device in ("cuda", "cpu"): + return device + try: + import torch + return "cuda" if torch.cuda.is_available() else "cpu" + except Exception: + return "cpu" + + +class _WhisperXModels: + _cache: dict = {} + + @classmethod + def get(cls, model_name: str, device: str, compute_type: Optional[str]): + if compute_type is None: + compute_type = "int8_float16" if device == "cuda" else "int8" + 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 + ) + return cls._cache[key] + + +class _GigaAMModels: + _cache: dict = {} + + @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"], + "auto": ["CUDAExecutionProvider", "CPUExecutionProvider"], + }.get(provider, ["CPUExecutionProvider"]) + 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)) + return cls._cache[key] + + +def transcribe( + audio_path: Path, + engine: str = "whisperx", + model: str = "large-v3", + language: Optional[str] = "ru", + device: str = "auto", + compute_type: Optional[str] = None, + batch_size: int = 8, + gigaam_provider: str = "auto", +) -> str: + device = _resolve_device(device) + + if engine == "whisperx": + 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}") + + +def _transcribe_whisperx( + audio_path: Path, + model_name: str, + language: Optional[str], + device: str, + compute_type: Optional[str], + batch_size: int, +) -> str: + import whisperx + model = _WhisperXModels.get(model_name, device, compute_type) + audio = whisperx.load_audio(str(audio_path)) + result = model.transcribe(audio, batch_size=batch_size, language=language) + return " ".join(seg["text"].strip() for seg in result["segments"]).strip() + + +def _transcribe_gigaam(audio_path: Path, provider: str) -> str: + model = _GigaAMModels.get(provider) + return model.recognize(str(audio_path), channel="mean").strip() diff --git a/gui/init.py b/gui/init.py new file mode 100644 index 0000000..19ed991 --- /dev/null +++ b/gui/init.py @@ -0,0 +1 @@ +"""Qt GUI package.""" diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..3ac27a6 --- /dev/null +++ b/gui/main_window.py @@ -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) diff --git a/gui/worker.py b/gui/worker.py new file mode 100644 index 0000000..d9b3ad7 --- /dev/null +++ b/gui/worker.py @@ -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}") diff --git a/main.py b/main.py new file mode 100644 index 0000000..84e3570 --- /dev/null +++ b/main.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Song Transcriber v3 — Qt GUI entry point. + +Run: python main.py +""" +from __future__ import annotations + +import ctypes +import os +import sys + + +def preload_cudnn() -> None: + """Preload cuDNN shared libraries so torch / onnxruntime can find them.""" + try: + import nvidia.cudnn + except ImportError: + return + lib_dir = os.path.join(nvidia.cudnn.__path__[0], "lib") + if not os.path.isdir(lib_dir): + return + for fname in sorted(os.listdir(lib_dir)): + if ".so" in fname: + try: + ctypes.CDLL(os.path.join(lib_dir, fname), mode=ctypes.RTLD_GLOBAL) + except OSError: + pass + + +def main() -> int: + preload_cudnn() + from PySide6.QtWidgets import QApplication + from gui.main_window import MainWindow + + app = QApplication(sys.argv) + app.setApplicationName("Audio Transcriber") + window = MainWindow() + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..820d4d4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# GUI +PySide6>=6.6 + +# ASR +whisperx>=3.1 +onnx-asr>=0.4 + +# WhisperX pulls torch; for CUDA you must install the matching torch build, e.g.: +# pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu121 +# For cuDNN preload helper used in main.py: +# pip install nvidia-cudnn-cu12 + +# System dependencies (not pip-installable): +# ffmpeg — must be in PATH +# unblend — vocal separation CLI (Mel-Band RoFormer), must be in PATH