Compare commits

..

14 Commits

Author SHA1 Message Date
jadssss
f023653c71 align LRC from ready text.txt via stable-ts and auto-detect language
- add align_text_to_audio(): forced alignment without ASR, fast
- add _StableTSModels cache; detect language via stable-ts when language=None
- LRC stage: use existing text.txt, copy it if already LRC, fall back to ASR only
- filter junk lyrics from tags and ASR hallucinations (promo phrases, tics)
- mark empty ASR results with metadata.empty_text and skip them on resume
- warn in GUI when LRC is run with transcription disabled
- add mark_no_speech.py one-shot script for existing tracks
2026-11-02 01:30:30 +03:00
jadssss
a2e0070ace update output layout hints in the GUI 2026-10-29 23:33:34 +03:00
jadssss
2ac7dfd7b7 store outputs under the input-relative path
- add _output_dir_for(): output/<relpath_without_ext>\n- use it in sequential, staged, LRC and embed pipelines\n- keep track folders unique for equal stems in different albums\n- log the layout and report folder collisions\n- add a safe one-shot flat-to-path migration script
2026-10-29 23:32:37 +03:00
jadssss
841fc8902d handle dead CUDA contexts and empty ASR results
- detect unrecoverable CUDA errors before OOM retries and clear model caches\n- keep normal OOM retries to two attempts (configured batch, then 1)\n- do not create text.txt when ASR returns only whitespace\n- warn per file and report empty_result / lrc+empty in the summary\n- keep at least 12 log lines visible in the GUI
2026-10-28 23:53:27 +03:00
jadssss
60846e3c6c load the ASR model once per process
- keep WhisperX, align and GigaAM caches process-wide and thread-safe\n- preload the ASR model at the start of the staged stage 3\n- log that the model is loaded once and then reused\n- never clear caches between files, batches or OOM retries
2026-10-28 09:54:06 +03:00
jadssss
e23acf1555 stop passing vad_filter to whisperx transcribe
- retry only lowers batch_size: configured, 1, 1\n- keep vad_filter parameters for future model-load-level control\n- do not log success in staged finalize when no text was produced
2026-10-28 09:47:27 +03:00
jadssss
5f16a6f87b base skip_existing on the last enabled stage
- add _final_artifact_present and use it for resume decisions\n- text.txt no longer marks a track done when separation is the last stage\n- apply the same rule to pre-scan, staged seeding and sequential skipping\n- keep identification-only runs resolving through text.txt\n- preserve existing metadata tags when a stage-only rerun has less info
2026-10-28 00:48:53 +03:00
jadssss
84d7a28c69 pre-scan finished tracks and load CUDA lazily
- pre-scan all inputs before chunking so ready files stay out of batches\n- start overall progress at the already-finished count\n- count skipped/resumed files correctly in sequential and staged runs\n- keep batches full and log ready/pending counts before starting\n- avoid importing Torch at startup or in cleanup for separation-only runs\n- move TF32 setup into lazy WhisperX model loading\n- detect the GPU through nvidia-smi for the GUI status line
2026-10-27 23:19:56 +03:00
jadssss
f88ca913ce make long-file CUDA OOM recoverable per file
- replace permanent _CUDA_BROKEN flag with CudaOOMError and retry helper
- retry WhisperX with configured settings, then batch=1 without VAD, then once more
- keep the loaded model cached between retries instead of reloading it
- actually pass vad_filter through transcribe() and use it in aligned ASR
- skip only the OOM file in sequential, staged and LRC pipelines
- mark OOM files with oom_skipped / lrc+oom for the summary
- fix staged vocal separation writing input_dir/vocals.wav when convert is off
- route original inputs and resumed files through safe per-file separation
- validate ffmpeg before starting the LRC task
2026-10-27 23:03:29 +03:00
jadssss
999934f174 improve GUI layout and LRC stage controls
- make the main page scrollable and keep the console reachable on small screens
- add fullscreen toggle (button + F11, Escape to exit)
- add enable switches for LRC generation/embedding and skip ready text.lrc
- sanitize multi-line ASR text before writing LRC timestamps
- emit LRC/embed progress once per file instead of twice
- increase progress-bar contrast
2026-10-27 22:29:13 +03:00
jadssss
15c0ba5d66 add resume support, metadata paths, LRCLIB order and overall progress
- store source track path in metadata.json and validate it when skipping ready files
- add skip_existing/resume_partial options to config and GUI
- identify lyrics via embedded text, Shazam, then one LRCLIB author search
- keep post-ASR LRCLIB text matching and allow cancelling safely
- recover from CUDA OOM and free VRAM between batch stages
- add overall completed/total file progress bar to GUI and pipelines
2026-10-25 20:24:01 +03:00
jadssss
199d12a274 fix some bugs, add LRC synchronizer, text saver (if text not in LIBLRC, for upgrade library) 2026-10-24 10:02:05 +03:00
jadssss
fe338e6880 fix some bugs, and fix models 2026-10-24 08:39:33 +03:00
jadssss
78636699dc update shazam and liblrc services. adding text synchronizations 2026-10-24 08:30:35 +03:00
14 changed files with 2685 additions and 501 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ output/
preset.json preset.json
*.log *.log
combined_files.txt

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Mark tracks with metadata.empty_text=true if their text is missing or hallucinated."""
import json, sys
from pathlib import Path
HALLUCINATION = (
"продолжение следует", "субтитры сделан", "субтитры создава",
"created with", "russian-area.de", "музыка музыка",
)
def main(root: Path) -> int:
count = 0
for meta in root.rglob("metadata.json"):
track = meta.parent
txt = track / "text.txt"
if txt.is_file():
body = txt.read_text(encoding="utf-8", errors="ignore").strip().lower()
# If the text is real (not hallucination) and not empty, skip marking.
if body and not any(h in body for h in HALLUCINATION):
continue
# Remove the text.txt so the next run will re-process it.
txt.unlink()
# Set empty_text=True in metadata.
try:
data = json.loads(meta.read_text(encoding="utf-8"))
except (OSError, ValueError):
data = {}
data["empty_text"] = True
meta.write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8",
)
print(f"marked: {track}")
count += 1
print(f"\nmarked {count} tracks")
return 0
if __name__ == "__main__":
sys.exit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))

View File

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

View File

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