fix some bugs, add LRC synchronizer, text saver (if text not in LIBLRC, for upgrade library)

This commit is contained in:
jadssss
2026-10-24 10:02:05 +03:00
parent fe338e6880
commit 199d12a274
9 changed files with 829 additions and 2967 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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

View File

@@ -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,21 +621,23 @@ 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
ident_map = _run_identification_parallel(
config, files, log, on_progress, stop_event, ident_tmp,
)
finally:
shutil.rmtree(ident_tmp, ignore_errors=True)
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
@@ -661,55 +645,31 @@ def _run_staged(
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"])
_save_unrecognized(
config, f, ident.text, ident.source,
metadata=ident.metadata,
)
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}")
finally:
shutil.rmtree(ident_tmp, ignore_errors=True)
# --- Stage 1: convert ---
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 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)
_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)

View File

@@ -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}")

View File

@@ -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

View File

@@ -1,4 +1,4 @@
"""Main application window — dark theme, mode selector, staged/sequential pipelines."""
"""Main application window — dark theme, mode selector, LRC tab."""
from __future__ import annotations
import shutil
@@ -15,7 +15,7 @@ from PySide6.QtWidgets import (
)
from core.config import (
ConvertConfig, LyricsConfig, PipelineConfig, SeparateConfig,
ConvertConfig, LrcConfig, LyricsConfig, PipelineConfig, SeparateConfig,
ShazamConfig, TranscribeConfig,
)
from gui.worker import PipelineWorker
@@ -31,6 +31,8 @@ STAGE_LABELS = {
"convert": "Конвертация",
"separate": "Отделение",
"transcribe": "Транскрибация",
"lrc": "LRC",
"embed": "Встраивание LRC",
}
MODE_LABELS = {
@@ -44,7 +46,6 @@ def _detect_device_info() -> tuple[str, str]:
import torch
except ImportError:
return "⚠ PyTorch не установлен", "#f38ba8"
if torch.cuda.is_available():
try:
name = torch.cuda.get_device_name(0)
@@ -52,7 +53,6 @@ def _detect_device_info() -> tuple[str, str]:
return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1"
except Exception: # noqa: BLE001
return "✓ CUDA доступна", "#a6e3a1"
return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af"
@@ -60,7 +60,7 @@ class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Song Transcriber")
self.resize(1140, 880)
self.resize(1140, 920)
self._worker: Optional[PipelineWorker] = None
self._build_ui()
self._apply_defaults()
@@ -93,7 +93,6 @@ class MainWindow(QMainWindow):
self.input_edit = QLineEdit()
top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir))
self.output_edit = QLineEdit()
top_layout.addRow("Выходная папка:", self._with_browse(self.output_edit, self._pick_output_dir))
@@ -111,7 +110,6 @@ class MainWindow(QMainWindow):
opts.addWidget(self.overwrite_cb)
opts.addStretch(1)
top_layout.addRow("Опции:", opts)
root.addWidget(top_box)
self.tabs = QTabWidget()
@@ -120,6 +118,7 @@ class MainWindow(QMainWindow):
self.tabs.addTab(self._build_convert_tab(), "Конвертация")
self.tabs.addTab(self._build_separate_tab(), "Отделение вокала")
self.tabs.addTab(self._build_transcribe_tab(), "Транскрибация")
self.tabs.addTab(self._build_lrc_tab(), "LRC")
root.addWidget(self.tabs)
self.shazam_enabled_cb.toggled.connect(self._sync_tab_indicators)
@@ -131,9 +130,9 @@ class MainWindow(QMainWindow):
row = QHBoxLayout()
row.setSpacing(10)
self.start_btn = QPushButton("▶ Запустить")
self.start_btn = QPushButton("▶ Запустить пайплайн")
self.start_btn.setObjectName("primary")
self.start_btn.clicked.connect(self._on_start)
self.start_btn.clicked.connect(lambda: self._run_task("main"))
self.stop_btn = QPushButton("⏹ Стоп")
self.stop_btn.setObjectName("danger")
self.stop_btn.setEnabled(False)
@@ -180,26 +179,18 @@ class MainWindow(QMainWindow):
form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10)
self.shazam_enabled_cb = QCheckBox("Включён")
self.shazam_concurrency = QSpinBox()
self.shazam_concurrency.setRange(1, 16)
self.shazam_concurrency.setToolTip(
"Сколько файлов опознавать параллельно.\n"
"1 — последовательно, 4–8 — оптимум для Shazam."
)
form.addRow("Параллельных запросов:", self.shazam_concurrency)
form.addRow("", self.shazam_enabled_cb)
info = QLabel(
"Идентификация трека через Shazam (unofficial API).\n"
"При совпадении сохраняются метаданные и (если возможно) текст из LRCLIB.\n"
"Требует ffmpeg (для конвертации в 16 кГц моно)."
"При совпадении сохраняются метаданные и (если возможно) текст из LRCLIB."
)
info.setWordWrap(True)
info.setStyleSheet("color: #a6adc8;")
form.addRow(info)
return w
def _build_lrclib_tab(self) -> QWidget:
@@ -207,37 +198,47 @@ class MainWindow(QMainWindow):
form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10)
self.lyrics_enabled_cb = QCheckBox("Включён")
form.addRow("", self.lyrics_enabled_cb)
self.lyrics_use_embedded_cb = QCheckBox("Использовать встроенный текст из тегов (USLT/LYRICS/©lyr)")
self.lyrics_use_embedded_cb.setToolTip(
"Сначала проверяем, есть ли текст (LRC или обычный) уже в тегах аудио.\n"
"Если есть — ASR/Shazam/LRCLIB не запускаются."
)
form.addRow("", self.lyrics_use_embedded_cb)
self.lyrics_user_agent = QLineEdit()
form.addRow("User-Agent:", self.lyrics_user_agent)
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам (artist + title)")
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам")
form.addRow("", self.lyrics_use_tags_cb)
self.lyrics_use_shazam_cb = QCheckBox("Искать по результату Shazam")
self.lyrics_use_shazam_cb = QCheckBox("Искать по Shazam")
form.addRow("", self.lyrics_use_shazam_cb)
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy-match)")
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy)")
form.addRow("", self.lyrics_after_asr_cb)
self.lyrics_transcribe_unresolved_cb = QCheckBox(
"Запускать ASR, если Shazam опознал, но текста нет"
"ASR, если Shazam опознал без текста"
)
form.addRow("", self.lyrics_transcribe_unresolved_cb)
self.lyrics_threshold = QDoubleSpinBox()
self.lyrics_threshold.setRange(0.0, 1.0)
self.lyrics_threshold.setSingleStep(0.05)
self.lyrics_threshold.setDecimals(2)
self.lyrics_threshold.setToolTip(
"Насколько должен совпасть распознанный ASR-текст с текстом из LRCLIB,\n"
"чтобы считать это тем же треком. 1.0 = точное совпадение, 0.0 = любое."
)
form.addRow("Порог fuzzy:", self.lyrics_threshold)
self.lyrics_save_unrecognized_cb = QCheckBox(
"Сохранять тексты без LRCLIB в JSONL (для добавления в LRCLIB)"
)
self.lyrics_save_unrecognized_cb.setToolTip(
"Если текст получен НЕ из LRCLIB (WhisperX / GigaAM / embedded), "
"записать его в <output>/unrecognized_lyrics.jsonl.\n"
"Формат совместим с LRCLIB API для ручной отправки."
)
form.addRow("", self.lyrics_save_unrecognized_cb)
self.lyrics_unrecognized_path_edit = QLineEdit()
self.lyrics_unrecognized_path_edit.setPlaceholderText(
"(пусто — <output>/unrecognized_lyrics.jsonl)"
)
form.addRow("Файл unrecognized:", self.lyrics_unrecognized_path_edit)
return w
def _build_convert_tab(self) -> QWidget:
@@ -245,27 +246,19 @@ class MainWindow(QMainWindow):
form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10)
self.convert_enabled_cb = QCheckBox("Включён")
form.addRow("", self.convert_enabled_cb)
self.convert_sr = QComboBox()
for sr in SAMPLE_RATES:
self.convert_sr.addItem(f"{sr} Hz", sr)
form.addRow("Sample rate:", self.convert_sr)
self.convert_ch = QComboBox()
self.convert_ch.addItem("Моно", 1)
self.convert_ch.addItem("Стерео", 2)
form.addRow("Каналы:", self.convert_ch)
self.convert_ffmpeg = QLineEdit()
self.convert_concurrency = QSpinBox()
self.convert_concurrency.setRange(1, 16)
self.convert_concurrency.setToolTip(
"Сколько ffmpeg-процессов запускать одновременно.\n"
"Для SSD 4–8 нормально; для HDD лучше 1–2."
)
form.addRow("Параллельных конвертаций:", self.convert_concurrency)
form.addRow("ffmpeg:", self.convert_ffmpeg)
return w
@@ -275,21 +268,22 @@ class MainWindow(QMainWindow):
form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10)
self.separate_enabled_cb = QCheckBox("Включён")
form.addRow("", self.separate_enabled_cb)
self.separate_model = QLineEdit()
form.addRow("Модель:", self.separate_model)
self.separate_stem = QLineEdit()
form.addRow("Isolate stem:", self.separate_stem)
self.separate_unblend = QLineEdit()
form.addRow("unblend CLI:", self.separate_unblend)
self.separate_kernels_cb = QCheckBox("Использовать кастомные ядра")
form.addRow("", self.separate_kernels_cb)
self.separate_save_vocals_cb = QCheckBox("Всегда сохранять vocals.wav в output")
self.separate_save_vocals_cb.setToolTip(
"Независимо от 'Сохранять промежуточные': вокал всегда будет лежать "
"в <output>/<stem>/vocals.wav — полезно для LRC-этапа."
)
form.addRow("", self.separate_save_vocals_cb)
return w
def _build_transcribe_tab(self) -> QWidget:
@@ -297,53 +291,90 @@ class MainWindow(QMainWindow):
form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10)
self.transcribe_enabled_cb = QCheckBox("Включён")
form.addRow("", self.transcribe_enabled_cb)
self.engine_cb = QComboBox()
self.engine_cb.addItems(ENGINES)
form.addRow("Движок:", self.engine_cb)
self.model_edit = QLineEdit()
form.addRow("Модель:", self.model_edit)
self.lang_cb = QComboBox()
self.lang_cb.setEditable(True)
self.lang_cb.addItems(LANGUAGES)
self.lang_cb.setToolTip(
"'auto' — WhisperX определяет язык на каждом файле.\n"
"Если знаешь язык — выбери его явно, будет быстрее."
)
form.addRow("Язык:", self.lang_cb)
self.device_cb = QComboBox()
self.device_cb.addItems(["auto", "cuda", "cpu"])
form.addRow("Device:", self.device_cb)
self.compute_type_cb = QComboBox()
self.compute_type_cb.addItems(["auto", "int8_float16", "int8", "float16", "float32"])
form.addRow("Compute type:", self.compute_type_cb)
self.batch_spin = QSpinBox()
self.batch_spin.setRange(1, 512)
self.batch_spin.setToolTip(
"Размер батча сегментов ВНУТРИ одного файла (не файлов!).\n"
"8–16 для 8 ГБ VRAM, 32–64 для 24 ГБ."
)
form.addRow("Batch size:", self.batch_spin)
self.gigaam_provider_cb = QComboBox()
self.gigaam_provider_cb.addItems(["auto", "cuda", "cpu"])
form.addRow("GigaAM provider:", self.gigaam_provider_cb)
self.local_files_only_cb = QCheckBox("Не проверять HuggingFace (offline)")
self.local_files_only_cb.setToolTip(
"Если модель уже скачана — включите, чтобы не ждать сетевую проверку."
)
form.addRow("", self.local_files_only_cb)
return w
def _build_lrc_tab(self) -> QWidget:
w = QWidget()
form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10)
info = QLabel(
"1) Сгенерировать LRC: WhisperX + align → <output>/<stem>/text.lrc.\n"
"2) Встроить LRC: записывает LRC в тег lyrics аудио (USLT/LYRICS/©lyr).\n"
"Если 'in-place' выключено — оригинал не трогается, создаётся копия в output."
)
info.setWordWrap(True)
info.setStyleSheet("color: #a6adc8;")
form.addRow(info)
self.lrc_audio_source_cb = QComboBox()
self.lrc_audio_source_cb.addItems(["auto", "vocals", "convert", "input"])
self.lrc_audio_source_cb.setToolTip(
"auto: vocals.wav → convert.wav → 16k WAV из исходника.\n"
"vocals/convert: только соответствующий файл.\n"
"input: оригинальный mp3/flac."
)
form.addRow("Источник аудио:", self.lrc_audio_source_cb)
self.lrc_language_cb = QComboBox()
self.lrc_language_cb.setEditable(True)
self.lrc_language_cb.addItems(LANGUAGES)
form.addRow("Язык (для align):", self.lrc_language_cb)
self.lrc_align_model_edit = QLineEdit()
self.lrc_align_model_edit.setPlaceholderText("(пусто — whisperx по языку)")
form.addRow("Align модель:", self.lrc_align_model_edit)
self.lrc_device_cb = QComboBox()
self.lrc_device_cb.addItems(["auto", "cuda", "cpu"])
form.addRow("Device:", self.lrc_device_cb)
self.lrc_embed_in_place_cb = QCheckBox("Встраивать in-place (перезаписывать оригинал)")
self.lrc_embed_in_place_cb.setToolTip(
"Если выключено (по умолчанию) — создаётся копия в <output>/<stem>/<name>.\n"
"Если включено — оригинальный файл модифицируется."
)
form.addRow("", self.lrc_embed_in_place_cb)
btns = QHBoxLayout()
self.lrc_generate_btn = QPushButton("🎵 Сгенерировать LRC")
self.lrc_generate_btn.setObjectName("primary")
self.lrc_generate_btn.clicked.connect(lambda: self._run_task("lrc"))
self.lrc_embed_btn = QPushButton("📝 Встроить LRC в аудио")
self.lrc_embed_btn.clicked.connect(lambda: self._run_task("embed"))
btns.addWidget(self.lrc_generate_btn)
btns.addWidget(self.lrc_embed_btn)
btns.addStretch(1)
form.addRow("", btns)
return w
# ---------------- visual sync ----------------
def _log_device_info(self) -> None:
@@ -379,7 +410,6 @@ class MainWindow(QMainWindow):
self.recursive_cb.setChecked(cfg.recursive)
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
self.overwrite_cb.setChecked(cfg.overwrite)
i = self.mode_cb.findData(cfg.mode)
if i >= 0:
self.mode_cb.setCurrentIndex(i)
@@ -392,10 +422,11 @@ class MainWindow(QMainWindow):
self.lyrics_use_tags_cb.setChecked(cfg.lyrics.use_tags_first)
self.lyrics_use_shazam_cb.setChecked(cfg.lyrics.use_shazam_result)
self.lyrics_after_asr_cb.setChecked(cfg.lyrics.try_after_asr)
self.lyrics_transcribe_unresolved_cb.setChecked(
cfg.lyrics.transcribe_if_identified_no_lyrics
)
self.lyrics_transcribe_unresolved_cb.setChecked(cfg.lyrics.transcribe_if_identified_no_lyrics)
self.lyrics_threshold.setValue(cfg.lyrics.fuzzy_threshold)
self.lyrics_use_embedded_cb.setChecked(cfg.lyrics.use_embedded_lyrics)
self.lyrics_save_unrecognized_cb.setChecked(cfg.lyrics.save_unrecognized)
self.lyrics_unrecognized_path_edit.setText(cfg.lyrics.unrecognized_path)
self.convert_enabled_cb.setChecked(cfg.convert.enabled)
i = self.convert_sr.findData(cfg.convert.sample_rate)
@@ -412,6 +443,7 @@ class MainWindow(QMainWindow):
self.separate_stem.setText(cfg.separate.isolate_stem)
self.separate_unblend.setText(cfg.separate.unblend_path)
self.separate_kernels_cb.setChecked(cfg.separate.custom_kernels)
self.separate_save_vocals_cb.setChecked(cfg.separate.save_vocals_copy)
self.transcribe_enabled_cb.setChecked(cfg.transcribe.enabled)
self.engine_cb.setCurrentText(cfg.transcribe.engine)
@@ -423,6 +455,12 @@ class MainWindow(QMainWindow):
self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider)
self.local_files_only_cb.setChecked(cfg.transcribe.local_files_only)
self.lrc_audio_source_cb.setCurrentText(cfg.lrc.audio_source)
self.lrc_language_cb.setCurrentText(cfg.lrc.language or "auto")
self.lrc_align_model_edit.setText(cfg.lrc.align_model or "")
self.lrc_device_cb.setCurrentText(cfg.lrc.device)
self.lrc_embed_in_place_cb.setChecked(cfg.lrc.embed_in_place)
def _collect_config(self) -> PipelineConfig:
cfg = PipelineConfig()
cfg.input_dir = self.input_edit.text().strip()
@@ -436,7 +474,6 @@ class MainWindow(QMainWindow):
enabled=self.shazam_enabled_cb.isChecked(),
identify_concurrency=self.shazam_concurrency.value(),
)
cfg.lyrics = LyricsConfig(
enabled=self.lyrics_enabled_cb.isChecked(),
user_agent=self.lyrics_user_agent.text().strip() or "audio-transcriber/1.0",
@@ -445,8 +482,10 @@ class MainWindow(QMainWindow):
try_after_asr=self.lyrics_after_asr_cb.isChecked(),
transcribe_if_identified_no_lyrics=self.lyrics_transcribe_unresolved_cb.isChecked(),
fuzzy_threshold=float(self.lyrics_threshold.value()),
use_embedded_lyrics=self.lyrics_use_embedded_cb.isChecked(),
save_unrecognized=self.lyrics_save_unrecognized_cb.isChecked(),
unrecognized_path=self.lyrics_unrecognized_path_edit.text().strip(),
)
cfg.convert = ConvertConfig(
enabled=self.convert_enabled_cb.isChecked(),
sample_rate=int(self.convert_sr.currentData()),
@@ -460,8 +499,8 @@ class MainWindow(QMainWindow):
isolate_stem=self.separate_stem.text().strip() or "vocals",
unblend_path=self.separate_unblend.text().strip() or "unblend",
custom_kernels=self.separate_kernels_cb.isChecked(),
save_vocals_copy=self.separate_save_vocals_cb.isChecked(),
)
lang = self.lang_cb.currentText().strip()
compute = self.compute_type_cb.currentText().strip()
cfg.transcribe = TranscribeConfig(
@@ -475,6 +514,14 @@ class MainWindow(QMainWindow):
gigaam_provider=self.gigaam_provider_cb.currentText(),
local_files_only=self.local_files_only_cb.isChecked(),
)
lrc_lang = self.lrc_language_cb.currentText().strip()
cfg.lrc = LrcConfig(
language=None if lrc_lang in ("", "auto") else lrc_lang,
align_model=self.lrc_align_model_edit.text().strip() or None,
device=self.lrc_device_cb.currentText(),
audio_source=self.lrc_audio_source_cb.currentText(),
embed_in_place=self.lrc_embed_in_place_cb.isChecked(),
)
return cfg
# ---------------- handlers ----------------
@@ -512,11 +559,19 @@ class MainWindow(QMainWindow):
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Ошибка", str(exc))
def _on_start(self) -> None:
def _set_busy(self, busy: bool) -> None:
self.start_btn.setEnabled(not busy)
self.lrc_generate_btn.setEnabled(not busy)
self.lrc_embed_btn.setEnabled(not busy)
self.stop_btn.setEnabled(busy)
def _run_task(self, task: str) -> None:
cfg = self._collect_config()
if not cfg.input_dir or not Path(cfg.input_dir).is_dir():
QMessageBox.warning(self, "Нет входной папки", "Выберите существующую входную папку.")
return
if task == "main":
has_work = any([
cfg.shazam.enabled, cfg.lyrics.enabled,
cfg.convert.enabled, cfg.separate.enabled, cfg.transcribe.enabled,
@@ -534,17 +589,20 @@ class MainWindow(QMainWindow):
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.")
return
if task == "lrc" and cfg.transcribe.engine != "whisperx":
QMessageBox.warning(
self, "LRC требует WhisperX",
"Align-модель доступна только для WhisperX. Переключите движок на whisperx."
)
return
self.log_view.clear()
self._log_device_info()
if cfg.transcribe.enabled and cfg.transcribe.language is None:
self._append_log("⚠ Язык = auto: WhisperX определит язык на каждом файле.")
self.progress.setValue(0)
self.progress.setFormat("Запуск…")
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self._set_busy(True)
self._worker = PipelineWorker(cfg)
self._worker = PipelineWorker(cfg, task=task)
self._worker.log.connect(self._append_log)
self._worker.progress.connect(self._on_progress)
self._worker.finished_ok.connect(self._on_finished)
@@ -568,16 +626,14 @@ class MainWindow(QMainWindow):
self.progress.setFormat(f"{label} — %v / %m")
def _on_finished(self, results: list) -> None:
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self._set_busy(False)
ok = sum(1 for r in results if r.error is None)
fail = len(results) - ok
self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}")
self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
def _on_failed(self, message: str) -> None:
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self._set_busy(False)
self.progress.setFormat("Ошибка")
self._append_log(f"\n✗ Ошибка пайплайна: {message}")
QMessageBox.critical(self, "Ошибка", message)

View File

@@ -1,4 +1,4 @@
"""QThread wrapper around core.pipeline.run_pipeline."""
"""QThread wrapper. Supports three task types: main, lrc, embed."""
from __future__ import annotations
import threading
@@ -6,7 +6,6 @@ import threading
from PySide6.QtCore import QThread, Signal
from core.config import PipelineConfig
from core.pipeline import run_pipeline
class PipelineWorker(QThread):
@@ -15,9 +14,10 @@ class PipelineWorker(QThread):
finished_ok = Signal(list)
failed = Signal(str)
def __init__(self, config: PipelineConfig, parent=None):
def __init__(self, config: PipelineConfig, task: str = "main", parent=None):
super().__init__(parent)
self._config = config
self._task = task
self._stop_event = threading.Event()
def stop(self) -> None:
@@ -25,7 +25,14 @@ class PipelineWorker(QThread):
def run(self) -> None:
try:
results = run_pipeline(
if self._task == "lrc":
from core.lrc_pipeline import run_lrc_pipeline as runner
elif self._task == "embed":
from core.lrc_pipeline import run_embed_pipeline as runner
else:
from core.pipeline import run_pipeline as runner
results = runner(
self._config,
log=self.log.emit,
on_progress=lambda d, t, s: self.progress.emit(d, t, s),