fix some bugs, add LRC synchronizer, text saver (if text not in LIBLRC, for upgrade library)
This commit is contained in:
@@ -26,6 +26,7 @@ class SeparateConfig:
|
||||
isolate_stem: str = "vocals"
|
||||
unblend_path: str = "unblend"
|
||||
custom_kernels: bool = False
|
||||
save_vocals_copy: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,6 +58,19 @@ class LyricsConfig:
|
||||
try_after_asr: bool = True
|
||||
fuzzy_threshold: float = 0.6
|
||||
transcribe_if_identified_no_lyrics: bool = True
|
||||
# NEW:
|
||||
use_embedded_lyrics: bool = True # read lyrics already stored in file tags first
|
||||
save_unrecognized: bool = False # write unresolved texts to JSONL for LRCLIB submission
|
||||
unrecognized_path: str = "" # "" = <output>/unrecognized_lyrics.jsonl
|
||||
|
||||
|
||||
@dataclass
|
||||
class LrcConfig:
|
||||
language: Optional[str] = "ru"
|
||||
align_model: Optional[str] = None
|
||||
device: str = "auto"
|
||||
audio_source: str = "auto"
|
||||
embed_in_place: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -73,6 +87,7 @@ class PipelineConfig:
|
||||
transcribe: TranscribeConfig = field(default_factory=TranscribeConfig)
|
||||
shazam: ShazamConfig = field(default_factory=ShazamConfig)
|
||||
lyrics: LyricsConfig = field(default_factory=LyricsConfig)
|
||||
lrc: LrcConfig = field(default_factory=LrcConfig)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
195
core/lrc.py
Normal file
195
core/lrc.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""LRC generation, parsing, embedding, and reading embedded lyrics.
|
||||
|
||||
Reading embedded lyrics checks all tag locations for a given audio format:
|
||||
- MP3 -> ID3 USLT / SYLT
|
||||
- FLAC/OGG -> LYRICS, UNSYNCEDLYRICS (Vorbis comments)
|
||||
- M4A/MP4 -> `©lyr` atom
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_LRC_LINE_RE = re.compile(r"^\s*\[\d{1,3}:\d{2}(?:\.\d{1,3})?\]")
|
||||
_LRC_PARSE_RE = re.compile(r"\[(\d+):(\d+)\.(\d+)\](.*)$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LrcLine:
|
||||
start: float
|
||||
text: str
|
||||
|
||||
|
||||
def format_timestamp(seconds: float) -> str:
|
||||
if seconds < 0:
|
||||
seconds = 0.0
|
||||
minutes = int(seconds // 60)
|
||||
secs = seconds - minutes * 60
|
||||
return f"[{minutes:02d}:{secs:05.2f}]"
|
||||
|
||||
|
||||
def segments_to_lrc(segments: list[dict]) -> str:
|
||||
lines: list[str] = []
|
||||
for seg in segments:
|
||||
text = (seg.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
start = float(seg.get("start", 0.0))
|
||||
lines.append(f"{format_timestamp(start)}{text}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def parse_lrc(content: str) -> list[LrcLine]:
|
||||
out: list[LrcLine] = []
|
||||
for line in content.splitlines():
|
||||
m = _LRC_PARSE_RE.match(line.strip())
|
||||
if not m:
|
||||
continue
|
||||
mm, ss, frac, text = m.groups()
|
||||
frac_sec = float(f"0.{frac}") if frac else 0.0
|
||||
out.append(LrcLine(start=int(mm) * 60 + int(ss) + frac_sec, text=text.strip()))
|
||||
return out
|
||||
|
||||
|
||||
def is_lrc(content: str) -> bool:
|
||||
"""True if content contains at least one LRC-style timestamp line."""
|
||||
if not content:
|
||||
return False
|
||||
for line in content.splitlines():
|
||||
if _LRC_LINE_RE.match(line):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- read embedded
|
||||
|
||||
def read_embedded_lyrics(audio_path: Path) -> Optional[str]:
|
||||
"""Return lyrics stored in the file's tags, or None.
|
||||
|
||||
Order: prefer synced (LRC-like) -> plain. Never raises.
|
||||
"""
|
||||
suffix = audio_path.suffix.lower()
|
||||
try:
|
||||
if suffix in (".mp3", ".mp2"):
|
||||
return _read_mp3(audio_path)
|
||||
if suffix in (".flac", ".ogg", ".opus"):
|
||||
return _read_vorbis(audio_path)
|
||||
if suffix in (".m4a", ".mp4", ".m4b", ".aac"):
|
||||
return _read_mp4(audio_path)
|
||||
except Exception: # noqa: BLE001 — never break the pipeline on tag issues
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _read_mp3(path: Path) -> Optional[str]:
|
||||
from mutagen.id3 import ID3, ID3NoHeaderError
|
||||
try:
|
||||
tags = ID3(str(path))
|
||||
except ID3NoHeaderError:
|
||||
return None
|
||||
|
||||
# SYLT = synced lyrics (rare); USLT = unsynced (common, may contain LRC text)
|
||||
sylt = tags.getall("SYLT")
|
||||
for tag in sylt:
|
||||
if tag.text:
|
||||
try:
|
||||
content = "\n".join(
|
||||
f"{format_timestamp(ms / 1000.0)}{txt}"
|
||||
for txt, ms in tag.text
|
||||
)
|
||||
if content.strip():
|
||||
return content
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
|
||||
uslt = tags.getall("USLT")
|
||||
for tag in uslt:
|
||||
if tag.text and str(tag.text).strip():
|
||||
return str(tag.text)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_vorbis(path: Path) -> Optional[str]:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".flac":
|
||||
from mutagen.flac import FLAC as Audio
|
||||
elif suffix == ".ogg":
|
||||
from mutagen.oggvorbis import OggVorbis as Audio
|
||||
else:
|
||||
from mutagen.oggopus import OggOpus as Audio
|
||||
audio = Audio(str(path))
|
||||
for key in ("LYRICS", "UNSYNCEDLYRICS", "LYRIC", "SYNCEDLYRICS"):
|
||||
if key in audio:
|
||||
values = audio[key]
|
||||
if isinstance(values, list) and values:
|
||||
text = str(values[0])
|
||||
else:
|
||||
text = str(values)
|
||||
if text.strip():
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _read_mp4(path: Path) -> Optional[str]:
|
||||
from mutagen.mp4 import MP4
|
||||
audio = MP4(str(path))
|
||||
for key in ("\xa9lyr", "©lyr"):
|
||||
if key in audio:
|
||||
values = audio[key]
|
||||
if isinstance(values, list) and values:
|
||||
text = str(values[0])
|
||||
else:
|
||||
text = str(values)
|
||||
if text.strip():
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- embedding
|
||||
|
||||
def embed_lrc(audio_path: Path, lrc_content: str) -> None:
|
||||
suffix = audio_path.suffix.lower()
|
||||
if suffix in (".mp3", ".mp2"):
|
||||
_embed_mp3(audio_path, lrc_content)
|
||||
elif suffix in (".flac", ".ogg", ".opus"):
|
||||
_embed_vorbis(audio_path, lrc_content)
|
||||
elif suffix in (".m4a", ".mp4", ".m4b", ".aac"):
|
||||
_embed_mp4(audio_path, lrc_content)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format for LRC embedding: {suffix}")
|
||||
|
||||
|
||||
def _embed_mp3(path: Path, lrc_content: str) -> None:
|
||||
from mutagen.id3 import ID3, USLT, ID3NoHeaderError
|
||||
try:
|
||||
tags = ID3(str(path))
|
||||
except ID3NoHeaderError:
|
||||
tags = ID3()
|
||||
tags.delall("USLT")
|
||||
tags.add(USLT(encoding=3, lang="rus", desc="", text=lrc_content))
|
||||
tags.save(str(path), v2_version=3)
|
||||
|
||||
|
||||
def _embed_vorbis(path: Path, lrc_content: str) -> None:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".flac":
|
||||
from mutagen.flac import FLAC as Audio
|
||||
elif suffix == ".ogg":
|
||||
from mutagen.oggvorbis import OggVorbis as Audio
|
||||
else:
|
||||
from mutagen.oggopus import OggOpus as Audio
|
||||
audio = Audio(str(path))
|
||||
audio["LYRICS"] = lrc_content
|
||||
audio["UNSYNCEDLYRICS"] = lrc_content
|
||||
audio.save()
|
||||
|
||||
|
||||
def _embed_mp4(path: Path, lrc_content: str) -> None:
|
||||
from mutagen.mp4 import MP4
|
||||
audio = MP4(str(path))
|
||||
audio["\xa9lyr"] = [lrc_content]
|
||||
audio.save()
|
||||
195
core/lrc_pipeline.py
Normal file
195
core/lrc_pipeline.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""LRC-generation and LRC-embedding pipelines."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .config import PipelineConfig
|
||||
from .convert import convert_to_wav
|
||||
from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc
|
||||
from .metadata import read_metadata
|
||||
from .pipeline import FileResult, _save_unrecognized, collect_audio_files
|
||||
from .transcribe import transcribe_aligned
|
||||
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
ProgressFn = Callable[[int, int, str], None]
|
||||
|
||||
|
||||
def _pick_audio_source(
|
||||
f: Path, output_dir: Path, config: PipelineConfig, work_dir: Path, log: LogFn,
|
||||
) -> Path:
|
||||
mode = (config.lrc.audio_source or "auto").lower()
|
||||
vocals = output_dir / f.stem / "vocals.wav"
|
||||
convert = output_dir / f.stem / "convert.wav"
|
||||
if mode == "vocals":
|
||||
return vocals
|
||||
if mode == "convert":
|
||||
return convert
|
||||
if mode == "input":
|
||||
return f
|
||||
if vocals.exists():
|
||||
return vocals
|
||||
if convert.exists():
|
||||
return convert
|
||||
target = work_dir / "input_16k.wav"
|
||||
log(f" [convert] {f.name} -> 16k mono WAV (для align)")
|
||||
convert_to_wav(
|
||||
f, target, sample_rate=16000, channels=1,
|
||||
ffmpeg_path=config.convert.ffmpeg_path,
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def run_lrc_pipeline(
|
||||
config: PipelineConfig,
|
||||
log: LogFn = print,
|
||||
on_progress: Optional[ProgressFn] = None,
|
||||
stop_event: Optional[threading.Event] = None,
|
||||
) -> list[FileResult]:
|
||||
input_dir = Path(config.input_dir)
|
||||
if not input_dir.is_dir():
|
||||
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||
output_dir = Path(config.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files = collect_audio_files(input_dir, config.recursive)
|
||||
if not files:
|
||||
log("Аудиофайлы не найдены.")
|
||||
return []
|
||||
|
||||
lang = config.lrc.language or config.transcribe.language
|
||||
log(f"LRC: {len(files)} файлов | язык={lang or 'auto'} | "
|
||||
f"источник={config.lrc.audio_source}")
|
||||
|
||||
results: list[FileResult] = []
|
||||
total = len(files)
|
||||
|
||||
for idx, f in enumerate(files, 1):
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
log("⏹ Отменено")
|
||||
break
|
||||
if on_progress:
|
||||
on_progress(idx - 1, total, "lrc")
|
||||
log(f"\n▶ [{idx}/{total}] {f.name}")
|
||||
|
||||
track_out = output_dir / f.stem
|
||||
track_out.mkdir(parents=True, exist_ok=True)
|
||||
lrc_path = track_out / "text.lrc"
|
||||
error: Optional[str] = None
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_"))
|
||||
try:
|
||||
embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None
|
||||
if embedded and is_lrc(embedded):
|
||||
lrc_path.write_text(embedded, encoding="utf-8")
|
||||
log(" ✓ text.lrc (взят из тегов аудио)")
|
||||
results.append(FileResult(f, None, track_out, None, "embedded+lrc"))
|
||||
continue
|
||||
|
||||
audio_path = _pick_audio_source(f, output_dir, config, tmp, log)
|
||||
if not audio_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Источник аудио не найден: {audio_path}. "
|
||||
"Включите conversion/separation в основном пайплайне "
|
||||
"или выберите audio_source=input."
|
||||
)
|
||||
log(f" [align] {audio_path.name}")
|
||||
|
||||
segments, detected = transcribe_aligned(
|
||||
audio_path,
|
||||
model=config.transcribe.model,
|
||||
language=lang,
|
||||
device=config.lrc.device,
|
||||
compute_type=config.transcribe.compute_type,
|
||||
batch_size=config.transcribe.batch_size,
|
||||
align_model=config.lrc.align_model,
|
||||
local_files_only=config.transcribe.local_files_only,
|
||||
)
|
||||
|
||||
lrc_content = segments_to_lrc(segments)
|
||||
lrc_path.write_text(lrc_content, encoding="utf-8")
|
||||
log(f" ✓ text.lrc ({len(segments)} строк, язык: {detected})")
|
||||
|
||||
# NEW: save to JSONL for LRCLIB submission
|
||||
_save_unrecognized(
|
||||
config, f, lrc_content, source="lrc",
|
||||
metadata=read_metadata(f),
|
||||
)
|
||||
results.append(FileResult(f, None, track_out, None, "lrc"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
error = f"{type(e).__name__}: {e}"
|
||||
log(f" ✗ Ошибка: {error}")
|
||||
results.append(FileResult(f, None, None, error, "lrc"))
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
if on_progress:
|
||||
on_progress(idx, total, "lrc")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def run_embed_pipeline(
|
||||
config: PipelineConfig,
|
||||
log: LogFn = print,
|
||||
on_progress: Optional[ProgressFn] = None,
|
||||
stop_event: Optional[threading.Event] = None,
|
||||
) -> list[FileResult]:
|
||||
input_dir = Path(config.input_dir)
|
||||
if not input_dir.is_dir():
|
||||
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||
output_dir = Path(config.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files = collect_audio_files(input_dir, config.recursive)
|
||||
if not files:
|
||||
log("Аудиофайлы не найдены.")
|
||||
return []
|
||||
|
||||
log(f"Embed LRC: {len(files)} файлов | "
|
||||
f"режим={'in-place' if config.lrc.embed_in_place else 'копия в output'}")
|
||||
|
||||
results: list[FileResult] = []
|
||||
total = len(files)
|
||||
|
||||
for idx, f in enumerate(files, 1):
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
log("⏹ Отменено")
|
||||
break
|
||||
if on_progress:
|
||||
on_progress(idx - 1, total, "embed")
|
||||
|
||||
lrc_path = output_dir / f.stem / "text.lrc"
|
||||
error: Optional[str] = None
|
||||
|
||||
try:
|
||||
if not lrc_path.exists():
|
||||
log(f" ⊘ {f.name}: text.lrc отсутствует, пропуск")
|
||||
results.append(FileResult(f, None, None, "no LRC", "embed"))
|
||||
continue
|
||||
|
||||
lrc_content = lrc_path.read_text(encoding="utf-8")
|
||||
|
||||
if config.lrc.embed_in_place:
|
||||
target = f
|
||||
else:
|
||||
target = output_dir / f.stem / f.name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not target.exists() or config.overwrite:
|
||||
shutil.copy2(f, target)
|
||||
|
||||
embed_lrc(target, lrc_content)
|
||||
log(f" ✓ {f.name} → {target if config.lrc.embed_in_place else target.name}")
|
||||
results.append(FileResult(f, None, target.parent, None, "embed"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
error = f"{type(e).__name__}: {e}"
|
||||
log(f" ✗ {f.name}: {error}")
|
||||
results.append(FileResult(f, None, None, error, "embed"))
|
||||
finally:
|
||||
if on_progress:
|
||||
on_progress(idx, total, "embed")
|
||||
|
||||
return results
|
||||
403
core/pipeline.py
403
core/pipeline.py
@@ -1,17 +1,4 @@
|
||||
"""Orchestrate identify -> convert -> separate -> transcribe -> lyrics-match.
|
||||
|
||||
Pipeline order per track (short-circuits as soon as text is found):
|
||||
1. metadata tags -> LRCLIB search (if `use_tags_first`)
|
||||
2. Shazam fingerprint (if enabled)
|
||||
3. Shazam result -> LRCLIB search (if `use_shazam_result`)
|
||||
4. full pipeline: convert -> separate -> transcribe
|
||||
5. post-ASR: LRCLIB search by recognized text (fuzzy match)
|
||||
|
||||
Two modes:
|
||||
- sequential: run all of the above per file
|
||||
- staged: identification pass first, then convert/separate/transcribe
|
||||
passes only for unresolved files, then post-ASR lyrics pass.
|
||||
"""
|
||||
"""Orchestrate identify -> convert -> separate -> transcribe -> lyrics-match."""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
@@ -32,6 +19,7 @@ from .metadata import TrackMetadata, read_metadata
|
||||
from .recognize import ShazamResult, recognize
|
||||
from .separate import separate_vocals, separate_vocals_batch
|
||||
from .transcribe import transcribe
|
||||
from .lrc import is_lrc, read_embedded_lyrics
|
||||
|
||||
|
||||
AUDIO_EXTENSIONS = {
|
||||
@@ -64,7 +52,6 @@ def collect_audio_files(input_dir: Path, recursive: bool) -> list[Path]:
|
||||
|
||||
|
||||
def _common_parent(paths: list[Path]) -> Path:
|
||||
"""Return the deepest common parent directory of the given paths."""
|
||||
if not paths:
|
||||
raise ValueError("empty paths")
|
||||
parts = [p.resolve().parts for p in paths]
|
||||
@@ -117,10 +104,44 @@ def _write_metadata_json(path: Path, data: Optional[dict]) -> None:
|
||||
if not data:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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 _save_unrecognized(
|
||||
config: PipelineConfig,
|
||||
f: Path,
|
||||
text: Optional[str],
|
||||
source: Optional[str],
|
||||
metadata: Optional[TrackMetadata] = None,
|
||||
lrc_path: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Append a record to the JSONL file for texts NOT confirmed by LRCLIB."""
|
||||
if not config.lyrics.save_unrecognized or not text:
|
||||
return
|
||||
if source and "+lrclib" in source:
|
||||
return
|
||||
|
||||
if config.lyrics.unrecognized_path:
|
||||
out_path = Path(config.lyrics.unrecognized_path)
|
||||
else:
|
||||
out_path = Path(config.output_dir) / "unrecognized_lyrics.jsonl"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
record = {
|
||||
"file": str(f),
|
||||
"trackName": (metadata.title if metadata else None) or f.stem,
|
||||
"artistName": metadata.artist if metadata else None,
|
||||
"albumName": metadata.album if metadata else None,
|
||||
"duration": metadata.duration if metadata else None,
|
||||
"plainLyrics": text if not is_lrc(text) else None,
|
||||
"syncedLyrics": text if is_lrc(text) else None,
|
||||
"source": source,
|
||||
}
|
||||
try:
|
||||
with out_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -138,24 +159,30 @@ def _identify_file(
|
||||
config: PipelineConfig,
|
||||
log: LogFn,
|
||||
) -> IdentifyResult:
|
||||
"""Run cheap identification steps. May populate .text and .source,
|
||||
and set .needs_asr=False if the track is fully resolved."""
|
||||
result = IdentifyResult(metadata=read_metadata(input_path))
|
||||
|
||||
# --- tags -> LRCLIB ---
|
||||
if (
|
||||
config.lyrics.enabled
|
||||
and config.lyrics.use_tags_first
|
||||
and result.metadata.has_identity
|
||||
):
|
||||
# --- 0) embedded lyrics в самом файле ---
|
||||
if config.lyrics.enabled and config.lyrics.use_embedded_lyrics:
|
||||
embedded = read_embedded_lyrics(input_path)
|
||||
if embedded and embedded.strip():
|
||||
if is_lrc(embedded):
|
||||
result.text = embedded
|
||||
result.source = "embedded+lrc"
|
||||
log(" ✓ встроенный LRC найден")
|
||||
else:
|
||||
result.text = embedded
|
||||
result.source = "embedded"
|
||||
log(" ✓ встроенный текст найден")
|
||||
result.needs_asr = False
|
||||
return result
|
||||
|
||||
# --- 1) LRCLIB по тегам ---
|
||||
if config.lyrics.enabled and config.lyrics.use_tags_first and result.metadata.has_identity:
|
||||
log(" LRCLIB по тегам...")
|
||||
match = search_by_identity(
|
||||
title=result.metadata.title,
|
||||
artist=result.metadata.artist,
|
||||
album=result.metadata.album,
|
||||
duration=result.metadata.duration,
|
||||
user_agent=config.lyrics.user_agent,
|
||||
timeout=config.lyrics.timeout,
|
||||
title=result.metadata.title, artist=result.metadata.artist,
|
||||
album=result.metadata.album, duration=result.metadata.duration,
|
||||
user_agent=config.lyrics.user_agent, timeout=config.lyrics.timeout,
|
||||
)
|
||||
if match:
|
||||
result.text = match.lyrics
|
||||
@@ -164,14 +191,13 @@ def _identify_file(
|
||||
log(f" ✓ {match.artist} — {match.title}")
|
||||
return result
|
||||
|
||||
# --- Shazam ---
|
||||
# --- 2) Shazam ---
|
||||
if config.shazam.enabled:
|
||||
log(" Shazam...")
|
||||
shazam_wav = work_dir / "shazam_16k.wav"
|
||||
try:
|
||||
convert_to_wav(
|
||||
input_path, shazam_wav,
|
||||
sample_rate=16000, channels=1,
|
||||
input_path, shazam_wav, sample_rate=16000, channels=1,
|
||||
ffmpeg_path=config.convert.ffmpeg_path,
|
||||
)
|
||||
result.shazam = recognize(shazam_wav)
|
||||
@@ -181,15 +207,11 @@ def _identify_file(
|
||||
|
||||
if result.shazam:
|
||||
log(f" ✓ {result.shazam.artist} — {result.shazam.title}")
|
||||
|
||||
if config.lyrics.enabled and config.lyrics.use_shazam_result:
|
||||
match = search_by_identity(
|
||||
title=result.shazam.title,
|
||||
artist=result.shazam.artist,
|
||||
album=result.shazam.album,
|
||||
duration=result.metadata.duration,
|
||||
user_agent=config.lyrics.user_agent,
|
||||
timeout=config.lyrics.timeout,
|
||||
title=result.shazam.title, artist=result.shazam.artist,
|
||||
album=result.shazam.album, duration=result.metadata.duration,
|
||||
user_agent=config.lyrics.user_agent, timeout=config.lyrics.timeout,
|
||||
)
|
||||
if match:
|
||||
result.text = match.lyrics
|
||||
@@ -197,8 +219,6 @@ def _identify_file(
|
||||
result.needs_asr = False
|
||||
log(" ✓ текст найден через LRCLIB")
|
||||
return result
|
||||
|
||||
# Identified, but no lyrics
|
||||
result.source = "shazam"
|
||||
if not config.lyrics.transcribe_if_identified_no_lyrics:
|
||||
result.needs_asr = False
|
||||
@@ -209,29 +229,18 @@ def _identify_file(
|
||||
return result
|
||||
|
||||
|
||||
def _try_post_asr_match(
|
||||
text: str,
|
||||
config: PipelineConfig,
|
||||
log: LogFn,
|
||||
) -> Optional[str]:
|
||||
def _try_post_asr_match(text: str, config: PipelineConfig, log: LogFn) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
|
||||
# Filter out common ASR watermarks/credits
|
||||
ignore_phrases = ["субтитры", "редактор", "перевод", "сделал", "озвучка"]
|
||||
lines = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if any(p in line.lower() for p in ignore_phrases):
|
||||
if not line or any(p in line.lower() for p in ignore_phrases):
|
||||
continue
|
||||
lines.append(line)
|
||||
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
# Take the first line with at least 3 words, strip punctuation
|
||||
query = ""
|
||||
for line in lines:
|
||||
words = line.split()
|
||||
@@ -239,26 +248,20 @@ def _try_post_asr_match(
|
||||
clean_words = [re.sub(r'[^\w\s]', '', w) for w in words]
|
||||
query = " ".join(clean_words[:6])
|
||||
break
|
||||
|
||||
if not query:
|
||||
return None
|
||||
|
||||
log(f" LRCLIB по тексту: {query!r}...")
|
||||
|
||||
try:
|
||||
candidates = search_by_text(
|
||||
query, limit=5,
|
||||
user_agent=config.lyrics.user_agent,
|
||||
query, limit=5, user_agent=config.lyrics.user_agent,
|
||||
timeout=config.lyrics.timeout,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f" ⚠ LRCLIB error: {e}")
|
||||
return None
|
||||
|
||||
if not candidates:
|
||||
log(" ⚠ нет кандидатов")
|
||||
return None
|
||||
|
||||
best: Optional[LyricsMatch] = None
|
||||
best_ratio = 0.0
|
||||
for c in candidates:
|
||||
@@ -266,11 +269,9 @@ def _try_post_asr_match(
|
||||
if ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
best = c
|
||||
|
||||
if best is not None and best_ratio >= config.lyrics.fuzzy_threshold:
|
||||
log(f" ✓ совпадение {best_ratio:.2f}: {best.artist} — {best.title}")
|
||||
return best.lyrics
|
||||
|
||||
log(f" ⚠ лучшее совпадение {best_ratio:.2f} < {config.lyrics.fuzzy_threshold}")
|
||||
return None
|
||||
|
||||
@@ -283,7 +284,9 @@ def _persist_artifacts(
|
||||
has_metadata: bool,
|
||||
log: LogFn,
|
||||
) -> None:
|
||||
"""Move wanted artifacts from work_dir to persist_dir."""
|
||||
if work_dir.resolve() == persist_dir.resolve():
|
||||
return # nothing to move — same directory
|
||||
|
||||
persist_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
names: list[str] = []
|
||||
@@ -291,6 +294,8 @@ def _persist_artifacts(
|
||||
names.append("metadata.json")
|
||||
if has_text and (work_dir / "text.txt").exists():
|
||||
names.append("text.txt")
|
||||
if has_text and (work_dir / "text.lrc").exists():
|
||||
names.append("text.lrc")
|
||||
|
||||
if config.keep_intermediate:
|
||||
for n in ("convert.wav", "vocals.wav"):
|
||||
@@ -302,6 +307,10 @@ def _persist_artifacts(
|
||||
elif config.convert.enabled and (work_dir / "convert.wav").exists():
|
||||
names.append("convert.wav")
|
||||
|
||||
if config.separate.save_vocals_copy and (work_dir / "vocals.wav").exists():
|
||||
if "vocals.wav" not in names:
|
||||
names.append("vocals.wav")
|
||||
|
||||
for name in names:
|
||||
src = work_dir / name
|
||||
dst = persist_dir / name
|
||||
@@ -314,14 +323,10 @@ def _persist_artifacts(
|
||||
# ---------------------------------------------------------------- entry
|
||||
|
||||
def _run_identification_parallel(
|
||||
config: PipelineConfig,
|
||||
files: list[Path],
|
||||
log: LogFn,
|
||||
on_progress: Optional[ProgressFn],
|
||||
stop_event: Optional[threading.Event],
|
||||
config: PipelineConfig, files: list[Path], log: LogFn,
|
||||
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
|
||||
work_root: Path,
|
||||
) -> dict[Path, Optional[IdentifyResult]]:
|
||||
"""Parallel identification pass (tags → LRCLIB, Shazam, etc.)."""
|
||||
if not (config.shazam.enabled or config.lyrics.enabled):
|
||||
return {}
|
||||
|
||||
@@ -347,13 +352,10 @@ def _run_identification_parallel(
|
||||
def worker(f: Path) -> tuple[Path, Optional[IdentifyResult]]:
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
return f, None
|
||||
|
||||
def prefixed(msg: str) -> None:
|
||||
log(f" [{f.name}] {msg.strip()}")
|
||||
|
||||
try:
|
||||
ident = _identify_file(f, work_root / f.stem, config, prefixed)
|
||||
return f, ident
|
||||
return f, _identify_file(f, work_root / f.stem, config, prefixed)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f" ✗ {f.name} (identify): {e}")
|
||||
return f, None
|
||||
@@ -373,19 +375,13 @@ def _run_identification_parallel(
|
||||
done += 1
|
||||
if on_progress:
|
||||
on_progress(done, total, "identify")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _run_convert_parallel(
|
||||
config: PipelineConfig,
|
||||
files: list[Path],
|
||||
state: dict,
|
||||
log: LogFn,
|
||||
on_progress: Optional[ProgressFn],
|
||||
stop_event: Optional[threading.Event],
|
||||
config: PipelineConfig, files: list[Path], state: dict, log: LogFn,
|
||||
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
|
||||
) -> None:
|
||||
"""Parallel convert stage. Writes state[f]['audio_path'] or state[f]['error']."""
|
||||
concurrency = max(1, int(config.convert.convert_concurrency))
|
||||
total = len(files)
|
||||
|
||||
@@ -396,8 +392,7 @@ def _run_convert_parallel(
|
||||
target = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_")) / "convert.wav"
|
||||
if config.overwrite or not target.exists():
|
||||
convert_to_wav(
|
||||
f, target,
|
||||
sample_rate=config.convert.sample_rate,
|
||||
f, target, sample_rate=config.convert.sample_rate,
|
||||
channels=config.convert.channels,
|
||||
ffmpeg_path=config.convert.ffmpeg_path,
|
||||
)
|
||||
@@ -436,51 +431,49 @@ def _run_convert_parallel(
|
||||
if on_progress:
|
||||
on_progress(done, total, "convert")
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
config: PipelineConfig,
|
||||
log: LogFn = print,
|
||||
config: PipelineConfig, log: LogFn = print,
|
||||
on_progress: Optional[ProgressFn] = None,
|
||||
stop_event: Optional[threading.Event] = None,
|
||||
) -> list[FileResult]:
|
||||
input_dir = Path(config.input_dir)
|
||||
if not input_dir.is_dir():
|
||||
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||
|
||||
output_dir = Path(config.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files = collect_audio_files(input_dir, config.recursive)
|
||||
if not files:
|
||||
log("Аудиофайлы не найдены.")
|
||||
return []
|
||||
|
||||
mode = (config.mode or "sequential").lower()
|
||||
log(
|
||||
f"Найдено треков: {len(files)} | режим: {mode} | "
|
||||
f"keep_intermediate={config.keep_intermediate}"
|
||||
)
|
||||
log(f"Найдено треков: {len(files)} | режим: {mode} | "
|
||||
f"keep_intermediate={config.keep_intermediate}")
|
||||
if config.separate.save_vocals_copy:
|
||||
log(" ✓ Вокал будет сохраняться всегда (save_vocals_copy=True)")
|
||||
if config.shazam.enabled:
|
||||
log(" Shazam: включён")
|
||||
if config.lyrics.enabled:
|
||||
log(f" LRCLIB: включён (порог fuzzy={config.lyrics.fuzzy_threshold:.2f})")
|
||||
|
||||
if mode == "staged":
|
||||
return _run_staged(config, files, log, on_progress, stop_event)
|
||||
return _run_sequential(config, files, log, on_progress, stop_event)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sequential
|
||||
|
||||
def _run_sequential(
|
||||
config: PipelineConfig,
|
||||
files: list[Path],
|
||||
log: LogFn,
|
||||
on_progress: Optional[ProgressFn],
|
||||
stop_event: Optional[threading.Event],
|
||||
config: PipelineConfig, files: list[Path], log: LogFn,
|
||||
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
|
||||
) -> list[FileResult]:
|
||||
total = len(files)
|
||||
results: list[FileResult] = []
|
||||
output_root = Path(config.output_dir)
|
||||
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_"))
|
||||
try:
|
||||
ident_map = _run_identification_parallel(
|
||||
config, files, log, on_progress, stop_event, ident_tmp,
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(ident_tmp, ignore_errors=True)
|
||||
|
||||
for idx, f in enumerate(files, 1):
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
@@ -500,8 +493,7 @@ def _run_sequential(
|
||||
metadata_dict: Optional[dict] = None
|
||||
|
||||
try:
|
||||
# --- Phase 1: identification ---
|
||||
ident = _identify_file(f, tmp_root, config, log)
|
||||
ident = ident_map.get(f) or IdentifyResult(metadata=TrackMetadata())
|
||||
|
||||
if not ident.needs_asr:
|
||||
text = ident.text
|
||||
@@ -509,20 +501,22 @@ def _run_sequential(
|
||||
metadata_dict = _build_metadata_dict(ident.metadata, ident.shazam, source)
|
||||
if text:
|
||||
(tmp_root / "text.txt").write_text(text, encoding="utf-8")
|
||||
log(f" ✓ text.txt ({len(text)} символов, {source})")
|
||||
if metadata_dict:
|
||||
_write_metadata_json(tmp_root / "metadata.json", metadata_dict)
|
||||
log(" ✓ metadata.json")
|
||||
_persist_artifacts(
|
||||
tmp_root, track_out, config,
|
||||
has_text=text is not None,
|
||||
has_metadata=bool(metadata_dict),
|
||||
log=log,
|
||||
)
|
||||
_save_unrecognized(
|
||||
config, f, text, source,
|
||||
metadata=ident.metadata,
|
||||
)
|
||||
log(f" ✓ {f.name} [{source}]")
|
||||
results.append(FileResult(f, text, track_out, None, source, metadata_dict))
|
||||
continue
|
||||
|
||||
# --- Phase 2: full pipeline ---
|
||||
audio_path: Path = f
|
||||
if config.convert.enabled:
|
||||
_check_cancel(stop_event)
|
||||
@@ -530,8 +524,7 @@ def _run_sequential(
|
||||
target = tmp_root / "convert.wav"
|
||||
if config.overwrite or not target.exists():
|
||||
convert_to_wav(
|
||||
f, target,
|
||||
sample_rate=config.convert.sample_rate,
|
||||
f, target, sample_rate=config.convert.sample_rate,
|
||||
channels=config.convert.channels,
|
||||
ffmpeg_path=config.convert.ffmpeg_path,
|
||||
)
|
||||
@@ -558,8 +551,7 @@ def _run_sequential(
|
||||
_check_cancel(stop_event)
|
||||
log(" [3] Транскрибация...")
|
||||
text = transcribe(
|
||||
audio_path,
|
||||
engine=config.transcribe.engine,
|
||||
audio_path, engine=config.transcribe.engine,
|
||||
model=config.transcribe.model,
|
||||
language=config.transcribe.language,
|
||||
device=config.transcribe.device,
|
||||
@@ -570,13 +562,11 @@ def _run_sequential(
|
||||
)
|
||||
source = config.transcribe.engine
|
||||
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(ident.metadata, ident.shazam, source)
|
||||
@@ -589,6 +579,10 @@ def _run_sequential(
|
||||
has_metadata=bool(metadata_dict),
|
||||
log=log,
|
||||
)
|
||||
_save_unrecognized(
|
||||
config, f, text, source,
|
||||
metadata=ident.metadata,
|
||||
)
|
||||
|
||||
except Cancelled:
|
||||
log("⏹ Отменено")
|
||||
@@ -600,36 +594,24 @@ def _run_sequential(
|
||||
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||
if on_progress:
|
||||
on_progress(idx, total, "file")
|
||||
results.append(
|
||||
FileResult(f, text, track_out if not error else None, error, source, metadata_dict)
|
||||
)
|
||||
results.append(FileResult(
|
||||
f, text, track_out if not error else None, error, source, metadata_dict,
|
||||
))
|
||||
|
||||
_log_summary(results, log)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- staged
|
||||
|
||||
def _run_staged(
|
||||
config: PipelineConfig,
|
||||
files: list[Path],
|
||||
log: LogFn,
|
||||
on_progress: Optional[ProgressFn],
|
||||
stop_event: Optional[threading.Event],
|
||||
config: PipelineConfig, files: list[Path], log: LogFn,
|
||||
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
|
||||
) -> list[FileResult]:
|
||||
output_root = Path(config.output_dir)
|
||||
total = len(files)
|
||||
|
||||
state: dict[Path, dict] = {
|
||||
f: {
|
||||
"audio_path": f,
|
||||
"ident": None,
|
||||
"text": None,
|
||||
"source": None,
|
||||
"metadata_dict": None,
|
||||
"error": None,
|
||||
"resolved": False,
|
||||
}
|
||||
f: {"audio_path": f, "ident": None, "text": None, "source": None,
|
||||
"metadata_dict": None, "error": None, "resolved": False}
|
||||
for f in files
|
||||
}
|
||||
|
||||
@@ -639,77 +621,55 @@ def _run_staged(
|
||||
def cancelled() -> bool:
|
||||
return stop_event is not None and stop_event.is_set()
|
||||
|
||||
# --- Stage 0: identification ---
|
||||
if config.shazam.enabled or config.lyrics.enabled:
|
||||
log(f"\n══ Этап 0 · Идентификация ({total} файлов) ══")
|
||||
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_"))
|
||||
try:
|
||||
for i, f in enumerate(files, 1):
|
||||
if cancelled():
|
||||
log("⏹ Отменено")
|
||||
break
|
||||
if on_progress:
|
||||
on_progress(i, total, "identify")
|
||||
try:
|
||||
ident = _identify_file(f, ident_tmp / f.stem, config, log)
|
||||
state[f]["ident"] = ident
|
||||
|
||||
if not ident.needs_asr:
|
||||
state[f]["resolved"] = True
|
||||
state[f]["text"] = ident.text
|
||||
state[f]["source"] = ident.source
|
||||
state[f]["metadata_dict"] = _build_metadata_dict(
|
||||
ident.metadata, ident.shazam, ident.source,
|
||||
)
|
||||
# Persist resolved tracks right away
|
||||
track_out = output_root / f.stem
|
||||
track_out.mkdir(parents=True, exist_ok=True)
|
||||
if ident.text:
|
||||
(track_out / "text.txt").write_text(ident.text, encoding="utf-8")
|
||||
if state[f]["metadata_dict"]:
|
||||
_write_metadata_json(track_out / "metadata.json", state[f]["metadata_dict"])
|
||||
log(f" ✓ {f.name} [{ident.source}]")
|
||||
else:
|
||||
log(f" → {f.name} (нужен полный пайплайн)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
state[f]["error"] = f"{type(e).__name__}: {e}"
|
||||
log(f" ✗ {f.name}: {e}")
|
||||
ident_map = _run_identification_parallel(
|
||||
config, files, log, on_progress, stop_event, ident_tmp,
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(ident_tmp, ignore_errors=True)
|
||||
|
||||
# --- Stage 1: convert ---
|
||||
for f in files:
|
||||
ident = ident_map.get(f)
|
||||
if ident is None:
|
||||
state[f]["ident"] = IdentifyResult(metadata=TrackMetadata())
|
||||
log(f" → {f.name} (identification failed)")
|
||||
continue
|
||||
state[f]["ident"] = ident
|
||||
if not ident.needs_asr:
|
||||
state[f]["resolved"] = True
|
||||
state[f]["text"] = ident.text
|
||||
state[f]["source"] = ident.source
|
||||
state[f]["metadata_dict"] = _build_metadata_dict(
|
||||
ident.metadata, ident.shazam, ident.source,
|
||||
)
|
||||
track_out = output_root / f.stem
|
||||
track_out.mkdir(parents=True, exist_ok=True)
|
||||
if ident.text:
|
||||
(track_out / "text.txt").write_text(ident.text, encoding="utf-8")
|
||||
if state[f]["metadata_dict"]:
|
||||
_write_metadata_json(track_out / "metadata.json", state[f]["metadata_dict"])
|
||||
_save_unrecognized(
|
||||
config, f, ident.text, ident.source,
|
||||
metadata=ident.metadata,
|
||||
)
|
||||
log(f" ✓ {f.name} [{ident.source}]")
|
||||
else:
|
||||
log(f" → {f.name} (нужен полный пайплайн)")
|
||||
|
||||
if config.convert.enabled and not cancelled():
|
||||
todo = active()
|
||||
log(f"\n══ Этап 1 · Конвертация ({len(todo)} файлов) ══")
|
||||
for i, f in enumerate(todo, 1):
|
||||
if cancelled():
|
||||
log("⏹ Отменено")
|
||||
break
|
||||
if on_progress:
|
||||
on_progress(i, len(todo), "convert")
|
||||
try:
|
||||
target = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_")) / "convert.wav"
|
||||
if config.overwrite or not target.exists():
|
||||
convert_to_wav(
|
||||
f, target,
|
||||
sample_rate=config.convert.sample_rate,
|
||||
channels=config.convert.channels,
|
||||
ffmpeg_path=config.convert.ffmpeg_path,
|
||||
)
|
||||
state[f]["audio_path"] = target
|
||||
log(f" ✓ {f.name}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
state[f]["error"] = f"{type(e).__name__}: {e}"
|
||||
log(f" ✗ {f.name}: {e}")
|
||||
_run_convert_parallel(config, todo, state, log, on_progress, stop_event)
|
||||
|
||||
# ---- Stage 2: separate (batched) ----
|
||||
if config.separate.enabled and not cancelled():
|
||||
todo = active()
|
||||
log(f"\n══ Этап 2/3 · Отделение вокала ({len(todo)} файлов) ══")
|
||||
log(f"\n══ Этап 2 · Отделение вокала ({len(todo)} файлов) ══")
|
||||
if not todo:
|
||||
pass
|
||||
elif len(todo) == 1:
|
||||
# Single file — no batching advantage
|
||||
f = todo[0]
|
||||
if on_progress:
|
||||
on_progress(1, 1, "separate")
|
||||
@@ -718,8 +678,7 @@ def _run_staged(
|
||||
target = src.parent / "vocals.wav"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
raw = separate_vocals(
|
||||
src, Path(tmp),
|
||||
model=config.separate.model,
|
||||
src, Path(tmp), model=config.separate.model,
|
||||
isolate_stem=config.separate.isolate_stem,
|
||||
unblend_path=config.separate.unblend_path,
|
||||
custom_kernels=config.separate.custom_kernels,
|
||||
@@ -731,16 +690,14 @@ def _run_staged(
|
||||
state[f]["error"] = f"{type(e).__name__}: {e}"
|
||||
log(f" ✗ {f.name}: {e}")
|
||||
else:
|
||||
# Batch: one unblend invocation, model loaded once
|
||||
input_wavs = [state[f]["audio_path"] for f in todo]
|
||||
common_root = _common_parent(input_wavs)
|
||||
log(f" Батч: {len(todo)} файлов, один вызов unblend (модель загрузится один раз)")
|
||||
log(f" Батч: {len(todo)} файлов, один вызов unblend")
|
||||
if on_progress:
|
||||
on_progress(0, len(todo), "separate")
|
||||
try:
|
||||
mapping = separate_vocals_batch(
|
||||
input_wavs,
|
||||
common_root,
|
||||
input_wavs, common_root,
|
||||
model=config.separate.model,
|
||||
isolate_stem=config.separate.isolate_stem,
|
||||
unblend_path=config.separate.unblend_path,
|
||||
@@ -771,8 +728,7 @@ def _run_staged(
|
||||
target = src.parent / "vocals.wav"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
raw = separate_vocals(
|
||||
src, Path(tmp),
|
||||
model=config.separate.model,
|
||||
src, Path(tmp), model=config.separate.model,
|
||||
isolate_stem=config.separate.isolate_stem,
|
||||
unblend_path=config.separate.unblend_path,
|
||||
custom_kernels=config.separate.custom_kernels,
|
||||
@@ -784,7 +740,6 @@ def _run_staged(
|
||||
state[f]["error"] = f"{type(inner).__name__}: {inner}"
|
||||
log(f" ✗ {f.name}: {inner}")
|
||||
|
||||
# --- Stage 3: transcribe ---
|
||||
if config.transcribe.enabled and not cancelled():
|
||||
todo = active()
|
||||
log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══")
|
||||
@@ -794,8 +749,7 @@ def _run_staged(
|
||||
break
|
||||
try:
|
||||
text = transcribe(
|
||||
state[f]["audio_path"],
|
||||
engine=config.transcribe.engine,
|
||||
state[f]["audio_path"], engine=config.transcribe.engine,
|
||||
model=config.transcribe.model,
|
||||
language=config.transcribe.language,
|
||||
device=config.transcribe.device,
|
||||
@@ -814,7 +768,6 @@ def _run_staged(
|
||||
if on_progress:
|
||||
on_progress(i, len(todo), "transcribe")
|
||||
|
||||
# --- Stage 4: post-ASR lyrics match + persist ---
|
||||
if not cancelled():
|
||||
log("\n══ Финализация ══")
|
||||
for f in files:
|
||||
@@ -822,11 +775,9 @@ def _run_staged(
|
||||
if s["error"] is not None:
|
||||
continue
|
||||
if s["resolved"]:
|
||||
continue # already persisted in identification pass
|
||||
|
||||
continue
|
||||
text = s["text"]
|
||||
source = s["source"]
|
||||
|
||||
if text and config.lyrics.enabled and config.lyrics.try_after_asr:
|
||||
matched = _try_post_asr_match(text, config, log)
|
||||
if matched:
|
||||
@@ -834,7 +785,6 @@ def _run_staged(
|
||||
source = f"{source}+lrclib"
|
||||
s["text"] = text
|
||||
s["source"] = source
|
||||
|
||||
ident = s["ident"]
|
||||
metadata_dict = _build_metadata_dict(
|
||||
ident.metadata, ident.shazam, source,
|
||||
@@ -845,21 +795,31 @@ def _run_staged(
|
||||
track_out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
audio_work = s["audio_path"]
|
||||
work_dir = audio_work.parent if isinstance(audio_work, Path) else track_out
|
||||
if text:
|
||||
(work_dir / "text.txt").write_text(text, encoding="utf-8")
|
||||
if metadata_dict:
|
||||
_write_metadata_json(work_dir / "metadata.json", metadata_dict)
|
||||
if isinstance(audio_work, Path) and audio_work != f:
|
||||
work_dir = audio_work.parent # intermediates exist
|
||||
if text:
|
||||
(work_dir / "text.txt").write_text(text, encoding="utf-8")
|
||||
if metadata_dict:
|
||||
_write_metadata_json(work_dir / "metadata.json", metadata_dict)
|
||||
_persist_artifacts(
|
||||
work_dir, track_out, config,
|
||||
has_text=text is not None,
|
||||
has_metadata=bool(metadata_dict),
|
||||
log=log,
|
||||
)
|
||||
else:
|
||||
# no intermediates (convert/separate disabled): write directly
|
||||
if text:
|
||||
(track_out / "text.txt").write_text(text, encoding="utf-8")
|
||||
if metadata_dict:
|
||||
_write_metadata_json(track_out / "metadata.json", metadata_dict)
|
||||
|
||||
_persist_artifacts(
|
||||
work_dir, track_out, config,
|
||||
has_text=text is not None,
|
||||
has_metadata=bool(metadata_dict),
|
||||
log=log,
|
||||
_save_unrecognized(
|
||||
config, f, text, source,
|
||||
metadata=(ident.metadata if ident else None),
|
||||
)
|
||||
log(f" ✓ {f.name}")
|
||||
|
||||
# cleanup temp dirs
|
||||
for f in files:
|
||||
audio_path = state[f]["audio_path"]
|
||||
if isinstance(audio_path, Path) and audio_path.parent.name.startswith(f"at_{f.stem}"):
|
||||
@@ -867,12 +827,9 @@ def _run_staged(
|
||||
|
||||
results = [
|
||||
FileResult(
|
||||
f,
|
||||
state[f]["text"],
|
||||
f, state[f]["text"],
|
||||
output_root / f.stem 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 files
|
||||
]
|
||||
@@ -880,8 +837,6 @@ def _run_staged(
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- summary
|
||||
|
||||
def _log_summary(results: list[FileResult], log: LogFn) -> None:
|
||||
from collections import Counter
|
||||
counts = Counter(r.source or ("error" if r.error else "no_result") for r in results)
|
||||
|
||||
@@ -96,6 +96,8 @@ def separate_vocals_batch(
|
||||
break
|
||||
processed += 1
|
||||
progress_callback(processed)
|
||||
else:
|
||||
proc.wait(2.0)
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"unblend batch failed with exit code {proc.returncode}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Speech recognition via WhisperX or GigaAM (onnx-asr).
|
||||
|
||||
Includes aggressive VRAM cleanup to prevent OOM on long batches,
|
||||
and automatic model reload if the CUDA context gets corrupted.
|
||||
Also exposes `transcribe_aligned()` for word-level alignment (used by LRC).
|
||||
Models are cached; aggressive VRAM cleanup happens after each file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -33,6 +33,16 @@ def _retry(fn, attempts: int = 3, base_delay: float = 2.0):
|
||||
raise last
|
||||
|
||||
|
||||
def _free_vram() -> None:
|
||||
gc.collect()
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class _WhisperXModels:
|
||||
_cache: dict = {}
|
||||
|
||||
@@ -59,6 +69,28 @@ class _WhisperXModels:
|
||||
return cls._cache[key]
|
||||
|
||||
|
||||
class _AlignModels:
|
||||
_cache: dict = {}
|
||||
|
||||
@classmethod
|
||||
def get(cls, language_code: str, device: str, model_name: Optional[str] = None):
|
||||
key = (language_code, device, model_name or "")
|
||||
if key not in cls._cache:
|
||||
import whisperx
|
||||
print(f" [ALIGN] loading align model for {language_code} "
|
||||
f"({model_name or 'default'}) on {device}...")
|
||||
|
||||
def load():
|
||||
kwargs = {"language_code": language_code, "device": device}
|
||||
if model_name:
|
||||
kwargs["model_name"] = model_name
|
||||
return whisperx.load_align_model(**kwargs)
|
||||
|
||||
cls._cache[key] = _retry(load)
|
||||
print(" [ALIGN] align model loaded.")
|
||||
return cls._cache[key]
|
||||
|
||||
|
||||
class _GigaAMModels:
|
||||
_cache: dict = {}
|
||||
|
||||
@@ -105,13 +137,7 @@ def transcribe(
|
||||
if "out of memory" in err or "cuda" in err or "device ordinal" in err:
|
||||
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
|
||||
_WhisperXModels._cache.clear()
|
||||
gc.collect()
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
_free_vram()
|
||||
return _transcribe_whisperx(
|
||||
audio_path, model, language, device, compute_type,
|
||||
batch_size, local_files_only, vad_filter,
|
||||
@@ -136,28 +162,52 @@ def _transcribe_whisperx(
|
||||
import whisperx
|
||||
model = _WhisperXModels.get(model_name, device, compute_type, local_files_only)
|
||||
audio = whisperx.load_audio(str(audio_path))
|
||||
result = model.transcribe(
|
||||
audio,
|
||||
batch_size=batch_size,
|
||||
language=language,
|
||||
)
|
||||
|
||||
result = model.transcribe(audio, batch_size=batch_size, language=language)
|
||||
text = " ".join(seg["text"].strip() for seg in result["segments"]).strip()
|
||||
|
||||
# Free VRAM aggressively to prevent fragmentation/OOM on long batches
|
||||
del audio
|
||||
del result
|
||||
gc.collect()
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
del audio, result
|
||||
_free_vram()
|
||||
return text
|
||||
|
||||
|
||||
def _transcribe_gigaam(audio_path: Path, provider: str) -> str:
|
||||
model = _GigaAMModels.get(provider)
|
||||
return model.recognize(str(audio_path), channel="mean").strip()
|
||||
|
||||
|
||||
def transcribe_aligned(
|
||||
audio_path: Path,
|
||||
model: str = "large-v3",
|
||||
language: Optional[str] = "ru",
|
||||
device: str = "auto",
|
||||
compute_type: Optional[str] = None,
|
||||
batch_size: int = 8,
|
||||
align_model: Optional[str] = None,
|
||||
local_files_only: bool = False,
|
||||
) -> tuple[list[dict], str]:
|
||||
"""Transcribe + word-level align. Returns (aligned_segments, detected_language).
|
||||
|
||||
Each segment has 'start', 'end', 'text', and optionally 'words'.
|
||||
Requires whisperx engine.
|
||||
"""
|
||||
import whisperx
|
||||
device = _resolve_device(device)
|
||||
model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only)
|
||||
audio = whisperx.load_audio(str(audio_path))
|
||||
result = model_obj.transcribe(audio, batch_size=batch_size, language=language)
|
||||
|
||||
detected = result.get("language") or language or "en"
|
||||
|
||||
try:
|
||||
align_model_obj, align_metadata = _AlignModels.get(detected, device, align_model)
|
||||
aligned = whisperx.align(
|
||||
result["segments"], align_model_obj, align_metadata,
|
||||
audio, device, return_char_alignments=False,
|
||||
)
|
||||
segments = aligned["segments"]
|
||||
except Exception as e:
|
||||
print(f" [ALIGN] failed: {e}. Falling back to raw WhisperX segments.")
|
||||
segments = result["segments"]
|
||||
|
||||
del audio, result
|
||||
_free_vram()
|
||||
return segments, detected
|
||||
|
||||
Reference in New Issue
Block a user