Compare commits

...

9 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
9 changed files with 948 additions and 182 deletions

View File

@@ -70,6 +70,35 @@ def is_lrc(content: str) -> bool:
# ---------------------------------------------------------------- read embedded # ---------------------------------------------------------------- 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]: def read_embedded_lyrics(audio_path: Path) -> Optional[str]:
"""Return lyrics stored in the file's tags, or None. """Return lyrics stored in the file's tags, or None.
@@ -78,14 +107,18 @@ def read_embedded_lyrics(audio_path: Path) -> Optional[str]:
suffix = audio_path.suffix.lower() suffix = audio_path.suffix.lower()
try: try:
if suffix in (".mp3", ".mp2"): if suffix in (".mp3", ".mp2"):
return _read_mp3(audio_path) text = _read_mp3(audio_path)
if suffix in (".flac", ".ogg", ".opus"): elif suffix in (".flac", ".ogg", ".opus"):
return _read_vorbis(audio_path) text = _read_vorbis(audio_path)
if suffix in (".m4a", ".mp4", ".m4b", ".aac"): elif suffix in (".m4a", ".mp4", ".m4b", ".aac"):
return _read_mp4(audio_path) text = _read_mp4(audio_path)
except Exception: # noqa: BLE001 — never break the pipeline on tag issues else:
return None
except Exception: # noqa: BLE001
return None return None
return None if text and _is_junk_lyrics(text):
return None
return text
def _read_mp3(path: Path) -> Optional[str]: def _read_mp3(path: Path) -> Optional[str]:

View File

