Compare commits
2 Commits
199d12a274
...
999934f174
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
999934f174 | ||
|
|
15c0ba5d66 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,3 +14,4 @@ output/
|
||||
|
||||
preset.json
|
||||
*.log
|
||||
combined_files.txt
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
622
core/pipeline.py
622
core/pipeline.py
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
26
gui/theme.py
26
gui/theme.py
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user