Compare commits

...

11 Commits

Author SHA1 Message Date
jadssss
f023653c71 align LRC from ready text.txt via stable-ts and auto-detect language
- add align_text_to_audio(): forced alignment without ASR, fast
- add _StableTSModels cache; detect language via stable-ts when language=None
- LRC stage: use existing text.txt, copy it if already LRC, fall back to ASR only
- filter junk lyrics from tags and ASR hallucinations (promo phrases, tics)
- mark empty ASR results with metadata.empty_text and skip them on resume
- warn in GUI when LRC is run with transcription disabled
- add mark_no_speech.py one-shot script for existing tracks
2026-11-02 01:30:30 +03:00
jadssss
a2e0070ace update output layout hints in the GUI 2026-10-29 23:33:34 +03:00
jadssss
2ac7dfd7b7 store outputs under the input-relative path
- add _output_dir_for(): output/<relpath_without_ext>\n- use it in sequential, staged, LRC and embed pipelines\n- keep track folders unique for equal stems in different albums\n- log the layout and report folder collisions\n- add a safe one-shot flat-to-path migration script
2026-10-29 23:32:37 +03:00
jadssss
841fc8902d handle dead CUDA contexts and empty ASR results
- detect unrecoverable CUDA errors before OOM retries and clear model caches\n- keep normal OOM retries to two attempts (configured batch, then 1)\n- do not create text.txt when ASR returns only whitespace\n- warn per file and report empty_result / lrc+empty in the summary\n- keep at least 12 log lines visible in the GUI
2026-10-28 23:53:27 +03:00
jadssss
60846e3c6c load the ASR model once per process
- keep WhisperX, align and GigaAM caches process-wide and thread-safe\n- preload the ASR model at the start of the staged stage 3\n- log that the model is loaded once and then reused\n- never clear caches between files, batches or OOM retries
2026-10-28 09:54:06 +03:00
jadssss
e23acf1555 stop passing vad_filter to whisperx transcribe
- retry only lowers batch_size: configured, 1, 1\n- keep vad_filter parameters for future model-load-level control\n- do not log success in staged finalize when no text was produced
2026-10-28 09:47:27 +03:00
jadssss
5f16a6f87b base skip_existing on the last enabled stage
- add _final_artifact_present and use it for resume decisions\n- text.txt no longer marks a track done when separation is the last stage\n- apply the same rule to pre-scan, staged seeding and sequential skipping\n- keep identification-only runs resolving through text.txt\n- preserve existing metadata tags when a stage-only rerun has less info
2026-10-28 00:48:53 +03:00
jadssss
84d7a28c69 pre-scan finished tracks and load CUDA lazily
- pre-scan all inputs before chunking so ready files stay out of batches\n- start overall progress at the already-finished count\n- count skipped/resumed files correctly in sequential and staged runs\n- keep batches full and log ready/pending counts before starting\n- avoid importing Torch at startup or in cleanup for separation-only runs\n- move TF32 setup into lazy WhisperX model loading\n- detect the GPU through nvidia-smi for the GUI status line
2026-10-27 23:19:56 +03:00
jadssss
f88ca913ce make long-file CUDA OOM recoverable per file
- replace permanent _CUDA_BROKEN flag with CudaOOMError and retry helper
- retry WhisperX with configured settings, then batch=1 without VAD, then once more
- keep the loaded model cached between retries instead of reloading it
- actually pass vad_filter through transcribe() and use it in aligned ASR
- skip only the OOM file in sequential, staged and LRC pipelines
- mark OOM files with oom_skipped / lrc+oom for the summary
- fix staged vocal separation writing input_dir/vocals.wav when convert is off
- route original inputs and resumed files through safe per-file separation
- validate ffmpeg before starting the LRC task
2026-10-27 23:03:29 +03:00
jadssss
999934f174 improve GUI layout and LRC stage controls
- make the main page scrollable and keep the console reachable on small screens
- add fullscreen toggle (button + F11, Escape to exit)
- add enable switches for LRC generation/embedding and skip ready text.lrc
- sanitize multi-line ASR text before writing LRC timestamps
- emit LRC/embed progress once per file instead of twice
- increase progress-bar contrast
2026-10-27 22:29:13 +03:00
jadssss
15c0ba5d66 add resume support, metadata paths, LRCLIB order and overall progress
- store source track path in metadata.json and validate it when skipping ready files
- add skip_existing/resume_partial options to config and GUI
- identify lyrics via embedded text, Shazam, then one LRCLIB author search
- keep post-ASR LRCLIB text matching and allow cancelling safely
- recover from CUDA OOM and free VRAM between batch stages
- add overall completed/total file progress bar to GUI and pipelines
2026-10-25 20:24:01 +03:00
14 changed files with 1769 additions and 352 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ output/
preset.json
*.log
combined_files.txt

View File

@@ -66,6 +66,9 @@ class LyricsConfig:
@dataclass
class LrcConfig:
enabled: bool = True # LRC generation stage
embed_enabled: bool = True # LRC embedding stage
skip_existing: bool = True # skip tracks with a ready text.lrc
language: Optional[str] = "ru"
align_model: Optional[str] = None
device: str = "auto"
@@ -81,6 +84,9 @@ class PipelineConfig:
keep_intermediate: bool = True
overwrite: bool = True
mode: str = "sequential"
chunk_size: int = 64
skip_existing: bool = False # skip files that already have text.txt
resume_partial: bool = True # reuse existing vocals.wav / convert.wav
convert: ConvertConfig = field(default_factory=ConvertConfig)
separate: SeparateConfig = field(default_factory=SeparateConfig)

View File

