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:
@@ -81,6 +81,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)
|
||||
|
||||
@@ -16,7 +16,8 @@ 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(
|
||||
@@ -49,6 +50,7 @@ def run_lrc_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():
|
||||
@@ -67,13 +69,15 @@ def run_lrc_pipeline(
|
||||
|
||||
results: list[FileResult] = []
|
||||
total = len(files)
|
||||
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")
|
||||
on_progress("lrc", idx - 1, total)
|
||||
log(f"\n▶ [{idx}/{total}] {f.name}")
|
||||
|
||||
track_out = output_dir / f.stem
|
||||
@@ -127,7 +131,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 +143,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():
|
||||
@@ -154,13 +161,15 @@ def run_embed_pipeline(
|
||||
|
||||
results: list[FileResult] = []
|
||||
total = len(files)
|
||||
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")
|
||||
on_progress("embed", idx - 1, total)
|
||||
|
||||
lrc_path = output_dir / f.stem / "text.lrc"
|
||||
error: Optional[str] = None
|
||||
@@ -190,6 +199,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
|
||||
|
||||
792
core/pipeline.py
792
core/pipeline.py
File diff suppressed because it is too large
Load Diff
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
print(f" [ASR] CUDA error: {e}. Clearing model cache and retrying...")
|
||||
_WhisperXModels._cache.clear()
|
||||
_free_vram()
|
||||
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,
|
||||
)
|
||||
raise
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user