staged/sequential pipeline modes + dark themed Qt GUI

This commit is contained in:
jadssss
2026-10-23 23:57:43 +03:00
parent fcb9429c49
commit e1a42ec1df
7 changed files with 721 additions and 280 deletions

View File

@@ -7,6 +7,9 @@ from pathlib import Path
from typing import Optional
MODES = ("sequential", "staged")
@dataclass
class ConvertConfig:
enabled: bool = True
@@ -31,7 +34,7 @@ class TranscribeConfig:
model: str = "large-v3"
language: Optional[str] = "ru" # None = autodetect
device: str = "auto" # "cuda" | "cpu" | "auto"
compute_type: Optional[str] = None # None = auto (int8_float16 / int8)
compute_type: Optional[str] = None # None = auto
batch_size: int = 8
gigaam_provider: str = "auto" # "auto" | "cuda" | "cpu"
@@ -43,12 +46,12 @@ class PipelineConfig:
recursive: bool = False
keep_intermediate: bool = True
overwrite: bool = True
mode: str = "sequential" # "sequential" | "staged"
convert: ConvertConfig = field(default_factory=ConvertConfig)
separate: SeparateConfig = field(default_factory=SeparateConfig)
transcribe: TranscribeConfig = field(default_factory=TranscribeConfig)
# --- serialization ---
def to_dict(self) -> dict:
return asdict(self)
@@ -65,9 +68,9 @@ class PipelineConfig:
value = data[name]
current = getattr(cfg, name)
if hasattr(current, "__dataclass_fields__"):
sub_fields = {f.name for f in fields(current)}
sub = {f.name for f in fields(current)}
for k, v in value.items():
if k in sub_fields:
if k in sub:
setattr(current, k, v)
else:
setattr(cfg, name, value)

View File