@@ -37,6 +37,10 @@ def segments_to_lrc(segments: list[dict]) -> str:
text = (seg.get("text") or "").strip()
if not text:
continue
# WhisperX can return multi-line text on long segments. An LRC line
# must be a single line: any newline would produce a broken line
# without a timestamp. Collapse all whitespace into single spaces.
text = re.sub(r"\s+", " ", text)
start = float(seg.get("start", 0.0))
lines.append(f"{format_timestamp(start)}{text}")
return "\n".join(lines) + "\n"
@@ -66,6 +70,35 @@ def is_lrc(content: str) -> bool:
# ---------------------------------------------------------------- read embedded
_JUNK_LYRIC_MARKERS = (
"created with",
"russian-area.de",
"http://", "https://",
"www.",
"mp3tag",
"tag&rename",
"foobar2000",
"itunes",
)
def _is_junk_lyrics(text: str) -> bool:
"""Reject tag values that are really URL/signature junk, not lyrics."""
if not text or len(text.strip()) < 3:
return True
low = text.lower()
if any(m in low for m in _JUNK_LYRIC_MARKERS):
return True
# A "lyrics" blob that is just an email or URL — reject.
import re
stripped = re.sub(r"\s+", "", low)
if re.fullmatch(r"[a-z0-9.\-_/]+", stripped) and (
"." in stripped or "/" in stripped
):
return True
return False
def read_embedded_lyrics(audio_path: Path) -> Optional[str]:
"""Return lyrics stored in the file's tags, or None.
@@ -74,14 +107,18 @@ def read_embedded_lyrics(audio_path: Path) -> Optional[str]:
suffix = audio_path.suffix.lower()
try:
if suffix in (".mp3", ".mp2"):
return _read_mp3(audio_path)
if suffix in (".flac", ".ogg", ".opus"):
return _read_vorbis(audio_path)
if suffix in (".m4a", ".mp4", ".m4b", ".aac"):
return _read_mp4(audio_path)
except Exception: # noqa: BLE001 — never break the pipeline on tag issues
text = _read_mp3(audio_path)
elif suffix in (".flac", ".ogg", ".opus"):
text = _read_vorbis(audio_path)
elif suffix in (".m4a", ".mp4", ".m4b", ".aac"):
text = _read_mp4(audio_path)
else:
return None
except Exception: # noqa: BLE001
return None
return None
if text and _is_junk_lyrics(text):
return None
return text
def _read_mp3(path: Path) -> Optional[str]:

View File

@@ -11,20 +11,28 @@ from .config import PipelineConfig
from .convert import convert_to_wav
from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc
from .metadata import read_metadata
from .pipeline import FileResult, _save_unrecognized, collect_audio_files
from .transcribe import transcribe_aligned
from .pipeline import (
FileResult, _artifact_belongs_to, _output_dir_for, _save_unrecognized,
collect_audio_files,
)
from .transcribe import (
CudaOOMError, align_text_to_audio, transcribe, transcribe_aligned,
)
LogFn = Callable[[str], None]
ProgressFn = Callable[[int, int, str], None]
ProgressFn = Callable[[str, int, int], None] # stage, current, total
OverallFn = Callable[[int, int], None] # completed_files, total_files
def _pick_audio_source(
f: Path, output_dir: Path, config: PipelineConfig, work_dir: Path, log: LogFn,
f: Path, input_root: Path, output_dir: Path, config: PipelineConfig,
work_dir: Path, log: LogFn,
) -> Path:
mode = (config.lrc.audio_source or "auto").lower()
vocals = output_dir / f.stem / "vocals.wav"
convert = output_dir / f.stem / "convert.wav"
track_out = _output_dir_for(f, input_root, output_dir)
vocals = track_out / "vocals.wav"
convert = track_out / "convert.wav"
if mode == "vocals":
return vocals
if mode == "convert":
@@ -44,11 +52,26 @@ def _pick_audio_source(
return target
def _ready_lrc(track_out: Path, source: Path) -> Optional[str]:
"""Return existing valid LRC text for this exact source track, if any."""
if not _artifact_belongs_to(track_out, source):
return None
lrc_path = track_out / "text.lrc"
if not lrc_path.is_file():
return None
try:
content = lrc_path.read_text(encoding="utf-8")
except OSError:
return None
return content if content.strip() and is_lrc(content) else None
def run_lrc_pipeline(
config: PipelineConfig,
log: LogFn = print,
on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None,
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]:
input_dir = Path(config.input_dir)
if not input_dir.is_dir():
@@ -56,33 +79,78 @@ def run_lrc_pipeline(
output_dir = Path(config.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if not config.lrc.enabled:
log("LRC: генерация отключена — пропуск этапа")
return []
files = collect_audio_files(input_dir, config.recursive)
if not files:
log("Аудиофайлы не найдены.")
return []
lang = config.lrc.language or config.transcribe.language
# sanity: если ASR и конвертация/отделение выключены — alignment имеет смысл
# только для треков, у которых УЖЕ есть text.txt + vocals/convert.wav.
# Иначе почти всё упадёт. Предупредить заранее.
if not config.transcribe.enabled and not config.convert.enabled and not config.separate.enabled:
log(" ⚠ ASR, конвертация и отделение выключены. "
"LRC будет построен только для треков, где уже есть text.txt + vocals.wav "
"(или convert.wav). Остальные упадут с ошибкой.")
# `None` means "auto" — the language will be auto-detected during ASR
# (transcribe_aligned) or by the alignment model (align_text_to_audio).
lang = config.lrc.language
log(f"LRC: {len(files)} файлов | язык={lang or 'auto'} | "
f"источник={config.lrc.audio_source}")
results: list[FileResult] = []
total = len(files)
if on_progress:
on_progress("lrc", 0, total)
if on_overall_progress:
on_overall_progress(0, total)
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, "lrc")
log(f"\n▶ [{idx}/{total}] {f.name}")
track_out = output_dir / f.stem
track_out = _output_dir_for(f, input_dir, output_dir)
track_out.mkdir(parents=True, exist_ok=True)
lrc_path = track_out / "text.lrc"
error: Optional[str] = None
if config.lrc.skip_existing:
ready = _ready_lrc(track_out, f)
if ready is not None:
log(" ⊘ text.lrc уже готов — пропуск")
results.append(FileResult(f, None, track_out, None, "existing+lrc"))
if on_progress:
on_progress("lrc", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
continue
track_out = _output_dir_for(f, input_dir, output_dir)
track_out.mkdir(parents=True, exist_ok=True)
lrc_path = track_out / "text.lrc"
text_file = track_out / "text.txt"
error: Optional[str] = None
if config.lrc.skip_existing:
ready = _ready_lrc(track_out, f)
if ready is not None:
log(" ⊘ text.lrc уже готов — пропуск")
results.append(FileResult(f, None, track_out, None, "existing+lrc"))
if on_progress:
on_progress("lrc", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
continue
tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_"))
try:
# 0) embedded LRC from tags — бесплатно
embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None
if embedded and is_lrc(embedded):
lrc_path.write_text(embedded, encoding="utf-8")
@@ -90,36 +158,96 @@ def run_lrc_pipeline(
results.append(FileResult(f, None, track_out, None, "embedded+lrc"))
continue
audio_path = _pick_audio_source(f, output_dir, config, tmp, log)
# 1) готовый текст из text.txt
text: Optional[str] = None
if text_file.is_file():
try:
text = text_file.read_text(encoding="utf-8").strip()
except OSError as e:
raise RuntimeError(f"не читается text.txt: {e}") from e
if not text:
text = None
# 1a) text.txt уже LRC → писать его как есть, alignment не нужен
if text is not None and is_lrc(text):
lrc_path.write_text(text, encoding="utf-8")
log(" ✓ text.lrc (text.txt уже содержит LRC-таймкоды)")
results.append(FileResult(f, None, track_out, None, "text_already_lrc"))
continue
# 2) аудио для alignment
audio_path = _pick_audio_source(
f, input_dir, output_dir, config, tmp, log,
)
if not audio_path.exists():
# Если convert/separate выключены и vocals/convert.wav отсутствуют —
# понятная ошибка вместо «файл не найден»
if not config.convert.enabled and not config.separate.enabled:
raise FileNotFoundError(
f"для alignment нет аудио: {audio_path}. "
"Включите конвертацию/отделение в основном пайплайне, "
"либо запустите их один раз, либо положите vocals.wav/convert.wav."
)
raise FileNotFoundError(
f"Источник аудио не найден: {audio_path}. "
"Включите conversion/separation в основном пайплайне "
"или выберите audio_source=input."
f"источник аудио не найден: {audio_path}. "
f"Источник={config.lrc.audio_source}, "
f"convert.enabled={config.convert.enabled}, "
f"separate.enabled={config.separate.enabled}"
)
log(f" [align] {audio_path.name}")
segments, detected = transcribe_aligned(
audio_path,
model=config.transcribe.model,
language=lang,
device=config.lrc.device,
compute_type=config.transcribe.compute_type,
batch_size=config.transcribe.batch_size,
align_model=config.lrc.align_model,
local_files_only=config.transcribe.local_files_only,
)
# 3) готовый текст → forced alignment (быстро, без ASR)
if text is not None:
log(f" [align-only] text.txt найден ({len(text)} символов)")
segments, detected = align_text_to_audio(
audio_path,
text,
language=lang,
model=config.transcribe.model,
device=config.lrc.device,
compute_type=config.transcribe.compute_type,
local_files_only=config.transcribe.local_files_only,
)
else:
# 3a) нет text.txt — ASR разрешён только если transcribe.enabled
if not config.transcribe.enabled:
raise FileNotFoundError(
"text.txt отсутствует, а транскрибация выключена. "
"Включите транскрибацию в основном пайплайне или "
"положите text.txt в папку трека вручную."
)
log(" [asr+align] text.txt нет — выполняю ASR заново")
segments, detected = transcribe_aligned(
audio_path,
model=config.transcribe.model,
language=lang,
device=config.lrc.device,
compute_type=config.transcribe.compute_type,
batch_size=config.transcribe.batch_size,
align_model=config.lrc.align_model,
local_files_only=config.transcribe.local_files_only,
)
lrc_content = segments_to_lrc(segments)
if not lrc_content.strip():
log(f" ⚠ {f.name}: пустой LRC (нет сегментов)")
results.append(FileResult(
f, None, None,
"EmptyResult: alignment returned no text", "lrc+empty",
))
continue
lrc_path.write_text(lrc_content, encoding="utf-8")
log(f" ✓ text.lrc ({len(segments)} строк, язык: {detected})")
# NEW: save to JSONL for LRCLIB submission
_save_unrecognized(
config, f, lrc_content, source="lrc",
metadata=read_metadata(f),
)
results.append(FileResult(f, None, track_out, None, "lrc"))
except CudaOOMError as e:
error = f"CudaOOMError: {e}"
log(f" ⊘ {f.name}: OOM — пропущен, идём дальше")
results.append(FileResult(f, None, None, error, "lrc+oom"))
except Exception as e: # noqa: BLE001
error = f"{type(e).__name__}: {e}"
log(f" ✗ Ошибка: {error}")
@@ -127,7 +255,9 @@ def run_lrc_pipeline(
finally:
shutil.rmtree(tmp, ignore_errors=True)
if on_progress:
on_progress(idx, total, "lrc")
on_progress("lrc", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results
@@ -137,6 +267,7 @@ def run_embed_pipeline(
log: LogFn = print,
on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None,
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]:
input_dir = Path(config.input_dir)
if not input_dir.is_dir():
@@ -144,6 +275,10 @@ def run_embed_pipeline(
output_dir = Path(config.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if not config.lrc.embed_enabled:
log("LRC embed: встраивание отключено — пропуск этапа")
return []
files = collect_audio_files(input_dir, config.recursive)
if not files:
log("Аудиофайлы не найдены.")
@@ -154,15 +289,18 @@ def run_embed_pipeline(
results: list[FileResult] = []
total = len(files)
if on_progress:
on_progress("embed", 0, total)
if on_overall_progress:
on_overall_progress(0, total)
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, "embed")
lrc_path = output_dir / f.stem / "text.lrc"
track_out = _output_dir_for(f, input_dir, output_dir)
lrc_path = track_out / "text.lrc"
error: Optional[str] = None
try:
@@ -176,7 +314,7 @@ def run_embed_pipeline(
if config.lrc.embed_in_place:
target = f
else:
target = output_dir / f.stem / f.name
target = track_out / f.name
target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists() or config.overwrite:
shutil.copy2(f, target)
@@ -190,6 +328,8 @@ def run_embed_pipeline(
results.append(FileResult(f, None, None, error, "embed"))
finally:
if on_progress:
on_progress(idx, total, "embed")
on_progress("embed", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results

File diff suppressed because it is too large Load Diff

View File

@@ -91,13 +91,10 @@ def separate_vocals_batch(
total = len(input_wavs)
processed = 0
while proc.poll() is None:
time.sleep(2.0)
if progress_callback and processed < total - 1:
if proc.wait(2.0) is not None:
break
processed += 1
progress_callback(processed)
else:
proc.wait(2.0)
if proc.returncode != 0:
raise RuntimeError(f"unblend batch failed with exit code {proc.returncode}")

View File

@@ -6,11 +6,147 @@ Models are cached; aggressive VRAM cleanup happens after each file.
from __future__ import annotations
import gc
import threading
import time
from pathlib import Path
from typing import Optional
# The model caches below are process-wide. They are intentionally never
# cleared between files or batches: reloading WhisperX takes minutes and is
# what made runs look like they reloaded the model every batch. The lock makes
# the "load once, reuse forever" guarantee safe even if ASR is ever called
# from multiple threads.
_MODEL_LOCK = threading.RLock()
_REUSE_LOGGED: set = set()
# CUDA context failures that cannot be recovered by retrying or empty_cache().
# They require restarting the app (or switching to CPU).
_DEAD_CUDA_MARKERS = (
"invalid device ordinal",
"cudaerrorinvaliddevice",
"device-side assert",
"unspecified launch failure",
)
def _is_dead_cuda(exc: BaseException) -> bool:
err = str(exc).lower()
return any(m in err for m in _DEAD_CUDA_MARKERS)
_WhisperXModels._cache.clear()
_AlignModels._cache.clear()
_GigaAMModels._cache.clear()
_StableTSModels._cache.clear()
_REUSE_LOGGED.clear()
# Phrases that are strong signals of ASR hallucination (end credits)
_HALLUCINATION_PHRASES = (
"продолжение следует",
"спасибо за внимание",
"спасибо за просмотр",
"спасибо за подписку",
"подписывайтесь на канал",
"ставьте лайки",
"не забудьте подписаться",
"субтитры создавал",
"субтитры сделал",
"субтитры подготовил",
"редактор субтитров",
"корректор",
"thank you for watching",
"thanks for watching",
"please subscribe",
"subscribe to my channel",
"like and subscribe",
)
def _is_hallucination(text: str) -> bool:
"""Return True if the text looks like an ASR hallucination.
Checks:
- known call-to-action / promo phrases;
- repeated-word tic ("Музыка Музыка").
"""
if not text:
return False
t = " ".join(text.lower().split()).strip(" .!?…,;:\"'«»")
for phrase in _HALLUCINATION_PHRASES:
if phrase in t:
return True
words = t.split()
if len(words) == 2 and words[0] == words[1]:
return True
return False
def _detect_language(model_obj, audio_or_path, fallback: str = "ru") -> str:
"""Detect the spoken language from audio using the ASR model itself.
Returns a lowercase ISO code or `fallback` on failure.
"""
try:
lang, _prob = model_obj.detect_language(audio_or_path)
if lang:
return lang.lower()
except Exception:
pass
return fallback
class _StableTSModels:
_cache: dict = {}
@classmethod
def get(cls, model_name: str, device: str, compute_type: Optional[str],
local_files_only: bool = False) -> Any:
if compute_type is None:
compute_type = "int8_float16" if device == "cuda" else "int8"
key = (model_name, device, compute_type, local_files_only)
with _MODEL_LOCK:
if key not in cls._cache:
import stable_whisper
print(f" [ALIGN] loading stable-ts {model_name} on {device} "
f"({compute_type}, local_files_only={local_files_only})...")
def load() -> 'faster_whisper.WhisperModel':
return stable_whisper.load_faster_whisper(
model_name, device=device, compute_type=compute_type,
local_files_only=local_files_only,
)
cls._cache[key] = _retry(load)
print(" [ALIGN] stable-ts model loaded once — reuse.")
return cls._cache[key]
def _clear_model_caches() -> None:
_WhisperXModels._cache.clear()
_AlignModels._cache.clear()
_GigaAMModels._cache.clear()
_StableTSModels._cache.clear()
_REUSE_LOGGED.clear()
class CudaOOMError(RuntimeError):
"""CUDA OOM, переживший все стратегии понижения нагрузки."""
def _is_cuda_error(exc: BaseException) -> bool:
err = str(exc).lower()
return (
"out of memory" in err
or "cuda" in err
or "device ordinal" in err
or "cublas" in err
or "cudnn" in err
)
def _resolve_device(device: str) -> str:
if device in ("cuda", "cpu"):
return device
@@ -39,10 +175,63 @@ def _free_vram() -> None:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
except Exception: # noqa: BLE001 — cleanup must never mask the original error
pass
def _enable_tf32() -> None:
"""Enable TF32 matmul on Ampere+ GPUs (RTX 30/40).
Called lazily right before a WhisperX model is loaded, so runs that do not
use ASR never import Torch or create a CUDA context at startup.
"""
try:
import torch
if torch.cuda.is_available():
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
except Exception: # noqa: BLE001 — purely an optimization
pass
def _model_transcribe_with_retry(
model_obj, audio, language, batch_size, name="<audio>",
):
"""Try progressively lighter settings so one long file can still finish.
The loaded model is intentionally kept in the cache between attempts:
reloading it costs minutes and does not help with an OOM caused by a long
audio file. VAD is not touched here — WhisperX expects it at model-load
time (``vad_model``/``vad_options``), not per transcription call. A dead
CUDA context is never retried: caches are dropped and CudaOOMError is
raised immediately. If even batch=1 fails with a normal OOM, raise
CudaOOMError so the caller can skip this file and continue with the rest
of the batch.
"""
attempts = [batch_size, 1]
last: Optional[Exception] = None
for i, bs in enumerate(attempts, 1):
try:
return model_obj.transcribe(
audio, batch_size=bs, language=language,
)
except Exception as e: # noqa: BLE001
if _is_dead_cuda(e):
_clear_model_caches()
raise CudaOOMError(
f"CUDA context died on {name}: {e}. "
"GPU state is unrecoverable — restart the app or switch to CPU."
) from e
if not _is_cuda_error(e):
raise
last = e
print(f" [ASR] OOM on {name} (attempt {i}/{len(attempts)}, bs={bs}): {e}")
_free_vram()
raise CudaOOMError(
f"OOM after {len(attempts)} attempts on {name}: {last}"
) from last
class _WhisperXModels:
_cache: dict = {}
@@ -52,21 +241,27 @@ class _WhisperXModels:
if compute_type is None:
compute_type = "int8_float16" if device == "cuda" else "int8"
key = (model_name, device, compute_type, local_files_only)
if key not in cls._cache:
import whisperx
print(f" [ASR] loading WhisperX {model_name} on {device} "
f"({compute_type}, local_files_only={local_files_only})...")
with _MODEL_LOCK:
if key not in cls._cache:
if device == "cuda":
_enable_tf32()
import whisperx
print(f" [ASR] loading WhisperX {model_name} on {device} "
f"({compute_type}, local_files_only={local_files_only})...")
def load():
return whisperx.load_model(
model_name, device,
compute_type=compute_type,
local_files_only=local_files_only,
)
def load():
return whisperx.load_model(
model_name, device,
compute_type=compute_type,
local_files_only=local_files_only,
)
cls._cache[key] = _retry(load)
print(" [ASR] model loaded.")
return cls._cache[key]
cls._cache[key] = _retry(load)
print(" [ASR] model loaded once — subsequent files will reuse it.")
elif ("whisperx",) + key not in _REUSE_LOGGED:
_REUSE_LOGGED.add(("whisperx",) + key)
print(f" [ASR] reusing loaded WhisperX {model_name} ({device}).")
return cls._cache[key]
class _AlignModels:
@@ -75,20 +270,24 @@ class _AlignModels:
@classmethod
def get(cls, language_code: str, device: str, model_name: Optional[str] = None):
key = (language_code, device, model_name or "")
if key not in cls._cache:
import whisperx
print(f" [ALIGN] loading align model for {language_code} "
f"({model_name or 'default'}) on {device}...")
with _MODEL_LOCK:
if key not in cls._cache:
import whisperx
print(f" [ALIGN] loading align model for {language_code} "
f"({model_name or 'default'}) on {device}...")
def load():
kwargs = {"language_code": language_code, "device": device}
if model_name:
kwargs["model_name"] = model_name
return whisperx.load_align_model(**kwargs)
def load():
kwargs = {"language_code": language_code, "device": device}
if model_name:
kwargs["model_name"] = model_name
return whisperx.load_align_model(**kwargs)
cls._cache[key] = _retry(load)
print(" [ALIGN] align model loaded.")
return cls._cache[key]
cls._cache[key] = _retry(load)
print(" [ALIGN] align model loaded once — reusing it.")
elif ("align",) + key not in _REUSE_LOGGED:
_REUSE_LOGGED.add(("align",) + key)
print(f" [ALIGN] reusing loaded align model for {language_code}.")
return cls._cache[key]
class _GigaAMModels:
@@ -102,14 +301,37 @@ class _GigaAMModels:
"auto": ["CUDAExecutionProvider", "CPUExecutionProvider"],
}.get(provider, ["CPUExecutionProvider"])
key = tuple(providers)
if key not in cls._cache:
import onnx_asr
with _MODEL_LOCK:
if key not in cls._cache:
import onnx_asr
def load():
return 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]
cls._cache[key] = _retry(load)
return cls._cache[key]
def preload_transcription_model(
engine: str = "whisperx",
model: str = "large-v3",
device: str = "auto",
compute_type: Optional[str] = None,
gigaam_provider: str = "auto",
local_files_only: bool = False,
) -> None:
"""Load the ASR model once so the per-file loop only reuses it.
All model caches are process-wide and are never cleared between files,
batches or pipeline runs. This helper just makes the first load explicit.
"""
device = _resolve_device(device)
if engine == "whisperx":
_WhisperXModels.get(model, device, compute_type, local_files_only)
elif engine == "gigaam":
_GigaAMModels.get(gigaam_provider)
else:
raise ValueError(f"Unknown ASR engine: {engine!r}")
def transcribe(
@@ -127,22 +349,10 @@ def transcribe(
device = _resolve_device(device)
if engine == "whisperx":
try:
return _transcribe_whisperx(
audio_path, model, language, device, compute_type,
batch_size, local_files_only, vad_filter,
)
except Exception as e:
err = str(e).lower()
if "out of memory" in err or "cuda" in err or "device ordinal" in err:
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
_WhisperXModels._cache.clear()
_free_vram()
return _transcribe_whisperx(
audio_path, model, language, device, compute_type,
batch_size, local_files_only, vad_filter,
)
raise
return _transcribe_whisperx(
audio_path, model, language, device, compute_type,
batch_size, local_files_only, vad_filter,
)
if engine == "gigaam":
return _transcribe_gigaam(audio_path, gigaam_provider)
@@ -162,8 +372,24 @@ def _transcribe_whisperx(
import whisperx
model = _WhisperXModels.get(model_name, device, compute_type, local_files_only)
audio = whisperx.load_audio(str(audio_path))
result = model.transcribe(audio, batch_size=batch_size, language=language)
text = " ".join(seg["text"].strip() for seg in result["segments"]).strip()
result = _model_transcribe_with_retry(
model, audio, language, batch_size, name=audio_path.name,
)
# Filter out hallucinated / spammy segments.
segments = result.get("segments") or []
kept = []
for seg in segments:
t = (seg.get("text") or "").strip()
if not t or _is_hallucination(t):
continue
if seg.get("avg_logprob", 0.0) < -1.2:
continue
kept.append(seg)
text = " ".join(s["text"].strip() for s in kept).strip()
if _is_hallucination(text):
text = ""
del audio, result
_free_vram()
return text
@@ -183,6 +409,7 @@ def transcribe_aligned(
batch_size: int = 8,
align_model: Optional[str] = None,
local_files_only: bool = False,
vad_filter: bool = True,
) -> tuple[list[dict], str]:
"""Transcribe + word-level align. Returns (aligned_segments, detected_language).
@@ -193,7 +420,9 @@ def transcribe_aligned(
device = _resolve_device(device)
model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only)
audio = whisperx.load_audio(str(audio_path))
result = model_obj.transcribe(audio, batch_size=batch_size, language=language)
result = _model_transcribe_with_retry(
model_obj, audio, language, batch_size, name=audio_path.name,
)
detected = result.get("language") or language or "en"
@@ -208,6 +437,47 @@ def transcribe_aligned(
print(f" [ALIGN] failed: {e}. Falling back to raw WhisperX segments.")
segments = result["segments"]
# Filter out hallucinated segments.
segments = [s for s in segments if not _is_hallucination(s.get("text", ""))]
del audio, result
_free_vram()
return segments, detected
def align_text_to_audio(
audio_path: Path,
text: str,
language: Optional[str] = "ru",
model: str = "large-v3",
device: str = "auto",
compute_type: Optional[str] = None,
local_files_only: bool = False,
) -> tuple[list[dict], str]:
"""Forced alignment of EXISTING text to audio. No ASR.
When `language` is None, the language is auto-detected from the audio
using the alignment model itself (which is a faster-whisper model and
exposes `detect_language`).
"""
import stable_whisper as _sw
device = _resolve_device(device)
model_obj = _StableTSModels.get(model, device, compute_type, local_files_only)
if language:
lang = language
else:
lang = _detect_language(model_obj, str(audio_path), fallback="ru")
try:
result = model_obj.align(str(audio_path), text, language=lang)
except Exception as e: # noqa: BLE001
raise RuntimeError(f"align failed on {audio_path.name}: {e}") from e
segments = [
{"start": float(seg.start), "end": float(seg.end),
"text": (seg.text or "").strip()}
for seg in result.segments
if (seg.text or "").strip()
]
return segments, lang

View File

@@ -2,16 +2,17 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtGui import QColor, QDesktopServices, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QTabWidget,
QVBoxLayout, QWidget,
QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSpinBox,
QTabWidget, QVBoxLayout, QWidget,
)
from core.config import (
@@ -42,17 +43,30 @@ MODE_LABELS = {
def _detect_device_info() -> tuple[str, str]:
"""Report the GPU via nvidia-smi.
Torch must not be imported here: importing it initializes a CUDA context
and reserves VRAM even for runs that only need separation. The ASR stage
imports Torch lazily when it actually loads a model.
"""
try:
import torch
except ImportError:
return "⚠ PyTorch не установлен", "#f38ba8"
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"
out = subprocess.run(
["nvidia-smi", "--query-gpu=name,memory.total",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5, check=True,
).stdout.strip()
except (OSError, subprocess.SubprocessError):
out = ""
if out:
parts = [p.strip() for p in out.splitlines()[0].split(",")]
name = parts[0] or "CUDA GPU"
if len(parts) > 1:
try:
vram = float(parts[1]) / 1024 # MiB -> GiB
return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1"
except ValueError:
pass
return f"✓ CUDA: {name}", "#a6e3a1"
return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af"
@@ -71,8 +85,17 @@ class MainWindow(QMainWindow):
# ---------------- UI ----------------
def _build_ui(self) -> None:
# The whole page lives in a scroll area: on small screens the user can
# scroll down to the log instead of the console being cut off by the
# bottom edge of the window.
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setCentralWidget(scroll)
central = QWidget()
self.setCentralWidget(central)
scroll.setWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(18, 16, 18, 16)
root.setSpacing(12)
@@ -82,6 +105,10 @@ class MainWindow(QMainWindow):
title.setObjectName("appTitle")
header.addWidget(title)
header.addStretch(1)
self.fullscreen_btn = QPushButton("⛶ На весь экран")
self.fullscreen_btn.setToolTip("Развернуть окно на весь экран (F11)")
self.fullscreen_btn.clicked.connect(self._toggle_fullscreen)
header.addWidget(self.fullscreen_btn)
self.device_label = QLabel("")
self.device_label.setObjectName("deviceInfo")
header.addWidget(self.device_label)
@@ -101,6 +128,16 @@ class MainWindow(QMainWindow):
self.mode_cb.addItem(label, key)
top_layout.addRow("Режим:", self.mode_cb)
self.chunk_size_spin = QSpinBox()
self.chunk_size_spin.setRange(1, 10000)
self.chunk_size_spin.setToolTip(
"Размер батча в staged-режиме.\n"
"После каждого батча: удаляются временные файлы, освобождается VRAM.\n"
"Модели WhisperX остаются в памяти — перезагрузки не будет.\n"
"64 — разумный компромисс для 32 ГБ RAM и 8 ГБ VRAM."
)
top_layout.addRow("Размер батча:", self.chunk_size_spin)
opts = QHBoxLayout()
self.recursive_cb = QCheckBox("Рекурсивный обход")
self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные")
@@ -110,6 +147,23 @@ class MainWindow(QMainWindow):
opts.addWidget(self.overwrite_cb)
opts.addStretch(1)
top_layout.addRow("Опции:", opts)
resume_opts = QHBoxLayout()
self.skip_existing_cb = QCheckBox("Пропускать готовые (text.txt)")
self.skip_existing_cb.setToolTip(
"Не трогать файлы, у которых в <output>/<relpath>/<stem>/ уже есть text.txt.\n"
"Позволяет продолжить большой прогон после сбоя без пересчёта.\n"
"Если в metadata.json записан path другого файла — папка не считается готовой."
)
self.resume_partial_cb = QCheckBox("Подхватывать vocals.wav / convert.wav")
self.resume_partial_cb.setToolTip(
"Если text.txt ещё нет, но есть vocals.wav — сразу переходить к\n"
"транскрибации. Если есть только convert.wav — пропустить конвертацию."
)
resume_opts.addWidget(self.skip_existing_cb)
resume_opts.addWidget(self.resume_partial_cb)
resume_opts.addStretch(1)
top_layout.addRow("Resume:", resume_opts)
root.addWidget(top_box)
self.tabs = QTabWidget()
@@ -126,6 +180,7 @@ class MainWindow(QMainWindow):
self.convert_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.separate_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.transcribe_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.lrc_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.engine_cb.currentTextChanged.connect(self._sync_engine_fields)
row = QHBoxLayout()
@@ -156,12 +211,35 @@ class MainWindow(QMainWindow):
self.progress.setFormat("Ожидание")
root.addWidget(self.progress)
self.overall_progress = QProgressBar()
self.overall_progress.setObjectName("overallProgress")
self.overall_progress.setFormat("Общий прогресс: ожидание")
root.addWidget(self.overall_progress)
self.log_view = QPlainTextEdit()
self.log_view.setObjectName("log")
self.log_view.setReadOnly(True)
# Keep at least ~12 log lines visible regardless of the UI font size.
line_height = self.log_view.fontMetrics().lineSpacing()
self.log_view.setMinimumHeight(max(280, line_height * 12 + 24))
self.log_view.setPlaceholderText("Здесь появится лог выполнения…")
root.addWidget(self.log_view, 1)
self._fs_shortcut = QShortcut(QKeySequence("F11"), self)
self._fs_shortcut.activated.connect(self._toggle_fullscreen)
self._esc_shortcut = QShortcut(QKeySequence("Escape"), self)
self._esc_shortcut.activated.connect(self._leave_fullscreen)
def _toggle_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
def _leave_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
def _with_browse(self, edit: QLineEdit, picker) -> QWidget:
w = QWidget()
h = QHBoxLayout(w)
@@ -209,8 +287,17 @@ class MainWindow(QMainWindow):
self.lyrics_user_agent = QLineEdit()
form.addRow("User-Agent:", self.lyrics_user_agent)
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам")
self.lyrics_use_tags_cb.setToolTip(
"Использовать artist/title из тегов файла для поиска на LRCLIB.\n"
"Срабатывает как fallback, если Shazam ничего не нашёл\n"
"или его результат отключён."
)
form.addRow("", self.lyrics_use_tags_cb)
self.lyrics_use_shazam_cb = QCheckBox("Искать по Shazam")
self.lyrics_use_shazam_cb.setToolTip(
"Приоритетный источник для поиска на LRCLIB: artist/title из Shazam.\n"
"Теги файла используются только если Shazam не дал результата."
)
form.addRow("", self.lyrics_use_shazam_cb)
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy)")
form.addRow("", self.lyrics_after_asr_cb)
@@ -281,7 +368,7 @@ class MainWindow(QMainWindow):
self.separate_save_vocals_cb = QCheckBox("Всегда сохранять vocals.wav в output")
self.separate_save_vocals_cb.setToolTip(
"Независимо от 'Сохранять промежуточные': вокал всегда будет лежать "
"в <output>/<stem>/vocals.wav — полезно для LRC-этапа."
"в <output>/<relpath>/<stem>/vocals.wav — полезно для LRC-этапа."
)
form.addRow("", self.separate_save_vocals_cb)
return w
@@ -325,7 +412,7 @@ class MainWindow(QMainWindow):
form.setSpacing(10)
info = QLabel(
"1) Сгенерировать LRC: WhisperX + align → <output>/<stem>/text.lrc.\n"
"1) Сгенерировать LRC: WhisperX + align → <output>/<relpath>/<stem>/text.lrc.\n"
"2) Встроить LRC: записывает LRC в тег lyrics аудио (USLT/LYRICS/©lyr).\n"
"Если 'in-place' выключено — оригинал не трогается, создаётся копия в output."
)
@@ -333,6 +420,25 @@ class MainWindow(QMainWindow):
info.setStyleSheet("color: #a6adc8;")
form.addRow(info)
self.lrc_enabled_cb = QCheckBox("Включён (генерация LRC)")
self.lrc_enabled_cb.setToolTip(
"Отключите, чтобы пропустить этап генерации text.lrc.\n"
"Остальные этапы пайплайна продолжат работать."
)
form.addRow("", self.lrc_enabled_cb)
self.lrc_embed_enabled_cb = QCheckBox("Включён (встраивание LRC в аудио)")
self.lrc_embed_enabled_cb.setToolTip(
"Отключите, чтобы пропустить этап встраивания LRC в теги аудио."
)
form.addRow("", self.lrc_embed_enabled_cb)
self.lrc_skip_existing_cb = QCheckBox("Пропускать готовые text.lrc")
self.lrc_skip_existing_cb.setToolTip(
"Если <output>/<relpath>/<stem>/text.lrc уже существует и содержит корректный LRC,\n"
"файл не будет пересчитан. Папка с metadata.json от другого трека\n"
"готовой не считается."
)
form.addRow("", self.lrc_skip_existing_cb)
self.lrc_audio_source_cb = QComboBox()
self.lrc_audio_source_cb.addItems(["auto", "vocals", "convert", "input"])
self.lrc_audio_source_cb.setToolTip(
@@ -357,7 +463,7 @@ class MainWindow(QMainWindow):
self.lrc_embed_in_place_cb = QCheckBox("Встраивать in-place (перезаписывать оригинал)")
self.lrc_embed_in_place_cb.setToolTip(
"Если выключено (по умолчанию) — создаётся копия в <output>/<stem>/<name>.\n"
"Если выключено (по умолчанию) — создаётся копия в <output>/<relpath>/<stem>/<name>.\n"
"Если включено — оригинальный файл модифицируется."
)
form.addRow("", self.lrc_embed_in_place_cb)
@@ -392,6 +498,7 @@ class MainWindow(QMainWindow):
bar.setTabTextColor(2, active if self.convert_enabled_cb.isChecked() else inactive)
bar.setTabTextColor(3, active if self.separate_enabled_cb.isChecked() else inactive)
bar.setTabTextColor(4, active if self.transcribe_enabled_cb.isChecked() else inactive)
bar.setTabTextColor(5, active if self.lrc_enabled_cb.isChecked() else inactive)
def _sync_engine_fields(self) -> None:
is_whisperx = self.engine_cb.currentText() == "whisperx"
@@ -410,6 +517,8 @@ class MainWindow(QMainWindow):
self.recursive_cb.setChecked(cfg.recursive)
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
self.overwrite_cb.setChecked(cfg.overwrite)
self.skip_existing_cb.setChecked(cfg.skip_existing)
self.resume_partial_cb.setChecked(cfg.resume_partial)
i = self.mode_cb.findData(cfg.mode)
if i >= 0:
self.mode_cb.setCurrentIndex(i)
@@ -460,6 +569,10 @@ class MainWindow(QMainWindow):
self.lrc_align_model_edit.setText(cfg.lrc.align_model or "")
self.lrc_device_cb.setCurrentText(cfg.lrc.device)
self.lrc_embed_in_place_cb.setChecked(cfg.lrc.embed_in_place)
self.lrc_enabled_cb.setChecked(cfg.lrc.enabled)
self.lrc_embed_enabled_cb.setChecked(cfg.lrc.embed_enabled)
self.lrc_skip_existing_cb.setChecked(cfg.lrc.skip_existing)
self.chunk_size_spin.setValue(cfg.chunk_size)
def _collect_config(self) -> PipelineConfig:
cfg = PipelineConfig()
@@ -468,6 +581,8 @@ class MainWindow(QMainWindow):
cfg.recursive = self.recursive_cb.isChecked()
cfg.keep_intermediate = self.keep_intermediate_cb.isChecked()
cfg.overwrite = self.overwrite_cb.isChecked()
cfg.skip_existing = self.skip_existing_cb.isChecked()
cfg.resume_partial = self.resume_partial_cb.isChecked()
cfg.mode = self.mode_cb.currentData()
cfg.shazam = ShazamConfig(
@@ -516,12 +631,16 @@ class MainWindow(QMainWindow):
)
lrc_lang = self.lrc_language_cb.currentText().strip()
cfg.lrc = LrcConfig(
enabled=self.lrc_enabled_cb.isChecked(),
embed_enabled=self.lrc_embed_enabled_cb.isChecked(),
skip_existing=self.lrc_skip_existing_cb.isChecked(),
language=None if lrc_lang in ("", "auto") else lrc_lang,
align_model=self.lrc_align_model_edit.text().strip() or None,
device=self.lrc_device_cb.currentText(),
audio_source=self.lrc_audio_source_cb.currentText(),
embed_in_place=self.lrc_embed_in_place_cb.isChecked(),
)
cfg.chunk_size = self.chunk_size_spin.value()
return cfg
# ---------------- handlers ----------------
@@ -589,22 +708,52 @@ class MainWindow(QMainWindow):
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.")
return
if task == "lrc" and not shutil.which(cfg.convert.ffmpeg_path):
QMessageBox.warning(
self, "ffmpeg не найден",
f"'{cfg.convert.ffmpeg_path}' не найден в PATH.\n"
"LRC-этапу нужен ffmpeg для подготовки аудио."
)
return
if task == "lrc" and cfg.transcribe.engine != "whisperx":
QMessageBox.warning(
self, "LRC требует WhisperX",
"Align-модель доступна только для WhisperX. Переключите движок на whisperx."
)
return
if task == "lrc" and not cfg.lrc.enabled:
QMessageBox.warning(
self, "LRC отключён",
"Этап генерации LRC выключен. Включите его на вкладке LRC."
)
return
if task == "lrc" and not cfg.transcribe.enabled:
QMessageBox.information(
self, "Транскрибация выключена",
"Транскрибация отключена, ASR запускаться не будет."
"LRC будет построен только по готовым text.txt."
)
if task == "embed" and not cfg.lrc.embed_enabled:
QMessageBox.warning(
self, "Встраивание LRC отключено",
"Этап встраивания LRC выключен. Включите его на вкладке LRC."
)
return
self.log_view.clear()
self._log_device_info()
self.progress.setValue(0)
self.progress.setFormat("Запуск…")
self.overall_progress.setMaximum(1)
self.overall_progress.setValue(0)
self.overall_progress.setFormat("Общий прогресс: запуск…")
self._set_busy(True)
self._worker = PipelineWorker(cfg, task=task)
self._worker.log.connect(self._append_log)
self._worker.progress.connect(self._on_progress)
self._worker.overall_progress.connect(self._on_overall_progress)
self._worker.finished_ok.connect(self._on_finished)
self._worker.failed.connect(self._on_failed)
self._worker.start()
@@ -619,21 +768,61 @@ class MainWindow(QMainWindow):
sb = self.log_view.verticalScrollBar()
sb.setValue(sb.maximum())
def _on_progress(self, done: int, total: int, stage: str) -> None:
self.progress.setMaximum(total)
self.progress.setValue(done)
def _on_progress(self, stage: str, done: int, total: int) -> None:
label = STAGE_LABELS.get(stage, stage)
self.progress.setFormat(f"{label} — %v / %m")
if total > 0:
self.progress.setMaximum(total)
self.progress.setValue(min(done, total))
self.progress.setFormat(f"{label}: %v / %m")
else:
# Nothing to do for this stage — still show which stage we are on
# instead of leaving the previous stage's label frozen.
self.progress.setMaximum(1)
self.progress.setValue(0)
self.progress.setFormat(f"{label}: 0 / 0")
def _on_overall_progress(self, done: int, total: int) -> None:
"""Overall job progress: completed files / all files in the run."""
if total <= 0:
self.overall_progress.setMaximum(1)
self.overall_progress.setValue(0)
self.overall_progress.setFormat("Общий прогресс: 0 / 0")
return
done = max(0, min(done, total))
remaining = total - done
percent = int(done * 100 / total)
self.overall_progress.setMaximum(total)
self.overall_progress.setValue(done)
self.overall_progress.setFormat(
f"Общий прогресс: {done} / {total} ({percent}%) · осталось {remaining}"
)
def _on_finished(self, results: list) -> None:
self._set_busy(False)
ok = sum(1 for r in results if r.error is None)
fail = len(results) - ok
self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}")
total = self.overall_progress.maximum()
done = self.overall_progress.value()
if total > 0 and done >= total:
self.overall_progress.setFormat(
f"Общий прогресс: {done} / {total} — завершено"
)
elif total > 0:
remaining = total - done
self.overall_progress.setFormat(
f"Общий прогресс: {done} / {total} — остановлено, осталось {remaining}"
)
self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
def _on_failed(self, message: str) -> None:
self._set_busy(False)
self.progress.setFormat("Ошибка")
if self.overall_progress.maximum() > 0:
self.overall_progress.setFormat(
f"Общий прогресс: {self.overall_progress.value()} / "
f"{self.overall_progress.maximum()} — ошибка"
)
self._append_log(f"\n✗ Ошибка пайплайна: {message}")
QMessageBox.critical(self, "Ошибка", message)

View File

@@ -170,20 +170,32 @@ QCheckBox::indicator:checked {
}
QProgressBar {
background-color: #181825;
border: 1px solid #313244;
background-color: #0b0b12;
border: 1px solid #7f849c;
border-radius: 8px;
text-align: center;
color: #cdd6f4;
min-height: 24px;
max-height: 24px;
font-weight: 500;
color: #ffffff;
min-height: 26px;
max-height: 26px;
font-weight: 700;
}
QProgressBar::chunk {
background-color: #89b4fa;
background-color: #1e66f5;
border-radius: 7px;
margin: 1px;
}
QProgressBar#overallProgress {
min-height: 30px;
max-height: 30px;
font-weight: 700;
color: #ffffff;
border: 1px solid #a6adc8;
}
QProgressBar#overallProgress::chunk {
background-color: #2f9e44;
border-radius: 8px;
margin: 2px;
}
QScrollBar:vertical {
background: #181825;

View File

@@ -10,7 +10,8 @@ from core.config import PipelineConfig
class PipelineWorker(QThread):
log = Signal(str)
progress = Signal(int, int, str) # done, total, stage
progress = Signal(str, int, int) # stage, current, total_for_stage
overall_progress = Signal(int, int) # completed_files, total_files
finished_ok = Signal(list)
failed = Signal(str)
@@ -35,8 +36,9 @@ class PipelineWorker(QThread):
results = runner(
self._config,
log=self.log.emit,
on_progress=lambda d, t, s: self.progress.emit(d, t, s),
on_progress=lambda s, d, t: self.progress.emit(s, d, t),
stop_event=self._stop_event,
on_overall_progress=lambda d, t: self.overall_progress.emit(d, t),
)
self.finished_ok.emit(results)
except Exception as exc: # noqa: BLE001

12
main.py
View File

@@ -26,20 +26,8 @@ def preload_cudnn() -> None:
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:
preload_cudnn()
enable_tf32()
from PySide6.QtWidgets import QApplication
from gui.theme import DARK_STYLESHEET

40
mark_no_speech.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Mark tracks with metadata.empty_text=true if their text is missing or hallucinated."""
import json, sys
from pathlib import Path
HALLUCINATION = (
"продолжение следует", "субтитры сделан", "субтитры создава",
"created with", "russian-area.de", "музыка музыка",
)
def main(root: Path) -> int:
count = 0
for meta in root.rglob("metadata.json"):
track = meta.parent
txt = track / "text.txt"
if txt.is_file():
body = txt.read_text(encoding="utf-8", errors="ignore").strip().lower()
# If the text is real (not hallucination) and not empty, skip marking.
if body and not any(h in body for h in HALLUCINATION):
continue
# Remove the text.txt so the next run will re-process it.
txt.unlink()
# Set empty_text=True in metadata.
try:
data = json.loads(meta.read_text(encoding="utf-8"))
except (OSError, ValueError):
data = {}
data["empty_text"] = True
meta.write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8",
)
print(f"marked: {track}")
count += 1
print(f"\nmarked {count} tracks")
return 0
if __name__ == "__main__":
sys.exit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Migrate flat output/<stem>/ to output/<relpath>/<stem>/ (one-shot)."""
from __future__ import annotations
import argparse, json, shutil, sys
from pathlib import Path
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--input-root", required=True, type=Path)
ap.add_argument("--output-root", required=True, type=Path)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
input_root = args.input_root.resolve()
output_root = args.output_root.resolve()
if not input_root.is_dir():
print(f"input root not found: {input_root}", file=sys.stderr); return 1
if not output_root.is_dir():
print(f"output root not found: {output_root}", file=sys.stderr); return 1
moved = legacy = conflicts = errors = skipped = 0
artifact_names = (
"metadata.json", "text.txt", "text.lrc",
"vocals.wav", "convert.wav",
)
for track_dir in sorted(output_root.iterdir()):
if not track_dir.is_dir():
continue
if track_dir.name.startswith("_"):
continue
# Never treat an album/parent folder as a flat track folder.
if not any((track_dir / name).is_file() for name in artifact_names):
skipped += 1
continue
target = output_root / "_legacy" / track_dir.name
meta_file = track_dir / "metadata.json"
if meta_file.is_file():
try:
data = json.loads(meta_file.read_text(encoding="utf-8"))
stored = data.get("path") if isinstance(data, dict) else None
except (OSError, ValueError):
stored = None
if stored:
try:
rel = Path(stored).relative_to(input_root)
target = output_root / rel.with_suffix("")
except ValueError:
target = output_root / "_legacy" / track_dir.name
if target == track_dir:
continue
if track_dir in target.parents:
print(f"skip: {track_dir.name} -> target inside itself")
skipped += 1
continue
if target.exists():
print(f"conflict: {track_dir.name} -> "
f"{target.relative_to(output_root)}")
conflicts += 1
continue
if args.dry_run:
print(f"DRY: {track_dir.name} -> {target.relative_to(output_root)}")
continue
try:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(track_dir), str(target))
if "_legacy" in target.parts:
legacy += 1
else:
moved += 1
except OSError as e:
print(f"error: {track_dir.name}: {e}")
errors += 1
print(f"\nmoved: {moved} quarantined: {legacy} "
f"conflicts: {conflicts} skipped: {skipped} errors: {errors} "
f"(dry_run={args.dry_run})")
return 0 if errors == 0 else 2
if __name__ == "__main__":
sys.exit(main())

View File

@@ -3,6 +3,7 @@ PySide6>=6.6
# ASR
whisperx>=3.1
stable-ts>=2.16
onnx-asr>=0.4
# Recognition & lyrics