adding shazam, and LRClib support

This commit is contained in:
jadssss
2026-10-24 06:34:12 +03:00
parent 8ffb778241
commit f702cc87ef
7 changed files with 822 additions and 213 deletions

View File

@@ -38,7 +38,23 @@ class TranscribeConfig:
batch_size: int = 8
gigaam_provider: str = "auto"
local_files_only: bool = False
vad_filter: bool = True
@dataclass
class ShazamConfig:
enabled: bool = False
@dataclass
class LyricsConfig:
enabled: bool = False
user_agent: str = "audio-transcriber/1.0"
timeout: float = 10.0
use_tags_first: bool = True
use_shazam_result: bool = True
try_after_asr: bool = True
fuzzy_threshold: float = 0.6 # difflib ratio, 0..1
transcribe_if_identified_no_lyrics: bool = True
@dataclass
@@ -48,11 +64,13 @@ class PipelineConfig:
recursive: bool = False
keep_intermediate: bool = True
overwrite: bool = True
mode: str = "sequential" # "sequential" | "staged"
mode: str = "sequential"
convert: ConvertConfig = field(default_factory=ConvertConfig)
separate: SeparateConfig = field(default_factory=SeparateConfig)
transcribe: TranscribeConfig = field(default_factory=TranscribeConfig)
shazam: ShazamConfig = field(default_factory=ShazamConfig)
lyrics: LyricsConfig = field(default_factory=LyricsConfig)
def to_dict(self) -> dict:
return asdict(self)

115
core/lyrics.py Normal file
View File

@@ -0,0 +1,115 @@
"""Lyrics lookup via LRCLIB (https://lrclib.net) — free, no API key.
Rate limit: LRCLIB allows ~90 req/min with a proper User-Agent,
~45 without. We throttle to 0.4s between requests (150/min theoretical,
well within limits) and always send a UA.
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from typing import Optional
import requests
DEFAULT_USER_AGENT = "audio-transcriber/1.0"
_API_BASE = "https://lrclib.net/api"
_throttle_lock = threading.Lock()
_last_request_at = 0.0
_MIN_INTERVAL = 0.4 # seconds
@dataclass
class LyricsMatch:
title: str
artist: str
lyrics: str
duration: Optional[float] = None
def _throttle() -> None:
global _last_request_at
with _throttle_lock:
now = time.monotonic()
wait = _last_request_at + _MIN_INTERVAL - now
if wait > 0:
time.sleep(wait)
_last_request_at = time.monotonic()
def _get(url: str, params: dict, user_agent: str, timeout: float = 10.0) -> Optional[dict]:
_throttle()
try:
resp = requests.get(
url, params=params,
headers={"User-Agent": user_agent},
timeout=timeout,
)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
except requests.RequestException:
return None
def _make_match(item: dict) -> Optional[LyricsMatch]:
lyrics = item.get("plainLyrics") or item.get("syncedLyrics")
if not lyrics:
return None
return LyricsMatch(
title=item.get("trackName", ""),
artist=item.get("artistName", ""),
lyrics=lyrics.strip(),
duration=item.get("duration"),
)
def search_by_identity(
title: Optional[str],
artist: Optional[str],
album: Optional[str] = None,
duration: Optional[float] = None,
user_agent: str = DEFAULT_USER_AGENT,
timeout: float = 10.0,
) -> Optional[LyricsMatch]:
"""Search LRCLIB by track metadata. Works even with messy tags."""
if not title and not artist:
return None
query = " ".join(filter(None, [artist or "", title or ""])).strip()
if not query:
return None
data = _get(f"{_API_BASE}/search", {"q": query}, user_agent, timeout)
if not isinstance(data, list) or not data:
return None
for item in data[:5]:
match = _make_match(item)
if match:
return match
return None
def search_by_text(
query: str,
limit: int = 5,
user_agent: str = DEFAULT_USER_AGENT,
timeout: float = 10.0,
) -> list[LyricsMatch]:
"""Search LRCLIB by plain text (e.g. first words of recognized speech)."""
if not query or not query.strip():
return []
data = _get(f"{_API_BASE}/search", {"q": query.strip()}, user_agent, timeout)
if not isinstance(data, list):
return []
matches: list[LyricsMatch] = []
for item in data[:limit]:
match = _make_match(item)
if match:
matches.append(match)
return matches

58
core/metadata.py Normal file
View File

@@ -0,0 +1,58 @@
"""Read audio file tags via mutagen. Always safe — never raises."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass
class TrackMetadata:
title: Optional[str] = None
artist: Optional[str] = None
album: Optional[str] = None
duration: Optional[float] = None
has_identity: bool = False
def read_metadata(path: Path) -> TrackMetadata:
"""Extract title/artist/album/duration from tags. Never raises."""
try:
from mutagen import File
except ImportError:
return TrackMetadata()
try:
audio = File(str(path), easy=True)
except Exception:
return TrackMetadata()
if audio is None:
return TrackMetadata()
def _first(key: str) -> Optional[str]:
v = audio.get(key)
if isinstance(v, list):
return v[0] if v else None
if isinstance(v, str):
return v
return None
title = _first("title")
artist = _first("artist")
album = _first("album")
duration: Optional[float] = None
try:
if audio.info is not None:
duration = float(audio.info.length)
except Exception:
pass
return TrackMetadata(
title=title,
artist=artist,
album=album,
duration=duration,
has_identity=bool(title and artist),
)

