Compare commits

...

3 Commits

8 changed files with 1091 additions and 324 deletions

View File

@@ -16,6 +16,7 @@ class ConvertConfig:
sample_rate: int = 44100 sample_rate: int = 44100
channels: int = 2 channels: int = 2
ffmpeg_path: str = "ffmpeg" ffmpeg_path: str = "ffmpeg"
convert_concurrency: int = 4
@dataclass @dataclass
@@ -25,6 +26,7 @@ class SeparateConfig:
isolate_stem: str = "vocals" isolate_stem: str = "vocals"
unblend_path: str = "unblend" unblend_path: str = "unblend"
custom_kernels: bool = False custom_kernels: bool = False
save_vocals_copy: bool = False
@dataclass @dataclass
@@ -43,6 +45,7 @@ class TranscribeConfig:
@dataclass @dataclass
class ShazamConfig: class ShazamConfig:
enabled: bool = False enabled: bool = False
identify_concurrency: int = 4
@dataclass @dataclass
@@ -53,8 +56,21 @@ class LyricsConfig:
use_tags_first: bool = True use_tags_first: bool = True
use_shazam_result: bool = True use_shazam_result: bool = True
try_after_asr: bool = True try_after_asr: bool = True
fuzzy_threshold: float = 0.6 # difflib ratio, 0..1 fuzzy_threshold: float = 0.6
transcribe_if_identified_no_lyrics: bool = True 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 @dataclass
@@ -71,6 +87,7 @@ class PipelineConfig:
transcribe: TranscribeConfig = field(default_factory=TranscribeConfig) transcribe: TranscribeConfig = field(default_factory=TranscribeConfig)
shazam: ShazamConfig = field(default_factory=ShazamConfig) shazam: ShazamConfig = field(default_factory=ShazamConfig)
lyrics: LyricsConfig = field(default_factory=LyricsConfig) lyrics: LyricsConfig = field(default_factory=LyricsConfig)
lrc: LrcConfig = field(default_factory=LrcConfig)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return asdict(self) 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. """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.
"""
from __future__ import annotations from __future__ import annotations
import difflib import difflib
@@ -19,6 +6,8 @@ import json
import shutil import shutil
import tempfile import tempfile
import threading import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
@@ -30,6 +19,7 @@ from .metadata import TrackMetadata, read_metadata
from .recognize import ShazamResult, recognize from .recognize import ShazamResult, recognize
from .separate import separate_vocals, separate_vocals_batch from .separate import separate_vocals, separate_vocals_batch
from .transcribe import transcribe from .transcribe import transcribe
from .lrc import is_lrc, read_embedded_lyrics
AUDIO_EXTENSIONS = { AUDIO_EXTENSIONS = {
@@ -62,7 +52,6 @@ def collect_audio_files(input_dir: Path, recursive: bool) -> list[Path]:
def _common_parent(paths: list[Path]) -> Path: def _common_parent(paths: list[Path]) -> Path:
"""Return the deepest common parent directory of the given paths."""
if not paths: if not paths:
raise ValueError("empty paths") raise ValueError("empty paths")
parts = [p.resolve().parts for p in paths] parts = [p.resolve().parts for p in paths]
@@ -115,10 +104,44 @@ def _write_metadata_json(path: Path, data: Optional[dict]) -> None:
if not data: if not data:
return return
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text( path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
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 @dataclass
@@ -136,24 +159,30 @@ def _identify_file(
config: PipelineConfig, config: PipelineConfig,
log: LogFn, log: LogFn,
) -> IdentifyResult: ) -> 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)) result = IdentifyResult(metadata=read_metadata(input_path))
# --- tags -> LRCLIB --- # --- 0) embedded lyrics в самом файле ---
if ( if config.lyrics.enabled and config.lyrics.use_embedded_lyrics:
config.lyrics.enabled embedded = read_embedded_lyrics(input_path)
and config.lyrics.use_tags_first if embedded and embedded.strip():
and result.metadata.has_identity 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 по тегам...") log(" LRCLIB по тегам...")
match = search_by_identity( match = search_by_identity(
title=result.metadata.title, title=result.metadata.title, artist=result.metadata.artist,
artist=result.metadata.artist, album=result.metadata.album, duration=result.metadata.duration,
album=result.metadata.album, user_agent=config.lyrics.user_agent, timeout=config.lyrics.timeout,
duration=result.metadata.duration,
user_agent=config.lyrics.user_agent,
timeout=config.lyrics.timeout,
) )
if match: if match:
result.text = match.lyrics result.text = match.lyrics
@@ -162,14 +191,13 @@ def _identify_file(
log(f" ✓ {match.artist} — {match.title}") log(f" ✓ {match.artist} — {match.title}")
return result return result
# --- Shazam --- # --- 2) Shazam ---
if config.shazam.enabled: if config.shazam.enabled:
log(" Shazam...") log(" Shazam...")
shazam_wav = work_dir / "shazam_16k.wav" shazam_wav = work_dir / "shazam_16k.wav"
try: try:
convert_to_wav( convert_to_wav(
input_path, shazam_wav, input_path, shazam_wav, sample_rate=16000, channels=1,
sample_rate=16000, channels=1,
ffmpeg_path=config.convert.ffmpeg_path, ffmpeg_path=config.convert.ffmpeg_path,
) )
result.shazam = recognize(shazam_wav) result.shazam = recognize(shazam_wav)
@@ -179,15 +207,11 @@ def _identify_file(
if result.shazam: if result.shazam:
log(f" ✓ {result.shazam.artist} — {result.shazam.title}") log(f" ✓ {result.shazam.artist} — {result.shazam.title}")
if config.lyrics.enabled and config.lyrics.use_shazam_result: if config.lyrics.enabled and config.lyrics.use_shazam_result:
match = search_by_identity( match = search_by_identity(
title=result.shazam.title, title=result.shazam.title, artist=result.shazam.artist,
artist=result.shazam.artist, album=result.shazam.album, duration=result.metadata.duration,
album=result.shazam.album, user_agent=config.lyrics.user_agent, timeout=config.lyrics.timeout,
duration=result.metadata.duration,
user_agent=config.lyrics.user_agent,
timeout=config.lyrics.timeout,
) )
if match: if match:
result.text = match.lyrics result.text = match.lyrics
@@ -195,8 +219,6 @@ def _identify_file(
result.needs_asr = False result.needs_asr = False
log(" ✓ текст найден через LRCLIB") log(" ✓ текст найден через LRCLIB")
return result return result
# Identified, but no lyrics
result.source = "shazam" result.source = "shazam"
if not config.lyrics.transcribe_if_identified_no_lyrics: if not config.lyrics.transcribe_if_identified_no_lyrics:
result.needs_asr = False result.needs_asr = False
@@ -207,33 +229,39 @@ def _identify_file(
return result return result
def _try_post_asr_match( def _try_post_asr_match(text: str, config: PipelineConfig, log: LogFn) -> Optional[str]:
text: str, if not text:
config: PipelineConfig,
log: LogFn,
) -> Optional[str]:
"""Try to find the actual lyrics via LRCLIB using recognized text. Returns
the lyrics text on a confident match, else None."""
if not text or len(text.split()) < 3:
return None return None
ignore_phrases = ["субтитры", "редактор", "перевод", "сделал", "озвучка"]
head = _text_head(text, 8) lines = []
log(f" LRCLIB по тексту: {head!r}...") for line in text.splitlines():
line = line.strip()
if not line or any(p in line.lower() for p in ignore_phrases):
continue
lines.append(line)
if not lines:
return None
query = ""
for line in lines:
words = line.split()
if len(words) >= 3:
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: try:
candidates = search_by_text( candidates = search_by_text(
head, limit=5, query, limit=5, user_agent=config.lyrics.user_agent,
user_agent=config.lyrics.user_agent,
timeout=config.lyrics.timeout, timeout=config.lyrics.timeout,
) )
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log(f" ⚠ LRCLIB error: {e}") log(f" ⚠ LRCLIB error: {e}")
return None return None
if not candidates: if not candidates:
log(" ⚠ нет кандидатов") log(" ⚠ нет кандидатов")
return None return None
best: Optional[LyricsMatch] = None best: Optional[LyricsMatch] = None
best_ratio = 0.0 best_ratio = 0.0
for c in candidates: for c in candidates:
@@ -241,11 +269,9 @@ def _try_post_asr_match(
if ratio > best_ratio: if ratio > best_ratio:
best_ratio = ratio best_ratio = ratio
best = c best = c
if best is not None and best_ratio >= config.lyrics.fuzzy_threshold: if best is not None and best_ratio >= config.lyrics.fuzzy_threshold:
log(f" ✓ совпадение {best_ratio:.2f}: {best.artist} — {best.title}") log(f" ✓ совпадение {best_ratio:.2f}: {best.artist} — {best.title}")
return best.lyrics return best.lyrics
log(f" ⚠ лучшее совпадение {best_ratio:.2f} < {config.lyrics.fuzzy_threshold}") log(f" ⚠ лучшее совпадение {best_ratio:.2f} < {config.lyrics.fuzzy_threshold}")
return None return None
@@ -258,7 +284,9 @@ def _persist_artifacts(
has_metadata: bool, has_metadata: bool,
log: LogFn, log: LogFn,
) -> None: ) -> 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) persist_dir.mkdir(parents=True, exist_ok=True)
names: list[str] = [] names: list[str] = []
@@ -266,6 +294,8 @@ def _persist_artifacts(
names.append("metadata.json") names.append("metadata.json")
if has_text and (work_dir / "text.txt").exists(): if has_text and (work_dir / "text.txt").exists():
names.append("text.txt") names.append("text.txt")
if has_text and (work_dir / "text.lrc").exists():
names.append("text.lrc")
if config.keep_intermediate: if config.keep_intermediate:
for n in ("convert.wav", "vocals.wav"): for n in ("convert.wav", "vocals.wav"):
@@ -277,6 +307,10 @@ def _persist_artifacts(
elif config.convert.enabled and (work_dir / "convert.wav").exists(): elif config.convert.enabled and (work_dir / "convert.wav").exists():
names.append("convert.wav") 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: for name in names:
src = work_dir / name src = work_dir / name
dst = persist_dir / name dst = persist_dir / name
@@ -288,51 +322,158 @@ def _persist_artifacts(
# ---------------------------------------------------------------- entry # ---------------------------------------------------------------- entry
def _run_identification_parallel(
config: PipelineConfig, files: list[Path], log: LogFn,
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
work_root: Path,
) -> dict[Path, Optional[IdentifyResult]]:
if not (config.shazam.enabled or config.lyrics.enabled):
return {}
concurrency = max(1, int(config.shazam.identify_concurrency))
total = len(files)
if concurrency == 1 or total == 1:
results: dict[Path, Optional[IdentifyResult]] = {}
for i, f in enumerate(files, 1):
if stop_event is not None and stop_event.is_set():
break
if on_progress:
on_progress(i, total, "identify")
try:
results[f] = _identify_file(f, work_root / f.stem, config, log)
except Exception as e: # noqa: BLE001
log(f" ✗ {f.name} (identify): {e}")
results[f] = None
return results
log(f" Идентификация: {total} файлов, конкурентность {concurrency}")
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:
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
results: dict[Path, Optional[IdentifyResult]] = {}
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = {pool.submit(worker, f): f for f in files}
done = 0
for fut in as_completed(futures):
f = futures[fut]
try:
_, ident = fut.result()
results[f] = ident
except Exception as e: # noqa: BLE001
log(f" ✗ {f.name} (identify): {e}")
results[f] = None
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],
) -> None:
concurrency = max(1, int(config.convert.convert_concurrency))
total = len(files)
def worker(f: Path) -> tuple[Path, Optional[Path], Optional[str]]:
if stop_event is not None and stop_event.is_set():
return f, None, None
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,
)
return f, target, None
except Exception as e: # noqa: BLE001
return f, None, f"{type(e).__name__}: {e}"
if concurrency == 1 or total == 1:
for i, f in enumerate(files, 1):
if stop_event is not None and stop_event.is_set():
break
if on_progress:
on_progress(i, total, "convert")
f2, target, err = worker(f)
if err:
state[f2]["error"] = err
log(f" ✗ {f2.name}: {err}")
else:
state[f2]["audio_path"] = target
log(f" ✓ {f2.name}")
return
log(f" Конвертация: {total} файлов, конкурентность {concurrency}")
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = {pool.submit(worker, f): f for f in files}
done = 0
for fut in as_completed(futures):
f2, target, err = fut.result()
if err:
state[f2]["error"] = err
log(f" ✗ {f2.name}: {err}")
else:
state[f2]["audio_path"] = target
log(f" ✓ {f2.name}")
done += 1
if on_progress:
on_progress(done, total, "convert")
def run_pipeline( def run_pipeline(
config: PipelineConfig, config: PipelineConfig, log: LogFn = print,
log: LogFn = print,
on_progress: Optional[ProgressFn] = None, on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None, stop_event: Optional[threading.Event] = None,
) -> list[FileResult]: ) -> list[FileResult]:
input_dir = Path(config.input_dir) input_dir = Path(config.input_dir)
if not input_dir.is_dir(): if not input_dir.is_dir():
raise FileNotFoundError(f"Input directory not found: {input_dir}") raise FileNotFoundError(f"Input directory not found: {input_dir}")
output_dir = Path(config.output_dir) output_dir = Path(config.output_dir)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
files = collect_audio_files(input_dir, config.recursive) files = collect_audio_files(input_dir, config.recursive)
if not files: if not files:
log("Аудиофайлы не найдены.") log("Аудиофайлы не найдены.")
return [] return []
mode = (config.mode or "sequential").lower() mode = (config.mode or "sequential").lower()
log( log(f"Найдено треков: {len(files)} | режим: {mode} | "
f"Найдено треков: {len(files)} | режим: {mode} | " f"keep_intermediate={config.keep_intermediate}")
f"keep_intermediate={config.keep_intermediate}" if config.separate.save_vocals_copy:
) log(" ✓ Вокал будет сохраняться всегда (save_vocals_copy=True)")
if config.shazam.enabled: if config.shazam.enabled:
log(" Shazam: включён") log(" Shazam: включён")
if config.lyrics.enabled: if config.lyrics.enabled:
log(f" LRCLIB: включён (порог fuzzy={config.lyrics.fuzzy_threshold:.2f})") log(f" LRCLIB: включён (порог fuzzy={config.lyrics.fuzzy_threshold:.2f})")
if mode == "staged": if mode == "staged":
return _run_staged(config, files, log, on_progress, stop_event) return _run_staged(config, files, log, on_progress, stop_event)
return _run_sequential(config, files, log, on_progress, stop_event) return _run_sequential(config, files, log, on_progress, stop_event)
# ---------------------------------------------------------------- sequential
def _run_sequential( def _run_sequential(
config: PipelineConfig, config: PipelineConfig, files: list[Path], log: LogFn,
files: list[Path], on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
log: LogFn,
on_progress: Optional[ProgressFn],
stop_event: Optional[threading.Event],
) -> list[FileResult]: ) -> list[FileResult]:
total = len(files) total = len(files)
results: list[FileResult] = [] results: list[FileResult] = []
output_root = Path(config.output_dir) 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): for idx, f in enumerate(files, 1):
if stop_event is not None and stop_event.is_set(): if stop_event is not None and stop_event.is_set():
@@ -352,8 +493,7 @@ def _run_sequential(
metadata_dict: Optional[dict] = None metadata_dict: Optional[dict] = None
try: try:
# --- Phase 1: identification --- ident = ident_map.get(f) or IdentifyResult(metadata=TrackMetadata())
ident = _identify_file(f, tmp_root, config, log)
if not ident.needs_asr: if not ident.needs_asr:
text = ident.text text = ident.text
@@ -361,20 +501,22 @@ def _run_sequential(
metadata_dict = _build_metadata_dict(ident.metadata, ident.shazam, source) metadata_dict = _build_metadata_dict(ident.metadata, ident.shazam, source)
if text: if text:
(tmp_root / "text.txt").write_text(text, encoding="utf-8") (tmp_root / "text.txt").write_text(text, encoding="utf-8")
log(f" ✓ text.txt ({len(text)} символов, {source})")
if metadata_dict: if metadata_dict:
_write_metadata_json(tmp_root / "metadata.json", metadata_dict) _write_metadata_json(tmp_root / "metadata.json", metadata_dict)
log(" ✓ metadata.json")
_persist_artifacts( _persist_artifacts(
tmp_root, track_out, config, tmp_root, track_out, config,
has_text=text is not None, has_text=text is not None,
has_metadata=bool(metadata_dict), has_metadata=bool(metadata_dict),
log=log, 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)) results.append(FileResult(f, text, track_out, None, source, metadata_dict))
continue continue
# --- Phase 2: full pipeline ---
audio_path: Path = f audio_path: Path = f
if config.convert.enabled: if config.convert.enabled:
_check_cancel(stop_event) _check_cancel(stop_event)
@@ -382,8 +524,7 @@ def _run_sequential(
target = tmp_root / "convert.wav" target = tmp_root / "convert.wav"
if config.overwrite or not target.exists(): if config.overwrite or not target.exists():
convert_to_wav( convert_to_wav(
f, target, f, target, sample_rate=config.convert.sample_rate,
sample_rate=config.convert.sample_rate,
channels=config.convert.channels, channels=config.convert.channels,
ffmpeg_path=config.convert.ffmpeg_path, ffmpeg_path=config.convert.ffmpeg_path,
) )
@@ -410,8 +551,7 @@ def _run_sequential(
_check_cancel(stop_event) _check_cancel(stop_event)
log(" [3] Транскрибация...") log(" [3] Транскрибация...")
text = transcribe( text = transcribe(
audio_path, audio_path, engine=config.transcribe.engine,
engine=config.transcribe.engine,
model=config.transcribe.model, model=config.transcribe.model,
language=config.transcribe.language, language=config.transcribe.language,
device=config.transcribe.device, device=config.transcribe.device,
@@ -422,13 +562,11 @@ def _run_sequential(
) )
source = config.transcribe.engine source = config.transcribe.engine
log(f" ✓ распознано {len(text)} символов") log(f" ✓ распознано {len(text)} символов")
if config.lyrics.enabled and config.lyrics.try_after_asr: if config.lyrics.enabled and config.lyrics.try_after_asr:
matched = _try_post_asr_match(text, config, log) matched = _try_post_asr_match(text, config, log)
if matched: if matched:
text = matched text = matched
source = f"{source}+lrclib" source = f"{source}+lrclib"
(tmp_root / "text.txt").write_text(text, encoding="utf-8") (tmp_root / "text.txt").write_text(text, encoding="utf-8")
metadata_dict = _build_metadata_dict(ident.metadata, ident.shazam, source) metadata_dict = _build_metadata_dict(ident.metadata, ident.shazam, source)
@@ -441,6 +579,10 @@ def _run_sequential(
has_metadata=bool(metadata_dict), has_metadata=bool(metadata_dict),
log=log, log=log,
) )
_save_unrecognized(
config, f, text, source,
metadata=ident.metadata,
)
except Cancelled: except Cancelled:
log("⏹ Отменено") log("⏹ Отменено")
@@ -452,36 +594,24 @@ def _run_sequential(
shutil.rmtree(tmp_root, ignore_errors=True) shutil.rmtree(tmp_root, ignore_errors=True)
if on_progress: if on_progress:
on_progress(idx, total, "file") on_progress(idx, total, "file")
results.append( results.append(FileResult(
FileResult(f, text, track_out if not error else None, error, source, metadata_dict) f, text, track_out if not error else None, error, source, metadata_dict,
) ))
_log_summary(results, log) _log_summary(results, log)
return results return results
# ---------------------------------------------------------------- staged
def _run_staged( def _run_staged(
config: PipelineConfig, config: PipelineConfig, files: list[Path], log: LogFn,
files: list[Path], on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
log: LogFn,
on_progress: Optional[ProgressFn],
stop_event: Optional[threading.Event],
) -> list[FileResult]: ) -> list[FileResult]:
output_root = Path(config.output_dir) output_root = Path(config.output_dir)
total = len(files) total = len(files)
state: dict[Path, dict] = { state: dict[Path, dict] = {
f: { f: {"audio_path": f, "ident": None, "text": None, "source": None,
"audio_path": f, "metadata_dict": None, "error": None, "resolved": False}
"ident": None,
"text": None,
"source": None,
"metadata_dict": None,
"error": None,
"resolved": False,
}
for f in files for f in files
} }
@@ -491,77 +621,55 @@ def _run_staged(
def cancelled() -> bool: def cancelled() -> bool:
return stop_event is not None and stop_event.is_set() return stop_event is not None and stop_event.is_set()
# --- Stage 0: identification ---
if config.shazam.enabled or config.lyrics.enabled: if config.shazam.enabled or config.lyrics.enabled:
log(f"\n══ Этап 0 · Идентификация ({total} файлов) ══") log(f"\n══ Этап 0 · Идентификация ({total} файлов) ══")
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_")) ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_"))
try: try:
for i, f in enumerate(files, 1): ident_map = _run_identification_parallel(
if cancelled(): config, files, log, on_progress, stop_event, ident_tmp,
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}")
finally: finally:
shutil.rmtree(ident_tmp, ignore_errors=True) 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(): if config.convert.enabled and not cancelled():
todo = active() todo = active()
log(f"\n══ Этап 1 · Конвертация ({len(todo)} файлов) ══") log(f"\n══ Этап 1 · Конвертация ({len(todo)} файлов) ══")
for i, f in enumerate(todo, 1): _run_convert_parallel(config, todo, state, log, on_progress, stop_event)
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}")
# ---- Stage 2: separate (batched) ----
if config.separate.enabled and not cancelled(): if config.separate.enabled and not cancelled():
todo = active() todo = active()
log(f"\n══ Этап 2/3 · Отделение вокала ({len(todo)} файлов) ══") log(f"\n══ Этап 2 · Отделение вокала ({len(todo)} файлов) ══")
if not todo: if not todo:
pass pass
elif len(todo) == 1: elif len(todo) == 1:
# Single file — no batching advantage
f = todo[0] f = todo[0]
if on_progress: if on_progress:
on_progress(1, 1, "separate") on_progress(1, 1, "separate")
@@ -570,8 +678,7 @@ def _run_staged(
target = src.parent / "vocals.wav" target = src.parent / "vocals.wav"
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
raw = separate_vocals( raw = separate_vocals(
src, Path(tmp), src, Path(tmp), model=config.separate.model,
model=config.separate.model,
isolate_stem=config.separate.isolate_stem, isolate_stem=config.separate.isolate_stem,
unblend_path=config.separate.unblend_path, unblend_path=config.separate.unblend_path,
custom_kernels=config.separate.custom_kernels, custom_kernels=config.separate.custom_kernels,
@@ -583,20 +690,19 @@ def _run_staged(
state[f]["error"] = f"{type(e).__name__}: {e}" state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}") log(f" ✗ {f.name}: {e}")
else: else:
# Batch: one unblend invocation, model loaded once
input_wavs = [state[f]["audio_path"] for f in todo] input_wavs = [state[f]["audio_path"] for f in todo]
common_root = _common_parent(input_wavs) common_root = _common_parent(input_wavs)
log(f" Батч: {len(todo)} файлов, один вызов unblend (модель загрузится один раз)") log(f" Батч: {len(todo)} файлов, один вызов unblend")
if on_progress: if on_progress:
on_progress(0, len(todo), "separate") on_progress(0, len(todo), "separate")
try: try:
mapping = separate_vocals_batch( mapping = separate_vocals_batch(
input_wavs, input_wavs, common_root,
common_root,
model=config.separate.model, model=config.separate.model,
isolate_stem=config.separate.isolate_stem, isolate_stem=config.separate.isolate_stem,
unblend_path=config.separate.unblend_path, unblend_path=config.separate.unblend_path,
custom_kernels=config.separate.custom_kernels, custom_kernels=config.separate.custom_kernels,
progress_callback=lambda p: on_progress(p, len(todo), "separate") if on_progress else None,
) )
for f in todo: for f in todo:
src = state[f]["audio_path"] src = state[f]["audio_path"]
@@ -622,20 +728,18 @@ def _run_staged(
target = src.parent / "vocals.wav" target = src.parent / "vocals.wav"
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
raw = separate_vocals( raw = separate_vocals(
src, Path(tmp), src, Path(tmp), model=config.separate.model,
model=config.separate.model,
isolate_stem=config.separate.isolate_stem, isolate_stem=config.separate.isolate_stem,
unblend_path=config.separate.unblend_path, unblend_path=config.separate.unblend_path,
custom_kernels=config.separate.custom_kernels, custom_kernels=config.separate.custom_kernels,
) )
shutil.move(str(raw), str(target)) shutil.move(str(raw), str(target))
state[f]["audio_path"] = target state[f]["audio_path"] = target
log(f" ✓ {f.name}") log(f" ✓ {f.name}")
except Exception as inner: # noqa: BLE001 except Exception as inner: # noqa: BLE001
state[f]["error"] = f"{type(inner).__name__}: {inner}" state[f]["error"] = f"{type(inner).__name__}: {inner}"
log(f" ✗ {f.name}: {inner}") log(f" ✗ {f.name}: {inner}")
# --- Stage 3: transcribe ---
if config.transcribe.enabled and not cancelled(): if config.transcribe.enabled and not cancelled():
todo = active() todo = active()
log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══") log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══")
@@ -643,12 +747,9 @@ def _run_staged(
if cancelled(): if cancelled():
log("⏹ Отменено") log("⏹ Отменено")
break break
if on_progress:
on_progress(i, len(todo), "transcribe")
try: try:
text = transcribe( text = transcribe(
state[f]["audio_path"], state[f]["audio_path"], engine=config.transcribe.engine,
engine=config.transcribe.engine,
model=config.transcribe.model, model=config.transcribe.model,
language=config.transcribe.language, language=config.transcribe.language,
device=config.transcribe.device, device=config.transcribe.device,
@@ -663,8 +764,10 @@ def _run_staged(
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
state[f]["error"] = f"{type(e).__name__}: {e}" state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}") log(f" ✗ {f.name}: {e}")
finally:
if on_progress:
on_progress(i, len(todo), "transcribe")
# --- Stage 4: post-ASR lyrics match + persist ---
if not cancelled(): if not cancelled():
log("\n══ Финализация ══") log("\n══ Финализация ══")
for f in files: for f in files:
@@ -672,11 +775,9 @@ def _run_staged(
if s["error"] is not None: if s["error"] is not None:
continue continue
if s["resolved"]: if s["resolved"]:
continue # already persisted in identification pass continue
text = s["text"] text = s["text"]
source = s["source"] source = s["source"]
if text and config.lyrics.enabled and config.lyrics.try_after_asr: if text and config.lyrics.enabled and config.lyrics.try_after_asr:
matched = _try_post_asr_match(text, config, log) matched = _try_post_asr_match(text, config, log)
if matched: if matched:
@@ -684,7 +785,6 @@ def _run_staged(
source = f"{source}+lrclib" source = f"{source}+lrclib"
s["text"] = text s["text"] = text
s["source"] = source s["source"] = source
ident = s["ident"] ident = s["ident"]
metadata_dict = _build_metadata_dict( metadata_dict = _build_metadata_dict(
ident.metadata, ident.shazam, source, ident.metadata, ident.shazam, source,
@@ -695,21 +795,31 @@ def _run_staged(
track_out.mkdir(parents=True, exist_ok=True) track_out.mkdir(parents=True, exist_ok=True)
audio_work = s["audio_path"] 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:
if text: work_dir = audio_work.parent # intermediates exist
(work_dir / "text.txt").write_text(text, encoding="utf-8") if text:
if metadata_dict: (work_dir / "text.txt").write_text(text, encoding="utf-8")
_write_metadata_json(work_dir / "metadata.json", metadata_dict) 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( _save_unrecognized(
work_dir, track_out, config, config, f, text, source,
has_text=text is not None, metadata=(ident.metadata if ident else None),
has_metadata=bool(metadata_dict),
log=log,
) )
log(f" ✓ {f.name}") log(f" ✓ {f.name}")
# cleanup temp dirs
for f in files: for f in files:
audio_path = state[f]["audio_path"] audio_path = state[f]["audio_path"]
if isinstance(audio_path, Path) and audio_path.parent.name.startswith(f"at_{f.stem}"): if isinstance(audio_path, Path) and audio_path.parent.name.startswith(f"at_{f.stem}"):
@@ -717,12 +827,9 @@ def _run_staged(
results = [ results = [
FileResult( FileResult(
f, f, state[f]["text"],
state[f]["text"],
output_root / f.stem if state[f]["error"] is None else None, output_root / f.stem if state[f]["error"] is None else None,
state[f]["error"], state[f]["error"], state[f]["source"], state[f]["metadata_dict"],
state[f]["source"],
state[f]["metadata_dict"],
) )
for f in files for f in files
] ]
@@ -730,8 +837,6 @@ def _run_staged(
return results return results
# ---------------------------------------------------------------- summary
def _log_summary(results: list[FileResult], log: LogFn) -> None: def _log_summary(results: list[FileResult], log: LogFn) -> None:
from collections import Counter from collections import Counter
counts = Counter(r.source or ("error" if r.error else "no_result") for r in results) counts = Counter(r.source or ("error" if r.error else "no_result") for r in results)

View File

@@ -3,7 +3,22 @@ from __future__ import annotations
import os import os
import subprocess import subprocess
import time
from pathlib import Path from pathlib import Path
from typing import Callable, Optional
def _build_env(custom_kernels: bool) -> dict:
env = os.environ.copy()
env["UNBLEND_CUSTOM_KERNELS"] = "1" if custom_kernels else "0"
return env
def _check_result(result: subprocess.CompletedProcess, what: str) -> None:
if result.returncode != 0:
raise RuntimeError(
f"unblend failed ({what}):\n{result.stderr.strip()[:1200]}"
)
def separate_vocals( def separate_vocals(
@@ -13,8 +28,8 @@ def separate_vocals(
isolate_stem: str = "vocals", isolate_stem: str = "vocals",
unblend_path: str = "unblend", unblend_path: str = "unblend",
custom_kernels: bool = False, custom_kernels: bool = False,
device: str = "cuda",
) -> Path: ) -> Path:
"""Separate the isolated stem from input_wav. Returns path to the stem WAV."""
work_dir.mkdir(parents=True, exist_ok=True) work_dir.mkdir(parents=True, exist_ok=True)
output_pattern = work_dir / "{track}" / "{stem}.wav" output_pattern = work_dir / "{track}" / "{stem}.wav"
cmd = [ cmd = [
@@ -22,15 +37,10 @@ def separate_vocals(
"-m", model, "-m", model,
"-o", str(output_pattern), "-o", str(output_pattern),
"--isolate-stem", isolate_stem, "--isolate-stem", isolate_stem,
"--device", device,
] ]
env = os.environ.copy() result = subprocess.run(cmd, capture_output=True, text=True, env=_build_env(custom_kernels))
env["UNBLEND_CUSTOM_KERNELS"] = "1" if custom_kernels else "0" _check_result(result, input_wav.name)
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
if result.returncode != 0:
raise RuntimeError(
f"unblend failed for {input_wav.name}:\n{result.stderr.strip()[:800]}"
)
candidates = sorted(work_dir.rglob(f"{isolate_stem}.wav")) candidates = sorted(work_dir.rglob(f"{isolate_stem}.wav"))
if not candidates: if not candidates:
@@ -38,3 +48,81 @@ def separate_vocals(
f"unblend finished but stem '{isolate_stem}.wav' was not found in {work_dir}" f"unblend finished but stem '{isolate_stem}.wav' was not found in {work_dir}"
) )
return candidates[0] return candidates[0]
def separate_vocals_batch(
input_wavs: list[Path],
work_root: Path,
model: str = "bs_roformer_anvuew",
isolate_stem: str = "vocals",
unblend_path: str = "unblend",
custom_kernels: bool = False,
device: str = "cuda",
progress_callback: Optional[Callable[[int], None]] = None,
) -> dict[Path, Path]:
"""Separate many files in one unblend invocation.
unblend doesn't emit per-file progress, so progress_callback is called
with an incrementing counter roughly every 2 seconds, up to N-1.
The final N is always reported after the process exits.
"""
if not input_wavs:
return {}
work_root.mkdir(parents=True, exist_ok=True)
output_pattern = work_root / "{parent}" / "{stem}.wav"
cmd = [
unblend_path, "separate",
*[str(p) for p in input_wavs],
"-m", model,
"-o", str(output_pattern),
"--isolate-stem", isolate_stem,
"--device", device,
]
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=_build_env(custom_kernels),
)
total = len(input_wavs)
processed = 0
while proc.poll() is None:
if progress_callback and processed < total - 1:
if proc.wait(2.0) is not None:
break
processed += 1
progress_callback(processed)
else:
proc.wait(2.0)
if proc.returncode != 0:
raise RuntimeError(f"unblend batch failed with exit code {proc.returncode}")
if progress_callback:
progress_callback(total)
mapping: dict[Path, Path] = {}
missing: list[Path] = []
for src in input_wavs:
candidate = src.parent / f"{isolate_stem}.wav"
if candidate.exists():
mapping[src] = candidate
else:
missing.append(src)
if missing:
found = sorted(work_root.rglob(f"{isolate_stem}.wav"))
if len(found) == len(input_wavs):
for src, dst in zip(input_wavs, found):
mapping[src] = dst
else:
names = ", ".join(p.name for p in missing[:3])
raise RuntimeError(
f"unblend finished but outputs missing for {len(missing)} file(s): {names}"
)
return mapping

View File

@@ -1,11 +1,11 @@
"""Speech recognition via WhisperX or GigaAM (onnx-asr). """Speech recognition via WhisperX or GigaAM (onnx-asr).
Models are cached per (name, device, compute_type) so loading happens once Also exposes `transcribe_aligned()` for word-level alignment (used by LRC).
per process, not once per file. Loading is retried a few times to survive Models are cached; aggressive VRAM cleanup happens after each file.
transient HuggingFace / SSL hiccups.
""" """
from __future__ import annotations from __future__ import annotations
import gc
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -26,13 +26,23 @@ def _retry(fn, attempts: int = 3, base_delay: float = 2.0):
for i in range(attempts): for i in range(attempts):
try: try:
return fn() return fn()
except Exception as e: # noqa: BLE001 except Exception as e:
last = e last = e
if i < attempts - 1: if i < attempts - 1:
time.sleep(base_delay * (i + 1)) time.sleep(base_delay * (i + 1))
raise last 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: class _WhisperXModels:
_cache: dict = {} _cache: dict = {}
@@ -59,6 +69,28 @@ class _WhisperXModels:
return cls._cache[key] 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: class _GigaAMModels:
_cache: dict = {} _cache: dict = {}
@@ -93,11 +125,25 @@ def transcribe(
vad_filter: bool = True, vad_filter: bool = True,
) -> str: ) -> str:
device = _resolve_device(device) device = _resolve_device(device)
if engine == "whisperx": if engine == "whisperx":
return _transcribe_whisperx( try:
audio_path, model, language, device, compute_type, return _transcribe_whisperx(
batch_size, local_files_only, vad_filter, audio_path, model, language, device, compute_type,
) batch_size, local_files_only, vad_filter,
)
except Exception as e:
err = str(e).lower()
if "out of memory" in err or "cuda" in err or "device ordinal" in err:
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
_WhisperXModels._cache.clear()
_free_vram()
return _transcribe_whisperx(
audio_path, model, language, device, compute_type,
batch_size, local_files_only, vad_filter,
)
raise
if engine == "gigaam": if engine == "gigaam":
return _transcribe_gigaam(audio_path, gigaam_provider) return _transcribe_gigaam(audio_path, gigaam_provider)
raise ValueError(f"Unknown ASR engine: {engine!r}") raise ValueError(f"Unknown ASR engine: {engine!r}")
@@ -116,14 +162,52 @@ def _transcribe_whisperx(
import whisperx import whisperx
model = _WhisperXModels.get(model_name, device, compute_type, local_files_only) model = _WhisperXModels.get(model_name, device, compute_type, local_files_only)
audio = whisperx.load_audio(str(audio_path)) audio = whisperx.load_audio(str(audio_path))
result = model.transcribe( result = model.transcribe(audio, batch_size=batch_size, language=language)
audio, text = " ".join(seg["text"].strip() for seg in result["segments"]).strip()
batch_size=batch_size, del audio, result
language=language, _free_vram()
) return text
return " ".join(seg["text"].strip() for seg in result["segments"]).strip()
def _transcribe_gigaam(audio_path: Path, provider: str) -> str: def _transcribe_gigaam(audio_path: Path, provider: str) -> str:
model = _GigaAMModels.get(provider) model = _GigaAMModels.get(provider)
return model.recognize(str(audio_path), channel="mean").strip() 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 from __future__ import annotations
import shutil import shutil
@@ -15,7 +15,7 @@ from PySide6.QtWidgets import (
) )
from core.config import ( from core.config import (
ConvertConfig, LyricsConfig, PipelineConfig, SeparateConfig, ConvertConfig, LrcConfig, LyricsConfig, PipelineConfig, SeparateConfig,
ShazamConfig, TranscribeConfig, ShazamConfig, TranscribeConfig,
) )
from gui.worker import PipelineWorker from gui.worker import PipelineWorker
@@ -31,6 +31,8 @@ STAGE_LABELS = {
"convert": "Конвертация", "convert": "Конвертация",
"separate": "Отделение", "separate": "Отделение",
"transcribe": "Транскрибация", "transcribe": "Транскрибация",
"lrc": "LRC",
"embed": "Встраивание LRC",
} }
MODE_LABELS = { MODE_LABELS = {
@@ -44,7 +46,6 @@ def _detect_device_info() -> tuple[str, str]:
import torch import torch
except ImportError: except ImportError:
return "⚠ PyTorch не установлен", "#f38ba8" return "⚠ PyTorch не установлен", "#f38ba8"
if torch.cuda.is_available(): if torch.cuda.is_available():
try: try:
name = torch.cuda.get_device_name(0) 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" return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1"
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return "✓ CUDA доступна", "#a6e3a1" return "✓ CUDA доступна", "#a6e3a1"
return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af" return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af"
@@ -60,7 +60,7 @@ class MainWindow(QMainWindow):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setWindowTitle("Song Transcriber") self.setWindowTitle("Song Transcriber")
self.resize(1140, 880) self.resize(1140, 920)
self._worker: Optional[PipelineWorker] = None self._worker: Optional[PipelineWorker] = None
self._build_ui() self._build_ui()
self._apply_defaults() self._apply_defaults()
@@ -93,7 +93,6 @@ class MainWindow(QMainWindow):
self.input_edit = QLineEdit() self.input_edit = QLineEdit()
top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir)) top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir))
self.output_edit = QLineEdit() self.output_edit = QLineEdit()
top_layout.addRow("Выходная папка:", self._with_browse(self.output_edit, self._pick_output_dir)) 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.addWidget(self.overwrite_cb)
opts.addStretch(1) opts.addStretch(1)
top_layout.addRow("Опции:", opts) top_layout.addRow("Опции:", opts)
root.addWidget(top_box) root.addWidget(top_box)
self.tabs = QTabWidget() self.tabs = QTabWidget()
@@ -120,6 +118,7 @@ class MainWindow(QMainWindow):
self.tabs.addTab(self._build_convert_tab(), "Конвертация") self.tabs.addTab(self._build_convert_tab(), "Конвертация")
self.tabs.addTab(self._build_separate_tab(), "Отделение вокала") self.tabs.addTab(self._build_separate_tab(), "Отделение вокала")
self.tabs.addTab(self._build_transcribe_tab(), "Транскрибация") self.tabs.addTab(self._build_transcribe_tab(), "Транскрибация")
self.tabs.addTab(self._build_lrc_tab(), "LRC")
root.addWidget(self.tabs) root.addWidget(self.tabs)
self.shazam_enabled_cb.toggled.connect(self._sync_tab_indicators) self.shazam_enabled_cb.toggled.connect(self._sync_tab_indicators)
@@ -131,9 +130,9 @@ class MainWindow(QMainWindow):
row = QHBoxLayout() row = QHBoxLayout()
row.setSpacing(10) row.setSpacing(10)
self.start_btn = QPushButton("▶ Запустить") self.start_btn = QPushButton("▶ Запустить пайплайн")
self.start_btn.setObjectName("primary") 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 = QPushButton("⏹ Стоп")
self.stop_btn.setObjectName("danger") self.stop_btn.setObjectName("danger")
self.stop_btn.setEnabled(False) self.stop_btn.setEnabled(False)
@@ -180,19 +179,18 @@ class MainWindow(QMainWindow):
form = QFormLayout(w) form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16) form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10) form.setSpacing(10)
self.shazam_enabled_cb = QCheckBox("Включён") self.shazam_enabled_cb = QCheckBox("Включён")
self.shazam_concurrency = QSpinBox()
self.shazam_concurrency.setRange(1, 16)
form.addRow("Параллельных запросов:", self.shazam_concurrency)
form.addRow("", self.shazam_enabled_cb) form.addRow("", self.shazam_enabled_cb)
info = QLabel( info = QLabel(
"Идентификация трека через Shazam (unofficial API).\n" "Идентификация трека через Shazam (unofficial API).\n"
"При совпадении сохраняются метаданные и (если возможно) текст из LRCLIB.\n" "При совпадении сохраняются метаданные и (если возможно) текст из LRCLIB."
"Требует ffmpeg (для конвертации в 16 кГц моно)."
) )
info.setWordWrap(True) info.setWordWrap(True)
info.setStyleSheet("color: #a6adc8;") info.setStyleSheet("color: #a6adc8;")
form.addRow(info) form.addRow(info)
return w return w
def _build_lrclib_tab(self) -> QWidget: def _build_lrclib_tab(self) -> QWidget:
@@ -200,37 +198,47 @@ class MainWindow(QMainWindow):
form = QFormLayout(w) form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16) form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10) form.setSpacing(10)
self.lyrics_enabled_cb = QCheckBox("Включён") self.lyrics_enabled_cb = QCheckBox("Включён")
form.addRow("", self.lyrics_enabled_cb) 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() self.lyrics_user_agent = QLineEdit()
form.addRow("User-Agent:", self.lyrics_user_agent) form.addRow("User-Agent:", self.lyrics_user_agent)
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам")
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам (artist + title)")
form.addRow("", self.lyrics_use_tags_cb) 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) form.addRow("", self.lyrics_use_shazam_cb)
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy)")
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy-match)")
form.addRow("", self.lyrics_after_asr_cb) form.addRow("", self.lyrics_after_asr_cb)
self.lyrics_transcribe_unresolved_cb = QCheckBox( self.lyrics_transcribe_unresolved_cb = QCheckBox(
"Запускать ASR, если Shazam опознал, но текста нет" "ASR, если Shazam опознал без текста"
) )
form.addRow("", self.lyrics_transcribe_unresolved_cb) form.addRow("", self.lyrics_transcribe_unresolved_cb)
self.lyrics_threshold = QDoubleSpinBox() self.lyrics_threshold = QDoubleSpinBox()
self.lyrics_threshold.setRange(0.0, 1.0) self.lyrics_threshold.setRange(0.0, 1.0)
self.lyrics_threshold.setSingleStep(0.05) self.lyrics_threshold.setSingleStep(0.05)
self.lyrics_threshold.setDecimals(2) self.lyrics_threshold.setDecimals(2)
self.lyrics_threshold.setToolTip(
"Насколько должен совпасть распознанный ASR-текст с текстом из LRCLIB,\n"
"чтобы считать это тем же треком. 1.0 = точное совпадение, 0.0 = любое."
)
form.addRow("Порог fuzzy:", self.lyrics_threshold) 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 return w
def _build_convert_tab(self) -> QWidget: def _build_convert_tab(self) -> QWidget:
@@ -238,21 +246,20 @@ class MainWindow(QMainWindow):
form = QFormLayout(w) form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16) form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10) form.setSpacing(10)
self.convert_enabled_cb = QCheckBox("Включён") self.convert_enabled_cb = QCheckBox("Включён")
form.addRow("", self.convert_enabled_cb) form.addRow("", self.convert_enabled_cb)
self.convert_sr = QComboBox() self.convert_sr = QComboBox()
for sr in SAMPLE_RATES: for sr in SAMPLE_RATES:
self.convert_sr.addItem(f"{sr} Hz", sr) self.convert_sr.addItem(f"{sr} Hz", sr)
form.addRow("Sample rate:", self.convert_sr) form.addRow("Sample rate:", self.convert_sr)
self.convert_ch = QComboBox() self.convert_ch = QComboBox()
self.convert_ch.addItem("Моно", 1) self.convert_ch.addItem("Моно", 1)
self.convert_ch.addItem("Стерео", 2) self.convert_ch.addItem("Стерео", 2)
form.addRow("Каналы:", self.convert_ch) form.addRow("Каналы:", self.convert_ch)
self.convert_ffmpeg = QLineEdit() self.convert_ffmpeg = QLineEdit()
self.convert_concurrency = QSpinBox()
self.convert_concurrency.setRange(1, 16)
form.addRow("Параллельных конвертаций:", self.convert_concurrency)
form.addRow("ffmpeg:", self.convert_ffmpeg) form.addRow("ffmpeg:", self.convert_ffmpeg)
return w return w
@@ -261,21 +268,22 @@ class MainWindow(QMainWindow):
form = QFormLayout(w) form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16) form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10) form.setSpacing(10)
self.separate_enabled_cb = QCheckBox("Включён") self.separate_enabled_cb = QCheckBox("Включён")
form.addRow("", self.separate_enabled_cb) form.addRow("", self.separate_enabled_cb)
self.separate_model = QLineEdit() self.separate_model = QLineEdit()
form.addRow("Модель:", self.separate_model) form.addRow("Модель:", self.separate_model)
self.separate_stem = QLineEdit() self.separate_stem = QLineEdit()
form.addRow("Isolate stem:", self.separate_stem) form.addRow("Isolate stem:", self.separate_stem)
self.separate_unblend = QLineEdit() self.separate_unblend = QLineEdit()
form.addRow("unblend CLI:", self.separate_unblend) form.addRow("unblend CLI:", self.separate_unblend)
self.separate_kernels_cb = QCheckBox("Использовать кастомные ядра") self.separate_kernels_cb = QCheckBox("Использовать кастомные ядра")
form.addRow("", self.separate_kernels_cb) 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 return w
def _build_transcribe_tab(self) -> QWidget: def _build_transcribe_tab(self) -> QWidget:
@@ -283,53 +291,90 @@ class MainWindow(QMainWindow):
form = QFormLayout(w) form = QFormLayout(w)
form.setContentsMargins(16, 16, 16, 16) form.setContentsMargins(16, 16, 16, 16)
form.setSpacing(10) form.setSpacing(10)
self.transcribe_enabled_cb = QCheckBox("Включён") self.transcribe_enabled_cb = QCheckBox("Включён")
form.addRow("", self.transcribe_enabled_cb) form.addRow("", self.transcribe_enabled_cb)
self.engine_cb = QComboBox() self.engine_cb = QComboBox()
self.engine_cb.addItems(ENGINES) self.engine_cb.addItems(ENGINES)
form.addRow("Движок:", self.engine_cb) form.addRow("Движок:", self.engine_cb)
self.model_edit = QLineEdit() self.model_edit = QLineEdit()
form.addRow("Модель:", self.model_edit) form.addRow("Модель:", self.model_edit)
self.lang_cb = QComboBox() self.lang_cb = QComboBox()
self.lang_cb.setEditable(True) self.lang_cb.setEditable(True)
self.lang_cb.addItems(LANGUAGES) self.lang_cb.addItems(LANGUAGES)
self.lang_cb.setToolTip(
"'auto' — WhisperX определяет язык на каждом файле.\n"
"Если знаешь язык — выбери его явно, будет быстрее."
)
form.addRow("Язык:", self.lang_cb) form.addRow("Язык:", self.lang_cb)
self.device_cb = QComboBox() self.device_cb = QComboBox()
self.device_cb.addItems(["auto", "cuda", "cpu"]) self.device_cb.addItems(["auto", "cuda", "cpu"])
form.addRow("Device:", self.device_cb) form.addRow("Device:", self.device_cb)
self.compute_type_cb = QComboBox() self.compute_type_cb = QComboBox()
self.compute_type_cb.addItems(["auto", "int8_float16", "int8", "float16", "float32"]) self.compute_type_cb.addItems(["auto", "int8_float16", "int8", "float16", "float32"])
form.addRow("Compute type:", self.compute_type_cb) form.addRow("Compute type:", self.compute_type_cb)
self.batch_spin = QSpinBox() self.batch_spin = QSpinBox()
self.batch_spin.setRange(1, 512) 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) form.addRow("Batch size:", self.batch_spin)
self.gigaam_provider_cb = QComboBox() self.gigaam_provider_cb = QComboBox()
self.gigaam_provider_cb.addItems(["auto", "cuda", "cpu"]) self.gigaam_provider_cb.addItems(["auto", "cuda", "cpu"])
form.addRow("GigaAM provider:", self.gigaam_provider_cb) form.addRow("GigaAM provider:", self.gigaam_provider_cb)
self.local_files_only_cb = QCheckBox("Не проверять HuggingFace (offline)") self.local_files_only_cb = QCheckBox("Не проверять HuggingFace (offline)")
self.local_files_only_cb.setToolTip(
"Если модель уже скачана — включите, чтобы не ждать сетевую проверку."
)
form.addRow("", self.local_files_only_cb) form.addRow("", self.local_files_only_cb)
return w 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 ---------------- # ---------------- visual sync ----------------
def _log_device_info(self) -> None: def _log_device_info(self) -> None:
@@ -365,22 +410,23 @@ class MainWindow(QMainWindow):
self.recursive_cb.setChecked(cfg.recursive) self.recursive_cb.setChecked(cfg.recursive)
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate) self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
self.overwrite_cb.setChecked(cfg.overwrite) self.overwrite_cb.setChecked(cfg.overwrite)
i = self.mode_cb.findData(cfg.mode) i = self.mode_cb.findData(cfg.mode)
if i >= 0: if i >= 0:
self.mode_cb.setCurrentIndex(i) self.mode_cb.setCurrentIndex(i)
self.shazam_enabled_cb.setChecked(cfg.shazam.enabled) self.shazam_enabled_cb.setChecked(cfg.shazam.enabled)
self.shazam_concurrency.setValue(cfg.shazam.identify_concurrency)
self.lyrics_enabled_cb.setChecked(cfg.lyrics.enabled) self.lyrics_enabled_cb.setChecked(cfg.lyrics.enabled)
self.lyrics_user_agent.setText(cfg.lyrics.user_agent) self.lyrics_user_agent.setText(cfg.lyrics.user_agent)
self.lyrics_use_tags_cb.setChecked(cfg.lyrics.use_tags_first) self.lyrics_use_tags_cb.setChecked(cfg.lyrics.use_tags_first)
self.lyrics_use_shazam_cb.setChecked(cfg.lyrics.use_shazam_result) self.lyrics_use_shazam_cb.setChecked(cfg.lyrics.use_shazam_result)
self.lyrics_after_asr_cb.setChecked(cfg.lyrics.try_after_asr) self.lyrics_after_asr_cb.setChecked(cfg.lyrics.try_after_asr)
self.lyrics_transcribe_unresolved_cb.setChecked( self.lyrics_transcribe_unresolved_cb.setChecked(cfg.lyrics.transcribe_if_identified_no_lyrics)
cfg.lyrics.transcribe_if_identified_no_lyrics
)
self.lyrics_threshold.setValue(cfg.lyrics.fuzzy_threshold) 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) self.convert_enabled_cb.setChecked(cfg.convert.enabled)
i = self.convert_sr.findData(cfg.convert.sample_rate) i = self.convert_sr.findData(cfg.convert.sample_rate)
@@ -390,12 +436,14 @@ class MainWindow(QMainWindow):
if i >= 0: if i >= 0:
self.convert_ch.setCurrentIndex(i) self.convert_ch.setCurrentIndex(i)
self.convert_ffmpeg.setText(cfg.convert.ffmpeg_path) self.convert_ffmpeg.setText(cfg.convert.ffmpeg_path)
self.convert_concurrency.setValue(cfg.convert.convert_concurrency)
self.separate_enabled_cb.setChecked(cfg.separate.enabled) self.separate_enabled_cb.setChecked(cfg.separate.enabled)
self.separate_model.setText(cfg.separate.model) self.separate_model.setText(cfg.separate.model)
self.separate_stem.setText(cfg.separate.isolate_stem) self.separate_stem.setText(cfg.separate.isolate_stem)
self.separate_unblend.setText(cfg.separate.unblend_path) self.separate_unblend.setText(cfg.separate.unblend_path)
self.separate_kernels_cb.setChecked(cfg.separate.custom_kernels) 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.transcribe_enabled_cb.setChecked(cfg.transcribe.enabled)
self.engine_cb.setCurrentText(cfg.transcribe.engine) self.engine_cb.setCurrentText(cfg.transcribe.engine)
@@ -407,6 +455,12 @@ class MainWindow(QMainWindow):
self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider) self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider)
self.local_files_only_cb.setChecked(cfg.transcribe.local_files_only) 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: def _collect_config(self) -> PipelineConfig:
cfg = PipelineConfig() cfg = PipelineConfig()
cfg.input_dir = self.input_edit.text().strip() cfg.input_dir = self.input_edit.text().strip()
@@ -416,8 +470,10 @@ class MainWindow(QMainWindow):
cfg.overwrite = self.overwrite_cb.isChecked() cfg.overwrite = self.overwrite_cb.isChecked()
cfg.mode = self.mode_cb.currentData() cfg.mode = self.mode_cb.currentData()
cfg.shazam = ShazamConfig(enabled=self.shazam_enabled_cb.isChecked()) cfg.shazam = ShazamConfig(
enabled=self.shazam_enabled_cb.isChecked(),
identify_concurrency=self.shazam_concurrency.value(),
)
cfg.lyrics = LyricsConfig( cfg.lyrics = LyricsConfig(
enabled=self.lyrics_enabled_cb.isChecked(), enabled=self.lyrics_enabled_cb.isChecked(),
user_agent=self.lyrics_user_agent.text().strip() or "audio-transcriber/1.0", user_agent=self.lyrics_user_agent.text().strip() or "audio-transcriber/1.0",
@@ -426,13 +482,16 @@ class MainWindow(QMainWindow):
try_after_asr=self.lyrics_after_asr_cb.isChecked(), try_after_asr=self.lyrics_after_asr_cb.isChecked(),
transcribe_if_identified_no_lyrics=self.lyrics_transcribe_unresolved_cb.isChecked(), transcribe_if_identified_no_lyrics=self.lyrics_transcribe_unresolved_cb.isChecked(),
fuzzy_threshold=float(self.lyrics_threshold.value()), 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( cfg.convert = ConvertConfig(
enabled=self.convert_enabled_cb.isChecked(), enabled=self.convert_enabled_cb.isChecked(),
sample_rate=int(self.convert_sr.currentData()), sample_rate=int(self.convert_sr.currentData()),
channels=int(self.convert_ch.currentData()), channels=int(self.convert_ch.currentData()),
ffmpeg_path=self.convert_ffmpeg.text().strip() or "ffmpeg", ffmpeg_path=self.convert_ffmpeg.text().strip() or "ffmpeg",
convert_concurrency=self.convert_concurrency.value(),
) )
cfg.separate = SeparateConfig( cfg.separate = SeparateConfig(
enabled=self.separate_enabled_cb.isChecked(), enabled=self.separate_enabled_cb.isChecked(),
@@ -440,8 +499,8 @@ class MainWindow(QMainWindow):
isolate_stem=self.separate_stem.text().strip() or "vocals", isolate_stem=self.separate_stem.text().strip() or "vocals",
unblend_path=self.separate_unblend.text().strip() or "unblend", unblend_path=self.separate_unblend.text().strip() or "unblend",
custom_kernels=self.separate_kernels_cb.isChecked(), custom_kernels=self.separate_kernels_cb.isChecked(),
save_vocals_copy=self.separate_save_vocals_cb.isChecked(),
) )
lang = self.lang_cb.currentText().strip() lang = self.lang_cb.currentText().strip()
compute = self.compute_type_cb.currentText().strip() compute = self.compute_type_cb.currentText().strip()
cfg.transcribe = TranscribeConfig( cfg.transcribe = TranscribeConfig(
@@ -455,6 +514,14 @@ class MainWindow(QMainWindow):
gigaam_provider=self.gigaam_provider_cb.currentText(), gigaam_provider=self.gigaam_provider_cb.currentText(),
local_files_only=self.local_files_only_cb.isChecked(), 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 return cfg
# ---------------- handlers ---------------- # ---------------- handlers ----------------
@@ -492,39 +559,50 @@ class MainWindow(QMainWindow):
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Ошибка", str(exc)) 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() cfg = self._collect_config()
if not cfg.input_dir or not Path(cfg.input_dir).is_dir(): if not cfg.input_dir or not Path(cfg.input_dir).is_dir():
QMessageBox.warning(self, "Нет входной папки", "Выберите существующую входную папку.") QMessageBox.warning(self, "Нет входной папки", "Выберите существующую входную папку.")
return return
has_work = any([
cfg.shazam.enabled, cfg.lyrics.enabled, if task == "main":
cfg.convert.enabled, cfg.separate.enabled, cfg.transcribe.enabled, has_work = any([
]) cfg.shazam.enabled, cfg.lyrics.enabled,
if not has_work: cfg.convert.enabled, cfg.separate.enabled, cfg.transcribe.enabled,
QMessageBox.warning(self, "Нет этапов", "Включите хотя бы один этап.") ])
return if not has_work:
needs_ffmpeg = cfg.convert.enabled or cfg.shazam.enabled QMessageBox.warning(self, "Нет этапов", "Включите хотя бы один этап.")
if needs_ffmpeg and not shutil.which(cfg.convert.ffmpeg_path): return
QMessageBox.warning(self, "ffmpeg не найден", needs_ffmpeg = cfg.convert.enabled or cfg.shazam.enabled
f"Команда '{cfg.convert.ffmpeg_path}' не найдена в PATH.") if needs_ffmpeg and not shutil.which(cfg.convert.ffmpeg_path):
return QMessageBox.warning(self, "ffmpeg не найден",
if cfg.separate.enabled and not shutil.which(cfg.separate.unblend_path): f"Команда '{cfg.convert.ffmpeg_path}' не найдена в PATH.")
QMessageBox.warning(self, "unblend не найден", return
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.") if cfg.separate.enabled and not shutil.which(cfg.separate.unblend_path):
QMessageBox.warning(self, "unblend не найден",
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.")
return
if task == "lrc" and cfg.transcribe.engine != "whisperx":
QMessageBox.warning(
self, "LRC требует WhisperX",
"Align-модель доступна только для WhisperX. Переключите движок на whisperx."
)
return return
self.log_view.clear() self.log_view.clear()
self._log_device_info() 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.setValue(0)
self.progress.setFormat("Запуск…") self.progress.setFormat("Запуск…")
self.start_btn.setEnabled(False) self._set_busy(True)
self.stop_btn.setEnabled(True)
self._worker = PipelineWorker(cfg) self._worker = PipelineWorker(cfg, task=task)
self._worker.log.connect(self._append_log) self._worker.log.connect(self._append_log)
self._worker.progress.connect(self._on_progress) self._worker.progress.connect(self._on_progress)
self._worker.finished_ok.connect(self._on_finished) self._worker.finished_ok.connect(self._on_finished)
@@ -548,16 +626,14 @@ class MainWindow(QMainWindow):
self.progress.setFormat(f"{label} — %v / %m") self.progress.setFormat(f"{label} — %v / %m")
def _on_finished(self, results: list) -> None: def _on_finished(self, results: list) -> None:
self.start_btn.setEnabled(True) self._set_busy(False)
self.stop_btn.setEnabled(False)
ok = sum(1 for r in results if r.error is None) ok = sum(1 for r in results if r.error is None)
fail = len(results) - ok fail = len(results) - ok
self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}") self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}")
self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.") self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
def _on_failed(self, message: str) -> None: def _on_failed(self, message: str) -> None:
self.start_btn.setEnabled(True) self._set_busy(False)
self.stop_btn.setEnabled(False)
self.progress.setFormat("Ошибка") self.progress.setFormat("Ошибка")
self._append_log(f"\n✗ Ошибка пайплайна: {message}") self._append_log(f"\n✗ Ошибка пайплайна: {message}")
QMessageBox.critical(self, "Ошибка", 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 from __future__ import annotations
import threading import threading
@@ -6,7 +6,6 @@ import threading
from PySide6.QtCore import QThread, Signal from PySide6.QtCore import QThread, Signal
from core.config import PipelineConfig from core.config import PipelineConfig
from core.pipeline import run_pipeline
class PipelineWorker(QThread): class PipelineWorker(QThread):
@@ -15,9 +14,10 @@ class PipelineWorker(QThread):
finished_ok = Signal(list) finished_ok = Signal(list)
failed = Signal(str) failed = Signal(str)
def __init__(self, config: PipelineConfig, parent=None): def __init__(self, config: PipelineConfig, task: str = "main", parent=None):
super().__init__(parent) super().__init__(parent)
self._config = config self._config = config
self._task = task
self._stop_event = threading.Event() self._stop_event = threading.Event()
def stop(self) -> None: def stop(self) -> None:
@@ -25,7 +25,14 @@ class PipelineWorker(QThread):
def run(self) -> None: def run(self) -> None:
try: 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, self._config,
log=self.log.emit, log=self.log.emit,
on_progress=lambda d, t, s: self.progress.emit(d, t, s), on_progress=lambda d, t, s: self.progress.emit(d, t, s),