feat(transcriber): добавлен module выполнения с владением моделью и исполнением
Transcriber принимает ExecutionRequest, разрешает auto и device-aware умолчания, загружает модель один раз на запуск и держит fallback внутри. Caller получает TranscribeResult и ExecutionInfo вместо model/backend/path. Публичный transcribe() идёт через тот же module. Adapters отдают runtime_info() для диагностики; legacy load_model/_transcribe_file пока остаются для CLI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
343a31b35b
commit
4dea7f4bc6
10 files changed
+620
-29
No files matched your search
@@ -53,3 +53,7 @@ class Backend(Protocol):
|
||||
) -> TranscribeResult:
|
||||
"""Транскрибирует файл, возвращает результат."""
|
||||
...
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версии runtime и фактическая конфигурация для удалённой диагностики."""
|
||||
...
|
||||
@@ -16,6 +16,7 @@ from local_transcriber.types import (
|
||||
Word,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import package_version
|
||||
|
||||
MODEL_REPOS = {
|
||||
"tiny": "Systran/faster-whisper-tiny",
|
||||
@@ -157,6 +158,22 @@ class FasterWhisperBackend:
|
||||
words=words,
|
||||
)
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версии CTranslate2/faster-whisper и число видимых CUDA-устройств."""
|
||||
import ctranslate2
|
||||
|
||||
try:
|
||||
cuda_devices = str(ctranslate2.get_cuda_device_count())
|
||||
except Exception as exc: # noqa: BLE001 — диагностика не должна ронять запуск
|
||||
cuda_devices = f"недоступно ({exc})"
|
||||
return {
|
||||
"engine": "faster-whisper",
|
||||
"faster_whisper": package_version("faster-whisper"),
|
||||
"ctranslate2": package_version("ctranslate2"),
|
||||
"cuda_devices": cuda_devices,
|
||||
"compute_type": self.actual_compute_type or "",
|
||||
}
|
||||
|
||||
|
||||
def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
|
||||
if on_status is not None:
|
||||
|
||||
@@ -15,6 +15,7 @@ from local_transcriber.types import (
|
||||
Word,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import package_version
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -138,6 +139,8 @@ class OnnxAsrBackend:
|
||||
self._model_name: str | None = None
|
||||
self._model_spec: OnnxModelSpec | None = None
|
||||
self._vad: Any = None
|
||||
self._providers: list[str] = []
|
||||
self._quantization: str | None = None
|
||||
|
||||
@property
|
||||
def word_timestamps_available(self) -> bool:
|
||||
@@ -197,15 +200,37 @@ class OnnxAsrBackend:
|
||||
actual_compute_type = self.actual_compute_type or compute_type
|
||||
quantization = _normalize_quantization(actual_compute_type)
|
||||
|
||||
providers = ["CPUExecutionProvider"]
|
||||
model = onnx_asr.load_model(
|
||||
model=model_path,
|
||||
quantization=quantization,
|
||||
providers=["CPUExecutionProvider"],
|
||||
providers=providers,
|
||||
)
|
||||
vad = onnx_asr.load_vad("silero", providers=["CPUExecutionProvider"])
|
||||
vad = onnx_asr.load_vad("silero", providers=providers)
|
||||
self._vad = vad
|
||||
self._providers = providers
|
||||
self._quantization = quantization
|
||||
return model.with_vad(vad).with_timestamps()
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версии ONNX Runtime/onnx-asr и providers, заданные сессиям ASR и VAD.
|
||||
|
||||
Доступность provider в wheel не означает, что граф выполняется на нём:
|
||||
сессии получают только явно заданный список.
|
||||
"""
|
||||
import onnxruntime
|
||||
|
||||
configured = ", ".join(self._providers)
|
||||
return {
|
||||
"engine": "onnx-asr",
|
||||
"onnxruntime": package_version("onnxruntime"),
|
||||
"onnx_asr": package_version("onnx-asr"),
|
||||
"available_providers": ", ".join(onnxruntime.get_available_providers()),
|
||||
"asr_providers": configured,
|
||||
"vad_providers": configured,
|
||||
"quantization": self._quantization or "float32",
|
||||
}
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
model: Any,
|
||||
|
||||
@@ -19,6 +19,7 @@ from local_transcriber.types import (
|
||||
Word,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import package_version
|
||||
|
||||
# (model_alias, compute_type) → HF repo
|
||||
MODEL_REPOS: dict[tuple[str, str], str] = {
|
||||
@@ -203,6 +204,18 @@ class OpenVINOBackend:
|
||||
words=words,
|
||||
)
|
||||
|
||||
def runtime_info(self) -> dict[str, str]:
|
||||
"""Версия OpenVINO, доступные устройства и выбранное фактически."""
|
||||
from openvino import Core
|
||||
|
||||
return {
|
||||
"engine": "openvino",
|
||||
"openvino": package_version("openvino"),
|
||||
"available_devices": ", ".join(Core().available_devices),
|
||||
"device": self.actual_ov_device or "",
|
||||
"compute_type": self.actual_compute_type or "",
|
||||
}
|
||||
|
||||
def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
|
||||
"""Находит HF repo для пары (model, compute_type) с fallback.
|
||||
|
||||
|
||||
@@ -2,18 +2,230 @@
|
||||
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from local_transcriber.backends import get_backend
|
||||
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
|
||||
|
||||
# Re-export из types.py для обратной совместимости
|
||||
from local_transcriber.types import ( # noqa: F401
|
||||
from local_transcriber.types import (
|
||||
Segment,
|
||||
TranscribeFileResult,
|
||||
TranscribeResult,
|
||||
WordTimestampsUnavailableError,
|
||||
)
|
||||
from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name
|
||||
|
||||
# Движок распознавания для каждого значения device; остальное, как и в
|
||||
# get_backend, уходит в faster-whisper. Способ исполнения (CPU/GPU) остаётся
|
||||
# в самом значении device и трактуется adapter'ом.
|
||||
_ENGINES: dict[str, str] = {
|
||||
"cpu": "faster-whisper",
|
||||
"cuda": "faster-whisper",
|
||||
"openvino-cpu": "openvino",
|
||||
"openvino-gpu": "openvino",
|
||||
"onnx": "onnx-asr",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionRequest:
|
||||
"""Запрошенные настройки запуска до разрешения умолчаний.
|
||||
|
||||
``None`` в model/compute_type означает device-aware умолчание;
|
||||
``strict_device=None`` — strict для любого явного device, кроме ``auto``.
|
||||
"""
|
||||
|
||||
device: str = "auto"
|
||||
model: str | None = None
|
||||
compute_type: str | None = None
|
||||
language: str | None = None
|
||||
cpu_threads: int = 0
|
||||
strict_device: bool | None = None
|
||||
require_word_timestamps: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionInfo:
|
||||
"""Сведения о выполнении: что запрошено, что выбрано и чем выполняется."""
|
||||
|
||||
requested_device: str
|
||||
resolved_device: str # выбор до загрузки; отличается от device после fallback
|
||||
device: str
|
||||
engine: str
|
||||
model: str
|
||||
compute_type: str
|
||||
cpu_threads: int
|
||||
word_timestamps_available: bool
|
||||
description: str # строка исполнения для шапки транскрипта
|
||||
runtime: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Transcriber:
|
||||
"""Module выполнения распознавания на один запуск.
|
||||
|
||||
Владеет загруженной моделью, adapter'ом, фактическими настройками
|
||||
и разрешёнными переходами исполнения. Caller передаёт файлы по одному
|
||||
и получает результат; состояние между файлами не переносит.
|
||||
"""
|
||||
|
||||
def __init__(self, request: ExecutionRequest) -> None:
|
||||
self._request = request
|
||||
self._strict = (
|
||||
request.strict_device
|
||||
if request.strict_device is not None
|
||||
else request.device != "auto"
|
||||
)
|
||||
self._compute_type_explicit = request.compute_type is not None
|
||||
resolved = detect_device(request.device)
|
||||
defaults = DEVICE_DEFAULTS.get(resolved, {})
|
||||
self._model_name = request.model or defaults.get(
|
||||
"model", HARDCODED_DEFAULTS["model"]
|
||||
)
|
||||
self._compute_type = request.compute_type or defaults.get(
|
||||
"compute_type", HARDCODED_DEFAULTS["compute_type"]
|
||||
)
|
||||
self._resolved_device = resolved
|
||||
self._device = resolved
|
||||
self._backend: Any = None
|
||||
self._model: Any = None
|
||||
self._info: ExecutionInfo | None = None
|
||||
|
||||
@property
|
||||
def execution(self) -> ExecutionInfo:
|
||||
"""Текущие сведения о выполнении; до prepare() — без загрузки."""
|
||||
if self._info is None:
|
||||
raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()")
|
||||
return self._info
|
||||
|
||||
def prepare(self, on_status: Callable[[str], None] | None = None) -> ExecutionInfo:
|
||||
"""Загружает модель и проверяет возможности до первого файла. Идемпотентно."""
|
||||
if self._info is not None:
|
||||
return self._info
|
||||
try:
|
||||
self._load(self._device, on_status)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if not self._may_fall_back(exc):
|
||||
raise
|
||||
warnings.warn(
|
||||
f"Не удалось загрузить модель на {self._device}: {exc}. "
|
||||
"Переключение на CPU.",
|
||||
stacklevel=2,
|
||||
)
|
||||
self._load("cpu", on_status)
|
||||
if (
|
||||
self._request.require_word_timestamps
|
||||
and not self._info.word_timestamps_available
|
||||
):
|
||||
raise ValueError(
|
||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||
)
|
||||
return self._info
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
file_path: Path,
|
||||
on_segment: Callable[[Segment], None] | None = None,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
) -> TranscribeResult:
|
||||
"""Транскрибирует один файл; при разрешённом fallback перезагружает модель."""
|
||||
self.prepare(on_status)
|
||||
lang_arg = self._request.language
|
||||
if lang_arg == "auto":
|
||||
lang_arg = None
|
||||
try:
|
||||
result = self._recognize(file_path, lang_arg, on_segment, on_status)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if not self._may_fall_back(exc):
|
||||
raise
|
||||
warnings.warn(
|
||||
f"Ошибка при транскрипции на {self._device}: {exc}. "
|
||||
"Переключение на CPU и повтор.",
|
||||
stacklevel=2,
|
||||
)
|
||||
self._load("cpu", on_status)
|
||||
result = self._recognize(file_path, lang_arg, on_segment, on_status)
|
||||
return result
|
||||
|
||||
def _recognize(
|
||||
self,
|
||||
file_path: Path,
|
||||
language: str | None,
|
||||
on_segment: Callable[[Segment], None] | None,
|
||||
on_status: Callable[[str], None] | None,
|
||||
) -> TranscribeResult:
|
||||
_notify_status(on_status, "Транскрибирую...")
|
||||
result = self._backend.transcribe(
|
||||
self._model, file_path, language, on_segment, on_status
|
||||
)
|
||||
result.device_used = self._device
|
||||
return result
|
||||
|
||||
def _may_fall_back(self, exc: BaseException) -> bool:
|
||||
"""Разрешённый переход: не strict, не CPU и ошибка самого движка."""
|
||||
if self._strict or self._device == "cpu":
|
||||
return False
|
||||
return _is_backend_error(exc, self._device)
|
||||
|
||||
def _load(self, device: str, on_status: Callable[[str], None] | None) -> None:
|
||||
backend = get_backend(device, compute_type_explicit=self._compute_type_explicit)
|
||||
model_path = backend.ensure_model_available(
|
||||
self._model_name, self._compute_type, on_status
|
||||
)
|
||||
_notify_status(on_status, f"Инициализирую модель на {device}...")
|
||||
model = backend.create_model(
|
||||
model_path,
|
||||
device,
|
||||
self._compute_type,
|
||||
cpu_threads=self._request.cpu_threads,
|
||||
)
|
||||
self._backend = backend
|
||||
self._model = model
|
||||
self._device = _refine_openvino_device(device, backend)
|
||||
self._info = self._describe()
|
||||
|
||||
def _describe(self) -> ExecutionInfo:
|
||||
backend = self._backend
|
||||
compute_type = (
|
||||
getattr(backend, "actual_compute_type", None) or self._compute_type
|
||||
)
|
||||
return ExecutionInfo(
|
||||
requested_device=self._request.device,
|
||||
resolved_device=self._resolved_device,
|
||||
device=self._device,
|
||||
engine=_ENGINES.get(self._device, "faster-whisper"),
|
||||
model=self._model_name,
|
||||
compute_type=compute_type,
|
||||
cpu_threads=self._request.cpu_threads,
|
||||
word_timestamps_available=bool(backend.word_timestamps_available),
|
||||
description=_describe_device(self._device),
|
||||
runtime=dict(backend.runtime_info()),
|
||||
)
|
||||
|
||||
|
||||
def _refine_openvino_device(device: str, backend: Any) -> str:
|
||||
"""Заменяет запрошенный openvino-* на устройство, которое реально выбрал OpenVINO."""
|
||||
ov_dev = getattr(backend, "actual_ov_device", None)
|
||||
if ov_dev == "GPU" and device != "openvino-gpu":
|
||||
return "openvino-gpu"
|
||||
if ov_dev == "CPU" and device.startswith("openvino") and device != "openvino-cpu":
|
||||
return "openvino-cpu"
|
||||
return device
|
||||
|
||||
|
||||
def _describe_device(device: str) -> str:
|
||||
"""Строка исполнения для шапки транскрипта; GPU называется только по данным драйвера."""
|
||||
if device == "cuda":
|
||||
return f"CUDA ({get_gpu_name() or 'Unknown GPU'})"
|
||||
if device == "openvino-gpu":
|
||||
return f"OpenVINO ({get_intel_gpu_name() or 'Intel GPU'})"
|
||||
if device == "openvino-cpu":
|
||||
return "OpenVINO (CPU)"
|
||||
if device == "onnx":
|
||||
return "ONNX (CPU)"
|
||||
return "CPU"
|
||||
|
||||
|
||||
def load_model(
|
||||
@@ -132,40 +344,30 @@ def _transcribe_file(
|
||||
|
||||
def transcribe(
|
||||
file_path: Path,
|
||||
model_name: str = "large-v3",
|
||||
model_name: str | None = None,
|
||||
device: str = "auto",
|
||||
compute_type: str = "int8",
|
||||
compute_type: str | None = None,
|
||||
language: str | None = None,
|
||||
on_segment: Callable[[Segment], None] | None = None,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
strict_device: bool = False,
|
||||
cpu_threads: int = 0,
|
||||
) -> TranscribeResult:
|
||||
"""High-level API: загрузка модели + транскрипция за один вызов."""
|
||||
model, actual_device, backend, model_path = load_model(
|
||||
model_name,
|
||||
device,
|
||||
compute_type,
|
||||
on_status,
|
||||
strict_device,
|
||||
compute_type_explicit=True, # Python API — caller explicitly chose compute_type
|
||||
cpu_threads=cpu_threads,
|
||||
"""Публичный путь одного файла через тот же module выполнения, что и CLI.
|
||||
|
||||
``None`` в model_name/compute_type берёт умолчания выбранного исполнения.
|
||||
"""
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device=device,
|
||||
model=model_name,
|
||||
compute_type=compute_type,
|
||||
language=language,
|
||||
cpu_threads=cpu_threads,
|
||||
strict_device=strict_device,
|
||||
)
|
||||
)
|
||||
tfr = _transcribe_file(
|
||||
model,
|
||||
actual_device,
|
||||
backend,
|
||||
model_path,
|
||||
file_path,
|
||||
model_name,
|
||||
compute_type,
|
||||
language,
|
||||
on_segment,
|
||||
on_status,
|
||||
strict_device,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
return tfr.result
|
||||
return run.transcribe(file_path, on_segment, on_status)
|
||||
|
||||
|
||||
def ensure_model_available(
|
||||
|
||||
@@ -4,6 +4,7 @@ import glob
|
||||
import platform
|
||||
import subprocess
|
||||
import warnings
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
@@ -141,3 +142,11 @@ def expand_globs(paths: list[Path]) -> list[Path]:
|
||||
def has_existing_transcript(input_path: Path) -> bool:
|
||||
"""Проверяет наличие транскрипта для skip-логики батч-режима."""
|
||||
return build_output_path(input_path).exists()
|
||||
|
||||
|
||||
def package_version(name: str) -> str:
|
||||
"""Версия установленного пакета для диагностики; отсутствие не считается ошибкой."""
|
||||
try:
|
||||
return version(name)
|
||||
except PackageNotFoundError:
|
||||
return "не установлен"
|
||||
Reference in new issue
Block a user