Compare commits
1 Commits
a2e0070ace
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f023653c71 |
47
core/lrc.py
47
core/lrc.py
@@ -70,6 +70,35 @@ def is_lrc(content: str) -> bool:
|
||||
|
||||
# ---------------------------------------------------------------- read embedded
|
||||
|
||||
_JUNK_LYRIC_MARKERS = (
|
||||
"created with",
|
||||
"russian-area.de",
|
||||
"http://", "https://",
|
||||
"www.",
|
||||
"mp3tag",
|
||||
"tag&rename",
|
||||
"foobar2000",
|
||||
"itunes",
|
||||
)
|
||||
|
||||
|
||||
def _is_junk_lyrics(text: str) -> bool:
|
||||
"""Reject tag values that are really URL/signature junk, not lyrics."""
|
||||
if not text or len(text.strip()) < 3:
|
||||
return True
|
||||
low = text.lower()
|
||||
if any(m in low for m in _JUNK_LYRIC_MARKERS):
|
||||
return True
|
||||
# A "lyrics" blob that is just an email or URL — reject.
|
||||
import re
|
||||
stripped = re.sub(r"\s+", "", low)
|
||||
if re.fullmatch(r"[a-z0-9.\-_/]+", stripped) and (
|
||||
"." in stripped or "/" in stripped
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def read_embedded_lyrics(audio_path: Path) -> Optional[str]:
|
||||
"""Return lyrics stored in the file's tags, or None.
|
||||
|
||||
@@ -78,14 +107,18 @@ def read_embedded_lyrics(audio_path: Path) -> Optional[str]:
|
||||
suffix = audio_path.suffix.lower()
|
||||
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
|
||||
text = _read_mp3(audio_path)
|
||||
elif suffix in (".flac", ".ogg", ".opus"):
|
||||
text = _read_vorbis(audio_path)
|
||||
elif suffix in (".m4a", ".mp4", ".m4b", ".aac"):
|
||||
text = _read_mp4(audio_path)
|
||||
else:
|
||||
return None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
return None
|
||||
if text and _is_junk_lyrics(text):
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _read_mp3(path: Path) -> Optional[str]:
|
||||
|
||||
@@ -15,7 +15,9 @@ from .pipeline import (
|
||||
FileResult, _artifact_belongs_to, _output_dir_for, _save_unrecognized,
|
||||
collect_audio_files,
|
||||
)
|
||||
from .transcribe import CudaOOMError, transcribe_aligned
|
||||
from .transcribe import (
|
||||
CudaOOMError, align_text_to_audio, transcribe, transcribe_aligned,
|
||||
)
|
||||
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
@@ -86,7 +88,17 @@ def run_lrc_pipeline(
|
||||
log("Аудиофайлы не найдены.")
|
||||
return []
|
||||
|
||||
lang = config.lrc.language or config.transcribe.language
|
||||
# sanity: если ASR и конвертация/отделение выключены — alignment имеет смысл
|
||||
# только для треков, у которых УЖЕ есть text.txt + vocals/convert.wav.
|
||||
# Иначе почти всё упадёт. Предупредить заранее.
|
||||
if not config.transcribe.enabled and not config.convert.enabled and not config.separate.enabled:
|
||||
log(" ⚠ ASR, конвертация и отделение выключены. "
|
||||
"LRC будет построен только для треков, где уже есть text.txt + vocals.wav "
|
||||
"(или convert.wav). Остальные упадут с ошибкой.")
|
||||
|
||||
# `None` means "auto" — the language will be auto-detected during ASR
|
||||
# (transcribe_aligned) or by the alignment model (align_text_to_audio).
|
||||
lang = config.lrc.language
|
||||
log(f"LRC: {len(files)} файлов | язык={lang or 'auto'} | "
|
||||
f"источник={config.lrc.audio_source}")
|
||||
|
||||
@@ -108,6 +120,23 @@ def run_lrc_pipeline(
|
||||
lrc_path = track_out / "text.lrc"
|
||||
error: Optional[str] = None
|
||||
|
||||
if config.lrc.skip_existing:
|
||||
ready = _ready_lrc(track_out, f)
|
||||
if ready is not None:
|
||||
log(" ⊘ text.lrc уже готов — пропуск")
|
||||
results.append(FileResult(f, None, track_out, None, "existing+lrc"))
|
||||
if on_progress:
|
||||
on_progress("lrc", idx, total)
|
||||
if on_overall_progress:
|
||||
on_overall_progress(idx, total)
|
||||
continue
|
||||
|
||||
track_out = _output_dir_for(f, input_dir, output_dir)
|
||||
track_out.mkdir(parents=True, exist_ok=True)
|
||||
lrc_path = track_out / "text.lrc"
|
||||
text_file = track_out / "text.txt"
|
||||
error: Optional[str] = None
|
||||
|
||||
if config.lrc.skip_existing:
|
||||
ready = _ready_lrc(track_out, f)
|
||||
if ready is not None:
|
||||
@@ -121,6 +150,7 @@ def run_lrc_pipeline(
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_"))
|
||||
try:
|
||||
# 0) embedded LRC from tags — бесплатно
|
||||
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")
|
||||
@@ -128,40 +158,87 @@ def run_lrc_pipeline(
|
||||
results.append(FileResult(f, None, track_out, None, "embedded+lrc"))
|
||||
continue
|
||||
|
||||
# 1) готовый текст из text.txt
|
||||
text: Optional[str] = None
|
||||
if text_file.is_file():
|
||||
try:
|
||||
text = text_file.read_text(encoding="utf-8").strip()
|
||||
except OSError as e:
|
||||
raise RuntimeError(f"не читается text.txt: {e}") from e
|
||||
if not text:
|
||||
text = None
|
||||
|
||||
# 1a) text.txt уже LRC → писать его как есть, alignment не нужен
|
||||
if text is not None and is_lrc(text):
|
||||
lrc_path.write_text(text, encoding="utf-8")
|
||||
log(" ✓ text.lrc (text.txt уже содержит LRC-таймкоды)")
|
||||
results.append(FileResult(f, None, track_out, None, "text_already_lrc"))
|
||||
continue
|
||||
|
||||
# 2) аудио для alignment
|
||||
audio_path = _pick_audio_source(
|
||||
f, input_dir, output_dir, config, tmp, log,
|
||||
)
|
||||
if not audio_path.exists():
|
||||
# Если convert/separate выключены и vocals/convert.wav отсутствуют —
|
||||
# понятная ошибка вместо «файл не найден»
|
||||
if not config.convert.enabled and not config.separate.enabled:
|
||||
raise FileNotFoundError(
|
||||
f"для alignment нет аудио: {audio_path}. "
|
||||
"Включите конвертацию/отделение в основном пайплайне, "
|
||||
"либо запустите их один раз, либо положите vocals.wav/convert.wav."
|
||||
)
|
||||
raise FileNotFoundError(
|
||||
f"Источник аудио не найден: {audio_path}. "
|
||||
"Включите conversion/separation в основном пайплайне "
|
||||
"или выберите audio_source=input."
|
||||
f"источник аудио не найден: {audio_path}. "
|
||||
f"Источник={config.lrc.audio_source}, "
|
||||
f"convert.enabled={config.convert.enabled}, "
|
||||
f"separate.enabled={config.separate.enabled}"
|
||||
)
|
||||
log(f" [align] {audio_path.name}")
|
||||
|
||||
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,
|
||||
)
|
||||
# 3) готовый текст → forced alignment (быстро, без ASR)
|
||||
if text is not None:
|
||||
log(f" [align-only] text.txt найден ({len(text)} символов)")
|
||||
segments, detected = align_text_to_audio(
|
||||
audio_path,
|
||||
text,
|
||||
language=lang,
|
||||
model=config.transcribe.model,
|
||||
device=config.lrc.device,
|
||||
compute_type=config.transcribe.compute_type,
|
||||
local_files_only=config.transcribe.local_files_only,
|
||||
)
|
||||
else:
|
||||
# 3a) нет text.txt — ASR разрешён только если transcribe.enabled
|
||||
if not config.transcribe.enabled:
|
||||
raise FileNotFoundError(
|
||||
"text.txt отсутствует, а транскрибация выключена. "
|
||||
"Включите транскрибацию в основном пайплайне или "
|
||||
"положите text.txt в папку трека вручную."
|
||||
)
|
||||
log(" [asr+align] text.txt нет — выполняю ASR заново")
|
||||
segments, detected = transcribe_aligned(
|
||||
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)
|
||||
if not lrc_content.strip():
|
||||
log(f" ⚠ {f.name}: текст не извлечён (пустой результат ASR)")
|
||||
log(f" ⚠ {f.name}: пустой LRC (нет сегментов)")
|
||||
results.append(FileResult(
|
||||
f, None, None,
|
||||
"EmptyResult: ASR returned no text", "lrc+empty",
|
||||
"EmptyResult: alignment returned no text", "lrc+empty",
|
||||
))
|
||||
continue
|
||||
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),
|
||||
|
||||
@@ -228,6 +228,16 @@ def _is_track_finished(
|
||||
"""True when ``skip_existing`` may skip this output folder entirely."""
|
||||
if not belongs:
|
||||
return False
|
||||
# If metadata says this track had an empty ASR result, treat it as
|
||||
# "finished" so skip_existing does not keep re‑running it.
|
||||
meta = track_out / "metadata.json"
|
||||
if meta.is_file():
|
||||
try:
|
||||
data = json.loads(meta.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and data.get("empty_text"):
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if _final_artifact_present(track_out, config):
|
||||
return True
|
||||
# Identification-only runs still finish with text.txt (no other stage
|
||||
@@ -879,6 +889,8 @@ def _run_sequential(
|
||||
metadata_dict = _build_metadata_dict(
|
||||
ident.metadata, ident.shazam, source, input_path=f,
|
||||
)
|
||||
if metadata_dict.get("empty_text") is None:
|
||||
metadata_dict["empty_text"] = False
|
||||
if belongs:
|
||||
metadata_dict = _merge_metadata_file(
|
||||
track_out / "metadata.json", metadata_dict,
|
||||
@@ -1175,6 +1187,9 @@ def _run_staged_batch(
|
||||
track_out.mkdir(parents=True, exist_ok=True)
|
||||
if ident.text:
|
||||
(track_out / "text.txt").write_text(ident.text, encoding="utf-8")
|
||||
md = state[f].setdefault("metadata_dict", {})
|
||||
if md.get("empty_text") is None:
|
||||
md["empty_text"] = False
|
||||
if state[f]["metadata_dict"]:
|
||||
_write_metadata_json(track_out / "metadata.json", state[f]["metadata_dict"])
|
||||
_save_unrecognized(
|
||||
@@ -1434,6 +1449,9 @@ def _run_staged_batch(
|
||||
else:
|
||||
if text:
|
||||
(track_out / "text.txt").write_text(text, encoding="utf-8")
|
||||
md = metadata_dict.setdefault("empty_text", False)
|
||||
else:
|
||||
md = metadata_dict.setdefault("empty_text", True)
|
||||
if metadata_dict:
|
||||
_write_metadata_json(track_out / "metadata.json", metadata_dict)
|
||||
|
||||
|
||||
@@ -35,10 +35,100 @@ def _is_dead_cuda(exc: BaseException) -> bool:
|
||||
return any(m in err for m in _DEAD_CUDA_MARKERS)
|
||||
|
||||
|
||||
_WhisperXModels._cache.clear()
|
||||
_AlignModels._cache.clear()
|
||||
_GigaAMModels._cache.clear()
|
||||
_StableTSModels._cache.clear()
|
||||
_REUSE_LOGGED.clear()
|
||||
|
||||
|
||||
# Phrases that are strong signals of ASR hallucination (end credits)
|
||||
_HALLUCINATION_PHRASES = (
|
||||
"продолжение следует",
|
||||
"спасибо за внимание",
|
||||
"спасибо за просмотр",
|
||||
"спасибо за подписку",
|
||||
"подписывайтесь на канал",
|
||||
"ставьте лайки",
|
||||
"не забудьте подписаться",
|
||||
"субтитры создавал",
|
||||
"субтитры сделал",
|
||||
"субтитры подготовил",
|
||||
"редактор субтитров",
|
||||
"корректор",
|
||||
"thank you for watching",
|
||||
"thanks for watching",
|
||||
"please subscribe",
|
||||
"subscribe to my channel",
|
||||
"like and subscribe",
|
||||
)
|
||||
|
||||
|
||||
def _is_hallucination(text: str) -> bool:
|
||||
"""Return True if the text looks like an ASR hallucination.
|
||||
|
||||
Checks:
|
||||
- known call-to-action / promo phrases;
|
||||
- repeated-word tic ("Музыка Музыка").
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
t = " ".join(text.lower().split()).strip(" .!?…,;:\"'«»")
|
||||
for phrase in _HALLUCINATION_PHRASES:
|
||||
if phrase in t:
|
||||
return True
|
||||
words = t.split()
|
||||
if len(words) == 2 and words[0] == words[1]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detect_language(model_obj, audio_or_path, fallback: str = "ru") -> str:
|
||||
"""Detect the spoken language from audio using the ASR model itself.
|
||||
|
||||
Returns a lowercase ISO code or `fallback` on failure.
|
||||
"""
|
||||
try:
|
||||
lang, _prob = model_obj.detect_language(audio_or_path)
|
||||
if lang:
|
||||
return lang.lower()
|
||||
except Exception:
|
||||
pass
|
||||
return fallback
|
||||
|
||||
|
||||
class _StableTSModels:
|
||||
_cache: dict = {}
|
||||
|
||||
@classmethod
|
||||
def get(cls, model_name: str, device: str, compute_type: Optional[str],
|
||||
local_files_only: bool = False) -> Any:
|
||||
if compute_type is None:
|
||||
compute_type = "int8_float16" if device == "cuda" else "int8"
|
||||
key = (model_name, device, compute_type, local_files_only)
|
||||
with _MODEL_LOCK:
|
||||
if key not in cls._cache:
|
||||
import stable_whisper
|
||||
|
||||
print(f" [ALIGN] loading stable-ts {model_name} on {device} "
|
||||
f"({compute_type}, local_files_only={local_files_only})...")
|
||||
|
||||
def load() -> 'faster_whisper.WhisperModel':
|
||||
return stable_whisper.load_faster_whisper(
|
||||
model_name, device=device, compute_type=compute_type,
|
||||
local_files_only=local_files_only,
|
||||
)
|
||||
|
||||
cls._cache[key] = _retry(load)
|
||||
print(" [ALIGN] stable-ts model loaded once — reuse.")
|
||||
return cls._cache[key]
|
||||
|
||||
|
||||
def _clear_model_caches() -> None:
|
||||
_WhisperXModels._cache.clear()
|
||||
_AlignModels._cache.clear()
|
||||
_GigaAMModels._cache.clear()
|
||||
_StableTSModels._cache.clear()
|
||||
_REUSE_LOGGED.clear()
|
||||
|
||||
|
||||
@@ -285,7 +375,21 @@ def _transcribe_whisperx(
|
||||
result = _model_transcribe_with_retry(
|
||||
model, audio, language, batch_size, name=audio_path.name,
|
||||
)
|
||||
text = " ".join(seg["text"].strip() for seg in result["segments"]).strip()
|
||||
|
||||
# Filter out hallucinated / spammy segments.
|
||||
segments = result.get("segments") or []
|
||||
kept = []
|
||||
for seg in segments:
|
||||
t = (seg.get("text") or "").strip()
|
||||
if not t or _is_hallucination(t):
|
||||
continue
|
||||
if seg.get("avg_logprob", 0.0) < -1.2:
|
||||
continue
|
||||
kept.append(seg)
|
||||
text = " ".join(s["text"].strip() for s in kept).strip()
|
||||
if _is_hallucination(text):
|
||||
text = ""
|
||||
|
||||
del audio, result
|
||||
_free_vram()
|
||||
return text
|
||||
@@ -333,6 +437,47 @@ def transcribe_aligned(
|
||||
print(f" [ALIGN] failed: {e}. Falling back to raw WhisperX segments.")
|
||||
segments = result["segments"]
|
||||
|
||||
# Filter out hallucinated segments.
|
||||
segments = [s for s in segments if not _is_hallucination(s.get("text", ""))]
|
||||
|
||||
del audio, result
|
||||
_free_vram()
|
||||
return segments, detected
|
||||
|
||||
|
||||
def align_text_to_audio(
|
||||
audio_path: Path,
|
||||
text: str,
|
||||
language: Optional[str] = "ru",
|
||||
model: str = "large-v3",
|
||||
device: str = "auto",
|
||||
compute_type: Optional[str] = None,
|
||||
local_files_only: bool = False,
|
||||
) -> tuple[list[dict], str]:
|
||||
"""Forced alignment of EXISTING text to audio. No ASR.
|
||||
|
||||
When `language` is None, the language is auto-detected from the audio
|
||||
using the alignment model itself (which is a faster-whisper model and
|
||||
exposes `detect_language`).
|
||||
"""
|
||||
import stable_whisper as _sw
|
||||
device = _resolve_device(device)
|
||||
model_obj = _StableTSModels.get(model, device, compute_type, local_files_only)
|
||||
|
||||
if language:
|
||||
lang = language
|
||||
else:
|
||||
lang = _detect_language(model_obj, str(audio_path), fallback="ru")
|
||||
|
||||
try:
|
||||
result = model_obj.align(str(audio_path), text, language=lang)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise RuntimeError(f"align failed on {audio_path.name}: {e}") from e
|
||||
|
||||
segments = [
|
||||
{"start": float(seg.start), "end": float(seg.end),
|
||||
"text": (seg.text or "").strip()}
|
||||
for seg in result.segments
|
||||
if (seg.text or "").strip()
|
||||
]
|
||||
return segments, lang
|
||||
|
||||
@@ -728,6 +728,12 @@ class MainWindow(QMainWindow):
|
||||
"Этап генерации LRC выключен. Включите его на вкладке LRC."
|
||||
)
|
||||
return
|
||||
if task == "lrc" and not cfg.transcribe.enabled:
|
||||
QMessageBox.information(
|
||||
self, "Транскрибация выключена",
|
||||
"Транскрибация отключена, ASR запускаться не будет."
|
||||
"LRC будет построен только по готовым text.txt."
|
||||
)
|
||||
if task == "embed" and not cfg.lrc.embed_enabled:
|
||||
QMessageBox.warning(
|
||||
self, "Встраивание LRC отключено",
|
||||
|
||||
40
mark_no_speech.py
Normal file
40
mark_no_speech.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mark tracks with metadata.empty_text=true if their text is missing or hallucinated."""
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
|
||||
HALLUCINATION = (
|
||||
"продолжение следует", "субтитры сделан", "субтитры создава",
|
||||
"created with", "russian-area.de", "музыка музыка",
|
||||
)
|
||||
|
||||
|
||||
def main(root: Path) -> int:
|
||||
count = 0
|
||||
for meta in root.rglob("metadata.json"):
|
||||
track = meta.parent
|
||||
txt = track / "text.txt"
|
||||
if txt.is_file():
|
||||
body = txt.read_text(encoding="utf-8", errors="ignore").strip().lower()
|
||||
# If the text is real (not hallucination) and not empty, skip marking.
|
||||
if body and not any(h in body for h in HALLUCINATION):
|
||||
continue
|
||||
# Remove the text.txt so the next run will re-process it.
|
||||
txt.unlink()
|
||||
# Set empty_text=True in metadata.
|
||||
try:
|
||||
data = json.loads(meta.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
data = {}
|
||||
data["empty_text"] = True
|
||||
meta.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
print(f"marked: {track}")
|
||||
count += 1
|
||||
print(f"\nmarked {count} tracks")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
|
||||
@@ -3,6 +3,7 @@ PySide6>=6.6
|
||||
|
||||
# ASR
|
||||
whisperx>=3.1
|
||||
stable-ts>=2.16
|
||||
onnx-asr>=0.4
|
||||
|
||||
# Recognition & lyrics
|
||||
|
||||
Reference in New Issue
Block a user