@@ -1,4 +1,9 @@
"""Orchestrate convert -> separate -> transcribe over a directory of audio files."""
"""Orchestrate convert -> separate -> transcribe.
Two modes:
- sequential: for each file, run all enabled stages before moving to the next.
- staged: run stage 1 over all files, then stage 2 over all files, then stage 3.
"""
from __future__ import annotations
import shutil
@@ -19,8 +24,7 @@ AUDIO_EXTENSIONS = {
}
LogFn = Callable[[str], None]
StageFn = Callable[[str, str], None]
ProgressFn = Callable[[int, int], None]
ProgressFn = Callable[[int, int, str], None] # done, total, stage
@dataclass
@@ -32,24 +36,24 @@ class FileResult:
class Cancelled(Exception):
"""Raised internally when the stop event is set."""
"""Raised when the stop event is set."""
# ---------------------------------------------------------------- helpers
_ARTIFACT_NAMES = {
"convert": "convert.wav",
"separate": "vocals.wav",
"transcribe": "text.txt",
}
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
)
def _check_cancel(stop_event: Optional[threading.Event]) -> None:
if stop_event is not None and stop_event.is_set():
raise Cancelled()
return sorted(p for p in it if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS)
def _final_stages(config: PipelineConfig) -> set[str]:
"""Which stage produces the final artifact that must survive cleanup."""
if config.transcribe.enabled:
return {"transcribe"}
if config.separate.enabled:
@@ -59,137 +63,9 @@ def _final_stages(config: PipelineConfig) -> set[str]:
return set()
_ARTIFACT_NAMES = {
"convert": "convert.wav",
"separate": "vocals.wav",
"transcribe": "text.txt",
}
def run_pipeline(
config: PipelineConfig,
log: LogFn = print,
on_stage: Optional[StageFn] = None,
on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None,
) -> list[FileResult]:
input_dir = Path(config.input_dir)
if not input_dir.is_dir():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
output_dir = Path(config.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
files = collect_audio_files(input_dir, config.recursive)
total = len(files)
results: list[FileResult] = []
if total == 0:
log("Аудиофайлы не найдены.")
return results
log(f"Найдено треков: {total}")
for idx, input_path in enumerate(files, start=1):
_check_cancel(stop_event)
on_progress and on_progress(idx - 1, total)
log(f"\n▶ [{idx}/{total}] {input_path.name}")
try:
results.append(_process_one(input_path, config, log, on_stage, stop_event))
except Cancelled:
log("⏹ Отменено пользователем")
break
except Exception as exc: # noqa: BLE001 — we log per-file and continue
log(f" ✗ Ошибка: {exc}")
results.append(FileResult(input_path, None, None, str(exc)))
finally:
on_progress and on_progress(idx, total)
return results
def _process_one(
input_path: Path,
config: PipelineConfig,
log: LogFn,
on_stage: Optional[StageFn],
stop_event: Optional[threading.Event],
) -> FileResult:
track_stem = input_path.stem
track_out = Path(config.output_dir) / track_stem
track_out.mkdir(parents=True, exist_ok=True)
tmp_work = Path(tempfile.mkdtemp(prefix=f"at_{track_stem}_"))
finals = _final_stages(config)
try:
# ---------- 1. convert ----------
source_wav: Path = input_path
if config.convert.enabled:
_check_cancel(stop_event)
on_stage and on_stage("convert", input_path.name)
log(" [1] Конвертация в WAV...")
target = (
track_out / "convert.wav"
if ("convert" in finals or config.keep_intermediate)
else tmp_work / "convert.wav"
)
if config.overwrite or not target.exists():
convert_to_wav(
input_path, target,
sample_rate=config.convert.sample_rate,
channels=config.convert.channels,
ffmpeg_path=config.convert.ffmpeg_path,
)
source_wav = target
log(f" ✓ {target.name}")
# ---------- 2. separate ----------
vocal_path: Path = source_wav
if config.separate.enabled:
_check_cancel(stop_event)
on_stage and on_stage("separate", input_path.name)
log(" [2] Отделение вокала...")
raw_vocal = separate_vocals(
source_wav, tmp_work,
model=config.separate.model,
isolate_stem=config.separate.isolate_stem,
unblend_path=config.separate.unblend_path,
custom_kernels=config.separate.custom_kernels,
)
if "separate" in finals or config.keep_intermediate:
vocal_path = track_out / "vocals.wav"
shutil.move(str(raw_vocal), str(vocal_path))
else:
vocal_path = raw_vocal # stays in tmp_work
log(f" ✓ {vocal_path.name}")
# ---------- 3. transcribe ----------
text: Optional[str] = None
if config.transcribe.enabled:
_check_cancel(stop_event)
on_stage and on_stage("transcribe", input_path.name)
log(" [3] Транскрибация...")
text = transcribe(
vocal_path,
engine=config.transcribe.engine,
model=config.transcribe.model,
language=config.transcribe.language,
device=config.transcribe.device,
compute_type=config.transcribe.compute_type,
batch_size=config.transcribe.batch_size,
gigaam_provider=config.transcribe.gigaam_provider,
)
(track_out / "text.txt").write_text(text, encoding="utf-8")
log(f" ✓ text.txt ({len(text)} символов)")
if not config.keep_intermediate:
_cleanup_keeping(track_out, finals)
return FileResult(input_path, text, track_out)
finally:
shutil.rmtree(tmp_work, ignore_errors=True)
def _check_cancel(stop_event: Optional[threading.Event]) -> None:
if stop_event is not None and stop_event.is_set():
raise Cancelled()
def _cleanup_keeping(track_out: Path, finals: set[str]) -> None:
@@ -206,3 +82,248 @@ def _cleanup_keeping(track_out: Path, finals: set[str]) -> None:
shutil.rmtree(p, ignore_errors=True)
except OSError:
pass
# ---------------------------------------------------------------- entry
def run_pipeline(
config: PipelineConfig,
log: LogFn = print,
on_progress: Optional[ProgressFn] = None,
stop_event: Optional[threading.Event] = None,
) -> list[FileResult]:
input_dir = Path(config.input_dir)
if not input_dir.is_dir():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
output_dir = Path(config.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
files = collect_audio_files(input_dir, config.recursive)
if not files:
log("Аудиофайлы не найдены.")
return []
mode = (config.mode or "sequential").lower()
log(f"Найдено треков: {len(files)} | режим: {mode}")
if mode == "staged":
return _run_staged(config, files, log, on_progress, stop_event)
return _run_sequential(config, files, log, on_progress, stop_event)
# ---------------------------------------------------------------- sequential
def _run_sequential(
config: PipelineConfig,
files: list[Path],
log: LogFn,
on_progress: Optional[ProgressFn],
stop_event: Optional[threading.Event],
) -> list[FileResult]:
total = len(files)
finals = _final_stages(config)
results: list[FileResult] = []
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, "file")
log(f"\n▶ [{idx}/{total}] {f.name}")
track_out = Path(config.output_dir) / f.stem
track_out.mkdir(parents=True, exist_ok=True)
audio_path: Path = f
text: Optional[str] = None
error: Optional[str] = None
try:
if config.convert.enabled:
_check_cancel(stop_event)
log(" [1] Конвертация в WAV...")
target = track_out / "convert.wav"
if config.overwrite or not target.exists():
convert_to_wav(
f, target,
sample_rate=config.convert.sample_rate,
channels=config.convert.channels,
ffmpeg_path=config.convert.ffmpeg_path,
)
audio_path = target
log(" ✓ convert.wav")
if config.separate.enabled:
_check_cancel(stop_event)
log(" [2] Отделение вокала...")
target = track_out / "vocals.wav"
with tempfile.TemporaryDirectory() as tmp:
raw = separate_vocals(
audio_path, 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))
audio_path = target
log(" ✓ vocals.wav")
if config.transcribe.enabled:
_check_cancel(stop_event)
log(" [3] Транскрибация...")
text = transcribe(
audio_path,
engine=config.transcribe.engine,
model=config.transcribe.model,
language=config.transcribe.language,
device=config.transcribe.device,
compute_type=config.transcribe.compute_type,
batch_size=config.transcribe.batch_size,
gigaam_provider=config.transcribe.gigaam_provider,
)
(track_out / "text.txt").write_text(text, encoding="utf-8")
log(f" ✓ text.txt ({len(text)} символов)")
if not config.keep_intermediate:
_cleanup_keeping(track_out, finals)
except Cancelled:
log("⏹ Отменено")
break
except Exception as e: # noqa: BLE001 — per-file isolation
error = f"{type(e).__name__}: {e}"
log(f" ✗ Ошибка: {error}")
finally:
if on_progress:
on_progress(idx, total, "file")
results.append(FileResult(f, text, track_out if not error else None, error))
return results
# ---------------------------------------------------------------- staged
def _run_staged(
config: PipelineConfig,
files: list[Path],
log: LogFn,
on_progress: Optional[ProgressFn],
stop_event: Optional[threading.Event],
) -> list[FileResult]:
output_root = Path(config.output_dir)
total = len(files)
# per-file state
state: dict[Path, dict] = {
f: {"audio_path": f, "track_out": None, "text": None, "error": None}
for f in files
}
def track_out_for(f: Path) -> Path:
s = state[f]
if s["track_out"] is None:
p = output_root / f.stem
p.mkdir(parents=True, exist_ok=True)
s["track_out"] = p
return s["track_out"]
def active() -> list[Path]:
return [f for f in files if state[f]["error"] is None]
def cancelled() -> bool:
return stop_event is not None and stop_event.is_set()
# ---- Stage 1: convert ----
if config.convert.enabled:
log(f"\n══ Этап 1/3 · Конвертация ({total} файлов) ══")
for i, f in enumerate(files, 1):
if cancelled():
log("⏹ Отменено")
break
if on_progress:
on_progress(i, total, "convert")
try:
target = track_out_for(f) / "convert.wav"
if config.overwrite or not target.exists():
convert_to_wav(
f, target,
sample_rate=config.convert.sample_rate,
channels=config.convert.channels,
ffmpeg_path=config.convert.ffmpeg_path,
)
state[f]["audio_path"] = target
log(f" ✓ {f.name}")
except Exception as e: # noqa: BLE001
state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}")
# ---- Stage 2: separate ----
if config.separate.enabled and not cancelled():
todo = active()
log(f"\n══ Этап 2/3 · Отделение вокала ({len(todo)} файлов) ══")
for i, f in enumerate(todo, 1):
if cancelled():
log("⏹ Отменено")
break
if on_progress:
on_progress(i, len(todo), "separate")
try:
target = track_out_for(f) / "vocals.wav"
with tempfile.TemporaryDirectory() as tmp:
raw = separate_vocals(
state[f]["audio_path"], 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 e: # noqa: BLE001
state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}")
# ---- Stage 3: transcribe ----
if config.transcribe.enabled and not cancelled():
todo = active()
log(f"\n══ Этап 3/3 · Транскрибация ({len(todo)} файлов) ══")
for i, f in enumerate(todo, 1):
if cancelled():
log("⏹ Отменено")
break
if on_progress:
on_progress(i, len(todo), "transcribe")
try:
text = transcribe(
state[f]["audio_path"],
engine=config.transcribe.engine,
model=config.transcribe.model,
language=config.transcribe.language,
device=config.transcribe.device,
compute_type=config.transcribe.compute_type,
batch_size=config.transcribe.batch_size,
gigaam_provider=config.transcribe.gigaam_provider,
)
(track_out_for(f) / "text.txt").write_text(text, encoding="utf-8")
state[f]["text"] = text
log(f" ✓ {f.name} ({len(text)} символов)")
except Exception as e: # noqa: BLE001
state[f]["error"] = f"{type(e).__name__}: {e}"
log(f" ✗ {f.name}: {e}")
# ---- Cleanup ----
if not config.keep_intermediate:
finals = _final_stages(config)
for f in files:
s = state[f]
if s["error"] is None and s["track_out"] is not None:
_cleanup_keeping(s["track_out"], finals)
return [
FileResult(f, state[f]["text"], state[f]["track_out"], state[f]["error"])
for f in files
]