@@ -12,9 +12,12 @@ from .convert import convert_to_wav
from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc
from .metadata import read_metadata from .metadata import read_metadata
from .pipeline import ( from .pipeline import (
FileResult, _artifact_belongs_to, _save_unrecognized, collect_audio_files, FileResult, _artifact_belongs_to, _output_dir_for, _save_unrecognized,
collect_audio_files,
)
from .transcribe import (
CudaOOMError, align_text_to_audio, transcribe, transcribe_aligned,
) )
from .transcribe import transcribe_aligned
LogFn = Callable[[str], None] LogFn = Callable[[str], None]
@@ -23,11 +26,13 @@ OverallFn = Callable[[int, int], None] # completed_files, total_files
def _pick_audio_source( 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: ) -> Path:
mode = (config.lrc.audio_source or "auto").lower() mode = (config.lrc.audio_source or "auto").lower()
vocals = output_dir / f.stem / "vocals.wav" track_out = _output_dir_for(f, input_root, output_dir)
convert = output_dir / f.stem / "convert.wav" vocals = track_out / "vocals.wav"
convert = track_out / "convert.wav"
if mode == "vocals": if mode == "vocals":
return vocals return vocals
if mode == "convert": if mode == "convert":
@@ -83,7 +88,17 @@ def run_lrc_pipeline(
log("Аудиофайлы не найдены.") log("Аудиофайлы не найдены.")
return [] 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'} | " log(f"LRC: {len(files)} файлов | язык={lang or 'auto'} | "
f"источник={config.lrc.audio_source}") f"источник={config.lrc.audio_source}")
@@ -100,11 +115,28 @@ def run_lrc_pipeline(
break break
log(f"\n▶ [{idx}/{total}] {f.name}") 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) track_out.mkdir(parents=True, exist_ok=True)
lrc_path = track_out / "text.lrc" lrc_path = track_out / "text.lrc"
error: Optional[str] = None 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: if config.lrc.skip_existing:
ready = _ready_lrc(track_out, f) ready = _ready_lrc(track_out, f)
if ready is not None: if ready is not None:
@@ -118,6 +150,7 @@ def run_lrc_pipeline(
tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_")) tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_"))
try: try:
# 0) embedded LRC from tags — бесплатно
embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None
if embedded and is_lrc(embedded): if embedded and is_lrc(embedded):
lrc_path.write_text(embedded, encoding="utf-8") lrc_path.write_text(embedded, encoding="utf-8")
@@ -125,36 +158,96 @@ def run_lrc_pipeline(
results.append(FileResult(f, None, track_out, None, "embedded+lrc")) results.append(FileResult(f, None, track_out, None, "embedded+lrc"))
continue 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(): 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( raise FileNotFoundError(
f"Источник аудио не найден: {audio_path}. " f"источник аудио не найден: {audio_path}. "
"Включите conversion/separation в основном пайплайне " f"Источник={config.lrc.audio_source}, "
"или выберите audio_source=input." f"convert.enabled={config.convert.enabled}, "
f"separate.enabled={config.separate.enabled}"
) )
log(f" [align] {audio_path.name}") log(f" [align] {audio_path.name}")
segments, detected = transcribe_aligned( # 3) готовый текст → forced alignment (быстро, без ASR)
audio_path, if text is not None:
model=config.transcribe.model, log(f" [align-only] text.txt найден ({len(text)} символов)")
language=lang, segments, detected = align_text_to_audio(
device=config.lrc.device, audio_path,
compute_type=config.transcribe.compute_type, text,
batch_size=config.transcribe.batch_size, language=lang,
align_model=config.lrc.align_model, model=config.transcribe.model,
local_files_only=config.transcribe.local_files_only, 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) 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") lrc_path.write_text(lrc_content, encoding="utf-8")
log(f" ✓ text.lrc ({len(segments)} строк, язык: {detected})") log(f" ✓ text.lrc ({len(segments)} строк, язык: {detected})")
# NEW: save to JSONL for LRCLIB submission
_save_unrecognized( _save_unrecognized(
config, f, lrc_content, source="lrc", config, f, lrc_content, source="lrc",
metadata=read_metadata(f), metadata=read_metadata(f),
) )
results.append(FileResult(f, None, track_out, None, "lrc")) 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 except Exception as e: # noqa: BLE001
error = f"{type(e).__name__}: {e}" error = f"{type(e).__name__}: {e}"
log(f" ✗ Ошибка: {error}") log(f" ✗ Ошибка: {error}")
@@ -206,7 +299,8 @@ def run_embed_pipeline(
log("⏹ Отменено") log("⏹ Отменено")
break break
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 error: Optional[str] = None
try: try:
@@ -220,7 +314,7 @@ def run_embed_pipeline(
if config.lrc.embed_in_place: if config.lrc.embed_in_place:
target = f target = f
else: else:
target = output_dir / f.stem / f.name target = track_out / f.name
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists() or config.overwrite: if not target.exists() or config.overwrite:
shutil.copy2(f, target) shutil.copy2(f, target)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import difflib import difflib
import json import json
import shutil import shutil
import sys
import tempfile import tempfile
import threading import threading
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -18,7 +19,9 @@ from .lyrics import LyricsMatch, search_by_identity, search_by_text
from .metadata import TrackMetadata, read_metadata from .metadata import TrackMetadata, read_metadata
from .recognize import ShazamResult, recognize from .recognize import ShazamResult, recognize
from .separate import separate_vocals, separate_vocals_batch from .separate import separate_vocals, separate_vocals_batch
from .transcribe import transcribe from .transcribe import (
CudaOOMError, preload_transcription_model, transcribe,
)
from .lrc import is_lrc, read_embedded_lyrics from .lrc import is_lrc, read_embedded_lyrics
@@ -48,11 +51,18 @@ class Cancelled(Exception):
# ---------------------------------------------------------------- helpers # ---------------------------------------------------------------- helpers
def _free_memory() -> None: def _free_memory() -> None:
"""Free unreferenced RAM and VRAM. Keeps model caches alive.""" """Free unreferenced RAM and VRAM. Keeps model caches alive.
Never import torch here: if the current run does not use ASR, importing it
would initialize a CUDA context and steal VRAM from separation. If torch is
already loaded by the ASR stage, we can safely empty its cache.
"""
import gc import gc
gc.collect() gc.collect()
torch = sys.modules.get("torch")
if torch is None:
return
try: try:
import torch
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.cuda.empty_cache() torch.cuda.empty_cache()
except Exception: # noqa: BLE001 — never fail cleanup on a broken CUDA context except Exception: # noqa: BLE001 — never fail cleanup on a broken CUDA context
@@ -150,6 +160,130 @@ def _write_metadata_json(path: Path, data: Optional[dict]) -> None:
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
def _merge_metadata_file(existing: Path, data: Optional[dict]) -> Optional[dict]:
"""Keep tags/shazam from an earlier run when the new data has less info.
Reprocessing a track for one stage only (e.g. separation) must not replace
a rich metadata.json with a path-only dict.
"""
if not data or not existing.is_file():
return data
try:
old = json.loads(existing.read_text(encoding="utf-8"))
except (OSError, ValueError):
return data
if not isinstance(old, dict):
return data
merged = dict(old)
merged.update(data)
return merged
def _output_dir_for(f: Path, input_root: Path, output_root: Path) -> Path:
"""Path-based track folder: output/<relpath_without_ext>/."""
try:
rel = f.relative_to(input_root)
except ValueError:
rel = Path(f.name)
return output_root / rel.with_suffix("")
def _existing_artifacts(
input_root: Path, output_root: Path, input_path: Path,
) -> tuple[bool, bool, bool]:
"""Return (has_text, has_vocals, has_convert) for the matching output folder.
Artifacts are ignored when ``metadata.json`` says the folder belongs to a
different input file with the same stem.
"""
track_out = _output_dir_for(input_path, input_root, output_root)
if not _artifact_belongs_to(track_out, input_path):
return False, False, False
return (
(track_out / "text.txt").is_file(),
(track_out / "vocals.wav").is_file(),
(track_out / "convert.wav").is_file(),
)
def _final_artifact_present(track_out: Path, config: PipelineConfig) -> bool:
"""True if the artifact of the last enabled stage already exists.
``skip_existing`` must not treat an old ``text.txt`` as "done" when the
current run only needs separation: the final stage here is ``vocals.wav``,
and without it the track still has work to do.
"""
if config.transcribe.enabled:
return (track_out / "text.txt").is_file()
if config.separate.enabled:
return (track_out / "vocals.wav").is_file()
if config.convert.enabled:
return (track_out / "convert.wav").is_file()
return False
def _is_track_finished(
track_out: Path, config: PipelineConfig, belongs: bool = True,
) -> bool:
"""True when ``skip_existing`` may skip this output folder entirely."""
if not belongs:
return False
# If metadata says this track had an empty ASR result, treat it as
# "finished" so skip_existing does not keep re‑running it.
meta = track_out / "metadata.json"
if meta.is_file():
try:
data = json.loads(meta.read_text(encoding="utf-8"))
if isinstance(data, dict) and data.get("empty_text"):
return True
except (OSError, ValueError):
pass
if _final_artifact_present(track_out, config):
return True
# Identification-only runs still finish with text.txt (no other stage
# produces an artifact of its own).
if (not config.convert.enabled and not config.separate.enabled
and not config.transcribe.enabled
and (config.shazam.enabled or config.lyrics.enabled)):
return (track_out / "text.txt").is_file()
return False
def _needs_work(
config: PipelineConfig, input_root: Path, output_root: Path, f: Path,
) -> bool:
"""True if at least one enabled stage still has to do something for ``f``.
This mirrors the resume rules used by the staged/sequential pipelines:
* final artifact present + skip_existing -> resolved, nothing to do
* vocals.wav + resume_partial -> convert/separate are skipped
* convert.wav + resume_partial -> convert is skipped
Files with no pending work are filtered out before batching, so batches
stay full instead of being filled by already-finished tracks.
"""
track_out = _output_dir_for(f, input_root, output_root)
belongs = _artifact_belongs_to(track_out, f)
has_text, has_vocals, has_convert = _existing_artifacts(
input_root, output_root, f,
)
if config.skip_existing and _is_track_finished(track_out, config, belongs):
return False
vocals_ready = config.resume_partial and has_vocals
convert_ready = config.resume_partial and (has_vocals or has_convert)
if config.shazam.enabled or config.lyrics.enabled:
return True # identification still has to run
if config.convert.enabled and not convert_ready:
return True
if config.separate.enabled and not vocals_ready:
return True
if config.transcribe.enabled:
return True # text is missing or skip_existing is disabled
return False
def _save_unrecognized( def _save_unrecognized(
config: PipelineConfig, config: PipelineConfig,
f: Path, f: Path,
@@ -532,7 +666,8 @@ def run_pipeline(
log("Аудиофайлы не найдены.") log("Аудиофайлы не найдены.")
return [] return []
mode = (config.mode or "sequential").lower() mode = (config.mode or "sequential").lower()
log(f"Найдено треков: {len(files)} | режим: {mode} | " total_all = len(files)
log(f"Найдено треков: {total_all} | режим: {mode} | "
f"keep_intermediate={config.keep_intermediate}") f"keep_intermediate={config.keep_intermediate}")
if config.separate.save_vocals_copy: if config.separate.save_vocals_copy:
log(" ✓ Вокал будет сохраняться всегда (save_vocals_copy=True)") log(" ✓ Вокал будет сохраняться всегда (save_vocals_copy=True)")
@@ -541,17 +676,37 @@ def run_pipeline(
if config.lyrics.enabled: if config.lyrics.enabled:
log(f" LRCLIB: включён (порог fuzzy={config.lyrics.fuzzy_threshold:.2f})") log(f" LRCLIB: включён (порог fuzzy={config.lyrics.fuzzy_threshold:.2f})")
if config.skip_existing: if config.skip_existing:
log(" Resume: готовые файлы (text.txt) будут пропущены") log(" Resume: готовые файлы (результат последнего этапа) будут пропущены")
if config.resume_partial: if config.resume_partial:
log(" Resume: существующие vocals.wav / convert.wav будут переиспользованы") log(" Resume: существующие vocals.wav / convert.wav будут переиспользованы")
# Pre-scan before chunking: remove files that are already complete for the
# current set of enabled stages. Without this, skipped tracks consumed
# batch slots and a staged run produced many half-empty batches.
pending = [f for f in files if _needs_work(config, input_dir, output_dir, f)]
already_done = total_all - len(pending)
if already_done:
log(f" ✓ Уже готово: {already_done} — в батчи не попадут")
log(f" → К обработке: {len(pending)}")
files = pending
if not files:
log(" Все файлы уже готовы — работа не требуется.")
if on_overall_progress:
on_overall_progress(total_all, total_all)
return []
if on_overall_progress: if on_overall_progress:
on_overall_progress(0, len(files)) on_overall_progress(already_done, total_all)
if mode == "staged": if mode == "staged":
return _run_staged( return _run_staged(
config, files, log, on_progress, stop_event, on_overall_progress, config, files, log, on_progress, stop_event, on_overall_progress,
overall_base=already_done, overall_total=total_all,
input_root=input_dir,
) )
return _run_sequential( return _run_sequential(
config, files, log, on_progress, stop_event, on_overall_progress, config, files, log, on_progress, stop_event, on_overall_progress,
overall_base=already_done, overall_total=total_all,
input_root=input_dir,
) )
@@ -559,16 +714,24 @@ def _run_sequential(
config: PipelineConfig, files: list[Path], log: LogFn, config: PipelineConfig, files: list[Path], log: LogFn,
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event], on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
on_overall_progress: Optional[OverallFn] = None, on_overall_progress: Optional[OverallFn] = None,
overall_base: int = 0,
overall_total: Optional[int] = None,
input_root: Optional[Path] = None,
) -> list[FileResult]: ) -> list[FileResult]:
total = len(files) total = len(files)
if overall_total is None:
overall_total = overall_base + total
if input_root is None:
input_root = Path(config.input_dir)
results: list[FileResult] = [] results: list[FileResult] = []
output_root = Path(config.output_dir) output_root = Path(config.output_dir)
log("Раскладка: output/<relpath_from_input>/<stem>/")
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_")) ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_"))
ident_files = files ident_files = files
if config.skip_existing: if config.skip_existing:
ident_files = [ ident_files = [
f for f in files f for f in files
if not (output_root / f.stem / "text.txt").is_file() if not (_output_dir_for(f, input_root, output_root) / "text.txt").is_file()
] ]
try: try:
ident_map = _run_identification_parallel( ident_map = _run_identification_parallel(
@@ -585,24 +748,32 @@ def _run_sequential(
on_progress("file", idx - 1, total) on_progress("file", idx - 1, total)
log(f"\n▶ [{idx}/{total}] {f.name}") log(f"\n▶ [{idx}/{total}] {f.name}")
track_out = output_root / f.stem track_out = _output_dir_for(f, input_root, output_root)
track_out.mkdir(parents=True, exist_ok=True) track_out.mkdir(parents=True, exist_ok=True)
belongs = _artifact_belongs_to(track_out, f) belongs = _artifact_belongs_to(track_out, f)
if not belongs and any(
(track_out / name).exists()
for name in ("text.txt", "vocals.wav", "convert.wav")
):
log(f" ⚠ коллизия раскладки: {track_out} занят другим треком — обрабатываю заново")
if config.skip_existing: if config.skip_existing and _is_track_finished(track_out, config, belongs):
existing_text = track_out / "text.txt" existing_text = track_out / "text.txt"
if existing_text.is_file() and belongs: existing: Optional[str] = None
if existing_text.is_file():
try: try:
existing = existing_text.read_text(encoding="utf-8") existing = existing_text.read_text(encoding="utf-8")
except OSError: except OSError:
existing = None existing = None
log(" ⊘ text.txt уже есть — пропуск") log(" ⊘ результат последнего этапа уже есть — пропуск")
if on_progress: if on_progress:
on_progress("file", idx, total) on_progress("file", idx, total)
if on_overall_progress: if on_overall_progress:
on_overall_progress(idx, total) on_overall_progress(
results.append(FileResult(f, existing, track_out, None, "existing")) min(overall_base + idx, overall_total), overall_total,
continue )
results.append(FileResult(f, existing, track_out, None, "existing"))
continue
tmp_root = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_")) tmp_root = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_"))
@@ -699,18 +870,31 @@ def _run_sequential(
gigaam_provider=config.transcribe.gigaam_provider, gigaam_provider=config.transcribe.gigaam_provider,
local_files_only=config.transcribe.local_files_only, local_files_only=config.transcribe.local_files_only,
) )
text = (text or "").strip()
source = config.transcribe.engine source = config.transcribe.engine
log(f" ✓ распознано {len(text)} символов") if not text:
if (config.lyrics.enabled and config.lyrics.try_after_asr): text = None
matched = _try_post_asr_match(text, config, log) source = "empty_result"
if matched: error = "EmptyResult: ASR returned no text"
text = matched log(f" ⚠ {f.name}: текст не извлечён (пустой результат ASR)")
source = f"{source}+lrclib" else:
(tmp_root / "text.txt").write_text(text, encoding="utf-8") log(f" ✓ распознано {len(text)} символов")
if (config.lyrics.enabled and config.lyrics.try_after_asr):
matched = _try_post_asr_match(text, config, log)
if matched:
text = matched
source = f"{source}+lrclib"
(tmp_root / "text.txt").write_text(text, encoding="utf-8")
metadata_dict = _build_metadata_dict( metadata_dict = _build_metadata_dict(
ident.metadata, ident.shazam, source, input_path=f, ident.metadata, ident.shazam, source, input_path=f,
) )
if metadata_dict.get("empty_text") is None:
metadata_dict["empty_text"] = False
if belongs:
metadata_dict = _merge_metadata_file(
track_out / "metadata.json", metadata_dict,
)
if metadata_dict: if metadata_dict:
_write_metadata_json(tmp_root / "metadata.json", metadata_dict) _write_metadata_json(tmp_root / "metadata.json", metadata_dict)
@@ -729,6 +913,10 @@ def _run_sequential(
log("⏹ Отменено") log("⏹ Отменено")
was_cancelled = True was_cancelled = True
break break
except CudaOOMError as e:
error = f"CudaOOMError: {e}"
source = "oom_skipped"
log(f" ⊘ {f.name}: OOM после ретраев — пропущен, идём дальше")
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
error = f"{type(e).__name__}: {e}" error = f"{type(e).__name__}: {e}"
log(f" ✗ Ошибка: {error}") log(f" ✗ Ошибка: {error}")
@@ -758,7 +946,9 @@ def _run_sequential(
if on_progress: if on_progress:
on_progress("file", idx, total) on_progress("file", idx, total)
if on_overall_progress: if on_overall_progress:
on_overall_progress(idx, total) on_overall_progress(
min(overall_base + idx, overall_total), overall_total,
)
_log_summary(results, log) _log_summary(results, log)
return results return results
@@ -768,19 +958,28 @@ def _run_staged(
config: PipelineConfig, files: list[Path], log: LogFn, config: PipelineConfig, files: list[Path], log: LogFn,
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event], on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
on_overall_progress: Optional[OverallFn] = None, on_overall_progress: Optional[OverallFn] = None,
overall_base: int = 0,
overall_total: Optional[int] = None,
input_root: Optional[Path] = None,
) -> list[FileResult]: ) -> list[FileResult]:
"""Chunked staged mode: process `chunk_size` files, free memory, repeat.""" """Chunked staged mode: process `chunk_size` files, free memory, repeat."""
output_root = Path(config.output_dir) output_root = Path(config.output_dir)
if input_root is None:
input_root = Path(config.input_dir)
total = len(files) total = len(files)
if overall_total is None:
overall_total = overall_base + total
chunk_size = max(1, int(config.chunk_size or total)) chunk_size = max(1, int(config.chunk_size or total))
log("Раскладка: output/<relpath_from_input>/<stem>/")
if chunk_size >= total: if chunk_size >= total:
log(f"Один батч: {total} файлов (chunk_size={chunk_size})") log(f"Один батч: {total} файлов (chunk_size={chunk_size})")
results = _run_staged_batch( results = _run_staged_batch(
config, files, output_root, log, on_progress, stop_event, config, files, output_root, log, on_progress, stop_event,
overall_offset=0, overall_total=total, overall_offset=overall_base, overall_total=overall_total,
on_overall_progress=on_overall_progress, on_overall_progress=on_overall_progress,
overall_counter=[0], overall_counter=[overall_base],
input_root=input_root,
) )
_free_memory() _free_memory()
_log_summary(results, log) _log_summary(results, log)
@@ -790,7 +989,7 @@ def _run_staged(
log(f"Батчей: {n_batches} × {chunk_size} файлов (всего {total})") log(f"Батчей: {n_batches} × {chunk_size} файлов (всего {total})")
all_results: list[FileResult] = [] all_results: list[FileResult] = []
overall_counter = [0] overall_counter = [overall_base]
for b in range(n_batches): for b in range(n_batches):
if stop_event is not None and stop_event.is_set(): if stop_event is not None and stop_event.is_set():
log("⏹ Отменено") log("⏹ Отменено")
@@ -805,9 +1004,10 @@ def _run_staged(
try: try:
batch_results = _run_staged_batch( batch_results = _run_staged_batch(
config, chunk, output_root, log, on_progress, stop_event, config, chunk, output_root, log, on_progress, stop_event,
overall_offset=start, overall_total=total, overall_offset=overall_base + start, overall_total=overall_total,
on_overall_progress=on_overall_progress, on_overall_progress=on_overall_progress,
overall_counter=overall_counter, overall_counter=overall_counter,
input_root=input_root,
) )
all_results.extend(batch_results) all_results.extend(batch_results)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
@@ -831,9 +1031,12 @@ def _run_staged_batch(
overall_total: Optional[int] = None, overall_total: Optional[int] = None,
on_overall_progress: Optional[OverallFn] = None, on_overall_progress: Optional[OverallFn] = None,
overall_counter: Optional[list[int]] = None, overall_counter: Optional[list[int]] = None,
input_root: Optional[Path] = None,
) -> list[FileResult]: ) -> list[FileResult]:
"""One chunk of the staged pipeline. All temps are tracked and removed """One chunk of the staged pipeline. All temps are tracked and removed
in the `finally` block at the end of this call.""" in the `finally` block at the end of this call."""
if input_root is None:
input_root = Path(config.input_dir)
if overall_total is None: if overall_total is None:
overall_total = len(batch_files) overall_total = len(batch_files)
@@ -890,7 +1093,7 @@ def _run_staged_batch(
resumed_partial = 0 resumed_partial = 0
mismatched_folders = 0 mismatched_folders = 0
for f in batch_files: for f in batch_files:
track_out = output_root / f.stem track_out = _output_dir_for(f, input_root, output_root)
audio_path: Path = f audio_path: Path = f
skip_convert = False skip_convert = False
skip_separate = False skip_separate = False
@@ -906,17 +1109,18 @@ def _run_staged_batch(
) )
if has_artifacts: if has_artifacts:
mismatched_folders += 1 mismatched_folders += 1
log(f" ⚠ {f.name}: папка {track_out.name} относится к другому треку — обрабатываю заново") log(f" ⚠ коллизия раскладки: {track_out} занят другим треком — обрабатываю заново")
text_file = track_out / "text.txt" text_file = track_out / "text.txt"
if config.skip_existing and belongs and text_file.is_file(): if config.skip_existing and _is_track_finished(track_out, config, belongs):
resolved = True resolved = True
skipped_existing += 1 skipped_existing += 1
source = "existing" source = "existing"
try: if text_file.is_file():
text = text_file.read_text(encoding="utf-8") try:
except OSError: text = text_file.read_text(encoding="utf-8")
text = None except OSError:
text = None
elif config.resume_partial and belongs: elif config.resume_partial and belongs:
vocals = track_out / "vocals.wav" vocals = track_out / "vocals.wav"
convert = track_out / "convert.wav" convert = track_out / "convert.wav"
@@ -938,7 +1142,7 @@ def _run_staged_batch(
} }
if skipped_existing: if skipped_existing:
log(f" ⊘ Пропущено готовых (text.txt): {skipped_existing}") log(f" ⊘ Пропущено готовых (результат последнего этапа): {skipped_existing}")
if resumed_partial: if resumed_partial:
log(f" ↻ Подхвачено промежуточных (vocals/convert): {resumed_partial}") log(f" ↻ Подхвачено промежуточных (vocals/convert): {resumed_partial}")
if mismatched_folders: if mismatched_folders:
@@ -979,10 +1183,13 @@ def _run_staged_batch(
ident.metadata, ident.shazam, ident.source, ident.metadata, ident.shazam, ident.source,
input_path=f, input_path=f,
) )
track_out = output_root / f.stem track_out = _output_dir_for(f, input_root, output_root)
track_out.mkdir(parents=True, exist_ok=True) track_out.mkdir(parents=True, exist_ok=True)
if ident.text: if ident.text:
(track_out / "text.txt").write_text(ident.text, encoding="utf-8") (track_out / "text.txt").write_text(ident.text, encoding="utf-8")
md = state[f].setdefault("metadata_dict", {})
if md.get("empty_text") is None:
md["empty_text"] = False
if state[f]["metadata_dict"]: if state[f]["metadata_dict"]:
_write_metadata_json(track_out / "metadata.json", state[f]["metadata_dict"]) _write_metadata_json(track_out / "metadata.json", state[f]["metadata_dict"])
_save_unrecognized( _save_unrecognized(
@@ -1010,7 +1217,22 @@ def _run_staged_batch(
def separate_single(f: Path) -> None: def separate_single(f: Path) -> None:
try: try:
src = state[f]["audio_path"] src = state[f]["audio_path"]
target = src.parent / "vocals.wav" # Never write next to the original input: with
# convert.enabled=False src == f, so every track would
# overwrite input_dir/vocals.wav. Only reuse the track
# output folder when the source already lives there.
track_dir = _output_dir_for(f, input_root, output_root)
if src.parent == track_dir:
# Already in the persistent track folder (resume).
target = src.parent / "vocals.wav"
elif src.parent == f.parent:
# src is the original input file (convert disabled).
# Never write next to it: all tracks share input_dir
# and would overwrite input_dir/vocals.wav.
target = _mkdtemp("at_sep_") / "vocals.wav"
else:
# Freshly converted file in its own unique temp dir.
target = src.parent / "vocals.wav"
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
raw = separate_vocals( raw = separate_vocals(
src, Path(tmp), model=config.separate.model, src, Path(tmp), model=config.separate.model,
@@ -1027,11 +1249,15 @@ def _run_staged_batch(
# Resumed convert.wav files already live in output_root/<stem>, # Resumed convert.wav files already live in output_root/<stem>,
# while freshly converted files live in unique temp dirs. # while freshly converted files live in unique temp dirs.
# Original inputs (convert disabled) must also go through the
# one-by-one path: batching them shares input_dir and can
# collide on vocals.wav.
# unblend's `{parent}` batch layout would mix those roots, so # unblend's `{parent}` batch layout would mix those roots, so
# separate resumed files one by one and batch the rest. # separate resumed/original files one by one and batch the rest.
resumed = [ resumed = [
f for f in todo f for f in todo
if state[f]["audio_path"].parent == output_root / f.stem if state[f]["audio_path"].parent == _output_dir_for(f, input_root, output_root)
or state[f]["audio_path"].parent == f.parent
] ]
resumed_set = set(resumed) resumed_set = set(resumed)
batchable = [f for f in todo if f not in resumed_set] batchable = [f for f in todo if f not in resumed_set]
@@ -1092,6 +1318,20 @@ def _run_staged_batch(
if config.transcribe.enabled and not cancelled(): if config.transcribe.enabled and not cancelled():
todo = active() todo = active()
log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══") log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══")
if todo:
# Load once, after separation has finished. Every later file
# and every later batch reuses this instance.
try:
preload_transcription_model(
engine=config.transcribe.engine,
model=config.transcribe.model,
device=config.transcribe.device,
compute_type=config.transcribe.compute_type,
gigaam_provider=config.transcribe.gigaam_provider,
local_files_only=config.transcribe.local_files_only,
)
except Exception as e: # noqa: BLE001
log(f" ⚠ Не удалось загрузить модель заранее: {e}")
progress("transcribe", 0, len(todo)) progress("transcribe", 0, len(todo))
for i, f in enumerate(todo, 1): for i, f in enumerate(todo, 1):
if cancelled(): if cancelled():
@@ -1108,9 +1348,20 @@ def _run_staged_batch(
gigaam_provider=config.transcribe.gigaam_provider, gigaam_provider=config.transcribe.gigaam_provider,
local_files_only=config.transcribe.local_files_only, local_files_only=config.transcribe.local_files_only,
) )
state[f]["text"] = text text = (text or "").strip()
state[f]["source"] = config.transcribe.engine if not text:
log(f" ✓ {f.name} ({len(text)} символов)") state[f]["text"] = None
state[f]["source"] = "empty_result"
state[f]["error"] = "EmptyResult: ASR returned no text"
log(f" ⚠ {f.name}: текст не извлечён (пустой результат ASR)")
else:
state[f]["text"] = text
state[f]["source"] = config.transcribe.engine
log(f" ✓ {f.name} ({len(text)} символов)")
except CudaOOMError as e:
state[f]["error"] = f"CudaOOMError: {e}"
state[f]["source"] = "oom_skipped"
log(f" ⊘ {f.name}: OOM — пропущен")
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
state[f]["error"] = f"{type(e).__name__}: {e}" state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}") log(f" ✗ {f.name}: {e}")
@@ -1133,7 +1384,7 @@ def _run_staged_batch(
if isinstance(src_audio, Path): if isinstance(src_audio, Path):
vocal = src_audio.parent / "vocals.wav" vocal = src_audio.parent / "vocals.wav"
if vocal.exists(): if vocal.exists():
track_out = output_root / f.stem track_out = _output_dir_for(f, input_root, output_root)
track_out.mkdir(parents=True, exist_ok=True) track_out.mkdir(parents=True, exist_ok=True)
dst = track_out / "vocals.wav" dst = track_out / "vocals.wav"
if dst.exists(): if dst.exists():
@@ -1174,8 +1425,13 @@ def _run_staged_batch(
) )
s["metadata_dict"] = metadata_dict s["metadata_dict"] = metadata_dict
track_out = output_root / f.stem track_out = _output_dir_for(f, input_root, output_root)
track_out.mkdir(parents=True, exist_ok=True) track_out.mkdir(parents=True, exist_ok=True)
if _artifact_belongs_to(track_out, f):
metadata_dict = _merge_metadata_file(
track_out / "metadata.json", metadata_dict,
)
s["metadata_dict"] = metadata_dict
audio_work = s["audio_path"] audio_work = s["audio_path"]
if isinstance(audio_work, Path) and audio_work != f: if isinstance(audio_work, Path) and audio_work != f:
@@ -1193,6 +1449,9 @@ def _run_staged_batch(
else: else:
if text: if text:
(track_out / "text.txt").write_text(text, encoding="utf-8") (track_out / "text.txt").write_text(text, encoding="utf-8")
md = metadata_dict.setdefault("empty_text", False)
else:
md = metadata_dict.setdefault("empty_text", True)
if metadata_dict: if metadata_dict:
_write_metadata_json(track_out / "metadata.json", metadata_dict) _write_metadata_json(track_out / "metadata.json", metadata_dict)
@@ -1200,7 +1459,10 @@ def _run_staged_batch(
config, f, text, source, config, f, text, source,
metadata=(ident.metadata if ident else None), metadata=(ident.metadata if ident else None),
) )
log(f" ✓ {f.name}") if text is not None or s["resolved"]:
log(f" ✓ {f.name}")
else:
log(f" ⊘ {f.name}: пропущен (нет текста)")
mark_overall(is_complete(s)) mark_overall(is_complete(s))
finally: finally:
@@ -1216,7 +1478,7 @@ def _run_staged_batch(
return [ return [
FileResult( FileResult(
f, state[f]["text"], f, state[f]["text"],
output_root / f.stem if state[f]["error"] is None else None, _output_dir_for(f, input_root, output_root) if state[f]["error"] is None else None,
state[f]["error"], state[f]["source"], state[f]["metadata_dict"], state[f]["error"], state[f]["source"], state[f]["metadata_dict"],
) )
for f in batch_files for f in batch_files

View File

@@ -6,15 +6,134 @@ Models are cached; aggressive VRAM cleanup happens after each file.
from __future__ import annotations from __future__ import annotations
import gc import gc
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
# Set permanently once the CUDA context has died (e.g. after an unrecoverable # The model caches below are process-wide. They are intentionally never
# OOM). Every later CUDA transcription fails instantly instead of re-loading # cleared between files or batches: reloading WhisperX takes minutes and is
# the model forever and hanging the pipeline. # what made runs look like they reloaded the model every batch. The lock makes
_CUDA_BROKEN = False # 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: def _is_cuda_error(exc: BaseException) -> bool:
@@ -28,14 +147,6 @@ def _is_cuda_error(exc: BaseException) -> bool:
) )
def _check_cuda_usable(device: str) -> None:
if device == "cuda" and _CUDA_BROKEN:
raise RuntimeError(
"CUDA context is broken (previous OOM was unrecoverable). "
"Restart the app or switch device to CPU."
)
def _resolve_device(device: str) -> str: def _resolve_device(device: str) -> str:
if device in ("cuda", "cpu"): if device in ("cuda", "cpu"):
return device return device
@@ -68,6 +179,59 @@ def _free_vram() -> None:
pass 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: class _WhisperXModels:
_cache: dict = {} _cache: dict = {}
@@ -77,21 +241,27 @@ class _WhisperXModels:
if compute_type is None: if compute_type is None:
compute_type = "int8_float16" if device == "cuda" else "int8" compute_type = "int8_float16" if device == "cuda" else "int8"
key = (model_name, device, compute_type, local_files_only) key = (model_name, device, compute_type, local_files_only)
if key not in cls._cache: with _MODEL_LOCK:
import whisperx if key not in cls._cache:
print(f" [ASR] loading WhisperX {model_name} on {device} " if device == "cuda":
f"({compute_type}, local_files_only={local_files_only})...") _enable_tf32()
import whisperx
print(f" [ASR] loading WhisperX {model_name} on {device} "
f"({compute_type}, local_files_only={local_files_only})...")
def load(): def load():
return whisperx.load_model( return whisperx.load_model(
model_name, device, model_name, device,
compute_type=compute_type, compute_type=compute_type,
local_files_only=local_files_only, local_files_only=local_files_only,
) )
cls._cache[key] = _retry(load) cls._cache[key] = _retry(load)
print(" [ASR] model loaded.") print(" [ASR] model loaded once — subsequent files will reuse it.")
return cls._cache[key] 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: class _AlignModels:
@@ -100,20 +270,24 @@ class _AlignModels:
@classmethod @classmethod
def get(cls, language_code: str, device: str, model_name: Optional[str] = None): def get(cls, language_code: str, device: str, model_name: Optional[str] = None):
key = (language_code, device, model_name or "") key = (language_code, device, model_name or "")
if key not in cls._cache: with _MODEL_LOCK:
import whisperx if key not in cls._cache:
print(f" [ALIGN] loading align model for {language_code} " import whisperx
f"({model_name or 'default'}) on {device}...") print(f" [ALIGN] loading align model for {language_code} "
f"({model_name or 'default'}) on {device}...")
def load(): def load():
kwargs = {"language_code": language_code, "device": device} kwargs = {"language_code": language_code, "device": device}
if model_name: if model_name:
kwargs["model_name"] = model_name kwargs["model_name"] = model_name
return whisperx.load_align_model(**kwargs) return whisperx.load_align_model(**kwargs)
cls._cache[key] = _retry(load) cls._cache[key] = _retry(load)
print(" [ALIGN] align model loaded.") print(" [ALIGN] align model loaded once — reusing it.")
return cls._cache[key] 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: class _GigaAMModels:
@@ -127,14 +301,37 @@ class _GigaAMModels:
"auto": ["CUDAExecutionProvider", "CPUExecutionProvider"], "auto": ["CUDAExecutionProvider", "CPUExecutionProvider"],
}.get(provider, ["CPUExecutionProvider"]) }.get(provider, ["CPUExecutionProvider"])
key = tuple(providers) key = tuple(providers)
if key not in cls._cache: with _MODEL_LOCK:
import onnx_asr if key not in cls._cache:
import onnx_asr
def load(): def load():
return onnx_asr.load_model("gigaam-v3-ctc", providers=list(providers)) return onnx_asr.load_model("gigaam-v3-ctc", providers=list(providers))
cls._cache[key] = _retry(load) cls._cache[key] = _retry(load)
return cls._cache[key] 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( def transcribe(
@@ -149,37 +346,13 @@ def transcribe(
local_files_only: bool = False, local_files_only: bool = False,
vad_filter: bool = True, vad_filter: bool = True,
) -> str: ) -> str:
global _CUDA_BROKEN
device = _resolve_device(device) device = _resolve_device(device)
_check_cuda_usable(device)
if engine == "whisperx": if engine == "whisperx":
try: return _transcribe_whisperx(
return _transcribe_whisperx( audio_path, model, language, device, compute_type,
audio_path, model, language, device, compute_type, batch_size, local_files_only, vad_filter,
batch_size, local_files_only, vad_filter, )
)
except Exception as e:
if not _is_cuda_error(e):
raise
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
_WhisperXModels._cache.clear()
_free_vram()
try:
return _transcribe_whisperx(
audio_path, model, language, device, compute_type,
batch_size, local_files_only, vad_filter,
)
except Exception as retry_error:
if not _is_cuda_error(retry_error):
raise
_CUDA_BROKEN = True
print(" [ASR] CUDA context is unrecoverable — "
"GPU disabled for the rest of this run.")
raise RuntimeError(
"CUDA context is broken after OOM recovery failed. "
"Restart the app or switch device to CPU."
) from retry_error
if engine == "gigaam": if engine == "gigaam":
return _transcribe_gigaam(audio_path, gigaam_provider) return _transcribe_gigaam(audio_path, gigaam_provider)
@@ -199,8 +372,24 @@ def _transcribe_whisperx(
import whisperx import whisperx
model = _WhisperXModels.get(model_name, device, compute_type, local_files_only) model = _WhisperXModels.get(model_name, device, compute_type, local_files_only)
audio = whisperx.load_audio(str(audio_path)) audio = whisperx.load_audio(str(audio_path))
result = model.transcribe(audio, batch_size=batch_size, language=language) result = _model_transcribe_with_retry(
text = " ".join(seg["text"].strip() for seg in result["segments"]).strip() 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 del audio, result
_free_vram() _free_vram()
return text return text
@@ -220,6 +409,7 @@ def transcribe_aligned(
batch_size: int = 8, batch_size: int = 8,
align_model: Optional[str] = None, align_model: Optional[str] = None,
local_files_only: bool = False, local_files_only: bool = False,
vad_filter: bool = True,
) -> tuple[list[dict], str]: ) -> tuple[list[dict], str]:
"""Transcribe + word-level align. Returns (aligned_segments, detected_language). """Transcribe + word-level align. Returns (aligned_segments, detected_language).
@@ -228,10 +418,11 @@ def transcribe_aligned(
""" """
import whisperx import whisperx
device = _resolve_device(device) device = _resolve_device(device)
_check_cuda_usable(device)
model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only) model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only)
audio = whisperx.load_audio(str(audio_path)) 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" detected = result.get("language") or language or "en"
@@ -246,6 +437,47 @@ def transcribe_aligned(
print(f" [ALIGN] failed: {e}. Falling back to raw WhisperX segments.") print(f" [ALIGN] failed: {e}. Falling back to raw WhisperX segments.")
segments = result["segments"] segments = result["segments"]
# Filter out hallucinated segments.
segments = [s for s in segments if not _is_hallucination(s.get("text", ""))]
del audio, result del audio, result
_free_vram() _free_vram()
return segments, detected 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,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import shutil import shutil
import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -42,17 +43,30 @@ MODE_LABELS = {
def _detect_device_info() -> tuple[str, str]: 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: try:
import torch out = subprocess.run(
except ImportError: ["nvidia-smi", "--query-gpu=name,memory.total",
return "⚠ PyTorch не установлен", "#f38ba8" "--format=csv,noheader,nounits"],
if torch.cuda.is_available(): capture_output=True, text=True, timeout=5, check=True,
try: ).stdout.strip()
name = torch.cuda.get_device_name(0) except (OSError, subprocess.SubprocessError):
vram = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3) out = ""
return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1" if out:
except Exception: # noqa: BLE001 parts = [p.strip() for p in out.splitlines()[0].split(",")]
return "✓ CUDA доступна", "#a6e3a1" 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" return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af"
@@ -137,7 +151,7 @@ class MainWindow(QMainWindow):
resume_opts = QHBoxLayout() resume_opts = QHBoxLayout()
self.skip_existing_cb = QCheckBox("Пропускать готовые (text.txt)") self.skip_existing_cb = QCheckBox("Пропускать готовые (text.txt)")
self.skip_existing_cb.setToolTip( self.skip_existing_cb.setToolTip(
"Не трогать файлы, у которых в <output>/<stem>/ уже есть text.txt.\n" "Не трогать файлы, у которых в <output>/<relpath>/<stem>/ уже есть text.txt.\n"
"Позволяет продолжить большой прогон после сбоя без пересчёта.\n" "Позволяет продолжить большой прогон после сбоя без пересчёта.\n"
"Если в metadata.json записан path другого файла — папка не считается готовой." "Если в metadata.json записан path другого файла — папка не считается готовой."
) )
@@ -205,7 +219,9 @@ class MainWindow(QMainWindow):
self.log_view = QPlainTextEdit() self.log_view = QPlainTextEdit()
self.log_view.setObjectName("log") self.log_view.setObjectName("log")
self.log_view.setReadOnly(True) self.log_view.setReadOnly(True)
self.log_view.setMinimumHeight(240) # 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("Здесь появится лог выполнения…") self.log_view.setPlaceholderText("Здесь появится лог выполнения…")
root.addWidget(self.log_view, 1) root.addWidget(self.log_view, 1)
@@ -352,7 +368,7 @@ class MainWindow(QMainWindow):
self.separate_save_vocals_cb = QCheckBox("Всегда сохранять vocals.wav в output") self.separate_save_vocals_cb = QCheckBox("Всегда сохранять vocals.wav в output")
self.separate_save_vocals_cb.setToolTip( self.separate_save_vocals_cb.setToolTip(
"Независимо от 'Сохранять промежуточные': вокал всегда будет лежать " "Независимо от 'Сохранять промежуточные': вокал всегда будет лежать "
"в <output>/<stem>/vocals.wav — полезно для LRC-этапа." "в <output>/<relpath>/<stem>/vocals.wav — полезно для LRC-этапа."
) )
form.addRow("", self.separate_save_vocals_cb) form.addRow("", self.separate_save_vocals_cb)
return w return w
@@ -396,7 +412,7 @@ class MainWindow(QMainWindow):
form.setSpacing(10) form.setSpacing(10)
info = QLabel( 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" "2) Встроить LRC: записывает LRC в тег lyrics аудио (USLT/LYRICS/©lyr).\n"
"Если 'in-place' выключено — оригинал не трогается, создаётся копия в output." "Если 'in-place' выключено — оригинал не трогается, создаётся копия в output."
) )
@@ -417,7 +433,7 @@ class MainWindow(QMainWindow):
form.addRow("", self.lrc_embed_enabled_cb) form.addRow("", self.lrc_embed_enabled_cb)
self.lrc_skip_existing_cb = QCheckBox("Пропускать готовые text.lrc") self.lrc_skip_existing_cb = QCheckBox("Пропускать готовые text.lrc")
self.lrc_skip_existing_cb.setToolTip( self.lrc_skip_existing_cb.setToolTip(
"Если <output>/<stem>/text.lrc уже существует и содержит корректный LRC,\n" "Если <output>/<relpath>/<stem>/text.lrc уже существует и содержит корректный LRC,\n"
"файл не будет пересчитан. Папка с metadata.json от другого трека\n" "файл не будет пересчитан. Папка с metadata.json от другого трека\n"
"готовой не считается." "готовой не считается."
) )
@@ -447,7 +463,7 @@ class MainWindow(QMainWindow):
self.lrc_embed_in_place_cb = QCheckBox("Встраивать in-place (перезаписывать оригинал)") self.lrc_embed_in_place_cb = QCheckBox("Встраивать in-place (перезаписывать оригинал)")
self.lrc_embed_in_place_cb.setToolTip( self.lrc_embed_in_place_cb.setToolTip(
"Если выключено (по умолчанию) — создаётся копия в <output>/<stem>/<name>.\n" "Если выключено (по умолчанию) — создаётся копия в <output>/<relpath>/<stem>/<name>.\n"
"Если включено — оригинальный файл модифицируется." "Если включено — оригинальный файл модифицируется."
) )
form.addRow("", self.lrc_embed_in_place_cb) form.addRow("", self.lrc_embed_in_place_cb)
@@ -692,6 +708,14 @@ class MainWindow(QMainWindow):
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.") f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.")
return 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": if task == "lrc" and cfg.transcribe.engine != "whisperx":
QMessageBox.warning( QMessageBox.warning(
self, "LRC требует WhisperX", self, "LRC требует WhisperX",
@@ -704,6 +728,12 @@ class MainWindow(QMainWindow):
"Этап генерации LRC выключен. Включите его на вкладке LRC." "Этап генерации LRC выключен. Включите его на вкладке LRC."
) )
return 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: if task == "embed" and not cfg.lrc.embed_enabled:
QMessageBox.warning( QMessageBox.warning(
self, "Встраивание LRC отключено", self, "Встраивание LRC отключено",

12
main.py
View File

@@ -26,20 +26,8 @@ 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.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 # ASR
whisperx>=3.1 whisperx>=3.1
stable-ts>=2.16
onnx-asr>=0.4 onnx-asr>=0.4
# Recognition & lyrics # Recognition & lyrics