View File

@@ -1,24 +1,33 @@
"""Orchestrate convert -> separate -> transcribe.
"""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: for each file, run all enabled stages before moving to the next.
- staged: run stage 1 over all files, then stage 2 over all files, then stage 3.
Intermediate artifacts (convert.wav, vocals.wav) are written to a temp dir and
NEVER persisted to the output directory — unless `keep_intermediate=True`.
Only the artifact of the LAST enabled stage is moved into `<output>/<stem>/`.
- 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
import difflib
import json
import shutil
import tempfile
import threading
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
from .config import PipelineConfig
from .convert import convert_to_wav
from .lyrics import LyricsMatch, search_by_identity, search_by_text
from .metadata import TrackMetadata, read_metadata
from .recognize import ShazamResult, recognize
from .separate import separate_vocals
from .transcribe import transcribe
@@ -28,7 +37,7 @@ AUDIO_EXTENSIONS = {
}
LogFn = Callable[[str], None]
ProgressFn = Callable[[int, int, str], None] # done, total, stage
ProgressFn = Callable[[int, int, str], None]
@dataclass
@@ -37,6 +46,8 @@ class FileResult:
text: Optional[str]
output_dir: Optional[Path]
error: Optional[str] = None
source: Optional[str] = None
metadata: Optional[dict] = field(default=None)
class Cancelled(Exception):
@@ -45,68 +56,218 @@ class Cancelled(Exception):
# ---------------------------------------------------------------- helpers
_ARTIFACT_NAMES = {
"convert": "convert.wav",
"separate": "vocals.wav",
"transcribe": "text.txt",
}
def collect_audio_files(input_dir: Path, recursive: bool) -> list[Path]:
it = input_dir.rglob("*") if recursive else input_dir.iterdir()
return sorted(p for p in it if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS)
def _final_stage(config: PipelineConfig) -> Optional[str]:
"""The stage whose artifact is the final result (single element)."""
if config.transcribe.enabled:
return "transcribe"
if config.separate.enabled:
return "separate"
if config.convert.enabled:
return "convert"
return None
def _check_cancel(stop_event: Optional[threading.Event]) -> None:
if stop_event is not None and stop_event.is_set():
raise Cancelled()
def _persist_final(work_dir: Path, persist_dir: Path, final_stage: str, log: LogFn) -> None:
"""Move the final-stage artifact from work_dir into persist_dir."""
name = _ARTIFACT_NAMES[final_stage]
src = work_dir / name
if not src.exists():
raise FileNotFoundError(f"Final artifact missing: {src}")
persist_dir.mkdir(parents=True, exist_ok=True)
dst = persist_dir / name
if dst.exists():
dst.unlink()
shutil.move(str(src), str(dst))
log(f" [persist] {final_stage} -> {persist_dir.name}/{name}")
def _text_head(text: str, n_words: int = 8) -> str:
return " ".join(text.split()[:n_words])
def _work_dir_for(
def _build_metadata_dict(
metadata: TrackMetadata,
shazam: Optional[ShazamResult],
source: Optional[str],
) -> Optional[dict]:
d: dict = {}
if metadata.has_identity:
d["tags"] = {
"title": metadata.title,
"artist": metadata.artist,
"album": metadata.album,
"duration": metadata.duration,
}
if shazam:
d["shazam"] = {
"title": shazam.title,
"artist": shazam.artist,
"album": shazam.album,
"year": shazam.year,
}
if source:
d["source"] = source
return d if d else None
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",
)
@dataclass
class IdentifyResult:
metadata: TrackMetadata
shazam: Optional[ShazamResult] = None
text: Optional[str] = None
source: Optional[str] = None
needs_asr: bool = True
def _identify_file(
input_path: Path,
work_dir: Path,
config: PipelineConfig,
stem: str,
output_root: Path,
tmp_root: Optional[Path],
) -> tuple[Path, Path]:
"""Return (work_dir, persist_dir).
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
):
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,
)
if match:
result.text = match.lyrics
result.source = "tag+lrclib"
result.needs_asr = False
log(f" ✓ {match.artist} — {match.title}")
return result
# --- 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,
ffmpeg_path=config.convert.ffmpeg_path,
)
result.shazam = recognize(shazam_wav)
except Exception as e: # noqa: BLE001
log(f" ⚠ Shazam error: {e}")
result.shazam = None
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,
)
if match:
result.text = match.lyrics
result.source = "shazam+lrclib"
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
log(" ⚠ текста нет")
else:
log(" ⚠ не распознан")
return result
def _try_post_asr_match(
text: str,
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
head = _text_head(text, 8)
log(f" LRCLIB по тексту: {head!r}...")
try:
candidates = search_by_text(
head, 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:
ratio = difflib.SequenceMatcher(None, text.lower(), c.lyrics.lower()).ratio()
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
def _persist_artifacts(
work_dir: Path,
persist_dir: Path,
config: PipelineConfig,
has_text: bool,
has_metadata: bool,
log: LogFn,
) -> None:
"""Move wanted artifacts from work_dir to persist_dir."""
persist_dir.mkdir(parents=True, exist_ok=True)
names: list[str] = []
if has_metadata and (work_dir / "metadata.json").exists():
names.append("metadata.json")
if has_text and (work_dir / "text.txt").exists():
names.append("text.txt")
- keep_intermediate=True -> work_dir == persist_dir == output_root/stem
- keep_intermediate=False -> work_dir = tmp_root/stem, persist_dir = output_root/stem
"""
persist_dir = output_root / stem
if config.keep_intermediate:
persist_dir.mkdir(parents=True, exist_ok=True)
return persist_dir, persist_dir
if tmp_root is None:
raise ValueError("tmp_root required when keep_intermediate=False")
work_dir = tmp_root / stem
work_dir.mkdir(parents=True, exist_ok=True)
return work_dir, persist_dir
for n in ("convert.wav", "vocals.wav"):
if (work_dir / n).exists():
names.append(n)
elif not has_text:
if config.separate.enabled and (work_dir / "vocals.wav").exists():
names.append("vocals.wav")
elif config.convert.enabled and (work_dir / "convert.wav").exists():
names.append("convert.wav")
for name in names:
src = work_dir / name
dst = persist_dir / name
if dst.exists():
dst.unlink()
shutil.move(str(src), str(dst))
log(f" [persist] {name}")
# ---------------------------------------------------------------- entry
@@ -130,10 +291,14 @@ def run_pipeline(
return []
mode = (config.mode or "sequential").lower()
log(f"Найдено треков: {len(files)} | режим: {mode} | "
f"keep_intermediate={config.keep_intermediate}")
if not config.keep_intermediate:
log(" Промежуточные файлы будут во временной папке и удалены после прогона.")
log(
f"Найдено треков: {len(files)} | режим: {mode} | "
f"keep_intermediate={config.keep_intermediate}"
)
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)
@@ -150,7 +315,6 @@ def _run_sequential(
stop_event: Optional[threading.Event],
) -> list[FileResult]:
total = len(files)
final_stage = _final_stage(config)
results: list[FileResult] = []
output_root = Path(config.output_dir)
@@ -163,18 +327,43 @@ def _run_sequential(
log(f"\n▶ [{idx}/{total}] {f.name}")
tmp_root = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_"))
work_dir, persist_dir = _work_dir_for(config, f.stem, output_root, tmp_root)
track_out = output_root / f.stem
track_out.mkdir(parents=True, exist_ok=True)
audio_path: Path = f
text: Optional[str] = None
source: Optional[str] = None
error: Optional[str] = None
processed_ok = False
metadata_dict: Optional[dict] = None
try:
# --- Phase 1: identification ---
ident = _identify_file(f, tmp_root, config, log)
if not ident.needs_asr:
text = ident.text
source = ident.source
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,
)
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)
log(" [1] Конвертация в WAV...")
target = work_dir / "convert.wav"
target = tmp_root / "convert.wav"
if config.overwrite or not target.exists():
convert_to_wav(
f, target,
@@ -188,7 +377,7 @@ def _run_sequential(
if config.separate.enabled:
_check_cancel(stop_event)
log(" [2] Отделение вокала...")
target = work_dir / "vocals.wav"
target = tmp_root / "vocals.wav"
with tempfile.TemporaryDirectory() as tmp:
raw = separate_vocals(
audio_path, Path(tmp),
@@ -214,12 +403,28 @@ def _run_sequential(
batch_size=config.transcribe.batch_size,
gigaam_provider=config.transcribe.gigaam_provider,
local_files_only=config.transcribe.local_files_only,
vad_filter=config.transcribe.vad_filter,
)
(work_dir / "text.txt").write_text(text, encoding="utf-8")
log(f" ✓ text.txt ({len(text)} символов)")
source = config.transcribe.engine
log(f" ✓ распознано {len(text)} символов")
processed_ok = True
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)
if metadata_dict:
_write_metadata_json(tmp_root / "metadata.json", metadata_dict)
_persist_artifacts(
tmp_root, track_out, config,
has_text=text is not None,
has_metadata=bool(metadata_dict),
log=log,
)
except Cancelled:
log("⏹ Отменено")
@@ -228,17 +433,14 @@ def _run_sequential(
error = f"{type(e).__name__}: {e}"
log(f" ✗ Ошибка: {error}")
finally:
if processed_ok and not config.keep_intermediate and final_stage is not None:
try:
_persist_final(work_dir, persist_dir, final_stage, log)
except Exception as e: # noqa: BLE001
error = f"persist failed: {e}"
log(f" ✗ {error}")
shutil.rmtree(tmp_root, ignore_errors=True)
if on_progress:
on_progress(idx, total, "file")
results.append(FileResult(f, text, persist_dir if not error else None, error))
results.append(
FileResult(f, text, track_out if not error else None, error, source, metadata_dict)
)
_log_summary(results, log)
return results
@@ -253,134 +455,217 @@ def _run_staged(
) -> list[FileResult]:
output_root = Path(config.output_dir)
total = len(files)
final_stage = _final_stage(config)
# One tmp_root for the whole batch: stage 2 needs convert.wav of every file,
# stage 3 needs vocals.wav of every file. All of it is removed at the end.
tmp_root_ctx = tempfile.mkdtemp(prefix="at_staged_")
tmp_root = Path(tmp_root_ctx)
state: dict[Path, dict] = {}
for f in files:
work_dir, persist_dir = _work_dir_for(config, f.stem, output_root, tmp_root)
state[f] = {
state: dict[Path, dict] = {
f: {
"audio_path": f,
"work_dir": work_dir,
"persist_dir": persist_dir,
"ident": None,
"text": None,
"source": None,
"metadata_dict": None,
"error": None,
"resolved": False,
}
for f in files
}
def active() -> list[Path]:
return [f for f in files if state[f]["error"] is None]
return [f for f in files if state[f]["error"] is None and not state[f]["resolved"]]
def cancelled() -> bool:
return stop_event is not None and stop_event.is_set()
try:
# ---- Stage 1: convert ----
if config.convert.enabled:
log(f"\n══ Этап 1/3 · Конвертация ({total} файлов) ══")
# --- 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, "convert")
on_progress(i, total, "identify")
try:
target = state[f]["work_dir"] / "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,
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,
)
state[f]["audio_path"] = target
log(f" ✓ {f.name}")
# 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:
shutil.rmtree(ident_tmp, ignore_errors=True)
# ---- Stage 2: separate ----
if config.separate.enabled and not cancelled():
todo = active()
log(f"\n══ Этап 2/3 · Отделение вокала ({len(todo)} файлов) ══")
for i, f in enumerate(todo, 1):
if cancelled():
log("⏹ Отменено")
break
if on_progress:
on_progress(i, len(todo), "separate")
try:
target = state[f]["work_dir"] / "vocals.wav"
with tempfile.TemporaryDirectory() as tmp:
raw = separate_vocals(
state[f]["audio_path"], Path(tmp),
model=config.separate.model,
isolate_stem=config.separate.isolate_stem,
unblend_path=config.separate.unblend_path,
custom_kernels=config.separate.custom_kernels,
)
shutil.move(str(raw), str(target))
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 3: transcribe ----
if config.transcribe.enabled and not cancelled():
todo = active()
log(f"\n══ Этап 3/3 · Транскрибация ({len(todo)} файлов) ══")
for i, f in enumerate(todo, 1):
if cancelled():
log("⏹ Отменено")
break
if on_progress:
on_progress(i, len(todo), "transcribe")
try:
text = transcribe(
state[f]["audio_path"],
engine=config.transcribe.engine,
model=config.transcribe.model,
language=config.transcribe.language,
device=config.transcribe.device,
compute_type=config.transcribe.compute_type,
batch_size=config.transcribe.batch_size,
gigaam_provider=config.transcribe.gigaam_provider,
local_files_only=config.transcribe.local_files_only,
vad_filter=config.transcribe.vad_filter,
# --- 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]["work_dir"] / "text.txt").write_text(text, encoding="utf-8")
state[f]["text"] = text
log(f" ✓ {f.name} ({len(text)} символов)")
except Exception as e: # noqa: BLE001
state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}")
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}")
# ---- Persist final artifacts ----
if not config.keep_intermediate and final_stage is not None:
log(f"\n══ Перенос финальных файлов ({final_stage}) ══")
for f in files:
s = state[f]
if s["error"] is not None:
continue
try:
_persist_final(s["work_dir"], s["persist_dir"], final_stage, log)
except Exception as e: # noqa: BLE001
s["error"] = f"persist failed: {e}"
log(f" ✗ {f.name}: {e}")
# --- Stage 2: separate ---
if config.separate.enabled and not cancelled():
todo = active()
log(f"\n══ Этап 2 · Отделение вокала ({len(todo)} файлов) ══")
for i, f in enumerate(todo, 1):
if cancelled():
log("⏹ Отменено")
break
if on_progress:
on_progress(i, len(todo), "separate")
try:
src = state[f]["audio_path"]
target = src.parent / "vocals.wav"
with tempfile.TemporaryDirectory() as tmp:
raw = separate_vocals(
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,
)
shutil.move(str(raw), str(target))
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}")
finally:
shutil.rmtree(tmp_root, ignore_errors=True)
# --- Stage 3: transcribe ---
if config.transcribe.enabled and not cancelled():
todo = active()
log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══")
for i, f in enumerate(todo, 1):
if cancelled():
log("⏹ Отменено")
break
if on_progress:
on_progress(i, len(todo), "transcribe")
try:
text = transcribe(
state[f]["audio_path"],
engine=config.transcribe.engine,
model=config.transcribe.model,
language=config.transcribe.language,
device=config.transcribe.device,
compute_type=config.transcribe.compute_type,
batch_size=config.transcribe.batch_size,
gigaam_provider=config.transcribe.gigaam_provider,
local_files_only=config.transcribe.local_files_only,
)
state[f]["text"] = text
state[f]["source"] = config.transcribe.engine
log(f" ✓ {f.name} ({len(text)} символов)")
except Exception as e: # noqa: BLE001
state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}")
return [
# --- Stage 4: post-ASR lyrics match + persist ---
if not cancelled():
log("\n══ Финализация ══")
for f in files:
s = state[f]
if s["error"] is not None:
continue
if s["resolved"]:
continue # already persisted in identification pass
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:
text = matched
source = f"{source}+lrclib"
s["text"] = text
s["source"] = source
ident = s["ident"]
metadata_dict = _build_metadata_dict(
ident.metadata, ident.shazam, source,
) if ident else None
s["metadata_dict"] = metadata_dict
track_out = output_root / f.stem
track_out.mkdir(parents=True, exist_ok=True)
audio_work = s["audio_path"]
work_dir = audio_work.parent if isinstance(audio_work, Path) else track_out
if text:
(work_dir / "text.txt").write_text(text, encoding="utf-8")
if metadata_dict:
_write_metadata_json(work_dir / "metadata.json", metadata_dict)
_persist_artifacts(
work_dir, track_out, config,
has_text=text is not None,
has_metadata=bool(metadata_dict),
log=log,
)
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}"):
shutil.rmtree(audio_path.parent, ignore_errors=True)
results = [
FileResult(
f,
state[f]["text"],
state[f]["persist_dir"] 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]["source"],
state[f]["metadata_dict"],
)
for f in files
]
_log_summary(results, log)
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)
log("\n══ Итог ══")
for src, n in sorted(counts.items(), key=lambda kv: -kv[1]):
log(f" {src}: {n}")

52
core/recognize.py Normal file
View File

@@ -0,0 +1,52 @@
"""Audio fingerprinting via Shazam (unofficial, no API key needed).
Wraps shazamlite. If Shazam can't identify the track — return None.
Never raises on 'not found', only on missing dependency.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass
class ShazamResult:
title: str
artist: str
album: Optional[str] = None
year: Optional[str] = None
def recognize(audio_wav: Path) -> Optional[ShazamResult]:
"""Run Shazam fingerprinting on a WAV file. Returns None if not found."""
try:
from shazamlite import Shazam
except ImportError as e:
raise RuntimeError(
"shazamlite не установлен. Установите: pip install shazamlite numpy soundfile"
) from e
shazam = Shazam()
try:
track = shazam.recognize(str(audio_wav))
except Exception:
return None
if track is None:
return None
title = getattr(track, "title", None) or getattr(track, "track", None)
artist = getattr(track, "artist", None) or getattr(track, "subtitle", None)
album = getattr(track, "album", None)
year = getattr(track, "year", None) or getattr(track, "released", None)
if not title or not artist:
return None
return ShazamResult(
title=str(title),
artist=str(artist),
album=str(album) if album else None,
year=str(year) if year else None,
)