View File

@@ -1,10 +1,12 @@
"""Speech recognition via WhisperX or GigaAM (onnx-asr).
Models are cached per (name, device, compute_type) so loading happens once
per process, not once per file.
per process, not once per file. Loading is retried a few times to survive
transient HuggingFace / SSL hiccups.
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Optional
@@ -19,6 +21,18 @@ def _resolve_device(device: str) -> str:
return "cpu"
def _retry(fn, attempts: int = 3, base_delay: float = 2.0):
last = None
for i in range(attempts):
try:
return fn()
except Exception as e: # noqa: BLE001 — HF download / SSL are broad
last = e
if i < attempts - 1:
time.sleep(base_delay * (i + 1))
raise last
class _WhisperXModels:
_cache: dict = {}
@@ -29,9 +43,11 @@ class _WhisperXModels:
key = (model_name, device, compute_type)
if key not in cls._cache:
import whisperx
cls._cache[key] = whisperx.load_model(
model_name, device, compute_type=compute_type
)
def load():
return whisperx.load_model(model_name, device, compute_type=compute_type)
cls._cache[key] = _retry(load)
return cls._cache[key]
@@ -40,8 +56,6 @@ class _GigaAMModels:
@classmethod
def get(cls, provider: str):
# onnx-asr accepts a list; CUDAExecutionProvider silently falls back to CPU
# if CUDA isn't available at runtime.
providers = {
"cuda": ["CUDAExecutionProvider"],
"cpu": ["CPUExecutionProvider"],
@@ -50,7 +64,11 @@ class _GigaAMModels:
key = tuple(providers)
if key not in cls._cache:
import onnx_asr
cls._cache[key] = onnx_asr.load_model("gigaam-v3-ctc", providers=list(providers))
def load():
return onnx_asr.load_model("gigaam-v3-ctc", providers=list(providers))
cls._cache[key] = _retry(load)
return cls._cache[key]
@@ -65,11 +83,8 @@ def transcribe(
gigaam_provider: str = "auto",
) -> str:
device = _resolve_device(device)
if engine == "whisperx":
return _transcribe_whisperx(
audio_path, model, language, device, compute_type, batch_size
)
return _transcribe_whisperx(audio_path, model, language, device, compute_type, batch_size)
if engine == "gigaam":
return _transcribe_gigaam(audio_path, gigaam_provider)
raise ValueError(f"Unknown ASR engine: {engine!r}")