Compare commits
14 Commits
1e8e5e4433
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f023653c71 | ||
|
|
a2e0070ace | ||
|
|
2ac7dfd7b7 | ||
|
|
841fc8902d | ||
|
|
60846e3c6c | ||
|
|
e23acf1555 | ||
|
|
5f16a6f87b | ||
|
|
84d7a28c69 | ||
|
|
f88ca913ce | ||
|
|
999934f174 | ||
|
|
15c0ba5d66 | ||
|
|
199d12a274 | ||
|
|
fe338e6880 | ||
|
|
78636699dc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,3 +14,4 @@ output/
|
|||||||
|
|
||||||
preset.json
|
preset.json
|
||||||
*.log
|
*.log
|
||||||
|
combined_files.txt
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ConvertConfig:
|
|||||||
sample_rate: int = 44100
|
sample_rate: int = 44100
|
||||||
channels: int = 2
|
channels: int = 2
|
||||||
ffmpeg_path: str = "ffmpeg"
|
ffmpeg_path: str = "ffmpeg"
|
||||||
|
convert_concurrency: int = 4
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -25,6 +26,7 @@ class SeparateConfig:
|
|||||||
isolate_stem: str = "vocals"
|
isolate_stem: str = "vocals"
|
||||||
unblend_path: str = "unblend"
|
unblend_path: str = "unblend"
|
||||||
custom_kernels: bool = False
|
custom_kernels: bool = False
|
||||||
|
save_vocals_copy: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -43,6 +45,7 @@ class TranscribeConfig:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class ShazamConfig:
|
class ShazamConfig:
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
identify_concurrency: int = 4
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -53,8 +56,24 @@ class LyricsConfig:
|
|||||||
use_tags_first: bool = True
|
use_tags_first: bool = True
|
||||||
use_shazam_result: bool = True
|
use_shazam_result: bool = True
|
||||||
try_after_asr: bool = True
|
try_after_asr: bool = True
|
||||||
fuzzy_threshold: float = 0.6 # difflib ratio, 0..1
|
fuzzy_threshold: float = 0.6
|
||||||
transcribe_if_identified_no_lyrics: bool = True
|
transcribe_if_identified_no_lyrics: bool = True
|
||||||
|
# NEW:
|
||||||
|
use_embedded_lyrics: bool = True # read lyrics already stored in file tags first
|
||||||
|
save_unrecognized: bool = False # write unresolved texts to JSONL for LRCLIB submission
|
||||||
|
unrecognized_path: str = "" # "" = <output>/unrecognized_lyrics.jsonl
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LrcConfig:
|
||||||
|
enabled: bool = True # LRC generation stage
|
||||||
|
embed_enabled: bool = True # LRC embedding stage
|
||||||
|
skip_existing: bool = True # skip tracks with a ready text.lrc
|
||||||
|
language: Optional[str] = "ru"
|
||||||
|
align_model: Optional[str] = None
|
||||||
|
device: str = "auto"
|
||||||
|
audio_source: str = "auto"
|
||||||
|
embed_in_place: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -65,12 +84,16 @@ class PipelineConfig:
|
|||||||
keep_intermediate: bool = True
|
keep_intermediate: bool = True
|
||||||
overwrite: bool = True
|
overwrite: bool = True
|
||||||
mode: str = "sequential"
|
mode: str = "sequential"
|
||||||
|
chunk_size: int = 64
|
||||||
|
skip_existing: bool = False # skip files that already have text.txt
|
||||||
|
resume_partial: bool = True # reuse existing vocals.wav / convert.wav
|
||||||
|
|
||||||
convert: ConvertConfig = field(default_factory=ConvertConfig)
|
convert: ConvertConfig = field(default_factory=ConvertConfig)
|
||||||
separate: SeparateConfig = field(default_factory=SeparateConfig)
|
separate: SeparateConfig = field(default_factory=SeparateConfig)
|
||||||
transcribe: TranscribeConfig = field(default_factory=TranscribeConfig)
|
transcribe: TranscribeConfig = field(default_factory=TranscribeConfig)
|
||||||
shazam: ShazamConfig = field(default_factory=ShazamConfig)
|
shazam: ShazamConfig = field(default_factory=ShazamConfig)
|
||||||
lyrics: LyricsConfig = field(default_factory=LyricsConfig)
|
lyrics: LyricsConfig = field(default_factory=LyricsConfig)
|
||||||
|
lrc: LrcConfig = field(default_factory=LrcConfig)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
|
|||||||
232
core/lrc.py
Normal file
232
core/lrc.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""LRC generation, parsing, embedding, and reading embedded lyrics.
|
||||||
|
|
||||||
|
Reading embedded lyrics checks all tag locations for a given audio format:
|
||||||
|
- MP3 -> ID3 USLT / SYLT
|
||||||
|
- FLAC/OGG -> LYRICS, UNSYNCEDLYRICS (Vorbis comments)
|
||||||
|
- M4A/MP4 -> `©lyr` atom
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
_LRC_LINE_RE = re.compile(r"^\s*\[\d{1,3}:\d{2}(?:\.\d{1,3})?\]")
|
||||||
|
_LRC_PARSE_RE = re.compile(r"\[(\d+):(\d+)\.(\d+)\](.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LrcLine:
|
||||||
|
start: float
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
def format_timestamp(seconds: float) -> str:
|
||||||
|
if seconds < 0:
|
||||||
|
seconds = 0.0
|
||||||
|
minutes = int(seconds // 60)
|
||||||
|
secs = seconds - minutes * 60
|
||||||
|
return f"[{minutes:02d}:{secs:05.2f}]"
|
||||||
|
|
||||||
|
|
||||||
|
def segments_to_lrc(segments: list[dict]) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
for seg in segments:
|
||||||
|
text = (seg.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
# WhisperX can return multi-line text on long segments. An LRC line
|
||||||
|
# must be a single line: any newline would produce a broken line
|
||||||
|
# without a timestamp. Collapse all whitespace into single spaces.
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
start = float(seg.get("start", 0.0))
|
||||||
|
lines.append(f"{format_timestamp(start)}{text}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lrc(content: str) -> list[LrcLine]:
|
||||||
|
out: list[LrcLine] = []
|
||||||
|
for line in content.splitlines():
|
||||||
|
m = _LRC_PARSE_RE.match(line.strip())
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
mm, ss, frac, text = m.groups()
|
||||||
|
frac_sec = float(f"0.{frac}") if frac else 0.0
|
||||||
|
out.append(LrcLine(start=int(mm) * 60 + int(ss) + frac_sec, text=text.strip()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def is_lrc(content: str) -> bool:
|
||||||
|
"""True if content contains at least one LRC-style timestamp line."""
|
||||||
|
if not content:
|
||||||
|
return False
|
||||||
|
for line in content.splitlines():
|
||||||
|
if _LRC_LINE_RE.match(line):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 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.
|
||||||
|
|
||||||
|
Order: prefer synced (LRC-like) -> plain. Never raises.
|
||||||
|
"""
|
||||||
|
suffix = audio_path.suffix.lower()
|
||||||
|
try:
|
||||||
|
if suffix in (".mp3", ".mp2"):
|
||||||
|
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
|
||||||
|
if text and _is_junk_lyrics(text):
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mp3(path: Path) -> Optional[str]:
|
||||||
|
from mutagen.id3 import ID3, ID3NoHeaderError
|
||||||
|
try:
|
||||||
|
tags = ID3(str(path))
|
||||||
|
except ID3NoHeaderError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# SYLT = synced lyrics (rare); USLT = unsynced (common, may contain LRC text)
|
||||||
|
sylt = tags.getall("SYLT")
|
||||||
|
for tag in sylt:
|
||||||
|
if tag.text:
|
||||||
|
try:
|
||||||
|
content = "\n".join(
|
||||||
|
f"{format_timestamp(ms / 1000.0)}{txt}"
|
||||||
|
for txt, ms in tag.text
|
||||||
|
)
|
||||||
|
if content.strip():
|
||||||
|
return content
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
|
||||||
|
uslt = tags.getall("USLT")
|
||||||
|
for tag in uslt:
|
||||||
|
if tag.text and str(tag.text).strip():
|
||||||
|
return str(tag.text)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_vorbis(path: Path) -> Optional[str]:
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix == ".flac":
|
||||||
|
from mutagen.flac import FLAC as Audio
|
||||||
|
elif suffix == ".ogg":
|
||||||
|
from mutagen.oggvorbis import OggVorbis as Audio
|
||||||
|
else:
|
||||||
|
from mutagen.oggopus import OggOpus as Audio
|
||||||
|
audio = Audio(str(path))
|
||||||
|
for key in ("LYRICS", "UNSYNCEDLYRICS", "LYRIC", "SYNCEDLYRICS"):
|
||||||
|
if key in audio:
|
||||||
|
values = audio[key]
|
||||||
|
if isinstance(values, list) and values:
|
||||||
|
text = str(values[0])
|
||||||
|
else:
|
||||||
|
text = str(values)
|
||||||
|
if text.strip():
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mp4(path: Path) -> Optional[str]:
|
||||||
|
from mutagen.mp4 import MP4
|
||||||
|
audio = MP4(str(path))
|
||||||
|
for key in ("\xa9lyr", "©lyr"):
|
||||||
|
if key in audio:
|
||||||
|
values = audio[key]
|
||||||
|
if isinstance(values, list) and values:
|
||||||
|
text = str(values[0])
|
||||||
|
else:
|
||||||
|
text = str(values)
|
||||||
|
if text.strip():
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- embedding
|
||||||
|
|
||||||
|
def embed_lrc(audio_path: Path, lrc_content: str) -> None:
|
||||||
|
suffix = audio_path.suffix.lower()
|
||||||
|
if suffix in (".mp3", ".mp2"):
|
||||||
|
_embed_mp3(audio_path, lrc_content)
|
||||||
|
elif suffix in (".flac", ".ogg", ".opus"):
|
||||||
|
_embed_vorbis(audio_path, lrc_content)
|
||||||
|
elif suffix in (".m4a", ".mp4", ".m4b", ".aac"):
|
||||||
|
_embed_mp4(audio_path, lrc_content)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported format for LRC embedding: {suffix}")
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_mp3(path: Path, lrc_content: str) -> None:
|
||||||
|
from mutagen.id3 import ID3, USLT, ID3NoHeaderError
|
||||||
|
try:
|
||||||
|
tags = ID3(str(path))
|
||||||
|
except ID3NoHeaderError:
|
||||||
|
tags = ID3()
|
||||||
|
tags.delall("USLT")
|
||||||
|
tags.add(USLT(encoding=3, lang="rus", desc="", text=lrc_content))
|
||||||
|
tags.save(str(path), v2_version=3)
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_vorbis(path: Path, lrc_content: str) -> None:
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix == ".flac":
|
||||||
|
from mutagen.flac import FLAC as Audio
|
||||||
|
elif suffix == ".ogg":
|
||||||
|
from mutagen.oggvorbis import OggVorbis as Audio
|
||||||
|
else:
|
||||||
|
from mutagen.oggopus import OggOpus as Audio
|
||||||
|
audio = Audio(str(path))
|
||||||
|
audio["LYRICS"] = lrc_content
|
||||||
|
audio["UNSYNCEDLYRICS"] = lrc_content
|
||||||
|
audio.save()
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_mp4(path: Path, lrc_content: str) -> None:
|
||||||
|
from mutagen.mp4 import MP4
|
||||||
|
audio = MP4(str(path))
|
||||||
|
audio["\xa9lyr"] = [lrc_content]
|
||||||
|
audio.save()
|
||||||
335
core/lrc_pipeline.py
Normal file
335
core/lrc_pipeline.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
"""LRC-generation and LRC-embedding pipelines."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from .config import PipelineConfig
|
||||||
|
from .convert import convert_to_wav
|
||||||
|
from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc
|
||||||
|
from .metadata import read_metadata
|
||||||
|
from .pipeline import (
|
||||||
|
FileResult, _artifact_belongs_to, _output_dir_for, _save_unrecognized,
|
||||||
|
collect_audio_files,
|
||||||
|
)
|
||||||
|
from .transcribe import (
|
||||||
|
CudaOOMError, align_text_to_audio, transcribe, transcribe_aligned,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LogFn = Callable[[str], None]
|
||||||
|
ProgressFn = Callable[[str, int, int], None] # stage, current, total
|
||||||
|
OverallFn = Callable[[int, int], None] # completed_files, total_files
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_audio_source(
|
||||||
|
f: Path, input_root: Path, output_dir: Path, config: PipelineConfig,
|
||||||
|
work_dir: Path, log: LogFn,
|
||||||
|
) -> Path:
|
||||||
|
mode = (config.lrc.audio_source or "auto").lower()
|
||||||
|
track_out = _output_dir_for(f, input_root, output_dir)
|
||||||
|
vocals = track_out / "vocals.wav"
|
||||||
|
convert = track_out / "convert.wav"
|
||||||
|
if mode == "vocals":
|
||||||
|
return vocals
|
||||||
|
if mode == "convert":
|
||||||
|
return convert
|
||||||
|
if mode == "input":
|
||||||
|
return f
|
||||||
|
if vocals.exists():
|
||||||
|
return vocals
|
||||||
|
if convert.exists():
|
||||||
|
return convert
|
||||||
|
target = work_dir / "input_16k.wav"
|
||||||
|
log(f" [convert] {f.name} -> 16k mono WAV (для align)")
|
||||||
|
convert_to_wav(
|
||||||
|
f, target, sample_rate=16000, channels=1,
|
||||||
|
ffmpeg_path=config.convert.ffmpeg_path,
|
||||||
|
)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _ready_lrc(track_out: Path, source: Path) -> Optional[str]:
|
||||||
|
"""Return existing valid LRC text for this exact source track, if any."""
|
||||||
|
if not _artifact_belongs_to(track_out, source):
|
||||||
|
return None
|
||||||
|
lrc_path = track_out / "text.lrc"
|
||||||
|
if not lrc_path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
content = lrc_path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return content if content.strip() and is_lrc(content) else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_lrc_pipeline(
|
||||||
|
config: PipelineConfig,
|
||||||
|
log: LogFn = print,
|
||||||
|
on_progress: Optional[ProgressFn] = None,
|
||||||
|
stop_event: Optional[threading.Event] = None,
|
||||||
|
on_overall_progress: Optional[OverallFn] = None,
|
||||||
|
) -> list[FileResult]:
|
||||||
|
input_dir = Path(config.input_dir)
|
||||||
|
if not input_dir.is_dir():
|
||||||
|
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||||
|
output_dir = Path(config.output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not config.lrc.enabled:
|
||||||
|
log("LRC: генерация отключена — пропуск этапа")
|
||||||
|
return []
|
||||||
|
|
||||||
|
files = collect_audio_files(input_dir, config.recursive)
|
||||||
|
if not files:
|
||||||
|
log("Аудиофайлы не найдены.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
|
||||||
|
results: list[FileResult] = []
|
||||||
|
total = len(files)
|
||||||
|
if on_progress:
|
||||||
|
on_progress("lrc", 0, total)
|
||||||
|
if on_overall_progress:
|
||||||
|
on_overall_progress(0, total)
|
||||||
|
|
||||||
|
for idx, f in enumerate(files, 1):
|
||||||
|
if stop_event is not None and stop_event.is_set():
|
||||||
|
log("⏹ Отменено")
|
||||||
|
break
|
||||||
|
log(f"\n▶ [{idx}/{total}] {f.name}")
|
||||||
|
|
||||||
|
track_out = _output_dir_for(f, input_dir, output_dir)
|
||||||
|
track_out.mkdir(parents=True, exist_ok=True)
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
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")
|
||||||
|
log(" ✓ text.lrc (взят из тегов аудио)")
|
||||||
|
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}. "
|
||||||
|
f"Источник={config.lrc.audio_source}, "
|
||||||
|
f"convert.enabled={config.convert.enabled}, "
|
||||||
|
f"separate.enabled={config.separate.enabled}"
|
||||||
|
)
|
||||||
|
log(f" [align] {audio_path.name}")
|
||||||
|
|
||||||
|
# 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}: пустой LRC (нет сегментов)")
|
||||||
|
results.append(FileResult(
|
||||||
|
f, None, None,
|
||||||
|
"EmptyResult: alignment returned no text", "lrc+empty",
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
lrc_path.write_text(lrc_content, encoding="utf-8")
|
||||||
|
log(f" ✓ text.lrc ({len(segments)} строк, язык: {detected})")
|
||||||
|
|
||||||
|
_save_unrecognized(
|
||||||
|
config, f, lrc_content, source="lrc",
|
||||||
|
metadata=read_metadata(f),
|
||||||
|
)
|
||||||
|
results.append(FileResult(f, None, track_out, None, "lrc"))
|
||||||
|
except CudaOOMError as e:
|
||||||
|
error = f"CudaOOMError: {e}"
|
||||||
|
log(f" ⊘ {f.name}: OOM — пропущен, идём дальше")
|
||||||
|
results.append(FileResult(f, None, None, error, "lrc+oom"))
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
error = f"{type(e).__name__}: {e}"
|
||||||
|
log(f" ✗ Ошибка: {error}")
|
||||||
|
results.append(FileResult(f, None, None, error, "lrc"))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
if on_progress:
|
||||||
|
on_progress("lrc", idx, total)
|
||||||
|
if on_overall_progress:
|
||||||
|
on_overall_progress(idx, total)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_embed_pipeline(
|
||||||
|
config: PipelineConfig,
|
||||||
|
log: LogFn = print,
|
||||||
|
on_progress: Optional[ProgressFn] = None,
|
||||||
|
stop_event: Optional[threading.Event] = None,
|
||||||
|
on_overall_progress: Optional[OverallFn] = None,
|
||||||
|
) -> list[FileResult]:
|
||||||
|
input_dir = Path(config.input_dir)
|
||||||
|
if not input_dir.is_dir():
|
||||||
|
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||||
|
output_dir = Path(config.output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not config.lrc.embed_enabled:
|
||||||
|
log("LRC embed: встраивание отключено — пропуск этапа")
|
||||||
|
return []
|
||||||
|
|
||||||
|
files = collect_audio_files(input_dir, config.recursive)
|
||||||
|
if not files:
|
||||||
|
log("Аудиофайлы не найдены.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
log(f"Embed LRC: {len(files)} файлов | "
|
||||||
|
f"режим={'in-place' if config.lrc.embed_in_place else 'копия в output'}")
|
||||||
|
|
||||||
|
results: list[FileResult] = []
|
||||||
|
total = len(files)
|
||||||
|
if on_progress:
|
||||||
|
on_progress("embed", 0, total)
|
||||||
|
if on_overall_progress:
|
||||||
|
on_overall_progress(0, total)
|
||||||
|
|
||||||
|
for idx, f in enumerate(files, 1):
|
||||||
|
if stop_event is not None and stop_event.is_set():
|
||||||
|
log("⏹ Отменено")
|
||||||
|
break
|
||||||
|
|
||||||
|
track_out = _output_dir_for(f, input_dir, output_dir)
|
||||||
|
lrc_path = track_out / "text.lrc"
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not lrc_path.exists():
|
||||||
|
log(f" ⊘ {f.name}: text.lrc отсутствует, пропуск")
|
||||||
|
results.append(FileResult(f, None, None, "no LRC", "embed"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
lrc_content = lrc_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if config.lrc.embed_in_place:
|
||||||
|
target = f
|
||||||
|
else:
|
||||||
|
target = track_out / f.name
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not target.exists() or config.overwrite:
|
||||||
|
shutil.copy2(f, target)
|
||||||
|
|
||||||
|
embed_lrc(target, lrc_content)
|
||||||
|
log(f" ✓ {f.name} → {target if config.lrc.embed_in_place else target.name}")
|
||||||
|
results.append(FileResult(f, None, target.parent, None, "embed"))
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
error = f"{type(e).__name__}: {e}"
|
||||||
|
log(f" ✗ {f.name}: {error}")
|
||||||
|
results.append(FileResult(f, None, None, error, "embed"))
|
||||||
|
finally:
|
||||||
|
if on_progress:
|
||||||
|
on_progress("embed", idx, total)
|
||||||
|
if on_overall_progress:
|
||||||
|
on_overall_progress(idx, total)
|
||||||
|
|
||||||
|
return results
|
||||||
1263
core/pipeline.py
1263
core/pipeline.py
File diff suppressed because it is too large
Load Diff
103
core/separate.py
103
core/separate.py
@@ -3,7 +3,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _build_env(custom_kernels: bool) -> dict:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["UNBLEND_CUSTOM_KERNELS"] = "1" if custom_kernels else "0"
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _check_result(result: subprocess.CompletedProcess, what: str) -> None:
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"unblend failed ({what}):\n{result.stderr.strip()[:1200]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def separate_vocals(
|
def separate_vocals(
|
||||||
@@ -13,8 +28,8 @@ def separate_vocals(
|
|||||||
isolate_stem: str = "vocals",
|
isolate_stem: str = "vocals",
|
||||||
unblend_path: str = "unblend",
|
unblend_path: str = "unblend",
|
||||||
custom_kernels: bool = False,
|
custom_kernels: bool = False,
|
||||||
|
device: str = "cuda",
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Separate the isolated stem from input_wav. Returns path to the stem WAV."""
|
|
||||||
work_dir.mkdir(parents=True, exist_ok=True)
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
output_pattern = work_dir / "{track}" / "{stem}.wav"
|
output_pattern = work_dir / "{track}" / "{stem}.wav"
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -22,15 +37,10 @@ def separate_vocals(
|
|||||||
"-m", model,
|
"-m", model,
|
||||||
"-o", str(output_pattern),
|
"-o", str(output_pattern),
|
||||||
"--isolate-stem", isolate_stem,
|
"--isolate-stem", isolate_stem,
|
||||||
|
"--device", device,
|
||||||
]
|
]
|
||||||
env = os.environ.copy()
|
result = subprocess.run(cmd, capture_output=True, text=True, env=_build_env(custom_kernels))
|
||||||
env["UNBLEND_CUSTOM_KERNELS"] = "1" if custom_kernels else "0"
|
_check_result(result, input_wav.name)
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"unblend failed for {input_wav.name}:\n{result.stderr.strip()[:800]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
candidates = sorted(work_dir.rglob(f"{isolate_stem}.wav"))
|
candidates = sorted(work_dir.rglob(f"{isolate_stem}.wav"))
|
||||||
if not candidates:
|
if not candidates:
|
||||||
@@ -38,3 +48,78 @@ def separate_vocals(
|
|||||||
f"unblend finished but stem '{isolate_stem}.wav' was not found in {work_dir}"
|
f"unblend finished but stem '{isolate_stem}.wav' was not found in {work_dir}"
|
||||||
)
|
)
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def separate_vocals_batch(
|
||||||
|
input_wavs: list[Path],
|
||||||
|
work_root: Path,
|
||||||
|
model: str = "bs_roformer_anvuew",
|
||||||
|
isolate_stem: str = "vocals",
|
||||||
|
unblend_path: str = "unblend",
|
||||||
|
custom_kernels: bool = False,
|
||||||
|
device: str = "cuda",
|
||||||
|
progress_callback: Optional[Callable[[int], None]] = None,
|
||||||
|
) -> dict[Path, Path]:
|
||||||
|
"""Separate many files in one unblend invocation.
|
||||||
|
|
||||||
|
unblend doesn't emit per-file progress, so progress_callback is called
|
||||||
|
with an incrementing counter roughly every 2 seconds, up to N-1.
|
||||||
|
The final N is always reported after the process exits.
|
||||||
|
"""
|
||||||
|
if not input_wavs:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
work_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_pattern = work_root / "{parent}" / "{stem}.wav"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
unblend_path, "separate",
|
||||||
|
*[str(p) for p in input_wavs],
|
||||||
|
"-m", model,
|
||||||
|
"-o", str(output_pattern),
|
||||||
|
"--isolate-stem", isolate_stem,
|
||||||
|
"--device", device,
|
||||||
|
]
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
env=_build_env(custom_kernels),
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(input_wavs)
|
||||||
|
processed = 0
|
||||||
|
while proc.poll() is None:
|
||||||
|
time.sleep(2.0)
|
||||||
|
if progress_callback and processed < total - 1:
|
||||||
|
processed += 1
|
||||||
|
progress_callback(processed)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"unblend batch failed with exit code {proc.returncode}")
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(total)
|
||||||
|
|
||||||
|
mapping: dict[Path, Path] = {}
|
||||||
|
missing: list[Path] = []
|
||||||
|
for src in input_wavs:
|
||||||
|
candidate = src.parent / f"{isolate_stem}.wav"
|
||||||
|
if candidate.exists():
|
||||||
|
mapping[src] = candidate
|
||||||
|
else:
|
||||||
|
missing.append(src)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
found = sorted(work_root.rglob(f"{isolate_stem}.wav"))
|
||||||
|
if len(found) == len(input_wavs):
|
||||||
|
for src, dst in zip(input_wavs, found):
|
||||||
|
mapping[src] = dst
|
||||||
|
else:
|
||||||
|
names = ", ".join(p.name for p in missing[:3])
|
||||||
|
raise RuntimeError(
|
||||||
|
f"unblend finished but outputs missing for {len(missing)} file(s): {names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
|||||||
@@ -1,16 +1,152 @@
|
|||||||
"""Speech recognition via WhisperX or GigaAM (onnx-asr).
|
"""Speech recognition via WhisperX or GigaAM (onnx-asr).
|
||||||
|
|
||||||
Models are cached per (name, device, compute_type) so loading happens once
|
Also exposes `transcribe_aligned()` for word-level alignment (used by LRC).
|
||||||
per process, not once per file. Loading is retried a few times to survive
|
Models are cached; aggressive VRAM cleanup happens after each file.
|
||||||
transient HuggingFace / SSL hiccups.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gc
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# The model caches below are process-wide. They are intentionally never
|
||||||
|
# cleared between files or batches: reloading WhisperX takes minutes and is
|
||||||
|
# what made runs look like they reloaded the model every batch. The lock makes
|
||||||
|
# the "load once, reuse forever" guarantee safe even if ASR is ever called
|
||||||
|
# from multiple threads.
|
||||||
|
_MODEL_LOCK = threading.RLock()
|
||||||
|
_REUSE_LOGGED: set = set()
|
||||||
|
|
||||||
|
# CUDA context failures that cannot be recovered by retrying or empty_cache().
|
||||||
|
# They require restarting the app (or switching to CPU).
|
||||||
|
_DEAD_CUDA_MARKERS = (
|
||||||
|
"invalid device ordinal",
|
||||||
|
"cudaerrorinvaliddevice",
|
||||||
|
"device-side assert",
|
||||||
|
"unspecified launch failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dead_cuda(exc: BaseException) -> bool:
|
||||||
|
err = str(exc).lower()
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
class CudaOOMError(RuntimeError):
|
||||||
|
"""CUDA OOM, переживший все стратегии понижения нагрузки."""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cuda_error(exc: BaseException) -> bool:
|
||||||
|
err = str(exc).lower()
|
||||||
|
return (
|
||||||
|
"out of memory" in err
|
||||||
|
or "cuda" in err
|
||||||
|
or "device ordinal" in err
|
||||||
|
or "cublas" in err
|
||||||
|
or "cudnn" in err
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_device(device: str) -> str:
|
def _resolve_device(device: str) -> str:
|
||||||
if device in ("cuda", "cpu"):
|
if device in ("cuda", "cpu"):
|
||||||
return device
|
return device
|
||||||
@@ -26,13 +162,76 @@ def _retry(fn, attempts: int = 3, base_delay: float = 2.0):
|
|||||||
for i in range(attempts):
|
for i in range(attempts):
|
||||||
try:
|
try:
|
||||||
return fn()
|
return fn()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
last = e
|
last = e
|
||||||
if i < attempts - 1:
|
if i < attempts - 1:
|
||||||
time.sleep(base_delay * (i + 1))
|
time.sleep(base_delay * (i + 1))
|
||||||
raise last
|
raise last
|
||||||
|
|
||||||
|
|
||||||
|
def _free_vram() -> None:
|
||||||
|
gc.collect()
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception: # noqa: BLE001 — cleanup must never mask the original error
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_tf32() -> None:
|
||||||
|
"""Enable TF32 matmul on Ampere+ GPUs (RTX 30/40).
|
||||||
|
|
||||||
|
Called lazily right before a WhisperX model is loaded, so runs that do not
|
||||||
|
use ASR never import Torch or create a CUDA context at startup.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.backends.cuda.matmul.allow_tf32 = True
|
||||||
|
torch.backends.cudnn.allow_tf32 = True
|
||||||
|
except Exception: # noqa: BLE001 — purely an optimization
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _model_transcribe_with_retry(
|
||||||
|
model_obj, audio, language, batch_size, name="<audio>",
|
||||||
|
):
|
||||||
|
"""Try progressively lighter settings so one long file can still finish.
|
||||||
|
|
||||||
|
The loaded model is intentionally kept in the cache between attempts:
|
||||||
|
reloading it costs minutes and does not help with an OOM caused by a long
|
||||||
|
audio file. VAD is not touched here — WhisperX expects it at model-load
|
||||||
|
time (``vad_model``/``vad_options``), not per transcription call. A dead
|
||||||
|
CUDA context is never retried: caches are dropped and CudaOOMError is
|
||||||
|
raised immediately. If even batch=1 fails with a normal OOM, raise
|
||||||
|
CudaOOMError so the caller can skip this file and continue with the rest
|
||||||
|
of the batch.
|
||||||
|
"""
|
||||||
|
attempts = [batch_size, 1]
|
||||||
|
last: Optional[Exception] = None
|
||||||
|
for i, bs in enumerate(attempts, 1):
|
||||||
|
try:
|
||||||
|
return model_obj.transcribe(
|
||||||
|
audio, batch_size=bs, language=language,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
if _is_dead_cuda(e):
|
||||||
|
_clear_model_caches()
|
||||||
|
raise CudaOOMError(
|
||||||
|
f"CUDA context died on {name}: {e}. "
|
||||||
|
"GPU state is unrecoverable — restart the app or switch to CPU."
|
||||||
|
) from e
|
||||||
|
if not _is_cuda_error(e):
|
||||||
|
raise
|
||||||
|
last = e
|
||||||
|
print(f" [ASR] OOM on {name} (attempt {i}/{len(attempts)}, bs={bs}): {e}")
|
||||||
|
_free_vram()
|
||||||
|
raise CudaOOMError(
|
||||||
|
f"OOM after {len(attempts)} attempts on {name}: {last}"
|
||||||
|
) from last
|
||||||
|
|
||||||
|
|
||||||
class _WhisperXModels:
|
class _WhisperXModels:
|
||||||
_cache: dict = {}
|
_cache: dict = {}
|
||||||
|
|
||||||
@@ -42,7 +241,10 @@ class _WhisperXModels:
|
|||||||
if compute_type is None:
|
if compute_type is None:
|
||||||
compute_type = "int8_float16" if device == "cuda" else "int8"
|
compute_type = "int8_float16" if device == "cuda" else "int8"
|
||||||
key = (model_name, device, compute_type, local_files_only)
|
key = (model_name, device, compute_type, local_files_only)
|
||||||
|
with _MODEL_LOCK:
|
||||||
if key not in cls._cache:
|
if key not in cls._cache:
|
||||||
|
if device == "cuda":
|
||||||
|
_enable_tf32()
|
||||||
import whisperx
|
import whisperx
|
||||||
print(f" [ASR] loading WhisperX {model_name} on {device} "
|
print(f" [ASR] loading WhisperX {model_name} on {device} "
|
||||||
f"({compute_type}, local_files_only={local_files_only})...")
|
f"({compute_type}, local_files_only={local_files_only})...")
|
||||||
@@ -55,7 +257,36 @@ class _WhisperXModels:
|
|||||||
)
|
)
|
||||||
|
|
||||||
cls._cache[key] = _retry(load)
|
cls._cache[key] = _retry(load)
|
||||||
print(" [ASR] model loaded.")
|
print(" [ASR] model loaded once — subsequent files will reuse it.")
|
||||||
|
elif ("whisperx",) + key not in _REUSE_LOGGED:
|
||||||
|
_REUSE_LOGGED.add(("whisperx",) + key)
|
||||||
|
print(f" [ASR] reusing loaded WhisperX {model_name} ({device}).")
|
||||||
|
return cls._cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
class _AlignModels:
|
||||||
|
_cache: dict = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, language_code: str, device: str, model_name: Optional[str] = None):
|
||||||
|
key = (language_code, device, model_name or "")
|
||||||
|
with _MODEL_LOCK:
|
||||||
|
if key not in cls._cache:
|
||||||
|
import whisperx
|
||||||
|
print(f" [ALIGN] loading align model for {language_code} "
|
||||||
|
f"({model_name or 'default'}) on {device}...")
|
||||||
|
|
||||||
|
def load():
|
||||||
|
kwargs = {"language_code": language_code, "device": device}
|
||||||
|
if model_name:
|
||||||
|
kwargs["model_name"] = model_name
|
||||||
|
return whisperx.load_align_model(**kwargs)
|
||||||
|
|
||||||
|
cls._cache[key] = _retry(load)
|
||||||
|
print(" [ALIGN] align model loaded once — reusing it.")
|
||||||
|
elif ("align",) + key not in _REUSE_LOGGED:
|
||||||
|
_REUSE_LOGGED.add(("align",) + key)
|
||||||
|
print(f" [ALIGN] reusing loaded align model for {language_code}.")
|
||||||
return cls._cache[key]
|
return cls._cache[key]
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +301,7 @@ class _GigaAMModels:
|
|||||||
"auto": ["CUDAExecutionProvider", "CPUExecutionProvider"],
|
"auto": ["CUDAExecutionProvider", "CPUExecutionProvider"],
|
||||||
}.get(provider, ["CPUExecutionProvider"])
|
}.get(provider, ["CPUExecutionProvider"])
|
||||||
key = tuple(providers)
|
key = tuple(providers)
|
||||||
|
with _MODEL_LOCK:
|
||||||
if key not in cls._cache:
|
if key not in cls._cache:
|
||||||
import onnx_asr
|
import onnx_asr
|
||||||
|
|
||||||
@@ -80,6 +312,28 @@ class _GigaAMModels:
|
|||||||
return cls._cache[key]
|
return cls._cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def preload_transcription_model(
|
||||||
|
engine: str = "whisperx",
|
||||||
|
model: str = "large-v3",
|
||||||
|
device: str = "auto",
|
||||||
|
compute_type: Optional[str] = None,
|
||||||
|
gigaam_provider: str = "auto",
|
||||||
|
local_files_only: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Load the ASR model once so the per-file loop only reuses it.
|
||||||
|
|
||||||
|
All model caches are process-wide and are never cleared between files,
|
||||||
|
batches or pipeline runs. This helper just makes the first load explicit.
|
||||||
|
"""
|
||||||
|
device = _resolve_device(device)
|
||||||
|
if engine == "whisperx":
|
||||||
|
_WhisperXModels.get(model, device, compute_type, local_files_only)
|
||||||
|
elif engine == "gigaam":
|
||||||
|
_GigaAMModels.get(gigaam_provider)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown ASR engine: {engine!r}")
|
||||||
|
|
||||||
|
|
||||||
def transcribe(
|
def transcribe(
|
||||||
audio_path: Path,
|
audio_path: Path,
|
||||||
engine: str = "whisperx",
|
engine: str = "whisperx",
|
||||||
@@ -93,11 +347,13 @@ def transcribe(
|
|||||||
vad_filter: bool = True,
|
vad_filter: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
device = _resolve_device(device)
|
device = _resolve_device(device)
|
||||||
|
|
||||||
if engine == "whisperx":
|
if engine == "whisperx":
|
||||||
return _transcribe_whisperx(
|
return _transcribe_whisperx(
|
||||||
audio_path, model, language, device, compute_type,
|
audio_path, model, language, device, compute_type,
|
||||||
batch_size, local_files_only, vad_filter,
|
batch_size, local_files_only, vad_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
if engine == "gigaam":
|
if engine == "gigaam":
|
||||||
return _transcribe_gigaam(audio_path, gigaam_provider)
|
return _transcribe_gigaam(audio_path, gigaam_provider)
|
||||||
raise ValueError(f"Unknown ASR engine: {engine!r}")
|
raise ValueError(f"Unknown ASR engine: {engine!r}")
|
||||||
@@ -116,14 +372,112 @@ def _transcribe_whisperx(
|
|||||||
import whisperx
|
import whisperx
|
||||||
model = _WhisperXModels.get(model_name, device, compute_type, local_files_only)
|
model = _WhisperXModels.get(model_name, device, compute_type, local_files_only)
|
||||||
audio = whisperx.load_audio(str(audio_path))
|
audio = whisperx.load_audio(str(audio_path))
|
||||||
result = model.transcribe(
|
result = _model_transcribe_with_retry(
|
||||||
audio,
|
model, audio, language, batch_size, name=audio_path.name,
|
||||||
batch_size=batch_size,
|
|
||||||
language=language,
|
|
||||||
)
|
)
|
||||||
return " ".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
|
||||||
|
|
||||||
|
|
||||||
def _transcribe_gigaam(audio_path: Path, provider: str) -> str:
|
def _transcribe_gigaam(audio_path: Path, provider: str) -> str:
|
||||||
model = _GigaAMModels.get(provider)
|
model = _GigaAMModels.get(provider)
|
||||||
return model.recognize(str(audio_path), channel="mean").strip()
|
return model.recognize(str(audio_path), channel="mean").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_aligned(
|
||||||
|
audio_path: Path,
|
||||||
|
model: str = "large-v3",
|
||||||
|
language: Optional[str] = "ru",
|
||||||
|
device: str = "auto",
|
||||||
|
compute_type: Optional[str] = None,
|
||||||
|
batch_size: int = 8,
|
||||||
|
align_model: Optional[str] = None,
|
||||||
|
local_files_only: bool = False,
|
||||||
|
vad_filter: bool = True,
|
||||||
|
) -> tuple[list[dict], str]:
|
||||||
|
"""Transcribe + word-level align. Returns (aligned_segments, detected_language).
|
||||||
|
|
||||||
|
Each segment has 'start', 'end', 'text', and optionally 'words'.
|
||||||
|
Requires whisperx engine.
|
||||||
|
"""
|
||||||
|
import whisperx
|
||||||
|
device = _resolve_device(device)
|
||||||
|
model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only)
|
||||||
|
audio = whisperx.load_audio(str(audio_path))
|
||||||
|
result = _model_transcribe_with_retry(
|
||||||
|
model_obj, audio, language, batch_size, name=audio_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
detected = result.get("language") or language or "en"
|
||||||
|
|
||||||
|
try:
|
||||||
|
align_model_obj, align_metadata = _AlignModels.get(detected, device, align_model)
|
||||||
|
aligned = whisperx.align(
|
||||||
|
result["segments"], align_model_obj, align_metadata,
|
||||||
|
audio, device, return_char_alignments=False,
|
||||||
|
)
|
||||||
|
segments = aligned["segments"]
|
||||||
|
except Exception as e:
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
"""Main application window — dark theme, mode selector, staged/sequential pipelines."""
|
"""Main application window — dark theme, mode selector, LRC tab."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QUrl
|
from PySide6.QtCore import Qt, QUrl
|
||||||
from PySide6.QtGui import QColor, QDesktopServices
|
from PySide6.QtGui import QColor, QDesktopServices, QKeySequence, QShortcut
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox,
|
QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
|
||||||
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
|
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
|
||||||
QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QTabWidget,
|
QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSpinBox,
|
||||||
QVBoxLayout, QWidget,
|
QTabWidget, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from core.config import (
|
from core.config import (
|
||||||
ConvertConfig, LyricsConfig, PipelineConfig, SeparateConfig,
|
ConvertConfig, LrcConfig, LyricsConfig, PipelineConfig, SeparateConfig,
|
||||||
ShazamConfig, TranscribeConfig,
|
ShazamConfig, TranscribeConfig,
|
||||||
)
|
)
|
||||||
from gui.worker import PipelineWorker
|
from gui.worker import PipelineWorker
|
||||||
@@ -31,6 +32,8 @@ STAGE_LABELS = {
|
|||||||
"convert": "Конвертация",
|
"convert": "Конвертация",
|
||||||
"separate": "Отделение",
|
"separate": "Отделение",
|
||||||
"transcribe": "Транскрибация",
|
"transcribe": "Транскрибация",
|
||||||
|
"lrc": "LRC",
|
||||||
|
"embed": "Встраивание LRC",
|
||||||
}
|
}
|
||||||
|
|
||||||
MODE_LABELS = {
|
MODE_LABELS = {
|
||||||
@@ -40,19 +43,30 @@ MODE_LABELS = {
|
|||||||
|
|
||||||
|
|
||||||
def _detect_device_info() -> tuple[str, str]:
|
def _detect_device_info() -> tuple[str, str]:
|
||||||
try:
|
"""Report the GPU via nvidia-smi.
|
||||||
import torch
|
|
||||||
except ImportError:
|
|
||||||
return "⚠ PyTorch не установлен", "#f38ba8"
|
|
||||||
|
|
||||||
if torch.cuda.is_available():
|
Torch must not be imported here: importing it initializes a CUDA context
|
||||||
|
and reserves VRAM even for runs that only need separation. The ASR stage
|
||||||
|
imports Torch lazily when it actually loads a model.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
name = torch.cuda.get_device_name(0)
|
out = subprocess.run(
|
||||||
vram = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
|
["nvidia-smi", "--query-gpu=name,memory.total",
|
||||||
|
"--format=csv,noheader,nounits"],
|
||||||
|
capture_output=True, text=True, timeout=5, check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
out = ""
|
||||||
|
if out:
|
||||||
|
parts = [p.strip() for p in out.splitlines()[0].split(",")]
|
||||||
|
name = parts[0] or "CUDA GPU"
|
||||||
|
if len(parts) > 1:
|
||||||
|
try:
|
||||||
|
vram = float(parts[1]) / 1024 # MiB -> GiB
|
||||||
return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1"
|
return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1"
|
||||||
except Exception: # noqa: BLE001
|
except ValueError:
|
||||||
return "✓ CUDA доступна", "#a6e3a1"
|
pass
|
||||||
|
return f"✓ CUDA: {name}", "#a6e3a1"
|
||||||
return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af"
|
return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af"
|
||||||
|
|
||||||
|
|
||||||
@@ -60,7 +74,7 @@ class MainWindow(QMainWindow):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle("Song Transcriber")
|
self.setWindowTitle("Song Transcriber")
|
||||||
self.resize(1140, 880)
|
self.resize(1140, 920)
|
||||||
self._worker: Optional[PipelineWorker] = None
|
self._worker: Optional[PipelineWorker] = None
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._apply_defaults()
|
self._apply_defaults()
|
||||||
@@ -71,8 +85,17 @@ class MainWindow(QMainWindow):
|
|||||||
# ---------------- UI ----------------
|
# ---------------- UI ----------------
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
|
# The whole page lives in a scroll area: on small screens the user can
|
||||||
|
# scroll down to the log instead of the console being cut off by the
|
||||||
|
# bottom edge of the window.
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.setCentralWidget(scroll)
|
||||||
|
|
||||||
central = QWidget()
|
central = QWidget()
|
||||||
self.setCentralWidget(central)
|
scroll.setWidget(central)
|
||||||
root = QVBoxLayout(central)
|
root = QVBoxLayout(central)
|
||||||
root.setContentsMargins(18, 16, 18, 16)
|
root.setContentsMargins(18, 16, 18, 16)
|
||||||
root.setSpacing(12)
|
root.setSpacing(12)
|
||||||
@@ -82,6 +105,10 @@ class MainWindow(QMainWindow):
|
|||||||
title.setObjectName("appTitle")
|
title.setObjectName("appTitle")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
|
self.fullscreen_btn = QPushButton("⛶ На весь экран")
|
||||||
|
self.fullscreen_btn.setToolTip("Развернуть окно на весь экран (F11)")
|
||||||
|
self.fullscreen_btn.clicked.connect(self._toggle_fullscreen)
|
||||||
|
header.addWidget(self.fullscreen_btn)
|
||||||
self.device_label = QLabel("")
|
self.device_label = QLabel("")
|
||||||
self.device_label.setObjectName("deviceInfo")
|
self.device_label.setObjectName("deviceInfo")
|
||||||
header.addWidget(self.device_label)
|
header.addWidget(self.device_label)
|
||||||
@@ -93,7 +120,6 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.input_edit = QLineEdit()
|
self.input_edit = QLineEdit()
|
||||||
top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir))
|
top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir))
|
||||||
|
|
||||||
self.output_edit = QLineEdit()
|
self.output_edit = QLineEdit()
|
||||||
top_layout.addRow("Выходная папка:", self._with_browse(self.output_edit, self._pick_output_dir))
|
top_layout.addRow("Выходная папка:", self._with_browse(self.output_edit, self._pick_output_dir))
|
||||||
|
|
||||||
@@ -102,6 +128,16 @@ class MainWindow(QMainWindow):
|
|||||||
self.mode_cb.addItem(label, key)
|
self.mode_cb.addItem(label, key)
|
||||||
top_layout.addRow("Режим:", self.mode_cb)
|
top_layout.addRow("Режим:", self.mode_cb)
|
||||||
|
|
||||||
|
self.chunk_size_spin = QSpinBox()
|
||||||
|
self.chunk_size_spin.setRange(1, 10000)
|
||||||
|
self.chunk_size_spin.setToolTip(
|
||||||
|
"Размер батча в staged-режиме.\n"
|
||||||
|
"После каждого батча: удаляются временные файлы, освобождается VRAM.\n"
|
||||||
|
"Модели WhisperX остаются в памяти — перезагрузки не будет.\n"
|
||||||
|
"64 — разумный компромисс для 32 ГБ RAM и 8 ГБ VRAM."
|
||||||
|
)
|
||||||
|
top_layout.addRow("Размер батча:", self.chunk_size_spin)
|
||||||
|
|
||||||
opts = QHBoxLayout()
|
opts = QHBoxLayout()
|
||||||
self.recursive_cb = QCheckBox("Рекурсивный обход")
|
self.recursive_cb = QCheckBox("Рекурсивный обход")
|
||||||
self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные")
|
self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные")
|
||||||
@@ -112,6 +148,22 @@ class MainWindow(QMainWindow):
|
|||||||
opts.addStretch(1)
|
opts.addStretch(1)
|
||||||
top_layout.addRow("Опции:", opts)
|
top_layout.addRow("Опции:", opts)
|
||||||
|
|
||||||
|
resume_opts = QHBoxLayout()
|
||||||
|
self.skip_existing_cb = QCheckBox("Пропускать готовые (text.txt)")
|
||||||
|
self.skip_existing_cb.setToolTip(
|
||||||
|
"Не трогать файлы, у которых в <output>/<relpath>/<stem>/ уже есть text.txt.\n"
|
||||||
|
"Позволяет продолжить большой прогон после сбоя без пересчёта.\n"
|
||||||
|
"Если в metadata.json записан path другого файла — папка не считается готовой."
|
||||||
|
)
|
||||||
|
self.resume_partial_cb = QCheckBox("Подхватывать vocals.wav / convert.wav")
|
||||||
|
self.resume_partial_cb.setToolTip(
|
||||||
|
"Если text.txt ещё нет, но есть vocals.wav — сразу переходить к\n"
|
||||||
|
"транскрибации. Если есть только convert.wav — пропустить конвертацию."
|
||||||
|
)
|
||||||
|
resume_opts.addWidget(self.skip_existing_cb)
|
||||||
|
resume_opts.addWidget(self.resume_partial_cb)
|
||||||
|
resume_opts.addStretch(1)
|
||||||
|
top_layout.addRow("Resume:", resume_opts)
|
||||||
root.addWidget(top_box)
|
root.addWidget(top_box)
|
||||||
|
|
||||||
self.tabs = QTabWidget()
|
self.tabs = QTabWidget()
|
||||||
@@ -120,6 +172,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.tabs.addTab(self._build_convert_tab(), "Конвертация")
|
self.tabs.addTab(self._build_convert_tab(), "Конвертация")
|
||||||
self.tabs.addTab(self._build_separate_tab(), "Отделение вокала")
|
self.tabs.addTab(self._build_separate_tab(), "Отделение вокала")
|
||||||
self.tabs.addTab(self._build_transcribe_tab(), "Транскрибация")
|
self.tabs.addTab(self._build_transcribe_tab(), "Транскрибация")
|
||||||
|
self.tabs.addTab(self._build_lrc_tab(), "LRC")
|
||||||
root.addWidget(self.tabs)
|
root.addWidget(self.tabs)
|
||||||
|
|
||||||
self.shazam_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
self.shazam_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
||||||
@@ -127,13 +180,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.convert_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
self.convert_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
||||||
self.separate_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
self.separate_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
||||||
self.transcribe_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
self.transcribe_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
||||||
|
self.lrc_enabled_cb.toggled.connect(self._sync_tab_indicators)
|
||||||
self.engine_cb.currentTextChanged.connect(self._sync_engine_fields)
|
self.engine_cb.currentTextChanged.connect(self._sync_engine_fields)
|
||||||
|
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
row.setSpacing(10)
|
row.setSpacing(10)
|
||||||
self.start_btn = QPushButton("▶ Запустить")
|
self.start_btn = QPushButton("▶ Запустить пайплайн")
|
||||||
self.start_btn.setObjectName("primary")
|
self.start_btn.setObjectName("primary")
|
||||||
self.start_btn.clicked.connect(self._on_start)
|
self.start_btn.clicked.connect(lambda: self._run_task("main"))
|
||||||
self.stop_btn = QPushButton("⏹ Стоп")
|
self.stop_btn = QPushButton("⏹ Стоп")
|
||||||
self.stop_btn.setObjectName("danger")
|
self.stop_btn.setObjectName("danger")
|
||||||
self.stop_btn.setEnabled(False)
|
self.stop_btn.setEnabled(False)
|
||||||
@@ -157,12 +211,35 @@ class MainWindow(QMainWindow):
|
|||||||
self.progress.setFormat("Ожидание")
|
self.progress.setFormat("Ожидание")
|
||||||
root.addWidget(self.progress)
|
root.addWidget(self.progress)
|
||||||
|
|
||||||
|
self.overall_progress = QProgressBar()
|
||||||
|
self.overall_progress.setObjectName("overallProgress")
|
||||||
|
self.overall_progress.setFormat("Общий прогресс: ожидание")
|
||||||
|
root.addWidget(self.overall_progress)
|
||||||
|
|
||||||
self.log_view = QPlainTextEdit()
|
self.log_view = QPlainTextEdit()
|
||||||
self.log_view.setObjectName("log")
|
self.log_view.setObjectName("log")
|
||||||
self.log_view.setReadOnly(True)
|
self.log_view.setReadOnly(True)
|
||||||
|
# Keep at least ~12 log lines visible regardless of the UI font size.
|
||||||
|
line_height = self.log_view.fontMetrics().lineSpacing()
|
||||||
|
self.log_view.setMinimumHeight(max(280, line_height * 12 + 24))
|
||||||
self.log_view.setPlaceholderText("Здесь появится лог выполнения…")
|
self.log_view.setPlaceholderText("Здесь появится лог выполнения…")
|
||||||
root.addWidget(self.log_view, 1)
|
root.addWidget(self.log_view, 1)
|
||||||
|
|
||||||
|
self._fs_shortcut = QShortcut(QKeySequence("F11"), self)
|
||||||
|
self._fs_shortcut.activated.connect(self._toggle_fullscreen)
|
||||||
|
self._esc_shortcut = QShortcut(QKeySequence("Escape"), self)
|
||||||
|
self._esc_shortcut.activated.connect(self._leave_fullscreen)
|
||||||
|
|
||||||
|
def _toggle_fullscreen(self) -> None:
|
||||||
|
if self.isFullScreen():
|
||||||
|
self.showNormal()
|
||||||
|
else:
|
||||||
|
self.showFullScreen()
|
||||||
|
|
||||||
|
def _leave_fullscreen(self) -> None:
|
||||||
|
if self.isFullScreen():
|
||||||
|
self.showNormal()
|
||||||
|
|
||||||
def _with_browse(self, edit: QLineEdit, picker) -> QWidget:
|
def _with_browse(self, edit: QLineEdit, picker) -> QWidget:
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
h = QHBoxLayout(w)
|
h = QHBoxLayout(w)
|
||||||
@@ -180,19 +257,18 @@ class MainWindow(QMainWindow):
|
|||||||
form = QFormLayout(w)
|
form = QFormLayout(w)
|
||||||
form.setContentsMargins(16, 16, 16, 16)
|
form.setContentsMargins(16, 16, 16, 16)
|
||||||
form.setSpacing(10)
|
form.setSpacing(10)
|
||||||
|
|
||||||
self.shazam_enabled_cb = QCheckBox("Включён")
|
self.shazam_enabled_cb = QCheckBox("Включён")
|
||||||
|
self.shazam_concurrency = QSpinBox()
|
||||||
|
self.shazam_concurrency.setRange(1, 16)
|
||||||
|
form.addRow("Параллельных запросов:", self.shazam_concurrency)
|
||||||
form.addRow("", self.shazam_enabled_cb)
|
form.addRow("", self.shazam_enabled_cb)
|
||||||
|
|
||||||
info = QLabel(
|
info = QLabel(
|
||||||
"Идентификация трека через Shazam (unofficial API).\n"
|
"Идентификация трека через Shazam (unofficial API).\n"
|
||||||
"При совпадении сохраняются метаданные и (если возможно) текст из LRCLIB.\n"
|
"При совпадении сохраняются метаданные и (если возможно) текст из LRCLIB."
|
||||||
"Требует ffmpeg (для конвертации в 16 кГц моно)."
|
|
||||||
)
|
)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
info.setStyleSheet("color: #a6adc8;")
|
info.setStyleSheet("color: #a6adc8;")
|
||||||
form.addRow(info)
|
form.addRow(info)
|
||||||
|
|
||||||
return w
|
return w
|
||||||
|
|
||||||
def _build_lrclib_tab(self) -> QWidget:
|
def _build_lrclib_tab(self) -> QWidget:
|
||||||
@@ -200,37 +276,56 @@ class MainWindow(QMainWindow):
|
|||||||
form = QFormLayout(w)
|
form = QFormLayout(w)
|
||||||
form.setContentsMargins(16, 16, 16, 16)
|
form.setContentsMargins(16, 16, 16, 16)
|
||||||
form.setSpacing(10)
|
form.setSpacing(10)
|
||||||
|
|
||||||
self.lyrics_enabled_cb = QCheckBox("Включён")
|
self.lyrics_enabled_cb = QCheckBox("Включён")
|
||||||
form.addRow("", self.lyrics_enabled_cb)
|
form.addRow("", self.lyrics_enabled_cb)
|
||||||
|
self.lyrics_use_embedded_cb = QCheckBox("Использовать встроенный текст из тегов (USLT/LYRICS/©lyr)")
|
||||||
|
self.lyrics_use_embedded_cb.setToolTip(
|
||||||
|
"Сначала проверяем, есть ли текст (LRC или обычный) уже в тегах аудио.\n"
|
||||||
|
"Если есть — ASR/Shazam/LRCLIB не запускаются."
|
||||||
|
)
|
||||||
|
form.addRow("", self.lyrics_use_embedded_cb)
|
||||||
self.lyrics_user_agent = QLineEdit()
|
self.lyrics_user_agent = QLineEdit()
|
||||||
form.addRow("User-Agent:", self.lyrics_user_agent)
|
form.addRow("User-Agent:", self.lyrics_user_agent)
|
||||||
|
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам")
|
||||||
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам (artist + title)")
|
self.lyrics_use_tags_cb.setToolTip(
|
||||||
|
"Использовать artist/title из тегов файла для поиска на LRCLIB.\n"
|
||||||
|
"Срабатывает как fallback, если Shazam ничего не нашёл\n"
|
||||||
|
"или его результат отключён."
|
||||||
|
)
|
||||||
form.addRow("", self.lyrics_use_tags_cb)
|
form.addRow("", self.lyrics_use_tags_cb)
|
||||||
|
self.lyrics_use_shazam_cb = QCheckBox("Искать по Shazam")
|
||||||
self.lyrics_use_shazam_cb = QCheckBox("Искать по результату Shazam")
|
self.lyrics_use_shazam_cb.setToolTip(
|
||||||
|
"Приоритетный источник для поиска на LRCLIB: artist/title из Shazam.\n"
|
||||||
|
"Теги файла используются только если Shazam не дал результата."
|
||||||
|
)
|
||||||
form.addRow("", self.lyrics_use_shazam_cb)
|
form.addRow("", self.lyrics_use_shazam_cb)
|
||||||
|
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy)")
|
||||||
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy-match)")
|
|
||||||
form.addRow("", self.lyrics_after_asr_cb)
|
form.addRow("", self.lyrics_after_asr_cb)
|
||||||
|
|
||||||
self.lyrics_transcribe_unresolved_cb = QCheckBox(
|
self.lyrics_transcribe_unresolved_cb = QCheckBox(
|
||||||
"Запускать ASR, если Shazam опознал, но текста нет"
|
"ASR, если Shazam опознал без текста"
|
||||||
)
|
)
|
||||||
form.addRow("", self.lyrics_transcribe_unresolved_cb)
|
form.addRow("", self.lyrics_transcribe_unresolved_cb)
|
||||||
|
|
||||||
self.lyrics_threshold = QDoubleSpinBox()
|
self.lyrics_threshold = QDoubleSpinBox()
|
||||||
self.lyrics_threshold.setRange(0.0, 1.0)
|
self.lyrics_threshold.setRange(0.0, 1.0)
|
||||||
self.lyrics_threshold.setSingleStep(0.05)
|
self.lyrics_threshold.setSingleStep(0.05)
|
||||||
self.lyrics_threshold.setDecimals(2)
|
self.lyrics_threshold.setDecimals(2)
|
||||||
self.lyrics_threshold.setToolTip(
|
|
||||||
"Насколько должен совпасть распознанный ASR-текст с текстом из LRCLIB,\n"
|
|
||||||
"чтобы считать это тем же треком. 1.0 = точное совпадение, 0.0 = любое."
|
|
||||||
)
|
|
||||||
form.addRow("Порог fuzzy:", self.lyrics_threshold)
|
form.addRow("Порог fuzzy:", self.lyrics_threshold)
|
||||||
|
|
||||||
|
self.lyrics_save_unrecognized_cb = QCheckBox(
|
||||||
|
"Сохранять тексты без LRCLIB в JSONL (для добавления в LRCLIB)"
|
||||||
|
)
|
||||||
|
self.lyrics_save_unrecognized_cb.setToolTip(
|
||||||
|
"Если текст получен НЕ из LRCLIB (WhisperX / GigaAM / embedded), "
|
||||||
|
"записать его в <output>/unrecognized_lyrics.jsonl.\n"
|
||||||
|
"Формат совместим с LRCLIB API для ручной отправки."
|
||||||
|
)
|
||||||
|
form.addRow("", self.lyrics_save_unrecognized_cb)
|
||||||
|
|
||||||
|
self.lyrics_unrecognized_path_edit = QLineEdit()
|
||||||
|
self.lyrics_unrecognized_path_edit.setPlaceholderText(
|
||||||
|
"(пусто — <output>/unrecognized_lyrics.jsonl)"
|
||||||
|
)
|
||||||
|
form.addRow("Файл unrecognized:", self.lyrics_unrecognized_path_edit)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
def _build_convert_tab(self) -> QWidget:
|
def _build_convert_tab(self) -> QWidget:
|
||||||
@@ -238,21 +333,20 @@ class MainWindow(QMainWindow):
|
|||||||
form = QFormLayout(w)
|
form = QFormLayout(w)
|
||||||
form.setContentsMargins(16, 16, 16, 16)
|
form.setContentsMargins(16, 16, 16, 16)
|
||||||
form.setSpacing(10)
|
form.setSpacing(10)
|
||||||
|
|
||||||
self.convert_enabled_cb = QCheckBox("Включён")
|
self.convert_enabled_cb = QCheckBox("Включён")
|
||||||
form.addRow("", self.convert_enabled_cb)
|
form.addRow("", self.convert_enabled_cb)
|
||||||
|
|
||||||
self.convert_sr = QComboBox()
|
self.convert_sr = QComboBox()
|
||||||
for sr in SAMPLE_RATES:
|
for sr in SAMPLE_RATES:
|
||||||
self.convert_sr.addItem(f"{sr} Hz", sr)
|
self.convert_sr.addItem(f"{sr} Hz", sr)
|
||||||
form.addRow("Sample rate:", self.convert_sr)
|
form.addRow("Sample rate:", self.convert_sr)
|
||||||
|
|
||||||
self.convert_ch = QComboBox()
|
self.convert_ch = QComboBox()
|
||||||
self.convert_ch.addItem("Моно", 1)
|
self.convert_ch.addItem("Моно", 1)
|
||||||
self.convert_ch.addItem("Стерео", 2)
|
self.convert_ch.addItem("Стерео", 2)
|
||||||
form.addRow("Каналы:", self.convert_ch)
|
form.addRow("Каналы:", self.convert_ch)
|
||||||
|
|
||||||
self.convert_ffmpeg = QLineEdit()
|
self.convert_ffmpeg = QLineEdit()
|
||||||
|
self.convert_concurrency = QSpinBox()
|
||||||
|
self.convert_concurrency.setRange(1, 16)
|
||||||
|
form.addRow("Параллельных конвертаций:", self.convert_concurrency)
|
||||||
form.addRow("ffmpeg:", self.convert_ffmpeg)
|
form.addRow("ffmpeg:", self.convert_ffmpeg)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
@@ -261,21 +355,22 @@ class MainWindow(QMainWindow):
|
|||||||
form = QFormLayout(w)
|
form = QFormLayout(w)
|
||||||
form.setContentsMargins(16, 16, 16, 16)
|
form.setContentsMargins(16, 16, 16, 16)
|
||||||
form.setSpacing(10)
|
form.setSpacing(10)
|
||||||
|
|
||||||
self.separate_enabled_cb = QCheckBox("Включён")
|
self.separate_enabled_cb = QCheckBox("Включён")
|
||||||
form.addRow("", self.separate_enabled_cb)
|
form.addRow("", self.separate_enabled_cb)
|
||||||
|
|
||||||
self.separate_model = QLineEdit()
|
self.separate_model = QLineEdit()
|
||||||
form.addRow("Модель:", self.separate_model)
|
form.addRow("Модель:", self.separate_model)
|
||||||
|
|
||||||
self.separate_stem = QLineEdit()
|
self.separate_stem = QLineEdit()
|
||||||
form.addRow("Isolate stem:", self.separate_stem)
|
form.addRow("Isolate stem:", self.separate_stem)
|
||||||
|
|
||||||
self.separate_unblend = QLineEdit()
|
self.separate_unblend = QLineEdit()
|
||||||
form.addRow("unblend CLI:", self.separate_unblend)
|
form.addRow("unblend CLI:", self.separate_unblend)
|
||||||
|
|
||||||
self.separate_kernels_cb = QCheckBox("Использовать кастомные ядра")
|
self.separate_kernels_cb = QCheckBox("Использовать кастомные ядра")
|
||||||
form.addRow("", self.separate_kernels_cb)
|
form.addRow("", self.separate_kernels_cb)
|
||||||
|
self.separate_save_vocals_cb = QCheckBox("Всегда сохранять vocals.wav в output")
|
||||||
|
self.separate_save_vocals_cb.setToolTip(
|
||||||
|
"Независимо от 'Сохранять промежуточные': вокал всегда будет лежать "
|
||||||
|
"в <output>/<relpath>/<stem>/vocals.wav — полезно для LRC-этапа."
|
||||||
|
)
|
||||||
|
form.addRow("", self.separate_save_vocals_cb)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
def _build_transcribe_tab(self) -> QWidget:
|
def _build_transcribe_tab(self) -> QWidget:
|
||||||
@@ -283,53 +378,109 @@ class MainWindow(QMainWindow):
|
|||||||
form = QFormLayout(w)
|
form = QFormLayout(w)
|
||||||
form.setContentsMargins(16, 16, 16, 16)
|
form.setContentsMargins(16, 16, 16, 16)
|
||||||
form.setSpacing(10)
|
form.setSpacing(10)
|
||||||
|
|
||||||
self.transcribe_enabled_cb = QCheckBox("Включён")
|
self.transcribe_enabled_cb = QCheckBox("Включён")
|
||||||
form.addRow("", self.transcribe_enabled_cb)
|
form.addRow("", self.transcribe_enabled_cb)
|
||||||
|
|
||||||
self.engine_cb = QComboBox()
|
self.engine_cb = QComboBox()
|
||||||
self.engine_cb.addItems(ENGINES)
|
self.engine_cb.addItems(ENGINES)
|
||||||
form.addRow("Движок:", self.engine_cb)
|
form.addRow("Движок:", self.engine_cb)
|
||||||
|
|
||||||
self.model_edit = QLineEdit()
|
self.model_edit = QLineEdit()
|
||||||
form.addRow("Модель:", self.model_edit)
|
form.addRow("Модель:", self.model_edit)
|
||||||
|
|
||||||
self.lang_cb = QComboBox()
|
self.lang_cb = QComboBox()
|
||||||
self.lang_cb.setEditable(True)
|
self.lang_cb.setEditable(True)
|
||||||
self.lang_cb.addItems(LANGUAGES)
|
self.lang_cb.addItems(LANGUAGES)
|
||||||
self.lang_cb.setToolTip(
|
|
||||||
"'auto' — WhisperX определяет язык на каждом файле.\n"
|
|
||||||
"Если знаешь язык — выбери его явно, будет быстрее."
|
|
||||||
)
|
|
||||||
form.addRow("Язык:", self.lang_cb)
|
form.addRow("Язык:", self.lang_cb)
|
||||||
|
|
||||||
self.device_cb = QComboBox()
|
self.device_cb = QComboBox()
|
||||||
self.device_cb.addItems(["auto", "cuda", "cpu"])
|
self.device_cb.addItems(["auto", "cuda", "cpu"])
|
||||||
form.addRow("Device:", self.device_cb)
|
form.addRow("Device:", self.device_cb)
|
||||||
|
|
||||||
self.compute_type_cb = QComboBox()
|
self.compute_type_cb = QComboBox()
|
||||||
self.compute_type_cb.addItems(["auto", "int8_float16", "int8", "float16", "float32"])
|
self.compute_type_cb.addItems(["auto", "int8_float16", "int8", "float16", "float32"])
|
||||||
form.addRow("Compute type:", self.compute_type_cb)
|
form.addRow("Compute type:", self.compute_type_cb)
|
||||||
|
|
||||||
self.batch_spin = QSpinBox()
|
self.batch_spin = QSpinBox()
|
||||||
self.batch_spin.setRange(1, 512)
|
self.batch_spin.setRange(1, 512)
|
||||||
self.batch_spin.setToolTip(
|
|
||||||
"Размер батча сегментов ВНУТРИ одного файла (не файлов!).\n"
|
|
||||||
"8–16 для 8 ГБ VRAM, 32–64 для 24 ГБ."
|
|
||||||
)
|
|
||||||
form.addRow("Batch size:", self.batch_spin)
|
form.addRow("Batch size:", self.batch_spin)
|
||||||
|
|
||||||
self.gigaam_provider_cb = QComboBox()
|
self.gigaam_provider_cb = QComboBox()
|
||||||
self.gigaam_provider_cb.addItems(["auto", "cuda", "cpu"])
|
self.gigaam_provider_cb.addItems(["auto", "cuda", "cpu"])
|
||||||
form.addRow("GigaAM provider:", self.gigaam_provider_cb)
|
form.addRow("GigaAM provider:", self.gigaam_provider_cb)
|
||||||
|
|
||||||
self.local_files_only_cb = QCheckBox("Не проверять HuggingFace (offline)")
|
self.local_files_only_cb = QCheckBox("Не проверять HuggingFace (offline)")
|
||||||
self.local_files_only_cb.setToolTip(
|
|
||||||
"Если модель уже скачана — включите, чтобы не ждать сетевую проверку."
|
|
||||||
)
|
|
||||||
form.addRow("", self.local_files_only_cb)
|
form.addRow("", self.local_files_only_cb)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
|
def _build_lrc_tab(self) -> QWidget:
|
||||||
|
w = QWidget()
|
||||||
|
form = QFormLayout(w)
|
||||||
|
form.setContentsMargins(16, 16, 16, 16)
|
||||||
|
form.setSpacing(10)
|
||||||
|
|
||||||
|
info = QLabel(
|
||||||
|
"1) Сгенерировать LRC: WhisperX + align → <output>/<relpath>/<stem>/text.lrc.\n"
|
||||||
|
"2) Встроить LRC: записывает LRC в тег lyrics аудио (USLT/LYRICS/©lyr).\n"
|
||||||
|
"Если 'in-place' выключено — оригинал не трогается, создаётся копия в output."
|
||||||
|
)
|
||||||
|
info.setWordWrap(True)
|
||||||
|
info.setStyleSheet("color: #a6adc8;")
|
||||||
|
form.addRow(info)
|
||||||
|
|
||||||
|
self.lrc_enabled_cb = QCheckBox("Включён (генерация LRC)")
|
||||||
|
self.lrc_enabled_cb.setToolTip(
|
||||||
|
"Отключите, чтобы пропустить этап генерации text.lrc.\n"
|
||||||
|
"Остальные этапы пайплайна продолжат работать."
|
||||||
|
)
|
||||||
|
form.addRow("", self.lrc_enabled_cb)
|
||||||
|
self.lrc_embed_enabled_cb = QCheckBox("Включён (встраивание LRC в аудио)")
|
||||||
|
self.lrc_embed_enabled_cb.setToolTip(
|
||||||
|
"Отключите, чтобы пропустить этап встраивания LRC в теги аудио."
|
||||||
|
)
|
||||||
|
form.addRow("", self.lrc_embed_enabled_cb)
|
||||||
|
self.lrc_skip_existing_cb = QCheckBox("Пропускать готовые text.lrc")
|
||||||
|
self.lrc_skip_existing_cb.setToolTip(
|
||||||
|
"Если <output>/<relpath>/<stem>/text.lrc уже существует и содержит корректный LRC,\n"
|
||||||
|
"файл не будет пересчитан. Папка с metadata.json от другого трека\n"
|
||||||
|
"готовой не считается."
|
||||||
|
)
|
||||||
|
form.addRow("", self.lrc_skip_existing_cb)
|
||||||
|
|
||||||
|
self.lrc_audio_source_cb = QComboBox()
|
||||||
|
self.lrc_audio_source_cb.addItems(["auto", "vocals", "convert", "input"])
|
||||||
|
self.lrc_audio_source_cb.setToolTip(
|
||||||
|
"auto: vocals.wav → convert.wav → 16k WAV из исходника.\n"
|
||||||
|
"vocals/convert: только соответствующий файл.\n"
|
||||||
|
"input: оригинальный mp3/flac."
|
||||||
|
)
|
||||||
|
form.addRow("Источник аудио:", self.lrc_audio_source_cb)
|
||||||
|
|
||||||
|
self.lrc_language_cb = QComboBox()
|
||||||
|
self.lrc_language_cb.setEditable(True)
|
||||||
|
self.lrc_language_cb.addItems(LANGUAGES)
|
||||||
|
form.addRow("Язык (для align):", self.lrc_language_cb)
|
||||||
|
|
||||||
|
self.lrc_align_model_edit = QLineEdit()
|
||||||
|
self.lrc_align_model_edit.setPlaceholderText("(пусто — whisperx по языку)")
|
||||||
|
form.addRow("Align модель:", self.lrc_align_model_edit)
|
||||||
|
|
||||||
|
self.lrc_device_cb = QComboBox()
|
||||||
|
self.lrc_device_cb.addItems(["auto", "cuda", "cpu"])
|
||||||
|
form.addRow("Device:", self.lrc_device_cb)
|
||||||
|
|
||||||
|
self.lrc_embed_in_place_cb = QCheckBox("Встраивать in-place (перезаписывать оригинал)")
|
||||||
|
self.lrc_embed_in_place_cb.setToolTip(
|
||||||
|
"Если выключено (по умолчанию) — создаётся копия в <output>/<relpath>/<stem>/<name>.\n"
|
||||||
|
"Если включено — оригинальный файл модифицируется."
|
||||||
|
)
|
||||||
|
form.addRow("", self.lrc_embed_in_place_cb)
|
||||||
|
|
||||||
|
btns = QHBoxLayout()
|
||||||
|
self.lrc_generate_btn = QPushButton("🎵 Сгенерировать LRC")
|
||||||
|
self.lrc_generate_btn.setObjectName("primary")
|
||||||
|
self.lrc_generate_btn.clicked.connect(lambda: self._run_task("lrc"))
|
||||||
|
self.lrc_embed_btn = QPushButton("📝 Встроить LRC в аудио")
|
||||||
|
self.lrc_embed_btn.clicked.connect(lambda: self._run_task("embed"))
|
||||||
|
btns.addWidget(self.lrc_generate_btn)
|
||||||
|
btns.addWidget(self.lrc_embed_btn)
|
||||||
|
btns.addStretch(1)
|
||||||
|
form.addRow("", btns)
|
||||||
|
|
||||||
|
return w
|
||||||
|
|
||||||
# ---------------- visual sync ----------------
|
# ---------------- visual sync ----------------
|
||||||
|
|
||||||
def _log_device_info(self) -> None:
|
def _log_device_info(self) -> None:
|
||||||
@@ -347,6 +498,7 @@ class MainWindow(QMainWindow):
|
|||||||
bar.setTabTextColor(2, active if self.convert_enabled_cb.isChecked() else inactive)
|
bar.setTabTextColor(2, active if self.convert_enabled_cb.isChecked() else inactive)
|
||||||
bar.setTabTextColor(3, active if self.separate_enabled_cb.isChecked() else inactive)
|
bar.setTabTextColor(3, active if self.separate_enabled_cb.isChecked() else inactive)
|
||||||
bar.setTabTextColor(4, active if self.transcribe_enabled_cb.isChecked() else inactive)
|
bar.setTabTextColor(4, active if self.transcribe_enabled_cb.isChecked() else inactive)
|
||||||
|
bar.setTabTextColor(5, active if self.lrc_enabled_cb.isChecked() else inactive)
|
||||||
|
|
||||||
def _sync_engine_fields(self) -> None:
|
def _sync_engine_fields(self) -> None:
|
||||||
is_whisperx = self.engine_cb.currentText() == "whisperx"
|
is_whisperx = self.engine_cb.currentText() == "whisperx"
|
||||||
@@ -365,22 +517,25 @@ class MainWindow(QMainWindow):
|
|||||||
self.recursive_cb.setChecked(cfg.recursive)
|
self.recursive_cb.setChecked(cfg.recursive)
|
||||||
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
|
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
|
||||||
self.overwrite_cb.setChecked(cfg.overwrite)
|
self.overwrite_cb.setChecked(cfg.overwrite)
|
||||||
|
self.skip_existing_cb.setChecked(cfg.skip_existing)
|
||||||
|
self.resume_partial_cb.setChecked(cfg.resume_partial)
|
||||||
i = self.mode_cb.findData(cfg.mode)
|
i = self.mode_cb.findData(cfg.mode)
|
||||||
if i >= 0:
|
if i >= 0:
|
||||||
self.mode_cb.setCurrentIndex(i)
|
self.mode_cb.setCurrentIndex(i)
|
||||||
|
|
||||||
self.shazam_enabled_cb.setChecked(cfg.shazam.enabled)
|
self.shazam_enabled_cb.setChecked(cfg.shazam.enabled)
|
||||||
|
self.shazam_concurrency.setValue(cfg.shazam.identify_concurrency)
|
||||||
|
|
||||||
self.lyrics_enabled_cb.setChecked(cfg.lyrics.enabled)
|
self.lyrics_enabled_cb.setChecked(cfg.lyrics.enabled)
|
||||||
self.lyrics_user_agent.setText(cfg.lyrics.user_agent)
|
self.lyrics_user_agent.setText(cfg.lyrics.user_agent)
|
||||||
self.lyrics_use_tags_cb.setChecked(cfg.lyrics.use_tags_first)
|
self.lyrics_use_tags_cb.setChecked(cfg.lyrics.use_tags_first)
|
||||||
self.lyrics_use_shazam_cb.setChecked(cfg.lyrics.use_shazam_result)
|
self.lyrics_use_shazam_cb.setChecked(cfg.lyrics.use_shazam_result)
|
||||||
self.lyrics_after_asr_cb.setChecked(cfg.lyrics.try_after_asr)
|
self.lyrics_after_asr_cb.setChecked(cfg.lyrics.try_after_asr)
|
||||||
self.lyrics_transcribe_unresolved_cb.setChecked(
|
self.lyrics_transcribe_unresolved_cb.setChecked(cfg.lyrics.transcribe_if_identified_no_lyrics)
|
||||||
cfg.lyrics.transcribe_if_identified_no_lyrics
|
|
||||||
)
|
|
||||||
self.lyrics_threshold.setValue(cfg.lyrics.fuzzy_threshold)
|
self.lyrics_threshold.setValue(cfg.lyrics.fuzzy_threshold)
|
||||||
|
self.lyrics_use_embedded_cb.setChecked(cfg.lyrics.use_embedded_lyrics)
|
||||||
|
self.lyrics_save_unrecognized_cb.setChecked(cfg.lyrics.save_unrecognized)
|
||||||
|
self.lyrics_unrecognized_path_edit.setText(cfg.lyrics.unrecognized_path)
|
||||||
|
|
||||||
self.convert_enabled_cb.setChecked(cfg.convert.enabled)
|
self.convert_enabled_cb.setChecked(cfg.convert.enabled)
|
||||||
i = self.convert_sr.findData(cfg.convert.sample_rate)
|
i = self.convert_sr.findData(cfg.convert.sample_rate)
|
||||||
@@ -390,12 +545,14 @@ class MainWindow(QMainWindow):
|
|||||||
if i >= 0:
|
if i >= 0:
|
||||||
self.convert_ch.setCurrentIndex(i)
|
self.convert_ch.setCurrentIndex(i)
|
||||||
self.convert_ffmpeg.setText(cfg.convert.ffmpeg_path)
|
self.convert_ffmpeg.setText(cfg.convert.ffmpeg_path)
|
||||||
|
self.convert_concurrency.setValue(cfg.convert.convert_concurrency)
|
||||||
|
|
||||||
self.separate_enabled_cb.setChecked(cfg.separate.enabled)
|
self.separate_enabled_cb.setChecked(cfg.separate.enabled)
|
||||||
self.separate_model.setText(cfg.separate.model)
|
self.separate_model.setText(cfg.separate.model)
|
||||||
self.separate_stem.setText(cfg.separate.isolate_stem)
|
self.separate_stem.setText(cfg.separate.isolate_stem)
|
||||||
self.separate_unblend.setText(cfg.separate.unblend_path)
|
self.separate_unblend.setText(cfg.separate.unblend_path)
|
||||||
self.separate_kernels_cb.setChecked(cfg.separate.custom_kernels)
|
self.separate_kernels_cb.setChecked(cfg.separate.custom_kernels)
|
||||||
|
self.separate_save_vocals_cb.setChecked(cfg.separate.save_vocals_copy)
|
||||||
|
|
||||||
self.transcribe_enabled_cb.setChecked(cfg.transcribe.enabled)
|
self.transcribe_enabled_cb.setChecked(cfg.transcribe.enabled)
|
||||||
self.engine_cb.setCurrentText(cfg.transcribe.engine)
|
self.engine_cb.setCurrentText(cfg.transcribe.engine)
|
||||||
@@ -407,6 +564,16 @@ class MainWindow(QMainWindow):
|
|||||||
self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider)
|
self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider)
|
||||||
self.local_files_only_cb.setChecked(cfg.transcribe.local_files_only)
|
self.local_files_only_cb.setChecked(cfg.transcribe.local_files_only)
|
||||||
|
|
||||||
|
self.lrc_audio_source_cb.setCurrentText(cfg.lrc.audio_source)
|
||||||
|
self.lrc_language_cb.setCurrentText(cfg.lrc.language or "auto")
|
||||||
|
self.lrc_align_model_edit.setText(cfg.lrc.align_model or "")
|
||||||
|
self.lrc_device_cb.setCurrentText(cfg.lrc.device)
|
||||||
|
self.lrc_embed_in_place_cb.setChecked(cfg.lrc.embed_in_place)
|
||||||
|
self.lrc_enabled_cb.setChecked(cfg.lrc.enabled)
|
||||||
|
self.lrc_embed_enabled_cb.setChecked(cfg.lrc.embed_enabled)
|
||||||
|
self.lrc_skip_existing_cb.setChecked(cfg.lrc.skip_existing)
|
||||||
|
self.chunk_size_spin.setValue(cfg.chunk_size)
|
||||||
|
|
||||||
def _collect_config(self) -> PipelineConfig:
|
def _collect_config(self) -> PipelineConfig:
|
||||||
cfg = PipelineConfig()
|
cfg = PipelineConfig()
|
||||||
cfg.input_dir = self.input_edit.text().strip()
|
cfg.input_dir = self.input_edit.text().strip()
|
||||||
@@ -414,10 +581,14 @@ class MainWindow(QMainWindow):
|
|||||||
cfg.recursive = self.recursive_cb.isChecked()
|
cfg.recursive = self.recursive_cb.isChecked()
|
||||||
cfg.keep_intermediate = self.keep_intermediate_cb.isChecked()
|
cfg.keep_intermediate = self.keep_intermediate_cb.isChecked()
|
||||||
cfg.overwrite = self.overwrite_cb.isChecked()
|
cfg.overwrite = self.overwrite_cb.isChecked()
|
||||||
|
cfg.skip_existing = self.skip_existing_cb.isChecked()
|
||||||
|
cfg.resume_partial = self.resume_partial_cb.isChecked()
|
||||||
cfg.mode = self.mode_cb.currentData()
|
cfg.mode = self.mode_cb.currentData()
|
||||||
|
|
||||||
cfg.shazam = ShazamConfig(enabled=self.shazam_enabled_cb.isChecked())
|
cfg.shazam = ShazamConfig(
|
||||||
|
enabled=self.shazam_enabled_cb.isChecked(),
|
||||||
|
identify_concurrency=self.shazam_concurrency.value(),
|
||||||
|
)
|
||||||
cfg.lyrics = LyricsConfig(
|
cfg.lyrics = LyricsConfig(
|
||||||
enabled=self.lyrics_enabled_cb.isChecked(),
|
enabled=self.lyrics_enabled_cb.isChecked(),
|
||||||
user_agent=self.lyrics_user_agent.text().strip() or "audio-transcriber/1.0",
|
user_agent=self.lyrics_user_agent.text().strip() or "audio-transcriber/1.0",
|
||||||
@@ -426,13 +597,16 @@ class MainWindow(QMainWindow):
|
|||||||
try_after_asr=self.lyrics_after_asr_cb.isChecked(),
|
try_after_asr=self.lyrics_after_asr_cb.isChecked(),
|
||||||
transcribe_if_identified_no_lyrics=self.lyrics_transcribe_unresolved_cb.isChecked(),
|
transcribe_if_identified_no_lyrics=self.lyrics_transcribe_unresolved_cb.isChecked(),
|
||||||
fuzzy_threshold=float(self.lyrics_threshold.value()),
|
fuzzy_threshold=float(self.lyrics_threshold.value()),
|
||||||
|
use_embedded_lyrics=self.lyrics_use_embedded_cb.isChecked(),
|
||||||
|
save_unrecognized=self.lyrics_save_unrecognized_cb.isChecked(),
|
||||||
|
unrecognized_path=self.lyrics_unrecognized_path_edit.text().strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg.convert = ConvertConfig(
|
cfg.convert = ConvertConfig(
|
||||||
enabled=self.convert_enabled_cb.isChecked(),
|
enabled=self.convert_enabled_cb.isChecked(),
|
||||||
sample_rate=int(self.convert_sr.currentData()),
|
sample_rate=int(self.convert_sr.currentData()),
|
||||||
channels=int(self.convert_ch.currentData()),
|
channels=int(self.convert_ch.currentData()),
|
||||||
ffmpeg_path=self.convert_ffmpeg.text().strip() or "ffmpeg",
|
ffmpeg_path=self.convert_ffmpeg.text().strip() or "ffmpeg",
|
||||||
|
convert_concurrency=self.convert_concurrency.value(),
|
||||||
)
|
)
|
||||||
cfg.separate = SeparateConfig(
|
cfg.separate = SeparateConfig(
|
||||||
enabled=self.separate_enabled_cb.isChecked(),
|
enabled=self.separate_enabled_cb.isChecked(),
|
||||||
@@ -440,8 +614,8 @@ class MainWindow(QMainWindow):
|
|||||||
isolate_stem=self.separate_stem.text().strip() or "vocals",
|
isolate_stem=self.separate_stem.text().strip() or "vocals",
|
||||||
unblend_path=self.separate_unblend.text().strip() or "unblend",
|
unblend_path=self.separate_unblend.text().strip() or "unblend",
|
||||||
custom_kernels=self.separate_kernels_cb.isChecked(),
|
custom_kernels=self.separate_kernels_cb.isChecked(),
|
||||||
|
save_vocals_copy=self.separate_save_vocals_cb.isChecked(),
|
||||||
)
|
)
|
||||||
|
|
||||||
lang = self.lang_cb.currentText().strip()
|
lang = self.lang_cb.currentText().strip()
|
||||||
compute = self.compute_type_cb.currentText().strip()
|
compute = self.compute_type_cb.currentText().strip()
|
||||||
cfg.transcribe = TranscribeConfig(
|
cfg.transcribe = TranscribeConfig(
|
||||||
@@ -455,6 +629,18 @@ class MainWindow(QMainWindow):
|
|||||||
gigaam_provider=self.gigaam_provider_cb.currentText(),
|
gigaam_provider=self.gigaam_provider_cb.currentText(),
|
||||||
local_files_only=self.local_files_only_cb.isChecked(),
|
local_files_only=self.local_files_only_cb.isChecked(),
|
||||||
)
|
)
|
||||||
|
lrc_lang = self.lrc_language_cb.currentText().strip()
|
||||||
|
cfg.lrc = LrcConfig(
|
||||||
|
enabled=self.lrc_enabled_cb.isChecked(),
|
||||||
|
embed_enabled=self.lrc_embed_enabled_cb.isChecked(),
|
||||||
|
skip_existing=self.lrc_skip_existing_cb.isChecked(),
|
||||||
|
language=None if lrc_lang in ("", "auto") else lrc_lang,
|
||||||
|
align_model=self.lrc_align_model_edit.text().strip() or None,
|
||||||
|
device=self.lrc_device_cb.currentText(),
|
||||||
|
audio_source=self.lrc_audio_source_cb.currentText(),
|
||||||
|
embed_in_place=self.lrc_embed_in_place_cb.isChecked(),
|
||||||
|
)
|
||||||
|
cfg.chunk_size = self.chunk_size_spin.value()
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
# ---------------- handlers ----------------
|
# ---------------- handlers ----------------
|
||||||
@@ -492,11 +678,19 @@ class MainWindow(QMainWindow):
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
QMessageBox.critical(self, "Ошибка", str(exc))
|
QMessageBox.critical(self, "Ошибка", str(exc))
|
||||||
|
|
||||||
def _on_start(self) -> None:
|
def _set_busy(self, busy: bool) -> None:
|
||||||
|
self.start_btn.setEnabled(not busy)
|
||||||
|
self.lrc_generate_btn.setEnabled(not busy)
|
||||||
|
self.lrc_embed_btn.setEnabled(not busy)
|
||||||
|
self.stop_btn.setEnabled(busy)
|
||||||
|
|
||||||
|
def _run_task(self, task: str) -> None:
|
||||||
cfg = self._collect_config()
|
cfg = self._collect_config()
|
||||||
if not cfg.input_dir or not Path(cfg.input_dir).is_dir():
|
if not cfg.input_dir or not Path(cfg.input_dir).is_dir():
|
||||||
QMessageBox.warning(self, "Нет входной папки", "Выберите существующую входную папку.")
|
QMessageBox.warning(self, "Нет входной папки", "Выберите существующую входную папку.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if task == "main":
|
||||||
has_work = any([
|
has_work = any([
|
||||||
cfg.shazam.enabled, cfg.lyrics.enabled,
|
cfg.shazam.enabled, cfg.lyrics.enabled,
|
||||||
cfg.convert.enabled, cfg.separate.enabled, cfg.transcribe.enabled,
|
cfg.convert.enabled, cfg.separate.enabled, cfg.transcribe.enabled,
|
||||||
@@ -514,19 +708,52 @@ class MainWindow(QMainWindow):
|
|||||||
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.")
|
f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if task == "lrc" and not shutil.which(cfg.convert.ffmpeg_path):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "ffmpeg не найден",
|
||||||
|
f"'{cfg.convert.ffmpeg_path}' не найден в PATH.\n"
|
||||||
|
"LRC-этапу нужен ffmpeg для подготовки аудио."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if task == "lrc" and cfg.transcribe.engine != "whisperx":
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "LRC требует WhisperX",
|
||||||
|
"Align-модель доступна только для WhisperX. Переключите движок на whisperx."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if task == "lrc" and not cfg.lrc.enabled:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "LRC отключён",
|
||||||
|
"Этап генерации 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 отключено",
|
||||||
|
"Этап встраивания LRC выключен. Включите его на вкладке LRC."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self.log_view.clear()
|
self.log_view.clear()
|
||||||
self._log_device_info()
|
self._log_device_info()
|
||||||
if cfg.transcribe.enabled and cfg.transcribe.language is None:
|
|
||||||
self._append_log("⚠ Язык = auto: WhisperX определит язык на каждом файле.")
|
|
||||||
|
|
||||||
self.progress.setValue(0)
|
self.progress.setValue(0)
|
||||||
self.progress.setFormat("Запуск…")
|
self.progress.setFormat("Запуск…")
|
||||||
self.start_btn.setEnabled(False)
|
self.overall_progress.setMaximum(1)
|
||||||
self.stop_btn.setEnabled(True)
|
self.overall_progress.setValue(0)
|
||||||
|
self.overall_progress.setFormat("Общий прогресс: запуск…")
|
||||||
|
self._set_busy(True)
|
||||||
|
|
||||||
self._worker = PipelineWorker(cfg)
|
self._worker = PipelineWorker(cfg, task=task)
|
||||||
self._worker.log.connect(self._append_log)
|
self._worker.log.connect(self._append_log)
|
||||||
self._worker.progress.connect(self._on_progress)
|
self._worker.progress.connect(self._on_progress)
|
||||||
|
self._worker.overall_progress.connect(self._on_overall_progress)
|
||||||
self._worker.finished_ok.connect(self._on_finished)
|
self._worker.finished_ok.connect(self._on_finished)
|
||||||
self._worker.failed.connect(self._on_failed)
|
self._worker.failed.connect(self._on_failed)
|
||||||
self._worker.start()
|
self._worker.start()
|
||||||
@@ -541,23 +768,61 @@ class MainWindow(QMainWindow):
|
|||||||
sb = self.log_view.verticalScrollBar()
|
sb = self.log_view.verticalScrollBar()
|
||||||
sb.setValue(sb.maximum())
|
sb.setValue(sb.maximum())
|
||||||
|
|
||||||
def _on_progress(self, done: int, total: int, stage: str) -> None:
|
def _on_progress(self, stage: str, done: int, total: int) -> None:
|
||||||
self.progress.setMaximum(total)
|
|
||||||
self.progress.setValue(done)
|
|
||||||
label = STAGE_LABELS.get(stage, stage)
|
label = STAGE_LABELS.get(stage, stage)
|
||||||
self.progress.setFormat(f"{label} — %v / %m")
|
if total > 0:
|
||||||
|
self.progress.setMaximum(total)
|
||||||
|
self.progress.setValue(min(done, total))
|
||||||
|
self.progress.setFormat(f"{label}: %v / %m")
|
||||||
|
else:
|
||||||
|
# Nothing to do for this stage — still show which stage we are on
|
||||||
|
# instead of leaving the previous stage's label frozen.
|
||||||
|
self.progress.setMaximum(1)
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.progress.setFormat(f"{label}: 0 / 0")
|
||||||
|
|
||||||
|
def _on_overall_progress(self, done: int, total: int) -> None:
|
||||||
|
"""Overall job progress: completed files / all files in the run."""
|
||||||
|
if total <= 0:
|
||||||
|
self.overall_progress.setMaximum(1)
|
||||||
|
self.overall_progress.setValue(0)
|
||||||
|
self.overall_progress.setFormat("Общий прогресс: 0 / 0")
|
||||||
|
return
|
||||||
|
|
||||||
|
done = max(0, min(done, total))
|
||||||
|
remaining = total - done
|
||||||
|
percent = int(done * 100 / total)
|
||||||
|
self.overall_progress.setMaximum(total)
|
||||||
|
self.overall_progress.setValue(done)
|
||||||
|
self.overall_progress.setFormat(
|
||||||
|
f"Общий прогресс: {done} / {total} ({percent}%) · осталось {remaining}"
|
||||||
|
)
|
||||||
|
|
||||||
def _on_finished(self, results: list) -> None:
|
def _on_finished(self, results: list) -> None:
|
||||||
self.start_btn.setEnabled(True)
|
self._set_busy(False)
|
||||||
self.stop_btn.setEnabled(False)
|
|
||||||
ok = sum(1 for r in results if r.error is None)
|
ok = sum(1 for r in results if r.error is None)
|
||||||
fail = len(results) - ok
|
fail = len(results) - ok
|
||||||
self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}")
|
self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}")
|
||||||
|
total = self.overall_progress.maximum()
|
||||||
|
done = self.overall_progress.value()
|
||||||
|
if total > 0 and done >= total:
|
||||||
|
self.overall_progress.setFormat(
|
||||||
|
f"Общий прогресс: {done} / {total} — завершено"
|
||||||
|
)
|
||||||
|
elif total > 0:
|
||||||
|
remaining = total - done
|
||||||
|
self.overall_progress.setFormat(
|
||||||
|
f"Общий прогресс: {done} / {total} — остановлено, осталось {remaining}"
|
||||||
|
)
|
||||||
self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
|
self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")
|
||||||
|
|
||||||
def _on_failed(self, message: str) -> None:
|
def _on_failed(self, message: str) -> None:
|
||||||
self.start_btn.setEnabled(True)
|
self._set_busy(False)
|
||||||
self.stop_btn.setEnabled(False)
|
|
||||||
self.progress.setFormat("Ошибка")
|
self.progress.setFormat("Ошибка")
|
||||||
|
if self.overall_progress.maximum() > 0:
|
||||||
|
self.overall_progress.setFormat(
|
||||||
|
f"Общий прогресс: {self.overall_progress.value()} / "
|
||||||
|
f"{self.overall_progress.maximum()} — ошибка"
|
||||||
|
)
|
||||||
self._append_log(f"\n✗ Ошибка пайплайна: {message}")
|
self._append_log(f"\n✗ Ошибка пайплайна: {message}")
|
||||||
QMessageBox.critical(self, "Ошибка", message)
|
QMessageBox.critical(self, "Ошибка", message)
|
||||||
|
|||||||
26
gui/theme.py
26
gui/theme.py
@@ -170,20 +170,32 @@ QCheckBox::indicator:checked {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QProgressBar {
|
QProgressBar {
|
||||||
background-color: #181825;
|
background-color: #0b0b12;
|
||||||
border: 1px solid #313244;
|
border: 1px solid #7f849c;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #cdd6f4;
|
color: #ffffff;
|
||||||
min-height: 24px;
|
min-height: 26px;
|
||||||
max-height: 24px;
|
max-height: 26px;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
QProgressBar::chunk {
|
QProgressBar::chunk {
|
||||||
background-color: #89b4fa;
|
background-color: #1e66f5;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
margin: 1px;
|
margin: 1px;
|
||||||
}
|
}
|
||||||
|
QProgressBar#overallProgress {
|
||||||
|
min-height: 30px;
|
||||||
|
max-height: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #a6adc8;
|
||||||
|
}
|
||||||
|
QProgressBar#overallProgress::chunk {
|
||||||
|
background-color: #2f9e44;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
QScrollBar:vertical {
|
QScrollBar:vertical {
|
||||||
background: #181825;
|
background: #181825;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""QThread wrapper around core.pipeline.run_pipeline."""
|
"""QThread wrapper. Supports three task types: main, lrc, embed."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
@@ -6,18 +6,19 @@ import threading
|
|||||||
from PySide6.QtCore import QThread, Signal
|
from PySide6.QtCore import QThread, Signal
|
||||||
|
|
||||||
from core.config import PipelineConfig
|
from core.config import PipelineConfig
|
||||||
from core.pipeline import run_pipeline
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineWorker(QThread):
|
class PipelineWorker(QThread):
|
||||||
log = Signal(str)
|
log = Signal(str)
|
||||||
progress = Signal(int, int, str) # done, total, stage
|
progress = Signal(str, int, int) # stage, current, total_for_stage
|
||||||
|
overall_progress = Signal(int, int) # completed_files, total_files
|
||||||
finished_ok = Signal(list)
|
finished_ok = Signal(list)
|
||||||
failed = Signal(str)
|
failed = Signal(str)
|
||||||
|
|
||||||
def __init__(self, config: PipelineConfig, parent=None):
|
def __init__(self, config: PipelineConfig, task: str = "main", parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._config = config
|
self._config = config
|
||||||
|
self._task = task
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
@@ -25,11 +26,19 @@ class PipelineWorker(QThread):
|
|||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
results = run_pipeline(
|
if self._task == "lrc":
|
||||||
|
from core.lrc_pipeline import run_lrc_pipeline as runner
|
||||||
|
elif self._task == "embed":
|
||||||
|
from core.lrc_pipeline import run_embed_pipeline as runner
|
||||||
|
else:
|
||||||
|
from core.pipeline import run_pipeline as runner
|
||||||
|
|
||||||
|
results = runner(
|
||||||
self._config,
|
self._config,
|
||||||
log=self.log.emit,
|
log=self.log.emit,
|
||||||
on_progress=lambda d, t, s: self.progress.emit(d, t, s),
|
on_progress=lambda s, d, t: self.progress.emit(s, d, t),
|
||||||
stop_event=self._stop_event,
|
stop_event=self._stop_event,
|
||||||
|
on_overall_progress=lambda d, t: self.overall_progress.emit(d, t),
|
||||||
)
|
)
|
||||||
self.finished_ok.emit(results)
|
self.finished_ok.emit(results)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
|
|||||||
12
main.py
12
main.py
@@ -26,20 +26,8 @@ def preload_cudnn() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def enable_tf32() -> None:
|
|
||||||
"""Enable TF32 for faster matmul on Ampere+ GPUs (RTX 30/40 series)."""
|
|
||||||
try:
|
|
||||||
import torch
|
|
||||||
if torch.cuda.is_available():
|
|
||||||
torch.backends.cuda.matmul.allow_tf32 = True
|
|
||||||
torch.backends.cudnn.allow_tf32 = True
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
preload_cudnn()
|
preload_cudnn()
|
||||||
enable_tf32()
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
from gui.theme import DARK_STYLESHEET
|
from gui.theme import DARK_STYLESHEET
|
||||||
|
|||||||
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 ".")))
|
||||||
86
migrate_output_to_paths.py
Normal file
86
migrate_output_to_paths.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Migrate flat output/<stem>/ to output/<relpath>/<stem>/ (one-shot)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse, json, shutil, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--input-root", required=True, type=Path)
|
||||||
|
ap.add_argument("--output-root", required=True, type=Path)
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
input_root = args.input_root.resolve()
|
||||||
|
output_root = args.output_root.resolve()
|
||||||
|
|
||||||
|
if not input_root.is_dir():
|
||||||
|
print(f"input root not found: {input_root}", file=sys.stderr); return 1
|
||||||
|
if not output_root.is_dir():
|
||||||
|
print(f"output root not found: {output_root}", file=sys.stderr); return 1
|
||||||
|
|
||||||
|
moved = legacy = conflicts = errors = skipped = 0
|
||||||
|
artifact_names = (
|
||||||
|
"metadata.json", "text.txt", "text.lrc",
|
||||||
|
"vocals.wav", "convert.wav",
|
||||||
|
)
|
||||||
|
for track_dir in sorted(output_root.iterdir()):
|
||||||
|
if not track_dir.is_dir():
|
||||||
|
continue
|
||||||
|
if track_dir.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
# Never treat an album/parent folder as a flat track folder.
|
||||||
|
if not any((track_dir / name).is_file() for name in artifact_names):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
target = output_root / "_legacy" / track_dir.name
|
||||||
|
meta_file = track_dir / "metadata.json"
|
||||||
|
if meta_file.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||||
|
stored = data.get("path") if isinstance(data, dict) else None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
stored = None
|
||||||
|
if stored:
|
||||||
|
try:
|
||||||
|
rel = Path(stored).relative_to(input_root)
|
||||||
|
target = output_root / rel.with_suffix("")
|
||||||
|
except ValueError:
|
||||||
|
target = output_root / "_legacy" / track_dir.name
|
||||||
|
|
||||||
|
if target == track_dir:
|
||||||
|
continue
|
||||||
|
if track_dir in target.parents:
|
||||||
|
print(f"skip: {track_dir.name} -> target inside itself")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if target.exists():
|
||||||
|
print(f"conflict: {track_dir.name} -> "
|
||||||
|
f"{target.relative_to(output_root)}")
|
||||||
|
conflicts += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"DRY: {track_dir.name} -> {target.relative_to(output_root)}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(track_dir), str(target))
|
||||||
|
if "_legacy" in target.parts:
|
||||||
|
legacy += 1
|
||||||
|
else:
|
||||||
|
moved += 1
|
||||||
|
except OSError as e:
|
||||||
|
print(f"error: {track_dir.name}: {e}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
print(f"\nmoved: {moved} quarantined: {legacy} "
|
||||||
|
f"conflicts: {conflicts} skipped: {skipped} errors: {errors} "
|
||||||
|
f"(dry_run={args.dry_run})")
|
||||||
|
return 0 if errors == 0 else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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