staged/sequential pipeline modes + dark themed Qt GUI
This commit is contained in:
@@ -7,6 +7,9 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
MODES = ("sequential", "staged")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConvertConfig:
|
class ConvertConfig:
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
@@ -31,7 +34,7 @@ class TranscribeConfig:
|
|||||||
model: str = "large-v3"
|
model: str = "large-v3"
|
||||||
language: Optional[str] = "ru" # None = autodetect
|
language: Optional[str] = "ru" # None = autodetect
|
||||||
device: str = "auto" # "cuda" | "cpu" | "auto"
|
device: str = "auto" # "cuda" | "cpu" | "auto"
|
||||||
compute_type: Optional[str] = None # None = auto (int8_float16 / int8)
|
compute_type: Optional[str] = None # None = auto
|
||||||
batch_size: int = 8
|
batch_size: int = 8
|
||||||
gigaam_provider: str = "auto" # "auto" | "cuda" | "cpu"
|
gigaam_provider: str = "auto" # "auto" | "cuda" | "cpu"
|
||||||
|
|
||||||
@@ -43,12 +46,12 @@ class PipelineConfig:
|
|||||||
recursive: bool = False
|
recursive: bool = False
|
||||||
keep_intermediate: bool = True
|
keep_intermediate: bool = True
|
||||||
overwrite: bool = True
|
overwrite: bool = True
|
||||||
|
mode: str = "sequential" # "sequential" | "staged"
|
||||||
|
|
||||||
convert: ConvertConfig = field(default_factory=ConvertConfig)
|
convert: ConvertConfig = field(default_factory=ConvertConfig)
|
||||||
separate: SeparateConfig = field(default_factory=SeparateConfig)
|
separate: SeparateConfig = field(default_factory=SeparateConfig)
|
||||||
transcribe: TranscribeConfig = field(default_factory=TranscribeConfig)
|
transcribe: TranscribeConfig = field(default_factory=TranscribeConfig)
|
||||||
|
|
||||||
# --- serialization ---
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
|
|
||||||
@@ -65,9 +68,9 @@ class PipelineConfig:
|
|||||||
value = data[name]
|
value = data[name]
|
||||||
current = getattr(cfg, name)
|
current = getattr(cfg, name)
|
||||||
if hasattr(current, "__dataclass_fields__"):
|
if hasattr(current, "__dataclass_fields__"):
|
||||||
sub_fields = {f.name for f in fields(current)}
|
sub = {f.name for f in fields(current)}
|
||||||
for k, v in value.items():
|
for k, v in value.items():
|
||||||
if k in sub_fields:
|
if k in sub:
|
||||||
setattr(current, k, v)
|
setattr(current, k, v)
|
||||||
else:
|
else:
|
||||||
setattr(cfg, name, value)
|
setattr(cfg, name, value)
|
||||||
|
|||||||
411
core/pipeline.py
411
core/pipeline.py
@@ -1,4 +1,9 @@
|
|||||||
"""Orchestrate convert -> separate -> transcribe over a directory of audio files."""
|
"""Orchestrate convert -> separate -> transcribe.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
- sequential: for each file, run all enabled stages before moving to the next.
|
||||||
|
- staged: run stage 1 over all files, then stage 2 over all files, then stage 3.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
@@ -19,8 +24,7 @@ AUDIO_EXTENSIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LogFn = Callable[[str], None]
|
LogFn = Callable[[str], None]
|
||||||
StageFn = Callable[[str, str], None]
|
ProgressFn = Callable[[int, int, str], None] # done, total, stage
|
||||||
ProgressFn = Callable[[int, int], None]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -32,24 +36,24 @@ class FileResult:
|
|||||||
|
|
||||||
|
|
||||||
class Cancelled(Exception):
|
class Cancelled(Exception):
|
||||||
"""Raised internally when the stop event is set."""
|
"""Raised when the stop event is set."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
_ARTIFACT_NAMES = {
|
||||||
|
"convert": "convert.wav",
|
||||||
|
"separate": "vocals.wav",
|
||||||
|
"transcribe": "text.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def collect_audio_files(input_dir: Path, recursive: bool) -> list[Path]:
|
def collect_audio_files(input_dir: Path, recursive: bool) -> list[Path]:
|
||||||
it = input_dir.rglob("*") if recursive else input_dir.iterdir()
|
it = input_dir.rglob("*") if recursive else input_dir.iterdir()
|
||||||
return sorted(
|
return sorted(p for p in it if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS)
|
||||||
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]:
|
def _final_stages(config: PipelineConfig) -> set[str]:
|
||||||
"""Which stage produces the final artifact that must survive cleanup."""
|
|
||||||
if config.transcribe.enabled:
|
if config.transcribe.enabled:
|
||||||
return {"transcribe"}
|
return {"transcribe"}
|
||||||
if config.separate.enabled:
|
if config.separate.enabled:
|
||||||
@@ -59,137 +63,9 @@ def _final_stages(config: PipelineConfig) -> set[str]:
|
|||||||
return set()
|
return set()
|
||||||
|
|
||||||
|
|
||||||
_ARTIFACT_NAMES = {
|
def _check_cancel(stop_event: Optional[threading.Event]) -> None:
|
||||||
"convert": "convert.wav",
|
if stop_event is not None and stop_event.is_set():
|
||||||
"separate": "vocals.wav",
|
raise Cancelled()
|
||||||
"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:
|
def _cleanup_keeping(track_out: Path, finals: set[str]) -> None:
|
||||||
@@ -206,3 +82,248 @@ def _cleanup_keeping(track_out: Path, finals: set[str]) -> None:
|
|||||||
shutil.rmtree(p, ignore_errors=True)
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- entry
|
||||||
|
|
||||||
|
def run_pipeline(
|
||||||
|
config: PipelineConfig,
|
||||||
|
log: LogFn = print,
|
||||||
|
on_progress: Optional[ProgressFn] = None,
|
||||||
|
stop_event: Optional[threading.Event] = None,
|
||||||
|
) -> list[FileResult]:
|
||||||
|
input_dir = Path(config.input_dir)
|
||||||
|
if not input_dir.is_dir():
|
||||||
|
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||||
|
|
||||||
|
output_dir = Path(config.output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
files = collect_audio_files(input_dir, config.recursive)
|
||||||
|
if not files:
|
||||||
|
log("Аудиофайлы не найдены.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
mode = (config.mode or "sequential").lower()
|
||||||
|
log(f"Найдено треков: {len(files)} | режим: {mode}")
|
||||||
|
|
||||||
|
if mode == "staged":
|
||||||
|
return _run_staged(config, files, log, on_progress, stop_event)
|
||||||
|
return _run_sequential(config, files, log, on_progress, stop_event)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sequential
|
||||||
|
|
||||||
|
def _run_sequential(
|
||||||
|
config: PipelineConfig,
|
||||||
|
files: list[Path],
|
||||||
|
log: LogFn,
|
||||||
|
on_progress: Optional[ProgressFn],
|
||||||
|
stop_event: Optional[threading.Event],
|
||||||
|
) -> list[FileResult]:
|
||||||
|
total = len(files)
|
||||||
|
finals = _final_stages(config)
|
||||||
|
results: list[FileResult] = []
|
||||||
|
|
||||||
|
for idx, f in enumerate(files, 1):
|
||||||
|
if stop_event is not None and stop_event.is_set():
|
||||||
|
log("⏹ Отменено")
|
||||||
|
break
|
||||||
|
if on_progress:
|
||||||
|
on_progress(idx - 1, total, "file")
|
||||||
|
log(f"\n▶ [{idx}/{total}] {f.name}")
|
||||||
|
|
||||||
|
track_out = Path(config.output_dir) / f.stem
|
||||||
|
track_out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
audio_path: Path = f
|
||||||
|
text: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if config.convert.enabled:
|
||||||
|
_check_cancel(stop_event)
|
||||||
|
log(" [1] Конвертация в WAV...")
|
||||||
|
target = track_out / "convert.wav"
|
||||||
|
if config.overwrite or not target.exists():
|
||||||
|
convert_to_wav(
|
||||||
|
f, target,
|
||||||
|
sample_rate=config.convert.sample_rate,
|
||||||
|
channels=config.convert.channels,
|
||||||
|
ffmpeg_path=config.convert.ffmpeg_path,
|
||||||
|
)
|
||||||
|
audio_path = target
|
||||||
|
log(" ✓ convert.wav")
|
||||||
|
|
||||||
|
if config.separate.enabled:
|
||||||
|
_check_cancel(stop_event)
|
||||||
|
log(" [2] Отделение вокала...")
|
||||||
|
target = track_out / "vocals.wav"
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
raw = separate_vocals(
|
||||||
|
audio_path, Path(tmp),
|
||||||
|
model=config.separate.model,
|
||||||
|
isolate_stem=config.separate.isolate_stem,
|
||||||
|
unblend_path=config.separate.unblend_path,
|
||||||
|
custom_kernels=config.separate.custom_kernels,
|
||||||
|
)
|
||||||
|
shutil.move(str(raw), str(target))
|
||||||
|
audio_path = target
|
||||||
|
log(" ✓ vocals.wav")
|
||||||
|
|
||||||
|
if config.transcribe.enabled:
|
||||||
|
_check_cancel(stop_event)
|
||||||
|
log(" [3] Транскрибация...")
|
||||||
|
text = transcribe(
|
||||||
|
audio_path,
|
||||||
|
engine=config.transcribe.engine,
|
||||||
|
model=config.transcribe.model,
|
||||||
|
language=config.transcribe.language,
|
||||||
|
device=config.transcribe.device,
|
||||||
|
compute_type=config.transcribe.compute_type,
|
||||||
|
batch_size=config.transcribe.batch_size,
|
||||||
|
gigaam_provider=config.transcribe.gigaam_provider,
|
||||||
|
)
|
||||||
|
(track_out / "text.txt").write_text(text, encoding="utf-8")
|
||||||
|
log(f" ✓ text.txt ({len(text)} символов)")
|
||||||
|
|
||||||
|
if not config.keep_intermediate:
|
||||||
|
_cleanup_keeping(track_out, finals)
|
||||||
|
|
||||||
|
except Cancelled:
|
||||||
|
log("⏹ Отменено")
|
||||||
|
break
|
||||||
|
except Exception as e: # noqa: BLE001 — per-file isolation
|
||||||
|
error = f"{type(e).__name__}: {e}"
|
||||||
|
log(f" ✗ Ошибка: {error}")
|
||||||
|
finally:
|
||||||
|
if on_progress:
|
||||||
|
on_progress(idx, total, "file")
|
||||||
|
results.append(FileResult(f, text, track_out if not error else None, error))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- staged
|
||||||
|
|
||||||
|
def _run_staged(
|
||||||
|
config: PipelineConfig,
|
||||||
|
files: list[Path],
|
||||||
|
log: LogFn,
|
||||||
|
on_progress: Optional[ProgressFn],
|
||||||
|
stop_event: Optional[threading.Event],
|
||||||
|
) -> list[FileResult]:
|
||||||
|
output_root = Path(config.output_dir)
|
||||||
|
total = len(files)
|
||||||
|
|
||||||
|
# per-file state
|
||||||
|
state: dict[Path, dict] = {
|
||||||
|
f: {"audio_path": f, "track_out": None, "text": None, "error": None}
|
||||||
|
for f in files
|
||||||
|
}
|
||||||
|
|
||||||
|
def track_out_for(f: Path) -> Path:
|
||||||
|
s = state[f]
|
||||||
|
if s["track_out"] is None:
|
||||||
|
p = output_root / f.stem
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
s["track_out"] = p
|
||||||
|
return s["track_out"]
|
||||||
|
|
||||||
|
def active() -> list[Path]:
|
||||||
|
return [f for f in files if state[f]["error"] is None]
|
||||||
|
|
||||||
|
def cancelled() -> bool:
|
||||||
|
return stop_event is not None and stop_event.is_set()
|
||||||
|
|
||||||
|
# ---- Stage 1: convert ----
|
||||||
|
if config.convert.enabled:
|
||||||
|
log(f"\n══ Этап 1/3 · Конвертация ({total} файлов) ══")
|
||||||
|
for i, f in enumerate(files, 1):
|
||||||
|
if cancelled():
|
||||||
|
log("⏹ Отменено")
|
||||||
|
break
|
||||||
|
if on_progress:
|
||||||
|
on_progress(i, total, "convert")
|
||||||
|
try:
|
||||||
|
target = track_out_for(f) / "convert.wav"
|
||||||
|
if config.overwrite or not target.exists():
|
||||||
|
convert_to_wav(
|
||||||
|
f, target,
|
||||||
|
sample_rate=config.convert.sample_rate,
|
||||||
|
channels=config.convert.channels,
|
||||||
|
ffmpeg_path=config.convert.ffmpeg_path,
|
||||||
|
)
|
||||||
|
state[f]["audio_path"] = target
|
||||||
|
log(f" ✓ {f.name}")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
state[f]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
log(f" ✗ {f.name}: {e}")
|
||||||
|
|
||||||
|
# ---- Stage 2: separate ----
|
||||||
|
if config.separate.enabled and not cancelled():
|
||||||
|
todo = active()
|
||||||
|
log(f"\n══ Этап 2/3 · Отделение вокала ({len(todo)} файлов) ══")
|
||||||
|
for i, f in enumerate(todo, 1):
|
||||||
|
if cancelled():
|
||||||
|
log("⏹ Отменено")
|
||||||
|
break
|
||||||
|
if on_progress:
|
||||||
|
on_progress(i, len(todo), "separate")
|
||||||
|
try:
|
||||||
|
target = track_out_for(f) / "vocals.wav"
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
raw = separate_vocals(
|
||||||
|
state[f]["audio_path"], Path(tmp),
|
||||||
|
model=config.separate.model,
|
||||||
|
isolate_stem=config.separate.isolate_stem,
|
||||||
|
unblend_path=config.separate.unblend_path,
|
||||||
|
custom_kernels=config.separate.custom_kernels,
|
||||||
|
)
|
||||||
|
shutil.move(str(raw), str(target))
|
||||||
|
state[f]["audio_path"] = target
|
||||||
|
log(f" ✓ {f.name}")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
state[f]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
log(f" ✗ {f.name}: {e}")
|
||||||
|
|
||||||
|
# ---- Stage 3: transcribe ----
|
||||||
|
if config.transcribe.enabled and not cancelled():
|
||||||
|
todo = active()
|
||||||
|
log(f"\n══ Этап 3/3 · Транскрибация ({len(todo)} файлов) ══")
|
||||||
|
for i, f in enumerate(todo, 1):
|
||||||
|
if cancelled():
|
||||||
|
log("⏹ Отменено")
|
||||||
|
break
|
||||||
|
if on_progress:
|
||||||
|
on_progress(i, len(todo), "transcribe")
|
||||||
|
try:
|
||||||
|
text = transcribe(
|
||||||
|
state[f]["audio_path"],
|
||||||
|
engine=config.transcribe.engine,
|
||||||
|
model=config.transcribe.model,
|
||||||
|
language=config.transcribe.language,
|
||||||
|
device=config.transcribe.device,
|
||||||
|
compute_type=config.transcribe.compute_type,
|
||||||
|
batch_size=config.transcribe.batch_size,
|
||||||
|
gigaam_provider=config.transcribe.gigaam_provider,
|
||||||
|
)
|
||||||
|
(track_out_for(f) / "text.txt").write_text(text, encoding="utf-8")
|
||||||
|
state[f]["text"] = text
|
||||||
|
log(f" ✓ {f.name} ({len(text)} символов)")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
state[f]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
log(f" ✗ {f.name}: {e}")
|
||||||
|
|
||||||
|
# ---- Cleanup ----
|
||||||
|
if not config.keep_intermediate:
|
||||||
|
finals = _final_stages(config)
|
||||||
|
for f in files:
|
||||||
|
s = state[f]
|
||||||
|
if s["error"] is None and s["track_out"] is not None:
|
||||||
|
_cleanup_keeping(s["track_out"], finals)
|
||||||
|
|
||||||
|
return [
|
||||||
|
FileResult(f, state[f]["text"], state[f]["track_out"], state[f]["error"])
|
||||||
|
for f in files
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Speech recognition via WhisperX or GigaAM (onnx-asr).
|
"""Speech recognition via WhisperX or GigaAM (onnx-asr).
|
||||||
|
|
||||||
Models are cached per (name, device, compute_type) so loading happens once
|
Models are cached per (name, device, compute_type) so loading happens once
|
||||||
per process, not once per file.
|
per process, not once per file. Loading is retried a few times to survive
|
||||||
|
transient HuggingFace / SSL hiccups.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -19,6 +21,18 @@ def _resolve_device(device: str) -> str:
|
|||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def _retry(fn, attempts: int = 3, base_delay: float = 2.0):
|
||||||
|
last = None
|
||||||
|
for i in range(attempts):
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except Exception as e: # noqa: BLE001 — HF download / SSL are broad
|
||||||
|
last = e
|
||||||
|
if i < attempts - 1:
|
||||||
|
time.sleep(base_delay * (i + 1))
|
||||||
|
raise last
|
||||||
|
|
||||||
|
|
||||||
class _WhisperXModels:
|
class _WhisperXModels:
|
||||||
_cache: dict = {}
|
_cache: dict = {}
|
||||||
|
|
||||||
@@ -29,9 +43,11 @@ class _WhisperXModels:
|
|||||||
key = (model_name, device, compute_type)
|
key = (model_name, device, compute_type)
|
||||||
if key not in cls._cache:
|
if key not in cls._cache:
|
||||||
import whisperx
|
import whisperx
|
||||||
cls._cache[key] = whisperx.load_model(
|
|
||||||
model_name, device, compute_type=compute_type
|
def load():
|
||||||
)
|
return whisperx.load_model(model_name, device, compute_type=compute_type)
|
||||||
|
|
||||||
|
cls._cache[key] = _retry(load)
|
||||||
return cls._cache[key]
|
return cls._cache[key]
|
||||||
|
|
||||||
|
|
||||||
@@ -40,8 +56,6 @@ class _GigaAMModels:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, provider: str):
|
def get(cls, provider: str):
|
||||||
# onnx-asr accepts a list; CUDAExecutionProvider silently falls back to CPU
|
|
||||||
# if CUDA isn't available at runtime.
|
|
||||||
providers = {
|
providers = {
|
||||||
"cuda": ["CUDAExecutionProvider"],
|
"cuda": ["CUDAExecutionProvider"],
|
||||||
"cpu": ["CPUExecutionProvider"],
|
"cpu": ["CPUExecutionProvider"],
|
||||||
@@ -50,7 +64,11 @@ class _GigaAMModels:
|
|||||||
key = tuple(providers)
|
key = tuple(providers)
|
||||||
if key not in cls._cache:
|
if key not in cls._cache:
|
||||||
import onnx_asr
|
import onnx_asr
|
||||||
cls._cache[key] = onnx_asr.load_model("gigaam-v3-ctc", providers=list(providers))
|
|
||||||
|
def load():
|
||||||
|
return onnx_asr.load_model("gigaam-v3-ctc", providers=list(providers))
|
||||||
|
|
||||||
|
cls._cache[key] = _retry(load)
|
||||||
return cls._cache[key]
|
return cls._cache[key]
|
||||||
|
|
||||||
|
|
||||||
@@ -65,11 +83,8 @@ def transcribe(
|
|||||||
gigaam_provider: str = "auto",
|
gigaam_provider: str = "auto",
|
||||||
) -> str:
|
) -> str:
|
||||||
device = _resolve_device(device)
|
device = _resolve_device(device)
|
||||||
|
|
||||||
if engine == "whisperx":
|
if engine == "whisperx":
|
||||||
return _transcribe_whisperx(
|
return _transcribe_whisperx(audio_path, model, language, device, compute_type, batch_size)
|
||||||
audio_path, model, language, device, compute_type, batch_size
|
|
||||||
)
|
|
||||||
if engine == "gigaam":
|
if engine == "gigaam":
|
||||||
return _transcribe_gigaam(audio_path, gigaam_provider)
|
return _transcribe_gigaam(audio_path, gigaam_provider)
|
||||||
raise ValueError(f"Unknown ASR engine: {engine!r}")
|
raise ValueError(f"Unknown ASR engine: {engine!r}")
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
"""Main application window."""
|
"""Main application window — dark theme, mode selector, staged/sequential pipelines."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QUrl
|
from PySide6.QtCore import Qt, QUrl
|
||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QColor, QDesktopServices
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
|
QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
|
||||||
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
|
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
|
||||||
QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QSplitter,
|
QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QTabWidget,
|
||||||
QTabWidget, QVBoxLayout, QWidget,
|
QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from core.config import ConvertConfig, PipelineConfig, SeparateConfig, TranscribeConfig
|
from core.config import ConvertConfig, PipelineConfig, SeparateConfig, TranscribeConfig
|
||||||
@@ -24,33 +22,74 @@ LANGUAGES = ["auto", "ru", "en", "de", "fr", "es", "it", "uk", "ja", "zh", "ko"]
|
|||||||
SAMPLE_RATES = [16000, 22050, 44100, 48000]
|
SAMPLE_RATES = [16000, 22050, 44100, 48000]
|
||||||
ENGINES = ["whisperx", "gigaam"]
|
ENGINES = ["whisperx", "gigaam"]
|
||||||
|
|
||||||
|
STAGE_LABELS = {
|
||||||
|
"file": "Файлы",
|
||||||
|
"convert": "Конвертация",
|
||||||
|
"separate": "Отделение",
|
||||||
|
"transcribe": "Транскрибация",
|
||||||
|
}
|
||||||
|
|
||||||
def _detect_torch_cuda() -> bool:
|
MODE_LABELS = {
|
||||||
|
"sequential": "Последовательно (файл за файлом)",
|
||||||
|
"staged": "Пакетно (по этапам)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_device_info() -> tuple[str, str]:
|
||||||
|
"""Return (display_text, color_hex) describing the compute device."""
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch
|
||||||
return bool(torch.cuda.is_available())
|
except ImportError:
|
||||||
except Exception:
|
return "⚠ PyTorch не установлен", "#f38ba8"
|
||||||
return False
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
try:
|
||||||
|
name = torch.cuda.get_device_name(0)
|
||||||
|
vram = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
|
||||||
|
return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return "✓ CUDA доступна", "#a6e3a1"
|
||||||
|
|
||||||
|
return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af"
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle("Song Transcriber v3")
|
self.setWindowTitle("Song Transcriber")
|
||||||
self.resize(1080, 760)
|
self.resize(1100, 850)
|
||||||
self._worker: Optional[PipelineWorker] = None
|
self._worker: Optional[PipelineWorker] = None
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._apply_defaults()
|
self._apply_defaults()
|
||||||
|
self._sync_tab_indicators()
|
||||||
|
self._sync_engine_fields()
|
||||||
|
self._log_device_info()
|
||||||
|
|
||||||
# ---------------- UI ----------------
|
# ---------------- UI ----------------
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
central = QWidget()
|
central = QWidget()
|
||||||
self.setCentralWidget(central)
|
self.setCentralWidget(central)
|
||||||
root = QVBoxLayout(central)
|
root = QVBoxLayout(central)
|
||||||
|
root.setContentsMargins(18, 16, 18, 16)
|
||||||
|
root.setSpacing(12)
|
||||||
|
|
||||||
# --- top: paths + options ---
|
# --- header row: title + device badge ---
|
||||||
|
header = QHBoxLayout()
|
||||||
|
title = QLabel("🎵 Song Transcriber")
|
||||||
|
title.setObjectName("appTitle")
|
||||||
|
header.addWidget(title)
|
||||||
|
header.addStretch(1)
|
||||||
|
|
||||||
|
self.device_label = QLabel("")
|
||||||
|
self.device_label.setObjectName("deviceInfo")
|
||||||
|
header.addWidget(self.device_label)
|
||||||
|
root.addLayout(header)
|
||||||
|
|
||||||
|
# --- top settings ---
|
||||||
top_box = QGroupBox("Общие настройки")
|
top_box = QGroupBox("Общие настройки")
|
||||||
top_layout = QFormLayout(top_box)
|
top_layout = QFormLayout(top_box)
|
||||||
|
top_layout.setSpacing(10)
|
||||||
|
|
||||||
self.input_edit = QLineEdit()
|
self.input_edit = QLineEdit()
|
||||||
top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir))
|
top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir))
|
||||||
@@ -58,81 +97,97 @@ class MainWindow(QMainWindow):
|
|||||||
self.output_edit = QLineEdit()
|
self.output_edit = QLineEdit()
|
||||||
top_layout.addRow("Выходная папка:", self._with_browse(self.output_edit, self._pick_output_dir))
|
top_layout.addRow("Выходная папка:", self._with_browse(self.output_edit, self._pick_output_dir))
|
||||||
|
|
||||||
opts_row = QHBoxLayout()
|
self.mode_cb = QComboBox()
|
||||||
|
for key, label in MODE_LABELS.items():
|
||||||
|
self.mode_cb.addItem(label, key)
|
||||||
|
top_layout.addRow("Режим:", self.mode_cb)
|
||||||
|
|
||||||
|
opts = QHBoxLayout()
|
||||||
self.recursive_cb = QCheckBox("Рекурсивный обход")
|
self.recursive_cb = QCheckBox("Рекурсивный обход")
|
||||||
self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные файлы")
|
self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные")
|
||||||
self.overwrite_cb = QCheckBox("Перезаписывать")
|
self.overwrite_cb = QCheckBox("Перезаписывать")
|
||||||
opts_row.addWidget(self.recursive_cb)
|
opts.addWidget(self.recursive_cb)
|
||||||
opts_row.addWidget(self.keep_intermediate_cb)
|
opts.addWidget(self.keep_intermediate_cb)
|
||||||
opts_row.addWidget(self.overwrite_cb)
|
opts.addWidget(self.overwrite_cb)
|
||||||
opts_row.addStretch(1)
|
opts.addStretch(1)
|
||||||
top_layout.addRow("Опции:", opts_row)
|
top_layout.addRow("Опции:", opts)
|
||||||
|
|
||||||
root.addWidget(top_box)
|
root.addWidget(top_box)
|
||||||
|
|
||||||
# --- stages tabs ---
|
# --- stages ---
|
||||||
tabs = QTabWidget()
|
self.tabs = QTabWidget()
|
||||||
tabs.addTab(self._build_convert_tab(), "Конвертация")
|
self.tabs.addTab(self._build_convert_tab(), "Конвертация")
|
||||||
tabs.addTab(self._build_separate_tab(), "Отделение вокала")
|
self.tabs.addTab(self._build_separate_tab(), "Отделение вокала")
|
||||||
tabs.addTab(self._build_transcribe_tab(), "Транскрибация")
|
self.tabs.addTab(self._build_transcribe_tab(), "Транскрибация")
|
||||||
root.addWidget(tabs)
|
root.addWidget(self.tabs)
|
||||||
|
|
||||||
# --- run row ---
|
self.convert_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
||||||
run_row = QHBoxLayout()
|
self.separate_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
||||||
self.start_btn = QPushButton("▶ Запустить")
|
self.transcribe_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
||||||
|
self.engine_cb.currentTextChanged.connect(self._sync_engine_fields)
|
||||||
|
|
||||||
|
# --- controls row ---
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.setSpacing(10)
|
||||||
|
self.start_btn = QPushButton("▶ Запустить")
|
||||||
|
self.start_btn.setObjectName("primary")
|
||||||
self.start_btn.clicked.connect(self._on_start)
|
self.start_btn.clicked.connect(self._on_start)
|
||||||
self.stop_btn = QPushButton("⏹ Стоп")
|
self.stop_btn = QPushButton("⏹ Стоп")
|
||||||
|
self.stop_btn.setObjectName("danger")
|
||||||
self.stop_btn.setEnabled(False)
|
self.stop_btn.setEnabled(False)
|
||||||
self.stop_btn.clicked.connect(self._on_stop)
|
self.stop_btn.clicked.connect(self._on_stop)
|
||||||
self.open_out_btn = QPushButton("📂 Открыть выходную папку")
|
|
||||||
|
self.open_out_btn = QPushButton("📂 Открыть результат")
|
||||||
self.open_out_btn.clicked.connect(self._open_output_dir)
|
self.open_out_btn.clicked.connect(self._open_output_dir)
|
||||||
self.save_preset_btn = QPushButton("💾 Сохранить пресет")
|
self.save_preset_btn = QPushButton("💾 Сохранить пресет")
|
||||||
self.save_preset_btn.clicked.connect(self._save_preset)
|
self.save_preset_btn.clicked.connect(self._save_preset)
|
||||||
self.load_preset_btn = QPushButton("📁 Загрузить пресет")
|
self.load_preset_btn = QPushButton("📁 Загрузить пресет")
|
||||||
self.load_preset_btn.clicked.connect(self._load_preset)
|
self.load_preset_btn.clicked.connect(self._load_preset)
|
||||||
|
|
||||||
run_row.addWidget(self.start_btn)
|
row.addWidget(self.start_btn)
|
||||||
run_row.addWidget(self.stop_btn)
|
row.addWidget(self.stop_btn)
|
||||||
run_row.addStretch(1)
|
row.addStretch(1)
|
||||||
run_row.addWidget(self.open_out_btn)
|
row.addWidget(self.open_out_btn)
|
||||||
run_row.addWidget(self.save_preset_btn)
|
row.addWidget(self.save_preset_btn)
|
||||||
run_row.addWidget(self.load_preset_btn)
|
row.addWidget(self.load_preset_btn)
|
||||||
root.addLayout(run_row)
|
root.addLayout(row)
|
||||||
|
|
||||||
# --- progress ---
|
# --- progress ---
|
||||||
self.progress = QProgressBar()
|
self.progress = QProgressBar()
|
||||||
self.progress.setFormat("%v / %m")
|
self.progress.setFormat("Ожидание")
|
||||||
root.addWidget(self.progress)
|
root.addWidget(self.progress)
|
||||||
|
|
||||||
# --- log ---
|
# --- log ---
|
||||||
splitter = QSplitter(Qt.Vertical)
|
|
||||||
self.log_view = QPlainTextEdit()
|
self.log_view = QPlainTextEdit()
|
||||||
|
self.log_view.setObjectName("log")
|
||||||
self.log_view.setReadOnly(True)
|
self.log_view.setReadOnly(True)
|
||||||
self.log_view.setPlaceholderText("Здесь будет лог выполнения…")
|
self.log_view.setPlaceholderText("Здесь появится лог выполнения…")
|
||||||
splitter.addWidget(self.log_view)
|
root.addWidget(self.log_view, 1)
|
||||||
splitter.setStretchFactor(0, 1)
|
|
||||||
root.addWidget(splitter, 1)
|
|
||||||
|
|
||||||
def _with_browse(self, line_edit: QLineEdit, picker) -> QWidget:
|
def _with_browse(self, edit: QLineEdit, picker) -> QWidget:
|
||||||
wrapper = QWidget()
|
w = QWidget()
|
||||||
h = QHBoxLayout(wrapper)
|
h = QHBoxLayout(w)
|
||||||
h.setContentsMargins(0, 0, 0, 0)
|
h.setContentsMargins(0, 0, 0, 0)
|
||||||
h.addWidget(line_edit)
|
h.setSpacing(6)
|
||||||
|
h.addWidget(edit)
|
||||||
btn = QPushButton("…")
|
btn = QPushButton("…")
|
||||||
btn.setFixedWidth(32)
|
btn.setFixedWidth(36)
|
||||||
btn.clicked.connect(picker)
|
btn.clicked.connect(picker)
|
||||||
h.addWidget(btn)
|
h.addWidget(btn)
|
||||||
return wrapper
|
return w
|
||||||
|
|
||||||
def _build_convert_tab(self) -> QWidget:
|
def _build_convert_tab(self) -> QWidget:
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
form = QFormLayout(w)
|
form = QFormLayout(w)
|
||||||
|
form.setContentsMargins(16, 16, 16, 16)
|
||||||
|
form.setSpacing(10)
|
||||||
|
|
||||||
self.convert_enabled_cb = QCheckBox("Включён")
|
self.convert_enabled_cb = QCheckBox("Включён")
|
||||||
form.addRow("", self.convert_enabled_cb)
|
form.addRow("", self.convert_enabled_cb)
|
||||||
|
|
||||||
self.convert_sr = QComboBox()
|
self.convert_sr = QComboBox()
|
||||||
for sr in SAMPLE_RATES:
|
for sr in SAMPLE_RATES:
|
||||||
self.convert_sr.addItem(str(sr), sr)
|
self.convert_sr.addItem(f"{sr} Hz", sr)
|
||||||
form.addRow("Sample rate:", self.convert_sr)
|
form.addRow("Sample rate:", self.convert_sr)
|
||||||
|
|
||||||
self.convert_ch = QComboBox()
|
self.convert_ch = QComboBox()
|
||||||
@@ -147,6 +202,9 @@ class MainWindow(QMainWindow):
|
|||||||
def _build_separate_tab(self) -> QWidget:
|
def _build_separate_tab(self) -> QWidget:
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
form = QFormLayout(w)
|
form = QFormLayout(w)
|
||||||
|
form.setContentsMargins(16, 16, 16, 16)
|
||||||
|
form.setSpacing(10)
|
||||||
|
|
||||||
self.separate_enabled_cb = QCheckBox("Включён")
|
self.separate_enabled_cb = QCheckBox("Включён")
|
||||||
form.addRow("", self.separate_enabled_cb)
|
form.addRow("", self.separate_enabled_cb)
|
||||||
|
|
||||||
@@ -166,6 +224,9 @@ class MainWindow(QMainWindow):
|
|||||||
def _build_transcribe_tab(self) -> QWidget:
|
def _build_transcribe_tab(self) -> QWidget:
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
form = QFormLayout(w)
|
form = QFormLayout(w)
|
||||||
|
form.setContentsMargins(16, 16, 16, 16)
|
||||||
|
form.setSpacing(10)
|
||||||
|
|
||||||
self.transcribe_enabled_cb = QCheckBox("Включён")
|
self.transcribe_enabled_cb = QCheckBox("Включён")
|
||||||
form.addRow("", self.transcribe_enabled_cb)
|
form.addRow("", self.transcribe_enabled_cb)
|
||||||
|
|
||||||
@@ -179,6 +240,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.lang_cb = QComboBox()
|
self.lang_cb = QComboBox()
|
||||||
self.lang_cb.setEditable(True)
|
self.lang_cb.setEditable(True)
|
||||||
self.lang_cb.addItems(LANGUAGES)
|
self.lang_cb.addItems(LANGUAGES)
|
||||||
|
self.lang_cb.setToolTip(
|
||||||
|
"'auto' — WhisperX определяет язык на каждом файле отдельно.\n"
|
||||||
|
"Это заметно медленнее. Если знаешь язык — выбери его явно."
|
||||||
|
)
|
||||||
form.addRow("Язык:", self.lang_cb)
|
form.addRow("Язык:", self.lang_cb)
|
||||||
|
|
||||||
self.device_cb = QComboBox()
|
self.device_cb = QComboBox()
|
||||||
@@ -190,7 +255,13 @@ class MainWindow(QMainWindow):
|
|||||||
form.addRow("Compute type:", self.compute_type_cb)
|
form.addRow("Compute type:", self.compute_type_cb)
|
||||||
|
|
||||||
self.batch_spin = QSpinBox()
|
self.batch_spin = QSpinBox()
|
||||||
self.batch_spin.setRange(1, 64)
|
self.batch_spin.setRange(1, 512)
|
||||||
|
self.batch_spin.setToolTip(
|
||||||
|
"Размер батча сегментов ВНУТРИ одного файла (не файлов!).\n"
|
||||||
|
"Больше ≠ всегда быстрее: упирается в VRAM.\n"
|
||||||
|
"8–16 для 8 ГБ VRAM, 32–64 для 24 ГБ.\n"
|
||||||
|
"При 'CUDA out of memory' — уменьшайте."
|
||||||
|
)
|
||||||
form.addRow("Batch size:", self.batch_spin)
|
form.addRow("Batch size:", self.batch_spin)
|
||||||
|
|
||||||
self.gigaam_provider_cb = QComboBox()
|
self.gigaam_provider_cb = QComboBox()
|
||||||
@@ -198,21 +269,51 @@ class MainWindow(QMainWindow):
|
|||||||
form.addRow("GigaAM provider:", self.gigaam_provider_cb)
|
form.addRow("GigaAM provider:", self.gigaam_provider_cb)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
# ---------------- defaults ----------------
|
# ---------------- visual sync ----------------
|
||||||
|
|
||||||
|
def _log_device_info(self) -> None:
|
||||||
|
text, color = _detect_device_info()
|
||||||
|
self.device_label.setText(text)
|
||||||
|
self.device_label.setStyleSheet(f"color: {color};")
|
||||||
|
self._append_log(text)
|
||||||
|
|
||||||
|
def _sync_tab_indicators(self) -> None:
|
||||||
|
active = QColor("#89b4fa")
|
||||||
|
inactive = QColor("#585b70")
|
||||||
|
bar = self.tabs.tabBar()
|
||||||
|
bar.setTabTextColor(0, active if self.convert_enabled_cb.isChecked() else inactive)
|
||||||
|
bar.setTabTextColor(1, active if self.separate_enabled_cb.isChecked() else inactive)
|
||||||
|
bar.setTabTextColor(2, active if self.transcribe_enabled_cb.isChecked() else inactive)
|
||||||
|
|
||||||
|
def _sync_engine_fields(self) -> None:
|
||||||
|
is_whisperx = self.engine_cb.currentText() == "whisperx"
|
||||||
|
for w in (self.lang_cb, self.device_cb, self.compute_type_cb, self.batch_spin):
|
||||||
|
w.setEnabled(is_whisperx)
|
||||||
|
self.gigaam_provider_cb.setEnabled(not is_whisperx)
|
||||||
|
|
||||||
|
# ---------------- defaults & config <-> UI ----------------
|
||||||
|
|
||||||
def _apply_defaults(self) -> None:
|
def _apply_defaults(self) -> None:
|
||||||
cfg = PipelineConfig()
|
self._apply_config(PipelineConfig())
|
||||||
|
|
||||||
|
def _apply_config(self, cfg: PipelineConfig) -> None:
|
||||||
|
self.input_edit.setText(cfg.input_dir)
|
||||||
self.output_edit.setText(str(Path(cfg.output_dir).absolute()))
|
self.output_edit.setText(str(Path(cfg.output_dir).absolute()))
|
||||||
self.recursive_cb.setChecked(cfg.recursive)
|
self.recursive_cb.setChecked(cfg.recursive)
|
||||||
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
|
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
|
||||||
self.overwrite_cb.setChecked(cfg.overwrite)
|
self.overwrite_cb.setChecked(cfg.overwrite)
|
||||||
|
|
||||||
|
i = self.mode_cb.findData(cfg.mode)
|
||||||
|
if i >= 0:
|
||||||
|
self.mode_cb.setCurrentIndex(i)
|
||||||
|
|
||||||
self.convert_enabled_cb.setChecked(cfg.convert.enabled)
|
self.convert_enabled_cb.setChecked(cfg.convert.enabled)
|
||||||
idx = self.convert_sr.findData(cfg.convert.sample_rate)
|
i = self.convert_sr.findData(cfg.convert.sample_rate)
|
||||||
if idx >= 0:
|
if i >= 0:
|
||||||
self.convert_sr.setCurrentIndex(idx)
|
self.convert_sr.setCurrentIndex(i)
|
||||||
idx = self.convert_ch.findData(cfg.convert.channels)
|
i = self.convert_ch.findData(cfg.convert.channels)
|
||||||
if idx >= 0:
|
if i >= 0:
|
||||||
self.convert_ch.setCurrentIndex(idx)
|
self.convert_ch.setCurrentIndex(i)
|
||||||
self.convert_ffmpeg.setText(cfg.convert.ffmpeg_path)
|
self.convert_ffmpeg.setText(cfg.convert.ffmpeg_path)
|
||||||
|
|
||||||
self.separate_enabled_cb.setChecked(cfg.separate.enabled)
|
self.separate_enabled_cb.setChecked(cfg.separate.enabled)
|
||||||
@@ -230,7 +331,6 @@ class MainWindow(QMainWindow):
|
|||||||
self.batch_spin.setValue(cfg.transcribe.batch_size)
|
self.batch_spin.setValue(cfg.transcribe.batch_size)
|
||||||
self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider)
|
self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider)
|
||||||
|
|
||||||
# ---------------- config <-> ui ----------------
|
|
||||||
def _collect_config(self) -> PipelineConfig:
|
def _collect_config(self) -> PipelineConfig:
|
||||||
cfg = PipelineConfig()
|
cfg = PipelineConfig()
|
||||||
cfg.input_dir = self.input_edit.text().strip()
|
cfg.input_dir = self.input_edit.text().strip()
|
||||||
@@ -238,6 +338,7 @@ class MainWindow(QMainWindow):
|
|||||||
cfg.recursive = self.recursive_cb.isChecked()
|
cfg.recursive = self.recursive_cb.isChecked()
|
||||||
cfg.keep_intermediate = self.keep_intermediate_cb.isChecked()
|
cfg.keep_intermediate = self.keep_intermediate_cb.isChecked()
|
||||||
cfg.overwrite = self.overwrite_cb.isChecked()
|
cfg.overwrite = self.overwrite_cb.isChecked()
|
||||||
|
cfg.mode = self.mode_cb.currentData()
|
||||||
|
|
||||||
cfg.convert = ConvertConfig(
|
cfg.convert = ConvertConfig(
|
||||||
enabled=self.convert_enabled_cb.isChecked(),
|
enabled=self.convert_enabled_cb.isChecked(),
|
||||||
@@ -267,38 +368,8 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
return cfg
|
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 ----------------
|
# ---------------- handlers ----------------
|
||||||
|
|
||||||
def _pick_input_dir(self) -> None:
|
def _pick_input_dir(self) -> None:
|
||||||
d = QFileDialog.getExistingDirectory(self, "Выберите входную папку")
|
d = QFileDialog.getExistingDirectory(self, "Выберите входную папку")
|
||||||
if d:
|
if d:
|
||||||
@@ -315,9 +386,7 @@ class MainWindow(QMainWindow):
|
|||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(p)))
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(p)))
|
||||||
|
|
||||||
def _save_preset(self) -> None:
|
def _save_preset(self) -> None:
|
||||||
path, _ = QFileDialog.getSaveFileName(
|
path, _ = QFileDialog.getSaveFileName(self, "Сохранить пресет", "preset.json", "JSON (*.json)")
|
||||||
self, "Сохранить пресет", "preset.json", "JSON (*.json)"
|
|
||||||
)
|
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -326,9 +395,7 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.critical(self, "Ошибка", str(exc))
|
QMessageBox.critical(self, "Ошибка", str(exc))
|
||||||
|
|
||||||
def _load_preset(self) -> None:
|
def _load_preset(self) -> None:
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(self, "Загрузить пресет", "", "JSON (*.json)")
|
||||||
self, "Загрузить пресет", "", "JSON (*.json)"
|
|
||||||
)
|
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -344,23 +411,23 @@ class MainWindow(QMainWindow):
|
|||||||
if not (cfg.convert.enabled or cfg.separate.enabled or cfg.transcribe.enabled):
|
if not (cfg.convert.enabled or cfg.separate.enabled or cfg.transcribe.enabled):
|
||||||
QMessageBox.warning(self, "Нет этапов", "Включите хотя бы один этап.")
|
QMessageBox.warning(self, "Нет этапов", "Включите хотя бы один этап.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# sanity: ffmpeg / unblend availability
|
|
||||||
if cfg.convert.enabled and not shutil.which(cfg.convert.ffmpeg_path):
|
if cfg.convert.enabled and not shutil.which(cfg.convert.ffmpeg_path):
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(self, "ffmpeg не найден",
|
||||||
self, "ffmpeg не найден",
|
f"Команда '{cfg.convert.ffmpeg_path}' не найдена в PATH.")
|
||||||
f"Команда '{cfg.convert.ffmpeg_path}' не найдена в PATH.",
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
if cfg.separate.enabled and not shutil.which(cfg.separate.unblend_path):
|
if cfg.separate.enabled and not shutil.which(cfg.separate.unblend_path):
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(self, "unblend не найден",
|
||||||
self, "unblend не найден",
|
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.")
|
||||||
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.",
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log_view.clear()
|
self.log_view.clear()
|
||||||
|
self._log_device_info()
|
||||||
|
if cfg.transcribe.enabled and cfg.transcribe.language is None:
|
||||||
|
self._append_log("⚠ Язык = auto: WhisperX определит язык на каждом файле. "
|
||||||
|
"Если язык известен — задайте его явно, будет быстрее.")
|
||||||
|
|
||||||
self.progress.setValue(0)
|
self.progress.setValue(0)
|
||||||
|
self.progress.setFormat("Запуск…")
|
||||||
self.start_btn.setEnabled(False)
|
self.start_btn.setEnabled(False)
|
||||||
self.stop_btn.setEnabled(True)
|
self.stop_btn.setEnabled(True)
|
||||||
|
|
||||||
@@ -381,19 +448,23 @@ class MainWindow(QMainWindow):
|
|||||||
sb = self.log_view.verticalScrollBar()
|
sb = self.log_view.verticalScrollBar()
|
||||||
sb.setValue(sb.maximum())
|
sb.setValue(sb.maximum())
|
||||||
|
|
||||||
def _on_progress(self, done: int, total: int) -> None:
|
def _on_progress(self, done: int, total: int, stage: str) -> None:
|
||||||
self.progress.setMaximum(total)
|
self.progress.setMaximum(total)
|
||||||
self.progress.setValue(done)
|
self.progress.setValue(done)
|
||||||
|
label = STAGE_LABELS.get(stage, stage)
|
||||||
|
self.progress.setFormat(f"{label} — %v / %m")
|
||||||
|
|
||||||
def _on_finished(self, results: list) -> None:
|
def _on_finished(self, results: list) -> None:
|
||||||
self.start_btn.setEnabled(True)
|
self.start_btn.setEnabled(True)
|
||||||
self.stop_btn.setEnabled(False)
|
self.stop_btn.setEnabled(False)
|
||||||
ok = sum(1 for r in results if r.error is None)
|
ok = sum(1 for r in results if r.error is None)
|
||||||
fail = len(results) - ok
|
fail = len(results) - ok
|
||||||
|
self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}")
|
||||||
self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
|
self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
|
||||||
|
|
||||||
def _on_failed(self, message: str) -> None:
|
def _on_failed(self, message: str) -> None:
|
||||||
self.start_btn.setEnabled(True)
|
self.start_btn.setEnabled(True)
|
||||||
self.stop_btn.setEnabled(False)
|
self.stop_btn.setEnabled(False)
|
||||||
|
self.progress.setFormat("Ошибка")
|
||||||
self._append_log(f"\n✗ Ошибка пайплайна: {message}")
|
self._append_log(f"\n✗ Ошибка пайплайна: {message}")
|
||||||
QMessageBox.critical(self, "Ошибка", message)
|
QMessageBox.critical(self, "Ошибка", message)
|
||||||
|
|||||||
215
gui/theme.py
Normal file
215
gui/theme.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""Dark theme for the whole application (Catppuccin Macchiato-inspired)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
DARK_STYLESHEET = """
|
||||||
|
QWidget {
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-family: 'Inter', 'Segoe UI', 'Noto Sans', sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
QMainWindow { background-color: #181825; }
|
||||||
|
|
||||||
|
QLabel { background: transparent; }
|
||||||
|
QLabel#appTitle {
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #89b4fa;
|
||||||
|
padding: 4px 0 10px 0;
|
||||||
|
}
|
||||||
|
QLabel#appSubtitle {
|
||||||
|
color: #7f849c;
|
||||||
|
font-size: 9pt;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
QLabel#deviceInfo {
|
||||||
|
color: #a6adc8;
|
||||||
|
font-size: 9pt;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: #181825;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QGroupBox {
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 16px 12px 12px 12px;
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QGroupBox::title {
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
subcontrol-position: top left;
|
||||||
|
left: 12px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit, QSpinBox, QComboBox, QPlainTextEdit {
|
||||||
|
background-color: #181825;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
selection-background-color: #89b4fa;
|
||||||
|
selection-color: #1e1e2e;
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
|
QLineEdit:focus, QSpinBox:focus, QComboBox:focus, QPlainTextEdit:focus {
|
||||||
|
border: 1px solid #89b4fa;
|
||||||
|
}
|
||||||
|
QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled {
|
||||||
|
color: #585b70;
|
||||||
|
background-color: #11111b;
|
||||||
|
}
|
||||||
|
|
||||||
|
QComboBox::drop-down { border: none; width: 22px; }
|
||||||
|
QComboBox::down-arrow {
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 5px solid #89b4fa;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
QComboBox QAbstractItemView {
|
||||||
|
background-color: #181825;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
selection-background-color: #313244;
|
||||||
|
selection-color: #89b4fa;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── buttons: higher contrast ─────────────────────────────────── */
|
||||||
|
QPushButton {
|
||||||
|
background-color: #3d4159;
|
||||||
|
border: 1px solid #5c6080;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #4a4e6b; border-color: #7a7fa0; }
|
||||||
|
QPushButton:pressed { background-color: #585b70; }
|
||||||
|
QPushButton:focus { border: 1px solid #a6c8ff; outline: none; }
|
||||||
|
QPushButton:disabled {
|
||||||
|
background-color: #2a2d42;
|
||||||
|
color: #6c7086;
|
||||||
|
border-color: #3d4055;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#primary {
|
||||||
|
background-color: #89b4fa;
|
||||||
|
color: #11111b;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 20px;
|
||||||
|
}
|
||||||
|
QPushButton#primary:hover { background-color: #a6c8ff; }
|
||||||
|
QPushButton#primary:pressed { background-color: #74a0e0; }
|
||||||
|
QPushButton#primary:disabled {
|
||||||
|
background-color: #45475a;
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#danger {
|
||||||
|
background-color: #f38ba8;
|
||||||
|
color: #11111b;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 20px;
|
||||||
|
}
|
||||||
|
QPushButton#danger:hover { background-color: #f5a4b8; }
|
||||||
|
QPushButton#danger:pressed { background-color: #e07898; }
|
||||||
|
QPushButton#danger:disabled {
|
||||||
|
background-color: #45475a;
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabWidget::pane {
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
QTabBar::tab {
|
||||||
|
background-color: #181825;
|
||||||
|
color: #7f849c;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
margin-right: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
color: #89b4fa;
|
||||||
|
border-color: #313244;
|
||||||
|
}
|
||||||
|
QTabBar::tab:hover:!selected { background-color: #252535; }
|
||||||
|
|
||||||
|
QCheckBox {
|
||||||
|
spacing: 8px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid #5c6080;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #181825;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:hover { border-color: #89b4fa; }
|
||||||
|
QCheckBox::indicator:checked {
|
||||||
|
background-color: #89b4fa;
|
||||||
|
border-color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar {
|
||||||
|
background-color: #181825;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: #cdd6f4;
|
||||||
|
min-height: 24px;
|
||||||
|
max-height: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: #89b4fa;
|
||||||
|
border-radius: 7px;
|
||||||
|
margin: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: #181825;
|
||||||
|
width: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: #45475a;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 24px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical:hover { background: #585b70; }
|
||||||
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||||
|
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }
|
||||||
|
|
||||||
|
QSplitter::handle { background-color: #313244; }
|
||||||
|
|
||||||
|
QPlainTextEdit#log {
|
||||||
|
background-color: #11111b;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
"""
|
||||||
@@ -11,8 +11,8 @@ from core.pipeline import run_pipeline
|
|||||||
|
|
||||||
class PipelineWorker(QThread):
|
class PipelineWorker(QThread):
|
||||||
log = Signal(str)
|
log = Signal(str)
|
||||||
progress = Signal(int, int) # done, total
|
progress = Signal(int, int, str) # done, total, stage
|
||||||
finished_ok = Signal(list) # list[FileResult]
|
finished_ok = Signal(list)
|
||||||
failed = Signal(str)
|
failed = Signal(str)
|
||||||
|
|
||||||
def __init__(self, config: PipelineConfig, parent=None):
|
def __init__(self, config: PipelineConfig, parent=None):
|
||||||
@@ -23,12 +23,12 @@ class PipelineWorker(QThread):
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
|
||||||
def run(self) -> None: # noqa: D401 — Qt override
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
results = run_pipeline(
|
results = run_pipeline(
|
||||||
self._config,
|
self._config,
|
||||||
log=self.log.emit,
|
log=self.log.emit,
|
||||||
on_progress=lambda d, t: self.progress.emit(d, t),
|
on_progress=lambda d, t, s: self.progress.emit(d, t, s),
|
||||||
stop_event=self._stop_event,
|
stop_event=self._stop_event,
|
||||||
)
|
)
|
||||||
self.finished_ok.emit(results)
|
self.finished_ok.emit(results)
|
||||||
|
|||||||
26
main.py
26
main.py
@@ -1,17 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Song Transcriber v3 — Qt GUI entry point.
|
"""Song Transcriber — Qt GUI entry point."""
|
||||||
|
|
||||||
Run: python main.py
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
# Must be set BEFORE PySide6 is imported.
|
||||||
|
os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false")
|
||||||
|
|
||||||
|
|
||||||
def preload_cudnn() -> None:
|
def preload_cudnn() -> None:
|
||||||
"""Preload cuDNN shared libraries so torch / onnxruntime can find them."""
|
|
||||||
try:
|
try:
|
||||||
import nvidia.cudnn
|
import nvidia.cudnn
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -27,13 +26,30 @@ def preload_cudnn() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def enable_tf32() -> None:
|
||||||
|
"""Enable TF32 for faster matmul on Ampere+ GPUs (RTX 30/40 series)."""
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.backends.cuda.matmul.allow_tf32 = True
|
||||||
|
torch.backends.cudnn.allow_tf32 = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
preload_cudnn()
|
preload_cudnn()
|
||||||
|
enable_tf32()
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from gui.theme import DARK_STYLESHEET
|
||||||
from gui.main_window import MainWindow
|
from gui.main_window import MainWindow
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("Audio Transcriber")
|
app.setApplicationName("Audio Transcriber")
|
||||||
|
app.setStyle("Fusion")
|
||||||
|
app.setStyleSheet(DARK_STYLESHEET)
|
||||||
|
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.show()
|
window.show()
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|||||||
Reference in New Issue
Block a user