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
This commit is contained in:
jadssss
2026-10-25 20:24:01 +03:00
parent 199d12a274
commit 15c0ba5d66
8 changed files with 765 additions and 225 deletions

View File

@@ -81,6 +81,9 @@ 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)

View File

@@ -16,7 +16,8 @@ from .transcribe import transcribe_aligned
LogFn = Callable[[str], None] 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( def _pick_audio_source(
@@ -49,6 +50,7 @@ def run_lrc_pipeline(
log: LogFn = print, log: LogFn = print,
on_progress: Optional[ProgressFn] = None, on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None, stop_event: Optional[threading.Event] = None,
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]: ) -> list[FileResult]:
input_dir = Path(config.input_dir) input_dir = Path(config.input_dir)
if not input_dir.is_dir(): if not input_dir.is_dir():
@@ -67,13 +69,15 @@ def run_lrc_pipeline(
results: list[FileResult] = [] results: list[FileResult] = []
total = len(files) total = len(files)
if on_overall_progress:
on_overall_progress(0, total)
for idx, f in enumerate(files, 1): for idx, f in enumerate(files, 1):
if stop_event is not None and stop_event.is_set(): if stop_event is not None and stop_event.is_set():
log("⏹ Отменено") log("⏹ Отменено")
break break
if on_progress: if on_progress:
on_progress(idx - 1, total, "lrc") on_progress("lrc", idx - 1, total)
log(f"\n▶ [{idx}/{total}] {f.name}") log(f"\n▶ [{idx}/{total}] {f.name}")
track_out = output_dir / f.stem track_out = output_dir / f.stem
@@ -127,7 +131,9 @@ def run_lrc_pipeline(
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
if on_progress: if on_progress:
on_progress(idx, total, "lrc") on_progress("lrc", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results return results
@@ -137,6 +143,7 @@ def run_embed_pipeline(
log: LogFn = print, log: LogFn = print,
on_progress: Optional[ProgressFn] = None, on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None, stop_event: Optional[threading.Event] = None,
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]: ) -> list[FileResult]:
input_dir = Path(config.input_dir) input_dir = Path(config.input_dir)
if not input_dir.is_dir(): if not input_dir.is_dir():
@@ -154,13 +161,15 @@ def run_embed_pipeline(
results: list[FileResult] = [] results: list[FileResult] = []
total = len(files) total = len(files)
if on_overall_progress:
on_overall_progress(0, total)
for idx, f in enumerate(files, 1): for idx, f in enumerate(files, 1):
if stop_event is not None and stop_event.is_set(): if stop_event is not None and stop_event.is_set():
log("⏹ Отменено") log("⏹ Отменено")
break break
if on_progress: if on_progress:
on_progress(idx - 1, total, "embed") on_progress("embed", idx - 1, total)
lrc_path = output_dir / f.stem / "text.lrc" lrc_path = output_dir / f.stem / "text.lrc"
error: Optional[str] = None error: Optional[str] = None
@@ -190,6 +199,8 @@ def run_embed_pipeline(
results.append(FileResult(f, None, None, error, "embed")) results.append(FileResult(f, None, None, error, "embed"))
finally: finally:
if on_progress: if on_progress:
on_progress(idx, total, "embed") on_progress("embed", idx, total)
if on_overall_progress:
on_overall_progress(idx, total)
return results return results

View File

@@ -27,7 +27,8 @@ AUDIO_EXTENSIONS = {
} }
LogFn = Callable[[str], None] 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 @dataclass
@@ -46,6 +47,18 @@ class Cancelled(Exception):
# ---------------------------------------------------------------- helpers # ---------------------------------------------------------------- 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]: def collect_audio_files(input_dir: Path, recursive: bool) -> list[Path]:
it = input_dir.rglob("*") if recursive else input_dir.iterdir() 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) 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, metadata: TrackMetadata,
shazam: Optional[ShazamResult], shazam: Optional[ShazamResult],
source: Optional[str], source: Optional[str],
input_path: Optional[Path] = None,
) -> Optional[dict]: ) -> Optional[dict]:
d: 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: if metadata.has_identity:
d["tags"] = { d["tags"] = {
"title": metadata.title, "title": metadata.title,
@@ -100,6 +119,30 @@ def _build_metadata_dict(
return d if d else None 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: def _write_metadata_json(path: Path, data: Optional[dict]) -> None:
if not data: if not data:
return return
@@ -151,6 +194,7 @@ class IdentifyResult:
text: Optional[str] = None text: Optional[str] = None
source: Optional[str] = None source: Optional[str] = None
needs_asr: bool = True needs_asr: bool = True
lrclib_attempted: bool = False # author/title LRCLIB search was attempted
def _identify_file( def _identify_file(
@@ -159,6 +203,15 @@ def _identify_file(
config: PipelineConfig, config: PipelineConfig,
log: LogFn, log: LogFn,
) -> IdentifyResult: ) -> 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)) result = IdentifyResult(metadata=read_metadata(input_path))
# --- 0) embedded lyrics в самом файле --- # --- 0) embedded lyrics в самом файле ---
@@ -176,13 +229,66 @@ def _identify_file(
result.needs_asr = False result.needs_asr = False
return result return result
# --- 1) LRCLIB по тегам --- # --- 1) Shazam ---
if config.lyrics.enabled and config.lyrics.use_tags_first and result.metadata.has_identity: if config.shazam.enabled:
log(" LRCLIB по тегам...") 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( match = search_by_identity(
title=result.metadata.title, artist=result.metadata.artist, title=result.shazam.title,
album=result.metadata.album, duration=result.metadata.duration, artist=result.shazam.artist,
user_agent=config.lyrics.user_agent, timeout=config.lyrics.timeout, 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: if match:
result.text = match.lyrics result.text = match.lyrics
@@ -190,41 +296,15 @@ def _identify_file(
result.needs_asr = False result.needs_asr = False
log(f" ✓ {match.artist} — {match.title}") log(f" ✓ {match.artist} — {match.title}")
return result return result
log(" ⚠ LRCLIB: не найдено")
# --- 2) Shazam --- # --- 3) Identified, but no lyrics ---
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
if result.shazam: 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" result.source = "shazam"
if not config.lyrics.transcribe_if_identified_no_lyrics: if not config.lyrics.transcribe_if_identified_no_lyrics:
result.needs_asr = False result.needs_asr = False
if not result.lrclib_attempted:
log(" ⚠ текста нет") log(" ⚠ текста нет")
else:
log(" ⚠ не распознан")
return result return result
@@ -339,7 +419,7 @@ def _run_identification_parallel(
if stop_event is not None and stop_event.is_set(): if stop_event is not None and stop_event.is_set():
break break
if on_progress: if on_progress:
on_progress(i, total, "identify") on_progress("identify", i, total)
try: try:
results[f] = _identify_file(f, work_root / f.stem, config, log) results[f] = _identify_file(f, work_root / f.stem, config, log)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
@@ -374,13 +454,14 @@ def _run_identification_parallel(
results[f] = None results[f] = None
done += 1 done += 1
if on_progress: if on_progress:
on_progress(done, total, "identify") on_progress("identify", done, total)
return results return results
def _run_convert_parallel( def _run_convert_parallel(
config: PipelineConfig, files: list[Path], state: dict, log: LogFn, config: PipelineConfig, files: list[Path], state: dict, log: LogFn,
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event], on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
temp_registry: Optional[list[Path]] = None,
) -> None: ) -> None:
concurrency = max(1, int(config.convert.convert_concurrency)) concurrency = max(1, int(config.convert.convert_concurrency))
total = len(files) total = len(files)
@@ -389,7 +470,10 @@ def _run_convert_parallel(
if stop_event is not None and stop_event.is_set(): if stop_event is not None and stop_event.is_set():
return f, None, None return f, None, None
try: 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(): if config.overwrite or not target.exists():
convert_to_wav( convert_to_wav(
f, target, sample_rate=config.convert.sample_rate, 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(): if stop_event is not None and stop_event.is_set():
break break
if on_progress: if on_progress:
on_progress(i, total, "convert") on_progress("convert", i, total)
f2, target, err = worker(f) f2, target, err = worker(f)
if err: if err:
state[f2]["error"] = err state[f2]["error"] = err
@@ -429,13 +513,14 @@ def _run_convert_parallel(
log(f" ✓ {f2.name}") log(f" ✓ {f2.name}")
done += 1 done += 1
if on_progress: if on_progress:
on_progress(done, total, "convert") on_progress("convert", done, total)
def run_pipeline( def run_pipeline(
config: PipelineConfig, log: LogFn = print, config: PipelineConfig, log: LogFn = print,
on_progress: Optional[ProgressFn] = None, on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None, stop_event: Optional[threading.Event] = None,
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]: ) -> list[FileResult]:
input_dir = Path(config.input_dir) input_dir = Path(config.input_dir)
if not input_dir.is_dir(): if not input_dir.is_dir():
@@ -455,22 +540,39 @@ def run_pipeline(
log(" Shazam: включён") log(" Shazam: включён")
if config.lyrics.enabled: if config.lyrics.enabled:
log(f" LRCLIB: включён (порог fuzzy={config.lyrics.fuzzy_threshold:.2f})") 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": if mode == "staged":
return _run_staged(config, files, log, on_progress, stop_event) return _run_staged(
return _run_sequential(config, files, log, on_progress, stop_event) 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( def _run_sequential(
config: PipelineConfig, files: list[Path], log: LogFn, config: PipelineConfig, files: list[Path], log: LogFn,
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event], on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]: ) -> list[FileResult]:
total = len(files) total = len(files)
results: list[FileResult] = [] results: list[FileResult] = []
output_root = Path(config.output_dir) output_root = Path(config.output_dir)
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_")) 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: try:
ident_map = _run_identification_parallel( 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: finally:
shutil.rmtree(ident_tmp, ignore_errors=True) shutil.rmtree(ident_tmp, ignore_errors=True)
@@ -480,17 +582,36 @@ def _run_sequential(
log("⏹ Отменено") log("⏹ Отменено")
break break
if on_progress: if on_progress:
on_progress(idx - 1, total, "file") on_progress("file", idx - 1, total)
log(f"\n▶ [{idx}/{total}] {f.name}") 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 = output_root / f.stem
track_out.mkdir(parents=True, exist_ok=True) 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 text: Optional[str] = None
source: Optional[str] = None source: Optional[str] = None
error: Optional[str] = None error: Optional[str] = None
metadata_dict: Optional[dict] = None metadata_dict: Optional[dict] = None
appended = False
was_cancelled = False
try: try:
ident = ident_map.get(f) or IdentifyResult(metadata=TrackMetadata()) ident = ident_map.get(f) or IdentifyResult(metadata=TrackMetadata())
@@ -498,7 +619,9 @@ def _run_sequential(
if not ident.needs_asr: if not ident.needs_asr:
text = ident.text text = ident.text
source = ident.source 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: if text:
(tmp_root / "text.txt").write_text(text, encoding="utf-8") (tmp_root / "text.txt").write_text(text, encoding="utf-8")
if metadata_dict: if metadata_dict:
@@ -515,10 +638,26 @@ def _run_sequential(
) )
log(f" ✓ {f.name} [{source}]") log(f" ✓ {f.name} [{source}]")
results.append(FileResult(f, text, track_out, None, source, metadata_dict)) results.append(FileResult(f, text, track_out, None, source, metadata_dict))
appended = True
continue continue
audio_path: Path = f 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) _check_cancel(stop_event)
log(" [1] Конвертация в WAV...") log(" [1] Конвертация в WAV...")
target = tmp_root / "convert.wav" target = tmp_root / "convert.wav"
@@ -531,7 +670,7 @@ def _run_sequential(
audio_path = target audio_path = target
log(" ✓ convert.wav") log(" ✓ convert.wav")
if config.separate.enabled: if config.separate.enabled and not skip_separate:
_check_cancel(stop_event) _check_cancel(stop_event)
log(" [2] Отделение вокала...") log(" [2] Отделение вокала...")
target = tmp_root / "vocals.wav" target = tmp_root / "vocals.wav"
@@ -562,14 +701,16 @@ def _run_sequential(
) )
source = config.transcribe.engine source = config.transcribe.engine
log(f" ✓ распознано {len(text)} символов") 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) matched = _try_post_asr_match(text, config, log)
if matched: if matched:
text = matched text = matched
source = f"{source}+lrclib" source = f"{source}+lrclib"
(tmp_root / "text.txt").write_text(text, encoding="utf-8") (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: if metadata_dict:
_write_metadata_json(tmp_root / "metadata.json", metadata_dict) _write_metadata_json(tmp_root / "metadata.json", metadata_dict)
@@ -586,17 +727,38 @@ def _run_sequential(
except Cancelled: except Cancelled:
log("⏹ Отменено") log("⏹ Отменено")
was_cancelled = True
break break
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
error = f"{type(e).__name__}: {e}" error = f"{type(e).__name__}: {e}"
log(f" ✗ Ошибка: {error}") log(f" ✗ Ошибка: {error}")
finally: 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) shutil.rmtree(tmp_root, ignore_errors=True)
if on_progress: if was_cancelled:
on_progress(idx, total, "file") # 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( results.append(FileResult(
f, text, track_out if not error else None, error, source, metadata_dict, 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) _log_summary(results, log)
return results return results
@@ -605,33 +767,204 @@ def _run_sequential(
def _run_staged( def _run_staged(
config: PipelineConfig, files: list[Path], log: LogFn, config: PipelineConfig, files: list[Path], log: LogFn,
on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event], on_progress: Optional[ProgressFn], stop_event: Optional[threading.Event],
on_overall_progress: Optional[OverallFn] = None,
) -> list[FileResult]: ) -> list[FileResult]:
"""Chunked staged mode: process `chunk_size` files, free memory, repeat."""
output_root = Path(config.output_dir) output_root = Path(config.output_dir)
total = len(files) total = len(files)
chunk_size = max(1, int(config.chunk_size or total))
state: dict[Path, dict] = { if chunk_size >= total:
f: {"audio_path": f, "ident": None, "text": None, "source": None, log(f"Один батч: {total} файлов (chunk_size={chunk_size})")
"metadata_dict": None, "error": None, "resolved": False} results = _run_staged_batch(
for f in files 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]: 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: def cancelled() -> bool:
return stop_event is not None and stop_event.is_set() 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: if config.shazam.enabled or config.lyrics.enabled:
log(f"\n══ Этап 0 · Идентификация ({total} файлов) ══") to_identify = [f for f in batch_files if not state[f]["resolved"]]
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_")) if to_identify:
log(f"\n══ Этап 0 · Идентификация ({len(to_identify)} файлов) ══")
ident_tmp = _mkdtemp("at_ident_")
try: try:
ident_map = _run_identification_parallel( ident_map = _run_identification_parallel(
config, files, log, on_progress, stop_event, ident_tmp, config, to_identify, log, progress, stop_event, ident_tmp,
) )
finally: finally:
shutil.rmtree(ident_tmp, ignore_errors=True) 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) ident = ident_map.get(f)
if ident is None: if ident is None:
state[f]["ident"] = IdentifyResult(metadata=TrackMetadata()) state[f]["ident"] = IdentifyResult(metadata=TrackMetadata())
@@ -644,6 +977,7 @@ def _run_staged(
state[f]["source"] = ident.source state[f]["source"] = ident.source
state[f]["metadata_dict"] = _build_metadata_dict( state[f]["metadata_dict"] = _build_metadata_dict(
ident.metadata, ident.shazam, ident.source, ident.metadata, ident.shazam, ident.source,
input_path=f,
) )
track_out = output_root / f.stem track_out = output_root / f.stem
track_out.mkdir(parents=True, exist_ok=True) track_out.mkdir(parents=True, exist_ok=True)
@@ -659,20 +993,21 @@ def _run_staged(
else: else:
log(f" → {f.name} (нужен полный пайплайн)") log(f" → {f.name} (нужен полный пайплайн)")
# --- Stage 1: convert ---
if config.convert.enabled and not cancelled(): 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)} файлов) ══") 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(): 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)} файлов) ══") log(f"\n══ Этап 2 · Отделение вокала ({len(todo)} файлов) ══")
if not todo: if todo:
pass def separate_single(f: Path) -> None:
elif len(todo) == 1:
f = todo[0]
if on_progress:
on_progress(1, 1, "separate")
try: try:
src = state[f]["audio_path"] src = state[f]["audio_path"]
target = src.parent / "vocals.wav" target = src.parent / "vocals.wav"
@@ -689,12 +1024,28 @@ def _run_staged(
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
state[f]["error"] = f"{type(e).__name__}: {e}" state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.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) common_root = _common_parent(input_wavs)
log(f" Батч: {len(todo)} файлов, один вызов unblend") log(f" Батч: {len(batchable)} файлов, один вызов unblend")
if on_progress:
on_progress(0, len(todo), "separate")
try: try:
mapping = separate_vocals_batch( mapping = separate_vocals_batch(
input_wavs, common_root, input_wavs, common_root,
@@ -702,9 +1053,11 @@ def _run_staged(
isolate_stem=config.separate.isolate_stem, isolate_stem=config.separate.isolate_stem,
unblend_path=config.separate.unblend_path, unblend_path=config.separate.unblend_path,
custom_kernels=config.separate.custom_kernels, 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"] src = state[f]["audio_path"]
vocal = mapping.get(src) vocal = mapping.get(src)
if vocal is None: if vocal is None:
@@ -713,36 +1066,33 @@ def _run_staged(
continue continue
state[f]["audio_path"] = vocal state[f]["audio_path"] = vocal
log(f" ✓ {f.name}") log(f" ✓ {f.name}")
if on_progress: done = len(batchable)
on_progress(len(todo), len(todo), "separate") progress("separate", done, len(todo))
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log(f" ✗ Батч упал: {e}") log(f" ✗ Батч упал: {e}")
log(" ↻ Fallback: по одному...") log(" ↻ Fallback: по одному...")
for i, f in enumerate(todo, 1): for i, f in enumerate(batchable, 1):
if cancelled(): if cancelled():
break break
if on_progress: progress("separate", i - 1, len(todo))
on_progress(i, len(todo), "separate") separate_single(f)
try: done = i
src = state[f]["audio_path"] progress("separate", i, len(todo))
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}")
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(): if config.transcribe.enabled and not cancelled():
todo = active() todo = active()
log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══") log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══")
progress("transcribe", 0, len(todo))
for i, f in enumerate(todo, 1): for i, f in enumerate(todo, 1):
if cancelled(): if cancelled():
log("⏹ Отменено") log("⏹ Отменено")
@@ -765,30 +1115,63 @@ def _run_staged(
state[f]["error"] = f"{type(e).__name__}: {e}" state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}") log(f" ✗ {f.name}: {e}")
finally: finally:
if on_progress: progress("transcribe", i, len(todo))
on_progress(i, len(todo), "transcribe")
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══ Финализация ══") log("\n══ Финализация ══")
for f in files: for f in batch_files:
s = state[f] 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 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 continue
if s["resolved"]: if s["resolved"]:
mark_overall(True)
continue continue
text = s["text"] text = s["text"]
source = s["source"] 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) matched = _try_post_asr_match(text, config, log)
if matched: if matched:
text = matched text = matched
source = f"{source}+lrclib" source = f"{source}+lrclib"
s["text"] = text s["text"] = text
s["source"] = source s["source"] = source
ident = s["ident"]
metadata_dict = _build_metadata_dict( metadata_dict = _build_metadata_dict(
ident.metadata, ident.shazam, source, ident.metadata if ident else TrackMetadata(),
) if ident else None ident.shazam if ident else None,
source,
input_path=f,
)
s["metadata_dict"] = metadata_dict s["metadata_dict"] = metadata_dict
track_out = output_root / f.stem track_out = output_root / f.stem
@@ -796,7 +1179,7 @@ def _run_staged(
audio_work = s["audio_path"] audio_work = s["audio_path"]
if isinstance(audio_work, Path) and audio_work != f: if isinstance(audio_work, Path) and audio_work != f:
work_dir = audio_work.parent # intermediates exist work_dir = audio_work.parent
if text: if text:
(work_dir / "text.txt").write_text(text, encoding="utf-8") (work_dir / "text.txt").write_text(text, encoding="utf-8")
if metadata_dict: if metadata_dict:
@@ -808,7 +1191,6 @@ def _run_staged(
log=log, log=log,
) )
else: else:
# no intermediates (convert/separate disabled): write directly
if text: if text:
(track_out / "text.txt").write_text(text, encoding="utf-8") (track_out / "text.txt").write_text(text, encoding="utf-8")
if metadata_dict: if metadata_dict:
@@ -819,22 +1201,26 @@ def _run_staged(
metadata=(ident.metadata if ident else None), metadata=(ident.metadata if ident else None),
) )
log(f" ✓ {f.name}") log(f" ✓ {f.name}")
mark_overall(is_complete(s))
for f in files: finally:
audio_path = state[f]["audio_path"] # Clean every temp dir created in this batch, regardless of success/error
if isinstance(audio_path, Path) and audio_path.parent.name.startswith(f"at_{f.stem}"): for p in temp_registry:
shutil.rmtree(audio_path.parent, ignore_errors=True) try:
if p.exists():
shutil.rmtree(p, ignore_errors=True)
except OSError:
pass
log(f" [batch] удалено временных папок: {len(temp_registry)}")
results = [ return [
FileResult( FileResult(
f, state[f]["text"], f, state[f]["text"],
output_root / f.stem if state[f]["error"] is None else None, output_root / f.stem if state[f]["error"] is None else None,
state[f]["error"], state[f]["source"], state[f]["metadata_dict"], 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: def _log_summary(results: list[FileResult], log: LogFn) -> None:

View File

@@ -91,13 +91,10 @@ def separate_vocals_batch(
total = len(input_wavs) total = len(input_wavs)
processed = 0 processed = 0
while proc.poll() is None: while proc.poll() is None:
time.sleep(2.0)
if progress_callback and processed < total - 1: if progress_callback and processed < total - 1:
if proc.wait(2.0) is not None:
break
processed += 1 processed += 1
progress_callback(processed) progress_callback(processed)
else:
proc.wait(2.0)
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(f"unblend batch failed with exit code {proc.returncode}") 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 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: def _resolve_device(device: str) -> str:
if device in ("cuda", "cpu"): if device in ("cuda", "cpu"):
return device return device
@@ -39,7 +64,7 @@ def _free_vram() -> None:
import torch import torch
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.cuda.empty_cache() torch.cuda.empty_cache()
except ImportError: except Exception: # noqa: BLE001 — cleanup must never mask the original error
pass pass
@@ -124,7 +149,9 @@ def transcribe(
local_files_only: bool = False, local_files_only: bool = False,
vad_filter: bool = True, vad_filter: bool = True,
) -> str: ) -> str:
global _CUDA_BROKEN
device = _resolve_device(device) device = _resolve_device(device)
_check_cuda_usable(device)
if engine == "whisperx": if engine == "whisperx":
try: try:
@@ -133,16 +160,26 @@ def transcribe(
batch_size, local_files_only, vad_filter, batch_size, local_files_only, vad_filter,
) )
except Exception as e: except Exception as e:
err = str(e).lower() if not _is_cuda_error(e):
if "out of memory" in err or "cuda" in err or "device ordinal" in err: raise
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...") print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
_WhisperXModels._cache.clear() _WhisperXModels._cache.clear()
_free_vram() _free_vram()
try:
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,
) )
except Exception as retry_error:
if not _is_cuda_error(retry_error):
raise 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": if engine == "gigaam":
return _transcribe_gigaam(audio_path, gigaam_provider) return _transcribe_gigaam(audio_path, gigaam_provider)
@@ -191,6 +228,7 @@ def transcribe_aligned(
""" """
import whisperx import whisperx
device = _resolve_device(device) device = _resolve_device(device)
_check_cuda_usable(device)
model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only) model_obj = _WhisperXModels.get(model, device, compute_type, local_files_only)
audio = whisperx.load_audio(str(audio_path)) audio = whisperx.load_audio(str(audio_path))
result = model_obj.transcribe(audio, batch_size=batch_size, language=language) result = model_obj.transcribe(audio, batch_size=batch_size, language=language)

View File

@@ -101,6 +101,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("Сохранять промежуточные")
@@ -110,6 +120,23 @@ class MainWindow(QMainWindow):
opts.addWidget(self.overwrite_cb) opts.addWidget(self.overwrite_cb)
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>/<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()
@@ -156,6 +183,11 @@ 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)
@@ -209,8 +241,17 @@ class MainWindow(QMainWindow):
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("Искать по тегам")
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)")
form.addRow("", self.lyrics_after_asr_cb) form.addRow("", self.lyrics_after_asr_cb)
@@ -410,6 +451,8 @@ 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)
@@ -460,6 +503,7 @@ class MainWindow(QMainWindow):
self.lrc_align_model_edit.setText(cfg.lrc.align_model or "") self.lrc_align_model_edit.setText(cfg.lrc.align_model or "")
self.lrc_device_cb.setCurrentText(cfg.lrc.device) self.lrc_device_cb.setCurrentText(cfg.lrc.device)
self.lrc_embed_in_place_cb.setChecked(cfg.lrc.embed_in_place) self.lrc_embed_in_place_cb.setChecked(cfg.lrc.embed_in_place)
self.chunk_size_spin.setValue(cfg.chunk_size)
def _collect_config(self) -> PipelineConfig: def _collect_config(self) -> PipelineConfig:
cfg = PipelineConfig() cfg = PipelineConfig()
@@ -468,6 +512,8 @@ 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( cfg.shazam = ShazamConfig(
@@ -522,6 +568,7 @@ class MainWindow(QMainWindow):
audio_source=self.lrc_audio_source_cb.currentText(), audio_source=self.lrc_audio_source_cb.currentText(),
embed_in_place=self.lrc_embed_in_place_cb.isChecked(), embed_in_place=self.lrc_embed_in_place_cb.isChecked(),
) )
cfg.chunk_size = self.chunk_size_spin.value()
return cfg return cfg
# ---------------- handlers ---------------- # ---------------- handlers ----------------
@@ -600,11 +647,15 @@ class MainWindow(QMainWindow):
self._log_device_info() self._log_device_info()
self.progress.setValue(0) self.progress.setValue(0)
self.progress.setFormat("Запуск…") self.progress.setFormat("Запуск…")
self.overall_progress.setMaximum(1)
self.overall_progress.setValue(0)
self.overall_progress.setFormat("Общий прогресс: запуск…")
self._set_busy(True) self._set_busy(True)
self._worker = PipelineWorker(cfg, task=task) 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()
@@ -619,21 +670,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._set_busy(False) self._set_busy(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._set_busy(False) self._set_busy(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

@@ -184,6 +184,18 @@ QProgressBar::chunk {
border-radius: 7px; border-radius: 7px;
margin: 1px; margin: 1px;
} }
QProgressBar#overallProgress {
min-height: 28px;
max-height: 28px;
font-weight: 600;
color: #cdd6f4;
border-color: #45475a;
}
QProgressBar#overallProgress::chunk {
background-color: #a6e3a1;
border-radius: 8px;
margin: 2px;
}
QScrollBar:vertical { QScrollBar:vertical {
background: #181825; background: #181825;

View File

@@ -10,7 +10,8 @@ from core.config import PipelineConfig
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)
@@ -35,8 +36,9 @@ class PipelineWorker(QThread):
results = 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