Compare commits

..

2 Commits

Author SHA1 Message Date
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
10 changed files with 892 additions and 241 deletions

1
.gitignore vendored
View File

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

View File

@@ -66,6 +66,9 @@ class LyricsConfig:
@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"
@@ -81,6 +84,9 @@ class PipelineConfig:
keep_intermediate: bool = True
overwrite: bool = True
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)
separate: SeparateConfig = field(default_factory=SeparateConfig)

View File

@@ -37,6 +37,10 @@ def segments_to_lrc(segments: list[dict]) -> str:
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"

View File

@@ -11,12 +11,15 @@ 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, _save_unrecognized, collect_audio_files
from .pipeline import (
FileResult, _artifact_belongs_to, _save_unrecognized, collect_audio_files,
)
from .transcribe import transcribe_aligned
LogFn = Callable[[str], None]
ProgressFn = Callable[[int, int, str], None]
ProgressFn = Callable[[str, int, int], None] # stage, current, total
OverallFn = Callable[[int, int], None] # completed_files, total_files
def _pick_audio_source(
@@ -44,11 +47,26 @@ def _pick_audio_source(
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():
@@ -56,6 +74,10 @@ def run_lrc_pipeline(
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("Аудиофайлы не найдены.")
@@ -67,13 +89,15 @@ def run_lrc_pipeline(
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
if on_progress:
on_progress(idx - 1, total, "lrc")
log(f"\n▶ [{idx}/{total}] {f.name}")
track_out = output_dir / f.stem
@@ -81,6 +105,17 @@ def run_lrc_pipeline(
lrc_path = track_out / "text.lrc"
error: Optional[str] = None
if config.lrc.skip_existing:
ready = _ready_lrc(track_out, f)
if ready is not None:
log(" ⊘ text.lrc уже готов — пропуск")
results.append(FileResult(f, None, track_out, None, "existing+lrc"))
if on_progress:
on_progress("lrc", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
continue
tmp = Path(tempfile.mkdtemp(prefix=f"at_lrc_{f.stem}_"))
try:
embedded = read_embedded_lyrics(f) if config.lyrics.use_embedded_lyrics else None
@@ -127,7 +162,9 @@ def run_lrc_pipeline(
finally:
shutil.rmtree(tmp, ignore_errors=True)
if on_progress:
on_progress(idx, total, "lrc")
on_progress("lrc", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results
@@ -137,6 +174,7 @@ def run_embed_pipeline(
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():
@@ -144,6 +182,10 @@ def run_embed_pipeline(
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("Аудиофайлы не найдены.")
@@ -154,13 +196,15 @@ def run_embed_pipeline(
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
if on_progress:
on_progress(idx - 1, total, "embed")
lrc_path = output_dir / f.stem / "text.lrc"
error: Optional[str] = None
@@ -190,6 +234,8 @@ def run_embed_pipeline(
results.append(FileResult(f, None, None, error, "embed"))
finally:
if on_progress:
on_progress(idx, total, "embed")
on_progress("embed", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results

View File

@@ -27,7 +27,8 @@ AUDIO_EXTENSIONS = {
}
LogFn = Callable[[str], None]
ProgressFn = Callable[[int, int, str], None]
ProgressFn = Callable[[str, int, int], None] # stage, current, total_for_stage
OverallFn = Callable[[int, int], None] # completed_files, total_files
@dataclass
@@ -46,6 +47,18 @@ class Cancelled(Exception):
# ---------------------------------------------------------------- helpers
def _free_memory() -> None:
"""Free unreferenced RAM and VRAM. Keeps model caches alive."""
import gc
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception: # noqa: BLE001 — never fail cleanup on a broken CUDA context
pass
def collect_audio_files(input_dir: Path, recursive: bool) -> list[Path]:
it = input_dir.rglob("*") if recursive else input_dir.iterdir()
return sorted(p for p in it if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS)
@@ -79,8 +92,14 @@ def _build_metadata_dict(
metadata: TrackMetadata,
shazam: Optional[ShazamResult],
source: Optional[str],
input_path: Optional[Path] = None,
) -> Optional[dict]:
d: dict = {}
if input_path is not None:
try:
d["path"] = str(input_path.resolve())
except (OSError, RuntimeError):
d["path"] = str(input_path)
if metadata.has_identity:
d["tags"] = {
"title": metadata.title,
@@ -100,6 +119,30 @@ def _build_metadata_dict(
return d if d else None
def _artifact_belongs_to(track_out: Path, input_path: Path) -> bool:
"""Check that an output folder was created for this exact input file.
``metadata.json`` now stores the source path. If an output folder with the
same stem belongs to a different file (e.g. two albums both contain
``01 - Intro.mp3``), its artifacts must not be treated as "ready" for this
track. Legacy folders without a ``path`` key are still accepted.
"""
meta_file = track_out / "metadata.json"
if not meta_file.is_file():
return True
try:
data = json.loads(meta_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return True
stored = data.get("path") if isinstance(data, dict) else None
if not stored:
return True
try:
return Path(str(stored)).resolve() == input_path.resolve()
except (OSError, RuntimeError):
return Path(str(stored)) == input_path
def _write_metadata_json(path: Path, data: Optional[dict]) -> None:
if not data:
return
@@ -151,6 +194,7 @@ class IdentifyResult:
text: Optional[str] = None
source: Optional[str] = None
needs_asr: bool = True
lrclib_attempted: bool = False # author/title LRCLIB search was attempted
def _identify_file(
@@ -159,6 +203,15 @@ def _identify_file(
config: PipelineConfig,
log: LogFn,
) -> IdentifyResult:
"""Run cheap identification steps. May populate .text and .source,
and set .needs_asr=False if the track is fully resolved.
Order:
0. embedded lyrics in file tags (USLT/LYRICS/©lyr) — cheapest, no network
1. Shazam fingerprint
2. LRCLIB identity search — exactly once:
Shazam result if available, otherwise artist/title from file tags
"""
result = IdentifyResult(metadata=read_metadata(input_path))
# --- 0) embedded lyrics в самом файле ---
@@ -176,13 +229,66 @@ def _identify_file(
result.needs_asr = False
return result
# --- 1) LRCLIB по тегам ---
if config.lyrics.enabled and config.lyrics.use_tags_first and result.metadata.has_identity:
log(" LRCLIB по тегам...")
# --- 1) Shazam ---
if config.shazam.enabled:
log(" Shazam...")
shazam_wav = work_dir / "shazam_16k.wav"
try:
work_dir.mkdir(parents=True, exist_ok=True)
convert_to_wav(
input_path, shazam_wav,
sample_rate=16000, channels=1,
ffmpeg_path=config.convert.ffmpeg_path,
)
result.shazam = recognize(shazam_wav)
except Exception as e: # noqa: BLE001
log(f" ⚠ Shazam error: {e}")
result.shazam = None
finally:
# 16 kHz WAV для Shazam больше не нужен — удаляем сразу,
# чтобы не копить десятки гигабайт в /tmp на длинных батчах.
try:
shazam_wav.unlink(missing_ok=True)
except OSError:
pass
if result.shazam:
log(f" ✓ {result.shazam.artist} — {result.shazam.title}")
else:
log(" ⚠ не распознан")
# --- 2) LRCLIB: ровно один поиск по автору/названию ---
# Приоритет — результат Shazam. Если Shazam ничего не нашёл (или его
# результат не используется), берём artist/title из тегов файла.
if config.lyrics.enabled:
if result.shazam and config.lyrics.use_shazam_result:
log(" LRCLIB по Shazam...")
result.lrclib_attempted = True
match = search_by_identity(
title=result.metadata.title, artist=result.metadata.artist,
album=result.metadata.album, duration=result.metadata.duration,
user_agent=config.lyrics.user_agent, timeout=config.lyrics.timeout,
title=result.shazam.title,
artist=result.shazam.artist,
album=result.shazam.album,
duration=result.metadata.duration,
user_agent=config.lyrics.user_agent,
timeout=config.lyrics.timeout,
)
if match:
result.text = match.lyrics
result.source = "shazam+lrclib"
result.needs_asr = False
log(f" ✓ {match.artist} — {match.title}")
return result
log(" ⚠ LRCLIB: не найдено")
elif config.lyrics.use_tags_first and result.metadata.has_identity:
log(" LRCLIB по тегам...")
result.lrclib_attempted = True
match = search_by_identity(
title=result.metadata.title,
artist=result.metadata.artist,
album=result.metadata.album,
duration=result.metadata.duration,
user_agent=config.lyrics.user_agent,
timeout=config.lyrics.timeout,
)
if match:
result.text = match.lyrics
@@ -190,41 +296,15 @@ def _identify_file(
result.needs_asr = False
log(f" ✓ {match.artist} — {match.title}")
return result
log(" ⚠ LRCLIB: не найдено")
# --- 2) Shazam ---
if config.shazam.enabled:
log(" Shazam...")
shazam_wav = work_dir / "shazam_16k.wav"
try:
convert_to_wav(
input_path, shazam_wav, sample_rate=16000, channels=1,
ffmpeg_path=config.convert.ffmpeg_path,
)
result.shazam = recognize(shazam_wav)
except Exception as e: # noqa: BLE001
log(f" ⚠ Shazam error: {e}")
result.shazam = None
# --- 3) Identified, but no lyrics ---
if result.shazam:
log(f" ✓ {result.shazam.artist} — {result.shazam.title}")
if config.lyrics.enabled and config.lyrics.use_shazam_result:
match = search_by_identity(
title=result.shazam.title, artist=result.shazam.artist,
album=result.shazam.album, duration=result.metadata.duration,
user_agent=config.lyrics.user_agent, timeout=config.lyrics.timeout,
)
if match:
result.text = match.lyrics
result.source = "shazam+lrclib"
result.needs_asr = False
log(" ✓ текст найден через LRCLIB")
return result
result.source = "shazam"
if not config.lyrics.transcribe_if_identified_no_lyrics:
result.needs_asr = False
if not result.lrclib_attempted:
log(" ⚠ текста нет")
else:
log(" ⚠ не распознан")
return result
@@ -339,7 +419,7 @@ def _run_identification_parallel(
if stop_event is not None and stop_event.is_set():
break
if on_progress:
on_progress(i, total, "identify")
on_progress("identify", i, total)
try:
results[f] = _identify_file(f, work_root / f.stem, config, log)
except Exception as e: # noqa: BLE001
@@ -374,13 +454,14 @@ def _run_identification_parallel(
results[f] = None
done += 1
if on_progress:
on_progress(done, total, "identify")
on_progress("identify", done, total)
return results
def _run_convert_parallel(
config: PipelineConfig, files: list[Path], state: dict, log: LogFn,
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
temp_registry: Optional[list[Path]] = None,
) -> None:
concurrency = max(1, int(config.convert.convert_concurrency))
total = len(files)
@@ -389,7 +470,10 @@ def _run_convert_parallel(
if stop_event is not None and stop_event.is_set():
return f, None, None
try:
target = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_")) / "convert.wav"
temp_dir = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_"))
if temp_registry is not None:
temp_registry.append(temp_dir)
target = temp_dir / "convert.wav"
if config.overwrite or not target.exists():
convert_to_wav(
f, target, sample_rate=config.convert.sample_rate,
@@ -405,7 +489,7 @@ def _run_convert_parallel(
if stop_event is not None and stop_event.is_set():
break
if on_progress:
on_progress(i, total, "convert")
on_progress("convert", i, total)
f2, target, err = worker(f)
if err:
state[f2]["error"] = err
@@ -429,13 +513,14 @@ def _run_convert_parallel(
log(f" ✓ {f2.name}")
done += 1
if on_progress:
on_progress(done, total, "convert")
on_progress("convert", done, total)
def run_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():
@@ -455,22 +540,39 @@ def run_pipeline(
log(" Shazam: включён")
if config.lyrics.enabled:
log(f" LRCLIB: включён (порог fuzzy={config.lyrics.fuzzy_threshold:.2f})")
if config.skip_existing:
log(" Resume: готовые файлы (text.txt) будут пропущены")
if config.resume_partial:
log(" Resume: существующие vocals.wav / convert.wav будут переиспользованы")
if on_overall_progress:
on_overall_progress(0, len(files))
if mode == "staged":
return _run_staged(config, files, log, on_progress, stop_event)
return _run_sequential(config, files, log, on_progress, stop_event)
return _run_staged(
config, files, log, on_progress, stop_event, on_overall_progress,
)
return _run_sequential(
config, files, log, on_progress, stop_event, on_overall_progress,
)
def _run_sequential(
config: PipelineConfig, files: list[Path], log: LogFn,
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]:
total = len(files)
results: list[FileResult] = []
output_root = Path(config.output_dir)
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_"))
ident_files = files
if config.skip_existing:
ident_files = [
f for f in files
if not (output_root / f.stem / "text.txt").is_file()
]
try:
ident_map = _run_identification_parallel(
config, files, log, on_progress, stop_event, ident_tmp,
config, ident_files, log, on_progress, stop_event, ident_tmp,
)
finally:
shutil.rmtree(ident_tmp, ignore_errors=True)
@@ -480,17 +582,36 @@ def _run_sequential(
log("⏹ Отменено")
break
if on_progress:
on_progress(idx - 1, total, "file")
on_progress("file", idx - 1, total)
log(f"\n▶ [{idx}/{total}] {f.name}")
tmp_root = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_"))
track_out = output_root / f.stem
track_out.mkdir(parents=True, exist_ok=True)
belongs = _artifact_belongs_to(track_out, f)
if config.skip_existing:
existing_text = track_out / "text.txt"
if existing_text.is_file() and belongs:
try:
existing = existing_text.read_text(encoding="utf-8")
except OSError:
existing = None
log(" ⊘ text.txt уже есть — пропуск")
if on_progress:
on_progress("file", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
results.append(FileResult(f, existing, track_out, None, "existing"))
continue
tmp_root = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_"))
text: Optional[str] = None
source: Optional[str] = None
error: Optional[str] = None
metadata_dict: Optional[dict] = None
appended = False
was_cancelled = False
try:
ident = ident_map.get(f) or IdentifyResult(metadata=TrackMetadata())
@@ -498,7 +619,9 @@ def _run_sequential(
if not ident.needs_asr:
text = ident.text
source = ident.source
metadata_dict = _build_metadata_dict(ident.metadata, ident.shazam, source)
metadata_dict = _build_metadata_dict(
ident.metadata, ident.shazam, source, input_path=f,
)
if text:
(tmp_root / "text.txt").write_text(text, encoding="utf-8")
if metadata_dict:
@@ -515,10 +638,26 @@ def _run_sequential(
)
log(f" ✓ {f.name} [{source}]")
results.append(FileResult(f, text, track_out, None, source, metadata_dict))
appended = True
continue
audio_path: Path = f
if config.convert.enabled:
skip_convert = False
skip_separate = False
if config.resume_partial and belongs:
vocals = track_out / "vocals.wav"
convert = track_out / "convert.wav"
if vocals.is_file():
audio_path = vocals
skip_convert = True
skip_separate = True
log(" ↻ используем готовый vocals.wav")
elif convert.is_file():
audio_path = convert
skip_convert = True
log(" ↻ используем готовый convert.wav")
if config.convert.enabled and not skip_convert:
_check_cancel(stop_event)
log(" [1] Конвертация в WAV...")
target = tmp_root / "convert.wav"
@@ -531,7 +670,7 @@ def _run_sequential(
audio_path = target
log(" ✓ convert.wav")
if config.separate.enabled:
if config.separate.enabled and not skip_separate:
_check_cancel(stop_event)
log(" [2] Отделение вокала...")
target = tmp_root / "vocals.wav"
@@ -562,14 +701,16 @@ def _run_sequential(
)
source = config.transcribe.engine
log(f" ✓ распознано {len(text)} символов")
if config.lyrics.enabled and config.lyrics.try_after_asr:
if (config.lyrics.enabled and config.lyrics.try_after_asr):
matched = _try_post_asr_match(text, config, log)
if matched:
text = matched
source = f"{source}+lrclib"
(tmp_root / "text.txt").write_text(text, encoding="utf-8")
metadata_dict = _build_metadata_dict(ident.metadata, ident.shazam, source)
metadata_dict = _build_metadata_dict(
ident.metadata, ident.shazam, source, input_path=f,
)
if metadata_dict:
_write_metadata_json(tmp_root / "metadata.json", metadata_dict)
@@ -586,17 +727,38 @@ def _run_sequential(
except Cancelled:
log("⏹ Отменено")
was_cancelled = True
break
except Exception as e: # noqa: BLE001
error = f"{type(e).__name__}: {e}"
log(f" ✗ Ошибка: {error}")
finally:
# Even on error — preserve vocals if the user asked for it.
if error is not None and config.separate.save_vocals_copy:
vocal_src = tmp_root / "vocals.wav"
if vocal_src.exists():
try:
dst = track_out / "vocals.wav"
if dst.exists():
dst.unlink()
shutil.move(str(vocal_src), str(dst))
log(f" ✓ {f.name} (vocals сохранён, несмотря на ошибку)")
except OSError:
pass
shutil.rmtree(tmp_root, ignore_errors=True)
if on_progress:
on_progress(idx, total, "file")
if was_cancelled:
# The file was not processed to completion — do not count it
# in the overall bar and do not report a fake "success" row.
pass
else:
if not appended:
results.append(FileResult(
f, text, track_out if not error else None, error, source, metadata_dict,
))
if on_progress:
on_progress("file", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
_log_summary(results, log)
return results
@@ -605,33 +767,204 @@ def _run_sequential(
def _run_staged(
config: PipelineConfig, files: list[Path], log: LogFn,
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]:
"""Chunked staged mode: process `chunk_size` files, free memory, repeat."""
output_root = Path(config.output_dir)
total = len(files)
chunk_size = max(1, int(config.chunk_size or total))
state: dict[Path, dict] = {
f: {"audio_path": f, "ident": None, "text": None, "source": None,
"metadata_dict": None, "error": None, "resolved": False}
for f in files
}
if chunk_size >= total:
log(f"Один батч: {total} файлов (chunk_size={chunk_size})")
results = _run_staged_batch(
config, files, output_root, log, on_progress, stop_event,
overall_offset=0, overall_total=total,
on_overall_progress=on_overall_progress,
overall_counter=[0],
)
_free_memory()
_log_summary(results, log)
return results
n_batches = (total + chunk_size - 1) // chunk_size
log(f"Батчей: {n_batches} × {chunk_size} файлов (всего {total})")
all_results: list[FileResult] = []
overall_counter = [0]
for b in range(n_batches):
if stop_event is not None and stop_event.is_set():
log("⏹ Отменено")
break
start = b * chunk_size
end = min(start + chunk_size, total)
chunk = files[start:end]
log(f"\n╔═══════════════════════════════════════════════════════╗")
log(f"║ Батч {b+1}/{n_batches}: файлы {start+1}–{end} из {total}")
log(f"╚═══════════════════════════════════════════════════════╝")
try:
batch_results = _run_staged_batch(
config, chunk, output_root, log, on_progress, stop_event,
overall_offset=start, overall_total=total,
on_overall_progress=on_overall_progress,
overall_counter=overall_counter,
)
all_results.extend(batch_results)
except Exception as e: # noqa: BLE001
log(f" ✗ Батч {b+1} упал: {type(e).__name__}: {e}")
_free_memory()
log(f" [batch] память очищена")
_log_summary(all_results, log)
return all_results
def _run_staged_batch(
config: PipelineConfig,
batch_files: list[Path],
output_root: Path,
log: LogFn,
on_progress: Optional[ProgressFn],
stop_event: Optional[threading.Event],
overall_offset: int = 0,
overall_total: Optional[int] = None,
on_overall_progress: Optional[OverallFn] = None,
overall_counter: Optional[list[int]] = None,
) -> list[FileResult]:
"""One chunk of the staged pipeline. All temps are tracked and removed
in the `finally` block at the end of this call."""
if overall_total is None:
overall_total = len(batch_files)
if overall_counter is None:
overall_counter = [overall_offset]
def mark_overall(complete: bool) -> None:
if not complete:
return
overall_counter[0] += 1
if on_overall_progress:
on_overall_progress(
min(overall_counter[0], overall_total),
overall_total,
)
def is_complete(s: dict) -> bool:
return bool(
s["resolved"]
or s["error"] is not None
or s["text"] is not None
or not config.transcribe.enabled
)
def progress(stage: str, done: int, total: int) -> None:
"""Report progress for the current stage only.
The GUI bar is reset for every stage, so the counters never have to be
mixed across stages (identify sees all files, transcribe only the ones
that actually need ASR, etc.).
"""
if on_progress:
on_progress(stage, done, total)
temp_registry: list[Path] = []
def _mkdtemp(prefix: str) -> Path:
p = Path(tempfile.mkdtemp(prefix=prefix))
temp_registry.append(p)
return p
def active() -> list[Path]:
return [f for f in files if state[f]["error"] is None and not state[f]["resolved"]]
return [f for f in batch_files if state[f]["error"] is None and not state[f]["resolved"]]
def cancelled() -> bool:
return stop_event is not None and stop_event.is_set()
# Seed the state from artifacts already present in output_root/<stem>.
# This is what makes "Resume" work: a file that already has text.txt is
# resolved, a file with vocals.wav skips convert+separate, and a file with
# only convert.wav skips just the conversion step.
state: dict[Path, dict] = {}
skipped_existing = 0
resumed_partial = 0
mismatched_folders = 0
for f in batch_files:
track_out = output_root / f.stem
audio_path: Path = f
skip_convert = False
skip_separate = False
text: Optional[str] = None
source: Optional[str] = None
resolved = False
belongs = _artifact_belongs_to(track_out, f)
if not belongs:
has_artifacts = any(
(track_out / name).exists()
for name in ("text.txt", "vocals.wav", "convert.wav")
)
if has_artifacts:
mismatched_folders += 1
log(f" ⚠ {f.name}: папка {track_out.name} относится к другому треку — обрабатываю заново")
text_file = track_out / "text.txt"
if config.skip_existing and belongs and text_file.is_file():
resolved = True
skipped_existing += 1
source = "existing"
try:
text = text_file.read_text(encoding="utf-8")
except OSError:
text = None
elif config.resume_partial and belongs:
vocals = track_out / "vocals.wav"
convert = track_out / "convert.wav"
if vocals.is_file():
audio_path = vocals
skip_convert = True
skip_separate = True
resumed_partial += 1
elif convert.is_file():
audio_path = convert
skip_convert = True
resumed_partial += 1
state[f] = {
"audio_path": audio_path, "ident": None, "text": text,
"source": source, "metadata_dict": None, "error": None,
"resolved": resolved, "skip_convert": skip_convert,
"skip_separate": skip_separate,
}
if skipped_existing:
log(f" ⊘ Пропущено готовых (text.txt): {skipped_existing}")
if resumed_partial:
log(f" ↻ Подхвачено промежуточных (vocals/convert): {resumed_partial}")
if mismatched_folders:
log(f" ⚠ Папок с чужим path: {mismatched_folders} (пересчитаны заново)")
if on_overall_progress:
on_overall_progress(min(overall_counter[0], overall_total), overall_total)
try:
# --- Stage 0: identification ---
if config.shazam.enabled or config.lyrics.enabled:
log(f"\n══ Этап 0 · Идентификация ({total} файлов) ══")
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_"))
to_identify = [f for f in batch_files if not state[f]["resolved"]]
if to_identify:
log(f"\n══ Этап 0 · Идентификация ({len(to_identify)} файлов) ══")
ident_tmp = _mkdtemp("at_ident_")
try:
ident_map = _run_identification_parallel(
config, files, log, on_progress, stop_event, ident_tmp,
config, to_identify, log, progress, stop_event, ident_tmp,
)
finally:
shutil.rmtree(ident_tmp, ignore_errors=True)
try:
temp_registry.remove(ident_tmp)
except ValueError:
pass
for f in files:
for f in to_identify:
ident = ident_map.get(f)
if ident is None:
state[f]["ident"] = IdentifyResult(metadata=TrackMetadata())
@@ -644,6 +977,7 @@ def _run_staged(
state[f]["source"] = ident.source
state[f]["metadata_dict"] = _build_metadata_dict(
ident.metadata, ident.shazam, ident.source,
input_path=f,
)
track_out = output_root / f.stem
track_out.mkdir(parents=True, exist_ok=True)
@@ -659,20 +993,21 @@ def _run_staged(
else:
log(f" → {f.name} (нужен полный пайплайн)")
# --- Stage 1: convert ---
if config.convert.enabled and not cancelled():
todo = active()
todo = [f for f in active() if not state[f]["skip_convert"]]
log(f"\n══ Этап 1 · Конвертация ({len(todo)} файлов) ══")
_run_convert_parallel(config, todo, state, log, on_progress, stop_event)
progress("convert", 0, len(todo))
_run_convert_parallel(
config, todo, state, log, progress, stop_event, temp_registry,
)
# --- Stage 2: separate ---
if config.separate.enabled and not cancelled():
todo = active()
todo = [f for f in active() if not state[f]["skip_separate"]]
log(f"\n══ Этап 2 · Отделение вокала ({len(todo)} файлов) ══")
if not todo:
pass
elif len(todo) == 1:
f = todo[0]
if on_progress:
on_progress(1, 1, "separate")
if todo:
def separate_single(f: Path) -> None:
try:
src = state[f]["audio_path"]
target = src.parent / "vocals.wav"
@@ -689,12 +1024,28 @@ def _run_staged(
except Exception as e: # noqa: BLE001
state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}")
else:
input_wavs = [state[f]["audio_path"] for f in todo]
# Resumed convert.wav files already live in output_root/<stem>,
# while freshly converted files live in unique temp dirs.
# unblend's `{parent}` batch layout would mix those roots, so
# separate resumed files one by one and batch the rest.
resumed = [
f for f in todo
if state[f]["audio_path"].parent == output_root / f.stem
]
resumed_set = set(resumed)
batchable = [f for f in todo if f not in resumed_set]
done = 0
progress("separate", 0, len(todo))
if len(batchable) == 1:
separate_single(batchable[0])
done = 1
progress("separate", done, len(todo))
elif batchable:
input_wavs = [state[f]["audio_path"] for f in batchable]
common_root = _common_parent(input_wavs)
log(f" Батч: {len(todo)} файлов, один вызов unblend")
if on_progress:
on_progress(0, len(todo), "separate")
log(f" Батч: {len(batchable)} файлов, один вызов unblend")
try:
mapping = separate_vocals_batch(
input_wavs, common_root,
@@ -702,9 +1053,11 @@ def _run_staged(
isolate_stem=config.separate.isolate_stem,
unblend_path=config.separate.unblend_path,
custom_kernels=config.separate.custom_kernels,
progress_callback=lambda p: on_progress(p, len(todo), "separate") if on_progress else None,
progress_callback=lambda p: progress(
"separate", p, len(todo),
),
)
for f in todo:
for f in batchable:
src = state[f]["audio_path"]
vocal = mapping.get(src)
if vocal is None:
@@ -713,36 +1066,33 @@ def _run_staged(
continue
state[f]["audio_path"] = vocal
log(f" ✓ {f.name}")
if on_progress:
on_progress(len(todo), len(todo), "separate")
done = len(batchable)
progress("separate", done, len(todo))
except Exception as e: # noqa: BLE001
log(f" ✗ Батч упал: {e}")
log(" ↻ Fallback: по одному...")
for i, f in enumerate(todo, 1):
for i, f in enumerate(batchable, 1):
if cancelled():
break
if on_progress:
on_progress(i, len(todo), "separate")
try:
src = state[f]["audio_path"]
target = src.parent / "vocals.wav"
with tempfile.TemporaryDirectory() as tmp:
raw = separate_vocals(
src, Path(tmp), model=config.separate.model,
isolate_stem=config.separate.isolate_stem,
unblend_path=config.separate.unblend_path,
custom_kernels=config.separate.custom_kernels,
)
shutil.move(str(raw), str(target))
state[f]["audio_path"] = target
log(f" ✓ {f.name}")
except Exception as inner: # noqa: BLE001
state[f]["error"] = f"{type(inner).__name__}: {inner}"
log(f" ✗ {f.name}: {inner}")
progress("separate", i - 1, len(todo))
separate_single(f)
done = i
progress("separate", i, len(todo))
for f in resumed:
if cancelled():
break
done += 1
progress("separate", done, len(todo))
separate_single(f)
else:
progress("separate", 0, 0)
# --- Stage 3: transcribe ---
if config.transcribe.enabled and not cancelled():
todo = active()
log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══")
progress("transcribe", 0, len(todo))
for i, f in enumerate(todo, 1):
if cancelled():
log("⏹ Отменено")
@@ -765,30 +1115,63 @@ def _run_staged(
state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}")
finally:
if on_progress:
on_progress(i, len(todo), "transcribe")
progress("transcribe", i, len(todo))
if not cancelled():
# --- Stage 4: finalize ---
# NOTE: runs even if cancelled — we don't want to lose texts that
# were already transcribed before the user hit "Stop". Only the
# network call to LRCLIB (fuzzy match) is skipped when cancelled.
log("\n══ Финализация ══")
for f in files:
for f in batch_files:
s = state[f]
# Errored file: no text, but if separate succeeded we may still
# have a vocals.wav worth keeping (user asked for save_vocals_copy).
if s["error"] is not None:
if config.separate.save_vocals_copy:
src_audio = s["audio_path"]
if isinstance(src_audio, Path):
vocal = src_audio.parent / "vocals.wav"
if vocal.exists():
track_out = output_root / f.stem
track_out.mkdir(parents=True, exist_ok=True)
dst = track_out / "vocals.wav"
if dst.exists():
dst.unlink()
try:
shutil.move(str(vocal), str(dst))
log(f" ✓ {f.name} (vocals сохранён, несмотря на ошибку)")
except OSError:
pass
mark_overall(True)
continue
if s["resolved"]:
mark_overall(True)
continue
text = s["text"]
source = s["source"]
if text and config.lyrics.enabled and config.lyrics.try_after_asr:
ident = s["ident"]
# LRCLIB fuzzy-match is the only slow/network step here —
# skip it on cancel, but still save the text we already have.
# This is a *text* search, not a duplicate author/title search.
if (text and config.lyrics.enabled and config.lyrics.try_after_asr
and not cancelled()):
matched = _try_post_asr_match(text, config, log)
if matched:
text = matched
source = f"{source}+lrclib"
s["text"] = text
s["source"] = source
ident = s["ident"]
metadata_dict = _build_metadata_dict(
ident.metadata, ident.shazam, source,
) if ident else None
ident.metadata if ident else TrackMetadata(),
ident.shazam if ident else None,
source,
input_path=f,
)
s["metadata_dict"] = metadata_dict
track_out = output_root / f.stem
@@ -796,7 +1179,7 @@ def _run_staged(
audio_work = s["audio_path"]
if isinstance(audio_work, Path) and audio_work != f:
work_dir = audio_work.parent # intermediates exist
work_dir = audio_work.parent
if text:
(work_dir / "text.txt").write_text(text, encoding="utf-8")
if metadata_dict:
@@ -808,7 +1191,6 @@ def _run_staged(
log=log,
)
else:
# no intermediates (convert/separate disabled): write directly
if text:
(track_out / "text.txt").write_text(text, encoding="utf-8")
if metadata_dict:
@@ -819,22 +1201,26 @@ def _run_staged(
metadata=(ident.metadata if ident else None),
)
log(f" ✓ {f.name}")
mark_overall(is_complete(s))
for f in files:
audio_path = state[f]["audio_path"]
if isinstance(audio_path, Path) and audio_path.parent.name.startswith(f"at_{f.stem}"):
shutil.rmtree(audio_path.parent, ignore_errors=True)
finally:
# Clean every temp dir created in this batch, regardless of success/error
for p in temp_registry:
try:
if p.exists():
shutil.rmtree(p, ignore_errors=True)
except OSError:
pass
log(f" [batch] удалено временных папок: {len(temp_registry)}")
results = [
return [
FileResult(
f, state[f]["text"],
output_root / f.stem if state[f]["error"] is None else None,
state[f]["error"], state[f]["source"], state[f]["metadata_dict"],
)
for f in files
for f in batch_files
]
_log_summary(results, log)
return results
def _log_summary(results: list[FileResult], log: LogFn) -> None:

View File

@@ -91,13 +91,10 @@ def separate_vocals_batch(
total = len(input_wavs)
processed = 0
while proc.poll() is None:
time.sleep(2.0)
if progress_callback and processed < total - 1:
if proc.wait(2.0) is not None:
break
processed += 1
progress_callback(processed)
else:
proc.wait(2.0)
if proc.returncode != 0:
raise RuntimeError(f"unblend batch failed with exit code {proc.returncode}")

View File

@@ -11,6 +11,31 @@ from pathlib import Path
from typing import Optional
# Set permanently once the CUDA context has died (e.g. after an unrecoverable
# OOM). Every later CUDA transcription fails instantly instead of re-loading
# the model forever and hanging the pipeline.
_CUDA_BROKEN = False
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 _check_cuda_usable(device: str) -> None:
if device == "cuda" and _CUDA_BROKEN:
raise RuntimeError(
"CUDA context is broken (previous OOM was unrecoverable). "
"Restart the app or switch device to CPU."
)
def _resolve_device(device: str) -> str:
if device in ("cuda", "cpu"):
return device
@@ -39,7 +64,7 @@ def _free_vram() -> None:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
except Exception: # noqa: BLE001 — cleanup must never mask the original error
pass
@@ -124,7 +149,9 @@ def transcribe(
local_files_only: bool = False,
vad_filter: bool = True,
) -> str:
global _CUDA_BROKEN
device = _resolve_device(device)
_check_cuda_usable(device)
if engine == "whisperx":
try:
@@ -133,16 +160,26 @@ def transcribe(
batch_size, local_files_only, vad_filter,
)
except Exception as e:
err = str(e).lower()
if "out of memory" in err or "cuda" in err or "device ordinal" in err:
if not _is_cuda_error(e):
raise
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
_WhisperXModels._cache.clear()
_free_vram()
try:
return _transcribe_whisperx(
audio_path, model, language, device, compute_type,
batch_size, local_files_only, vad_filter,
)
except Exception as retry_error:
if not _is_cuda_error(retry_error):
raise
_CUDA_BROKEN = True
print(" [ASR] CUDA context is unrecoverable — "
"GPU disabled for the rest of this run.")
raise RuntimeError(
"CUDA context is broken after OOM recovery failed. "
"Restart the app or switch device to CPU."
) from retry_error
if engine == "gigaam":
return _transcribe_gigaam(audio_path, gigaam_provider)
@@ -191,6 +228,7 @@ def transcribe_aligned(
"""
import whisperx
device = _resolve_device(device)
_check_cuda_usable(device)
model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only)
audio = whisperx.load_audio(str(audio_path))
result = model_obj.transcribe(audio, batch_size=batch_size, language=language)

View File

@@ -6,12 +6,12 @@ from pathlib import Path
from typing import Optional
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtGui import QColor, QDesktopServices, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QTabWidget,
QVBoxLayout, QWidget,
QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSpinBox,
QTabWidget, QVBoxLayout, QWidget,
)
from core.config import (
@@ -71,8 +71,17 @@ class MainWindow(QMainWindow):
# ---------------- UI ----------------
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()
self.setCentralWidget(central)
scroll.setWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(18, 16, 18, 16)
root.setSpacing(12)
@@ -82,6 +91,10 @@ class MainWindow(QMainWindow):
title.setObjectName("appTitle")
header.addWidget(title)
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.setObjectName("deviceInfo")
header.addWidget(self.device_label)
@@ -101,6 +114,16 @@ class MainWindow(QMainWindow):
self.mode_cb.addItem(label, key)
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()
self.recursive_cb = QCheckBox("Рекурсивный обход")
self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные")
@@ -110,6 +133,23 @@ class MainWindow(QMainWindow):
opts.addWidget(self.overwrite_cb)
opts.addStretch(1)
top_layout.addRow("Опции:", opts)
resume_opts = QHBoxLayout()
self.skip_existing_cb = QCheckBox("Пропускать готовые (text.txt)")
self.skip_existing_cb.setToolTip(
"Не трогать файлы, у которых в <output>/<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)
self.tabs = QTabWidget()
@@ -126,6 +166,7 @@ class MainWindow(QMainWindow):
self.convert_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.lrc_enabled_cb.toggled.connect(self._sync_tab_indicators)
self.engine_cb.currentTextChanged.connect(self._sync_engine_fields)
row = QHBoxLayout()
@@ -156,12 +197,33 @@ class MainWindow(QMainWindow):
self.progress.setFormat("Ожидание")
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.setObjectName("log")
self.log_view.setReadOnly(True)
self.log_view.setMinimumHeight(240)
self.log_view.setPlaceholderText("Здесь появится лог выполнения…")
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:
w = QWidget()
h = QHBoxLayout(w)
@@ -209,8 +271,17 @@ class MainWindow(QMainWindow):
self.lyrics_user_agent = QLineEdit()
form.addRow("User-Agent:", self.lyrics_user_agent)
self.lyrics_use_tags_cb = QCheckBox("Искать по тегам")
self.lyrics_use_tags_cb.setToolTip(
"Использовать artist/title из тегов файла для поиска на LRCLIB.\n"
"Срабатывает как fallback, если Shazam ничего не нашёл\n"
"или его результат отключён."
)
form.addRow("", self.lyrics_use_tags_cb)
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)
self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy)")
form.addRow("", self.lyrics_after_asr_cb)
@@ -333,6 +404,25 @@ class MainWindow(QMainWindow):
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>/<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(
@@ -392,6 +482,7 @@ class MainWindow(QMainWindow):
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(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:
is_whisperx = self.engine_cb.currentText() == "whisperx"
@@ -410,6 +501,8 @@ class MainWindow(QMainWindow):
self.recursive_cb.setChecked(cfg.recursive)
self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
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)
if i >= 0:
self.mode_cb.setCurrentIndex(i)
@@ -460,6 +553,10 @@ class MainWindow(QMainWindow):
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:
cfg = PipelineConfig()
@@ -468,6 +565,8 @@ class MainWindow(QMainWindow):
cfg.recursive = self.recursive_cb.isChecked()
cfg.keep_intermediate = self.keep_intermediate_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.shazam = ShazamConfig(
@@ -516,12 +615,16 @@ class MainWindow(QMainWindow):
)
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
# ---------------- handlers ----------------
@@ -595,16 +698,32 @@ class MainWindow(QMainWindow):
"Align-модель доступна только для WhisperX. Переключите движок на whisperx."
)
return
if task == "lrc" and not cfg.lrc.enabled:
QMessageBox.warning(
self, "LRC отключён",
"Этап генерации LRC выключен. Включите его на вкладке LRC."
)
return
if task == "embed" and not cfg.lrc.embed_enabled:
QMessageBox.warning(
self, "Встраивание LRC отключено",
"Этап встраивания LRC выключен. Включите его на вкладке LRC."
)
return
self.log_view.clear()
self._log_device_info()
self.progress.setValue(0)
self.progress.setFormat("Запуск…")
self.overall_progress.setMaximum(1)
self.overall_progress.setValue(0)
self.overall_progress.setFormat("Общий прогресс: запуск…")
self._set_busy(True)
self._worker = PipelineWorker(cfg, task=task)
self._worker.log.connect(self._append_log)
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.failed.connect(self._on_failed)
self._worker.start()
@@ -619,21 +738,61 @@ class MainWindow(QMainWindow):
sb = self.log_view.verticalScrollBar()
sb.setValue(sb.maximum())
def _on_progress(self, done: int, total: int, stage: str) -> None:
self.progress.setMaximum(total)
self.progress.setValue(done)
def _on_progress(self, stage: str, done: int, total: int) -> None:
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:
self._set_busy(False)
ok = sum(1 for r in results if r.error is None)
fail = len(results) - ok
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}.")
def _on_failed(self, message: str) -> None:
self._set_busy(False)
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}")
QMessageBox.critical(self, "Ошибка", message)

View File

@@ -170,20 +170,32 @@ QCheckBox::indicator:checked {
}
QProgressBar {
background-color: #181825;
border: 1px solid #313244;
background-color: #0b0b12;
border: 1px solid #7f849c;
border-radius: 8px;
text-align: center;
color: #cdd6f4;
min-height: 24px;
max-height: 24px;
font-weight: 500;
color: #ffffff;
min-height: 26px;
max-height: 26px;
font-weight: 700;
}
QProgressBar::chunk {
background-color: #89b4fa;
background-color: #1e66f5;
border-radius: 7px;
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 {
background: #181825;

View File

@@ -10,7 +10,8 @@ from core.config import PipelineConfig
class PipelineWorker(QThread):
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)
failed = Signal(str)
@@ -35,8 +36,9 @@ class PipelineWorker(QThread):
results = runner(
self._config,
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,
on_overall_progress=lambda d, t: self.overall_progress.emit(d, t),
)
self.finished_ok.emit(results)
except Exception as exc: # noqa: BLE001