migrate to mikroservices architecture, and add more features
This commit is contained in:
85
core/config.py
Normal file
85
core/config.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Pipeline configuration dataclasses with JSON save/load."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertConfig:
|
||||
enabled: bool = True
|
||||
sample_rate: int = 44100
|
||||
channels: int = 2
|
||||
ffmpeg_path: str = "ffmpeg"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeparateConfig:
|
||||
enabled: bool = True
|
||||
model: str = "bs_roformer_anvuew"
|
||||
isolate_stem: str = "vocals"
|
||||
unblend_path: str = "unblend"
|
||||
custom_kernels: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscribeConfig:
|
||||
enabled: bool = True
|
||||
engine: str = "whisperx" # "whisperx" | "gigaam"
|
||||
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)
|
||||
batch_size: int = 8
|
||||
gigaam_provider: str = "auto" # "auto" | "cuda" | "cpu"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineConfig:
|
||||
input_dir: str = ""
|
||||
output_dir: str = "transcribed"
|
||||
recursive: bool = False
|
||||
keep_intermediate: bool = True
|
||||
overwrite: bool = True
|
||||
|
||||
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)
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict(), indent=2, ensure_ascii=False)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "PipelineConfig":
|
||||
cfg = cls()
|
||||
for top in fields(cls):
|
||||
name = top.name
|
||||
if name not in data:
|
||||
continue
|
||||
value = data[name]
|
||||
current = getattr(cfg, name)
|
||||
if hasattr(current, "__dataclass_fields__"):
|
||||
sub_fields = {f.name for f in fields(current)}
|
||||
for k, v in value.items():
|
||||
if k in sub_fields:
|
||||
setattr(current, k, v)
|
||||
else:
|
||||
setattr(cfg, name, value)
|
||||
return cfg
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, text: str) -> "PipelineConfig":
|
||||
return cls.from_dict(json.loads(text))
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
Path(path).write_text(self.to_json(), encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> "PipelineConfig":
|
||||
return cls.from_json(Path(path).read_text(encoding="utf-8"))
|
||||
28
core/convert.py
Normal file
28
core/convert.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Convert any audio file to a WAV with chosen sample rate and channels via ffmpeg."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def convert_to_wav(
|
||||
input_path: Path,
|
||||
output_wav: Path,
|
||||
sample_rate: int = 44100,
|
||||
channels: int = 2,
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
) -> None:
|
||||
"""Run ffmpeg to produce a WAV file. Raises RuntimeError on ffmpeg failure."""
|
||||
output_wav.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
ffmpeg_path, "-y", "-hide_banner", "-loglevel", "error",
|
||||
"-i", str(input_path),
|
||||
"-ar", str(sample_rate),
|
||||
"-ac", str(channels),
|
||||
str(output_wav),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg failed for {input_path.name}:\n{result.stderr.strip()[:800]}"
|
||||
)
|
||||
1
core/init.py
Normal file
1
core/init.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core pipeline: convert, separate, transcribe."""
|
||||
208
core/pipeline.py
Normal file
208
core/pipeline.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Orchestrate convert -> separate -> transcribe over a directory of audio files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .config import PipelineConfig
|
||||
from .convert import convert_to_wav
|
||||
from .separate import separate_vocals
|
||||
from .transcribe import transcribe
|
||||
|
||||
|
||||
AUDIO_EXTENSIONS = {
|
||||
".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus", ".wma",
|
||||
}
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
StageFn = Callable[[str, str], None]
|
||||
ProgressFn = Callable[[int, int], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileResult:
|
||||
input_path: Path
|
||||
text: Optional[str]
|
||||
output_dir: Optional[Path]
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class Cancelled(Exception):
|
||||
"""Raised internally when the stop event is set."""
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
return {"separate"}
|
||||
if config.convert.enabled:
|
||||
return {"convert"}
|
||||
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 _cleanup_keeping(track_out: Path, finals: set[str]) -> None:
|
||||
if not track_out.exists():
|
||||
return
|
||||
keep = {_ARTIFACT_NAMES[s] for s in finals if s in _ARTIFACT_NAMES}
|
||||
for p in track_out.iterdir():
|
||||
if p.name in keep:
|
||||
continue
|
||||
try:
|
||||
if p.is_file():
|
||||
p.unlink()
|
||||
elif p.is_dir():
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
40
core/separate.py
Normal file
40
core/separate.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Vocal separation via unblend (Mel-Band RoFormer)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def separate_vocals(
|
||||
input_wav: Path,
|
||||
work_dir: Path,
|
||||
model: str = "bs_roformer_anvuew",
|
||||
isolate_stem: str = "vocals",
|
||||
unblend_path: str = "unblend",
|
||||
custom_kernels: bool = False,
|
||||
) -> Path:
|
||||
"""Separate the isolated stem from input_wav. Returns path to the stem WAV."""
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_pattern = work_dir / "{track}" / "{stem}.wav"
|
||||
cmd = [
|
||||
unblend_path, "separate", str(input_wav),
|
||||
"-m", model,
|
||||
"-o", str(output_pattern),
|
||||
"--isolate-stem", isolate_stem,
|
||||
]
|
||||
env = os.environ.copy()
|
||||
env["UNBLEND_CUSTOM_KERNELS"] = "1" if custom_kernels else "0"
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"unblend failed for {input_wav.name}:\n{result.stderr.strip()[:800]}"
|
||||
)
|
||||
|
||||
candidates = sorted(work_dir.rglob(f"{isolate_stem}.wav"))
|
||||
if not candidates:
|
||||
raise RuntimeError(
|
||||
f"unblend finished but stem '{isolate_stem}.wav' was not found in {work_dir}"
|
||||
)
|
||||
return candidates[0]
|
||||
95
core/transcribe.py
Normal file
95
core/transcribe.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _resolve_device(device: str) -> str:
|
||||
if device in ("cuda", "cpu"):
|
||||
return device
|
||||
try:
|
||||
import torch
|
||||
return "cuda" if torch.cuda.is_available() else "cpu"
|
||||
except Exception:
|
||||
return "cpu"
|
||||
|
||||
|
||||
class _WhisperXModels:
|
||||
_cache: dict = {}
|
||||
|
||||
@classmethod
|
||||
def get(cls, model_name: str, device: str, compute_type: Optional[str]):
|
||||
if compute_type is None:
|
||||
compute_type = "int8_float16" if device == "cuda" else "int8"
|
||||
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
|
||||
)
|
||||
return cls._cache[key]
|
||||
|
||||
|
||||
class _GigaAMModels:
|
||||
_cache: dict = {}
|
||||
|
||||
@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"],
|
||||
"auto": ["CUDAExecutionProvider", "CPUExecutionProvider"],
|
||||
}.get(provider, ["CPUExecutionProvider"])
|
||||
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))
|
||||
return cls._cache[key]
|
||||
|
||||
|
||||
def transcribe(
|
||||
audio_path: Path,
|
||||
engine: str = "whisperx",
|
||||
model: str = "large-v3",
|
||||
language: Optional[str] = "ru",
|
||||
device: str = "auto",
|
||||
compute_type: Optional[str] = None,
|
||||
batch_size: int = 8,
|
||||
gigaam_provider: str = "auto",
|
||||
) -> str:
|
||||
device = _resolve_device(device)
|
||||
|
||||
if engine == "whisperx":
|
||||
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}")
|
||||
|
||||
|
||||
def _transcribe_whisperx(
|
||||
audio_path: Path,
|
||||
model_name: str,
|
||||
language: Optional[str],
|
||||
device: str,
|
||||
compute_type: Optional[str],
|
||||
batch_size: int,
|
||||
) -> str:
|
||||
import whisperx
|
||||
model = _WhisperXModels.get(model_name, device, compute_type)
|
||||
audio = whisperx.load_audio(str(audio_path))
|
||||
result = model.transcribe(audio, batch_size=batch_size, language=language)
|
||||
return " ".join(seg["text"].strip() for seg in result["segments"]).strip()
|
||||
|
||||
|
||||
def _transcribe_gigaam(audio_path: Path, provider: str) -> str:
|
||||
model = _GigaAMModels.get(provider)
|
||||
return model.recognize(str(audio_path), channel="mean").strip()
|
||||
Reference in New Issue
Block a user