migrate to mikroservices architecture, and add more features
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
transcribed/
|
||||||
|
output/
|
||||||
|
*.wav
|
||||||
|
*.flac
|
||||||
|
*.mp3
|
||||||
|
!tests/fixtures/*
|
||||||
|
|
||||||
|
preset.json
|
||||||
|
*.log
|
||||||
16
README.md
16
README.md
@@ -1,3 +1,15 @@
|
|||||||
# audio_transcriber_combine
|
# Audio Transcriber Combine
|
||||||
|
|
||||||
Комбайн по вытаскиванию текста из аудио
|
Локальное десктоп-приложение для извлечения текста из аудио.
|
||||||
|
|
||||||
|
## Этапы
|
||||||
|
|
||||||
|
1. **convert** — любой аудиоформат → WAV через `ffmpeg` (настраиваемый sample rate / каналы).
|
||||||
|
2. **separate** — отделение вокала через `unblend` (Mel-Band RoFormer).
|
||||||
|
3. **transcribe** — ASR через **WhisperX** или **GigaAM** (onnx-asr).
|
||||||
|
|
||||||
|
Каждый этап можно включать/выключать отдельно. Промежуточные файлы
|
||||||
|
можно сохранять (по умолчанию — да) или удалять, оставляя только финальный
|
||||||
|
результат последнего включённого этапа.
|
||||||
|
|
||||||
|
## Раскладка результатов
|
||||||
|
|||||||
85
core/config.py
Normal file
85
core/config.py
Normal file
@@ -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"))
|
||||||
28
core/convert.py
Normal file
28
core/convert.py
Normal file
@@ -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]}"
|
||||||
|
)
|
||||||
1
core/init.py
Normal file
1
core/init.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core pipeline: convert, separate, transcribe."""
|
||||||
208
core/pipeline.py
Normal file
208
core/pipeline.py
Normal file
@@ -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
|
||||||
40
core/separate.py
Normal file
40
core/separate.py
Normal file
@@ -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]
|
||||||
95
core/transcribe.py
Normal file
95
core/transcribe.py
Normal file
@@ -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()
|
||||||
1
gui/init.py
Normal file
1
gui/init.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Qt GUI package."""
|
||||||
399
gui/main_window.py
Normal file
399
gui/main_window.py
Normal 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
36
gui/worker.py
Normal 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}")
|
||||||
43
main.py
Normal file
43
main.py
Normal file
@@ -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())
|
||||||
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user