Compare commits
9 Commits
999934f174
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f023653c71 | ||
|
|
a2e0070ace | ||
|
|
2ac7dfd7b7 | ||
|
|
841fc8902d | ||
|
|
60846e3c6c | ||
|
|
e23acf1555 | ||
|
|
5f16a6f87b | ||
|
|
84d7a28c69 | ||
|
|
f88ca913ce |
45
core/lrc.py
45
core/lrc.py
@@ -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
|
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]:
|
def _read_mp3(path: Path) -> Optional[str]:
|
||||||
|
|||||||
@@ -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,15 +158,65 @@ 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(
|
raise FileNotFoundError(
|
||||||
f"Источник аудио не найден: {audio_path}. "
|
f"для alignment нет аудио: {audio_path}. "
|
||||||
"Включите conversion/separation в основном пайплайне "
|
"Включите конвертацию/отделение в основном пайплайне, "
|
||||||
"или выберите audio_source=input."
|
"либо запустите их один раз, либо положите vocals.wav/convert.wav."
|
||||||
|
)
|
||||||
|
raise FileNotFoundError(
|
||||||
|
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}")
|
log(f" [align] {audio_path.name}")
|
||||||
|
|
||||||
|
# 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(
|
segments, detected = transcribe_aligned(
|
||||||
audio_path,
|
audio_path,
|
||||||
model=config.transcribe.model,
|
model=config.transcribe.model,
|
||||||
@@ -146,15 +229,25 @@ def run_lrc_pipeline(
|
|||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
316
core/pipeline.py
316
core/pipeline.py
@@ -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:
|
if on_overall_progress:
|
||||||
on_overall_progress(0, len(files))
|
on_overall_progress(total_all, total_all)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if on_overall_progress:
|
||||||
|
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,22 +748,30 @@ 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(
|
||||||
|
min(overall_base + idx, overall_total), overall_total,
|
||||||
|
)
|
||||||
results.append(FileResult(f, existing, track_out, None, "existing"))
|
results.append(FileResult(f, existing, track_out, None, "existing"))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -699,7 +870,14 @@ 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
|
||||||
|
if not text:
|
||||||
|
text = None
|
||||||
|
source = "empty_result"
|
||||||
|
error = "EmptyResult: ASR returned no text"
|
||||||
|
log(f" ⚠ {f.name}: текст не извлечён (пустой результат ASR)")
|
||||||
|
else:
|
||||||
log(f" ✓ распознано {len(text)} символов")
|
log(f" ✓ распознано {len(text)} символов")
|
||||||
if (config.lyrics.enabled and config.lyrics.try_after_asr):
|
if (config.lyrics.enabled and config.lyrics.try_after_asr):
|
||||||
matched = _try_post_asr_match(text, config, log)
|
matched = _try_post_asr_match(text, config, log)
|
||||||
@@ -711,6 +889,12 @@ def _run_sequential(
|
|||||||
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,13 +1109,14 @@ 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"
|
||||||
|
if text_file.is_file():
|
||||||
try:
|
try:
|
||||||
text = text_file.read_text(encoding="utf-8")
|
text = text_file.read_text(encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -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,6 +1217,21 @@ 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"]
|
||||||
|
# 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"
|
target = src.parent / "vocals.wav"
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
raw = separate_vocals(
|
raw = separate_vocals(
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not 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]["text"] = text
|
||||||
state[f]["source"] = config.transcribe.engine
|
state[f]["source"] = config.transcribe.engine
|
||||||
log(f" ✓ {f.name} ({len(text)} символов)")
|
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),
|
||||||
)
|
)
|
||||||
|
if text is not None or s["resolved"]:
|
||||||
log(f" ✓ {f.name}")
|
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
|
||||||
|
|||||||
@@ -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,7 +241,10 @@ 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)
|
||||||
|
with _MODEL_LOCK:
|
||||||
if key not in cls._cache:
|
if key not in cls._cache:
|
||||||
|
if device == "cuda":
|
||||||
|
_enable_tf32()
|
||||||
import whisperx
|
import whisperx
|
||||||
print(f" [ASR] loading WhisperX {model_name} on {device} "
|
print(f" [ASR] loading WhisperX {model_name} on {device} "
|
||||||
f"({compute_type}, local_files_only={local_files_only})...")
|
f"({compute_type}, local_files_only={local_files_only})...")
|
||||||
@@ -90,7 +257,10 @@ class _WhisperXModels:
|
|||||||
)
|
)
|
||||||
|
|
||||||
cls._cache[key] = _retry(load)
|
cls._cache[key] = _retry(load)
|
||||||
print(" [ASR] model loaded.")
|
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]
|
return cls._cache[key]
|
||||||
|
|
||||||
|
|
||||||
@@ -100,6 +270,7 @@ 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 "")
|
||||||
|
with _MODEL_LOCK:
|
||||||
if key not in cls._cache:
|
if key not in cls._cache:
|
||||||
import whisperx
|
import whisperx
|
||||||
print(f" [ALIGN] loading align model for {language_code} "
|
print(f" [ALIGN] loading align model for {language_code} "
|
||||||
@@ -112,7 +283,10 @@ class _AlignModels:
|
|||||||
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.")
|
||||||
|
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]
|
return cls._cache[key]
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +301,7 @@ class _GigaAMModels:
|
|||||||
"auto": ["CUDAExecutionProvider", "CPUExecutionProvider"],
|
"auto": ["CUDAExecutionProvider", "CPUExecutionProvider"],
|
||||||
}.get(provider, ["CPUExecutionProvider"])
|
}.get(provider, ["CPUExecutionProvider"])
|
||||||
key = tuple(providers)
|
key = tuple(providers)
|
||||||
|
with _MODEL_LOCK:
|
||||||
if key not in cls._cache:
|
if key not in cls._cache:
|
||||||
import onnx_asr
|
import onnx_asr
|
||||||
|
|
||||||
@@ -137,6 +312,28 @@ class _GigaAMModels:
|
|||||||
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(
|
||||||
audio_path: Path,
|
audio_path: Path,
|
||||||
engine: str = "whisperx",
|
engine: str = "whisperx",
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
).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:
|
try:
|
||||||
name = torch.cuda.get_device_name(0)
|
vram = float(parts[1]) / 1024 # MiB -> GiB
|
||||||
vram = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
|
|
||||||
return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1"
|
return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1"
|
||||||
except Exception: # noqa: BLE001
|
except ValueError:
|
||||||
return "✓ CUDA доступна", "#a6e3a1"
|
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
12
main.py
@@ -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
40
mark_no_speech.py
Normal 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 ".")))
|
||||||
86
migrate_output_to_paths.py
Normal file
86
migrate_output_to_paths.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user