From f023653c7176eecb673ea87bbaab7367a5d48de2 Mon Sep 17 00:00:00 2001 From: jadssss Date: Mon, 2 Nov 2026 01:30:30 +0300 Subject: [PATCH] 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 --- core/lrc.py | 47 +++++++++++--- core/lrc_pipeline.py | 113 +++++++++++++++++++++++++++------ core/pipeline.py | 18 ++++++ core/transcribe.py | 147 ++++++++++++++++++++++++++++++++++++++++++- gui/main_window.py | 6 ++ mark_no_speech.py | 40 ++++++++++++ requirements.txt | 1 + 7 files changed, 346 insertions(+), 26 deletions(-) create mode 100644 mark_no_speech.py diff --git a/core/lrc.py b/core/lrc.py index 29c3a45..44874a4 100644 --- a/core/lrc.py +++ b/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]: diff --git a/core/lrc_pipeline.py b/core/lrc_pipeline.py index 12de3aa..41f93b4 100644 --- a/core/lrc_pipeline.py +++ b/core/lrc_pipeline.py @@ -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), diff --git a/core/pipeline.py b/core/pipeline.py index f1385ef..448417f 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -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) diff --git a/core/transcribe.py b/core/transcribe.py index 17381bc..c918ab7 100644 --- a/core/transcribe.py +++ b/core/transcribe.py @@ -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 diff --git a/gui/main_window.py b/gui/main_window.py index a8cb214..bc0258a 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -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 отключено", diff --git a/mark_no_speech.py b/mark_no_speech.py new file mode 100644 index 0000000..968dec5 --- /dev/null +++ b/mark_no_speech.py @@ -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 "."))) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3630693..4d57bae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ PySide6>=6.6 # ASR whisperx>=3.1 +stable-ts>=2.16 onnx-asr>=0.4 # Recognition & lyrics