Compare commits

...

1 Commits

Author SHA1 Message Date
jadssss
f023653c71 align LRC from ready text.txt via stable-ts and auto-detect language
- add align_text_to_audio(): forced alignment without ASR, fast
- add _StableTSModels cache; detect language via stable-ts when language=None
- LRC stage: use existing text.txt, copy it if already LRC, fall back to ASR only
- filter junk lyrics from tags and ASR hallucinations (promo phrases, tics)
- mark empty ASR results with metadata.empty_text and skip them on resume
- warn in GUI when LRC is run with transcription disabled
- add mark_no_speech.py one-shot script for existing tracks
2026-11-02 01:30:30 +03:00
7 changed files with 346 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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 ".")))

View File

@@ -3,6 +3,7 @@ PySide6>=6.6
# ASR
whisperx>=3.1
stable-ts>=2.16
onnx-asr>=0.4
# Recognition & lyrics