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
|
# ---------------------------------------------------------------- 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]:
|
def read_embedded_lyrics(audio_path: Path) -> Optional[str]:
|
||||||
"""Return lyrics stored in the file's tags, or None.
|
"""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()
|
suffix = audio_path.suffix.lower()
|
||||||
try:
|
try:
|
||||||
if suffix in (".mp3", ".mp2"):
|
if suffix in (".mp3", ".mp2"):
|
||||||
return _read_mp3(audio_path)
|
text = _read_mp3(audio_path)
|
||||||
if suffix in (".flac", ".ogg", ".opus"):
|
elif suffix in (".flac", ".ogg", ".opus"):
|
||||||
return _read_vorbis(audio_path)
|
text = _read_vorbis(audio_path)
|
||||||
if suffix in (".m4a", ".mp4", ".m4b", ".aac"):
|
elif suffix in (".m4a", ".mp4", ".m4b", ".aac"):
|
||||||
return _read_mp4(audio_path)
|
text = _read_mp4(audio_path)
|
||||||
except Exception: # noqa: BLE001 — never break the pipeline on tag issues
|
else:
|
||||||
|
return None
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
return None
|
return None
|
||||||
return None
|
if text and _is_junk_lyrics(text):
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _read_mp3(path: Path) -> Optional[str]:
|
def _read_mp3(path: Path) -> Optional[str]:
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from .pipeline import (
|
|||||||
FileResult, _artifact_belongs_to, _output_dir_for, _save_unrecognized,
|
FileResult, _artifact_belongs_to, _output_dir_for, _save_unrecognized,
|
||||||
collect_audio_files,
|
collect_audio_files,
|
||||||
)
|
)
|
||||||
from .transcribe import CudaOOMError, transcribe_aligned
|
from .transcribe import (
|
||||||
|
CudaOOMError, align_text_to_audio, transcribe, transcribe_aligned,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
LogFn = Callable[[str], None]
|
LogFn = Callable[[str], None]
|
||||||
@@ -86,7 +88,17 @@ def run_lrc_pipeline(
|
|||||||
log("Аудиофайлы не найдены.")
|
log("Аудиофайлы не найдены.")
|
||||||
return []
|
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'} | "
|
log(f"LRC: {len(files)} файлов | язык={lang or 'auto'} | "
|
||||||
f"источник={config.lrc.audio_source}")
|
f"источник={config.lrc.audio_source}")
|
||||||
|
|
||||||
@@ -108,6 +120,23 @@ def run_lrc_pipeline(
|
|||||||
lrc_path = track_out / "text.lrc"
|
lrc_path = track_out / "text.lrc"
|
||||||
error: Optional[str] = None
|
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:
|
if config.lrc.skip_existing:
|
||||||
ready = _ready_lrc(track_out, f)
|
ready = _ready_lrc(track_out, f)
|
||||||
if ready is not None:
|
if ready is not None:
|
||||||
@@ -121,6 +150,7 @@ def run_lrc_pipeline(
|
|||||||
|
|
||||||
tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_"))
|
tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_"))
|
||||||
try:
|
try:
|
||||||
|
# 0) embedded LRC from tags — бесплатно
|
||||||
embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None
|
embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None
|
||||||
if embedded and is_lrc(embedded):
|
if embedded and is_lrc(embedded):
|
||||||
lrc_path.write_text(embedded, encoding="utf-8")
|
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"))
|
results.append(FileResult(f, None, track_out, None, "embedded+lrc"))
|
||||||
continue
|
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(
|
audio_path = _pick_audio_source(
|
||||||
f, input_dir, output_dir, config, tmp, log,
|
f, input_dir, output_dir, config, tmp, log,
|
||||||
)
|
)
|
||||||
if not audio_path.exists():
|
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(
|
raise FileNotFoundError(
|
||||||
f"Источник аудио не найден: {audio_path}. "
|
f"источник аудио не найден: {audio_path}. "
|
||||||
"Включите conversion/separation в основном пайплайне "
|
f"Источник={config.lrc.audio_source}, "
|
||||||
"или выберите audio_source=input."
|
f"convert.enabled={config.convert.enabled}, "
|
||||||
|
f"separate.enabled={config.separate.enabled}"
|
||||||
)
|
)
|
||||||
log(f" [align] {audio_path.name}")
|
log(f" [align] {audio_path.name}")
|
||||||
|
|
||||||
segments, detected = transcribe_aligned(
|
# 3) готовый текст → forced alignment (быстро, без ASR)
|
||||||
audio_path,
|
if text is not None:
|
||||||
model=config.transcribe.model,
|
log(f" [align-only] text.txt найден ({len(text)} символов)")
|
||||||
language=lang,
|
segments, detected = align_text_to_audio(
|
||||||
device=config.lrc.device,
|
audio_path,
|
||||||
compute_type=config.transcribe.compute_type,
|
text,
|
||||||
batch_size=config.transcribe.batch_size,
|
language=lang,
|
||||||
align_model=config.lrc.align_model,
|
model=config.transcribe.model,
|
||||||
local_files_only=config.transcribe.local_files_only,
|
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)
|
lrc_content = segments_to_lrc(segments)
|
||||||
if not lrc_content.strip():
|
if not lrc_content.strip():
|
||||||
log(f" ⚠ {f.name}: текст не извлечён (пустой результат ASR)")
|
log(f" ⚠ {f.name}: пустой LRC (нет сегментов)")
|
||||||
results.append(FileResult(
|
results.append(FileResult(
|
||||||
f, None, None,
|
f, None, None,
|
||||||
"EmptyResult: ASR returned no text", "lrc+empty",
|
"EmptyResult: alignment returned no text", "lrc+empty",
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
lrc_path.write_text(lrc_content, encoding="utf-8")
|
lrc_path.write_text(lrc_content, encoding="utf-8")
|
||||||
log(f" ✓ text.lrc ({len(segments)} строк, язык: {detected})")
|
log(f" ✓ text.lrc ({len(segments)} строк, язык: {detected})")
|
||||||
|
|
||||||
# NEW: save to JSONL for LRCLIB submission
|
|
||||||
_save_unrecognized(
|
_save_unrecognized(
|
||||||
config, f, lrc_content, source="lrc",
|
config, f, lrc_content, source="lrc",
|
||||||
metadata=read_metadata(f),
|
metadata=read_metadata(f),
|
||||||
|
|||||||
@@ -228,6 +228,16 @@ def _is_track_finished(
|
|||||||
"""True when ``skip_existing`` may skip this output folder entirely."""
|
"""True when ``skip_existing`` may skip this output folder entirely."""
|
||||||
if not belongs:
|
if not belongs:
|
||||||
return False
|
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):
|
if _final_artifact_present(track_out, config):
|
||||||
return True
|
return True
|
||||||
# Identification-only runs still finish with text.txt (no other stage
|
# Identification-only runs still finish with text.txt (no other stage
|
||||||
@@ -879,6 +889,8 @@ def _run_sequential(
|
|||||||
metadata_dict = _build_metadata_dict(
|
metadata_dict = _build_metadata_dict(
|
||||||
ident.metadata, ident.shazam, source, input_path=f,
|
ident.metadata, ident.shazam, source, input_path=f,
|
||||||
)
|
)
|
||||||
|
if metadata_dict.get("empty_text") is None:
|
||||||
|
metadata_dict["empty_text"] = False
|
||||||
if belongs:
|
if belongs:
|
||||||
metadata_dict = _merge_metadata_file(
|
metadata_dict = _merge_metadata_file(
|
||||||
track_out / "metadata.json", metadata_dict,
|
track_out / "metadata.json", metadata_dict,
|
||||||
@@ -1175,6 +1187,9 @@ def _run_staged_batch(
|
|||||||
track_out.mkdir(parents=True, exist_ok=True)
|
track_out.mkdir(parents=True, exist_ok=True)
|
||||||
if ident.text:
|
if ident.text:
|
||||||
(track_out / "text.txt").write_text(ident.text, encoding="utf-8")
|
(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"]:
|
if state[f]["metadata_dict"]:
|
||||||
_write_metadata_json(track_out / "metadata.json", state[f]["metadata_dict"])
|
_write_metadata_json(track_out / "metadata.json", state[f]["metadata_dict"])
|
||||||
_save_unrecognized(
|
_save_unrecognized(
|
||||||
@@ -1434,6 +1449,9 @@ def _run_staged_batch(
|
|||||||
else:
|
else:
|
||||||
if text:
|
if text:
|
||||||
(track_out / "text.txt").write_text(text, encoding="utf-8")
|
(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:
|
if metadata_dict:
|
||||||
_write_metadata_json(track_out / "metadata.json", 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)
|
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:
|
def _clear_model_caches() -> None:
|
||||||
_WhisperXModels._cache.clear()
|
_WhisperXModels._cache.clear()
|
||||||
_AlignModels._cache.clear()
|
_AlignModels._cache.clear()
|
||||||
_GigaAMModels._cache.clear()
|
_GigaAMModels._cache.clear()
|
||||||
|
_StableTSModels._cache.clear()
|
||||||
_REUSE_LOGGED.clear()
|
_REUSE_LOGGED.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -285,7 +375,21 @@ def _transcribe_whisperx(
|
|||||||
result = _model_transcribe_with_retry(
|
result = _model_transcribe_with_retry(
|
||||||
model, audio, language, batch_size, name=audio_path.name,
|
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
|
del audio, result
|
||||||
_free_vram()
|
_free_vram()
|
||||||
return text
|
return text
|
||||||
@@ -333,6 +437,47 @@ def transcribe_aligned(
|
|||||||
print(f" [ALIGN] failed: {e}. Falling back to raw WhisperX segments.")
|
print(f" [ALIGN] failed: {e}. Falling back to raw WhisperX segments.")
|
||||||
segments = result["segments"]
|
segments = result["segments"]
|
||||||
|
|
||||||
|
# Filter out hallucinated segments.
|
||||||
|
segments = [s for s in segments if not _is_hallucination(s.get("text", ""))]
|
||||||
|
|
||||||
del audio, result
|
del audio, result
|
||||||
_free_vram()
|
_free_vram()
|
||||||
return segments, detected
|
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."
|
"Этап генерации LRC выключен. Включите его на вкладке LRC."
|
||||||
)
|
)
|
||||||
return
|
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:
|
if task == "embed" and not cfg.lrc.embed_enabled:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self, "Встраивание LRC отключено",
|
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
|
# ASR
|
||||||
whisperx>=3.1
|
whisperx>=3.1
|
||||||
|
stable-ts>=2.16
|
||||||
onnx-asr>=0.4
|
onnx-asr>=0.4
|
||||||
|
|
||||||
# Recognition & lyrics
|
# Recognition & lyrics
|
||||||
|
|||||||
Reference in New Issue
Block a user