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 "не установлен"
|
||||
@@ -83,3 +83,18 @@ def test_transcribe_rejects_one_nonempty_segment_without_word_timestamps(tmp_pat
|
||||
|
||||
with pytest.raises(RuntimeError, match="пословные таймкоды"):
|
||||
FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru")
|
||||
|
||||
|
||||
def test_runtime_info_reports_versions_and_cuda_devices(monkeypatch):
|
||||
"""Диагностика называет версии runtime и число CUDA-устройств, не обещая ускорения."""
|
||||
monkeypatch.setattr("ctranslate2.get_cuda_device_count", lambda: 0)
|
||||
backend = FasterWhisperBackend()
|
||||
backend.actual_compute_type = "float32"
|
||||
|
||||
info = backend.runtime_info()
|
||||
|
||||
assert info["engine"] == "faster-whisper"
|
||||
assert info["ctranslate2"]
|
||||
assert info["faster_whisper"]
|
||||
assert info["cuda_devices"] == "0"
|
||||
assert info["compute_type"] == "float32"
|
||||
@@ -463,3 +463,21 @@ def test_validate_model_dir_requires_alignment_heads_for_word_timestamps(tmp_pat
|
||||
|
||||
with pytest.raises(ValueError, match="alignment_heads"):
|
||||
_validate_model_dir(tmp_path)
|
||||
|
||||
|
||||
def test_runtime_info_reports_available_and_actual_devices(monkeypatch):
|
||||
"""Диагностика показывает устройства OpenVINO и то, что выбрано фактически."""
|
||||
core = MagicMock()
|
||||
core.available_devices = ["CPU", "GPU"]
|
||||
monkeypatch.setattr("openvino.Core", lambda: core)
|
||||
backend = OpenVINOBackend(ov_device="openvino-gpu", compute_type_explicit=True)
|
||||
backend.actual_ov_device = "GPU"
|
||||
backend.actual_compute_type = "fp16"
|
||||
|
||||
info = backend.runtime_info()
|
||||
|
||||
assert info["engine"] == "openvino"
|
||||
assert info["openvino"]
|
||||
assert info["available_devices"] == "CPU, GPU"
|
||||
assert info["device"] == "GPU"
|
||||
assert info["compute_type"] == "fp16"
|
||||
@@ -659,3 +659,27 @@ class TestModelAliases:
|
||||
assert "--device cpu --model medium" in message
|
||||
assert "--device cuda --model large-v3-turbo" not in message
|
||||
assert "--device cpu --model large-v3-turbo" not in message
|
||||
|
||||
|
||||
class TestRuntimeInfo:
|
||||
def test_reports_versions_and_configured_providers(self, monkeypatch):
|
||||
"""Диагностика различает доступные providers и те, что заданы сессиям ASR и VAD."""
|
||||
monkeypatch.setattr(
|
||||
"onnxruntime.get_available_providers",
|
||||
lambda: ["CoreMLExecutionProvider", "CPUExecutionProvider"],
|
||||
)
|
||||
monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: MagicMock())
|
||||
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kwargs: MagicMock())
|
||||
backend = OnnxAsrBackend()
|
||||
backend.actual_compute_type = "int8"
|
||||
backend.create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
|
||||
|
||||
info = backend.runtime_info()
|
||||
|
||||
assert info["engine"] == "onnx-asr"
|
||||
assert info["onnxruntime"]
|
||||
assert info["onnx_asr"]
|
||||
assert info["available_providers"] == "CoreMLExecutionProvider, CPUExecutionProvider"
|
||||
assert info["asr_providers"] == "CPUExecutionProvider"
|
||||
assert info["vad_providers"] == "CPUExecutionProvider"
|
||||
assert info["quantization"] == "int8"
|
||||
@@ -4,7 +4,9 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from local_transcriber.transcriber import (
|
||||
ExecutionRequest,
|
||||
Segment,
|
||||
Transcriber,
|
||||
TranscribeResult,
|
||||
_transcribe_file,
|
||||
cuda_error_hint,
|
||||
@@ -697,3 +699,265 @@ def test_ensure_model_available_openvino_default_compute_type():
|
||||
repo, ct = backend._resolve_repo("medium", "int8")
|
||||
assert repo == "OpenVINO/whisper-medium-int8-ov"
|
||||
assert ct == "int8"
|
||||
|
||||
|
||||
# === Transcriber: module выполнения ===
|
||||
|
||||
|
||||
def _make_run_backend(**kwargs):
|
||||
"""Fake adapter для external interface: runtime_info возвращает словарь."""
|
||||
backend = _make_backend(**kwargs)
|
||||
backend.runtime_info.return_value = {}
|
||||
backend.word_timestamps_available = True
|
||||
backend.actual_compute_type = None
|
||||
backend.actual_ov_device = None
|
||||
return backend
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_loads_model_once_for_two_files(mock_get_backend):
|
||||
"""Два файла переиспользуют одну загрузку модели."""
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(ExecutionRequest(device="cpu", model="tiny", compute_type="int8"))
|
||||
first = run.transcribe(Path("a.mp3"))
|
||||
second = run.transcribe(Path("b.mp3"))
|
||||
|
||||
assert backend.create_model.call_count == 1
|
||||
assert backend.transcribe.call_count == 2
|
||||
assert first.device_used == "cpu"
|
||||
assert second.device_used == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_does_not_load_model_before_first_file(mock_get_backend):
|
||||
"""Создание module без файлов не загружает модель."""
|
||||
mock_get_backend.return_value = _make_run_backend()
|
||||
|
||||
Transcriber(ExecutionRequest(device="cpu", model="tiny", compute_type="int8"))
|
||||
|
||||
mock_get_backend.assert_not_called()
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_auto_resolves_to_onnx_cpu_with_device_defaults(mock_get_backend):
|
||||
"""auto → ONNX CPU и его модель/квантизация по умолчанию; сведения различают запрошенное и выбранное."""
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(ExecutionRequest()).prepare()
|
||||
|
||||
mock_get_backend.assert_called_once_with("onnx", compute_type_explicit=False)
|
||||
backend.ensure_model_available.assert_called_once_with(
|
||||
"gigaam-v3-e2e-rnnt", "int8", None
|
||||
)
|
||||
assert info.requested_device == "auto"
|
||||
assert info.device == "onnx"
|
||||
assert info.engine == "onnx-asr"
|
||||
assert info.model == "gigaam-v3-e2e-rnnt"
|
||||
assert info.compute_type == "int8"
|
||||
assert info.description == "ONNX (CPU)"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_falls_back_to_cpu_at_load_and_keeps_state_for_next_file(
|
||||
mock_get_backend,
|
||||
):
|
||||
"""Не-strict CUDA-ошибка при загрузке → CPU; следующий файл идёт на CPU без перезагрузки."""
|
||||
cuda_backend = _make_run_backend(
|
||||
create_model_error=RuntimeError("CUDA out of memory")
|
||||
)
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="int8", strict_device=False
|
||||
)
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
first = run.transcribe(Path("a.mp3"))
|
||||
second = run.transcribe(Path("b.mp3"))
|
||||
|
||||
assert first.device_used == "cpu"
|
||||
assert second.device_used == "cpu"
|
||||
assert cpu_backend.create_model.call_count == 1
|
||||
assert run.execution.resolved_device == "cuda"
|
||||
assert run.execution.device == "cpu"
|
||||
assert run.execution.engine == "faster-whisper"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_strict_device_raises_without_fallback(mock_get_backend):
|
||||
"""Явный device (strict по умолчанию) не подменяется другим исполнением."""
|
||||
mock_get_backend.return_value = _make_run_backend(
|
||||
create_model_error=RuntimeError("CUDA driver version is insufficient")
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="driver version"):
|
||||
Transcriber(
|
||||
ExecutionRequest(device="cuda", model="tiny", compute_type="int8")
|
||||
).prepare()
|
||||
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_midstream_fallback_reloads_once_and_continues_batch(mock_get_backend):
|
||||
"""CUDA-ошибка во время распознавания → повтор файла на CPU; батч продолжается на CPU."""
|
||||
cuda_backend = _make_run_backend(
|
||||
transcribe_error=RuntimeError("CUBLAS_STATUS_ALLOC_FAILED")
|
||||
)
|
||||
cpu_backend = _make_run_backend()
|
||||
mock_get_backend.side_effect = lambda device, **_: (
|
||||
cuda_backend if device == "cuda" else cpu_backend
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="int8", strict_device=False
|
||||
)
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU и повтор"):
|
||||
first = run.transcribe(Path("a.mp3"))
|
||||
second = run.transcribe(Path("b.mp3"))
|
||||
|
||||
assert first.device_used == "cpu"
|
||||
assert second.device_used == "cpu"
|
||||
assert cpu_backend.create_model.call_count == 1
|
||||
assert cpu_backend.transcribe.call_count == 2
|
||||
assert run.execution.device == "cpu"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_ordinary_file_error_does_not_change_state(mock_get_backend):
|
||||
"""Ошибка файла (не движка) пробрасывается, модель и исполнение остаются прежними."""
|
||||
backend = _make_run_backend()
|
||||
backend.transcribe.side_effect = [
|
||||
ValueError("Не удалось декодировать"),
|
||||
_make_result(),
|
||||
]
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(device="cuda", model="tiny", compute_type="int8")
|
||||
)
|
||||
with pytest.raises(ValueError, match="декодировать"):
|
||||
run.transcribe(Path("bad.mp3"))
|
||||
result = run.transcribe(Path("good.mp3"))
|
||||
|
||||
assert result.device_used == "cuda"
|
||||
assert backend.create_model.call_count == 1
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_word_timestamps_error_is_not_a_fallback_reason(mock_get_backend):
|
||||
"""Нарушение пословного контракта не считается ошибкой движка."""
|
||||
mock_get_backend.return_value = _make_run_backend(
|
||||
transcribe_error=WordTimestampsUnavailableError("нет слов")
|
||||
)
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="int8", strict_device=False
|
||||
)
|
||||
)
|
||||
with pytest.raises(WordTimestampsUnavailableError):
|
||||
run.transcribe(Path("a.mp3"))
|
||||
|
||||
assert mock_get_backend.call_count == 1
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_requires_word_timestamps_before_first_file(mock_get_backend):
|
||||
"""Требование пословного контракта проверяется при подготовке, ASR не запускается."""
|
||||
backend = _make_run_backend()
|
||||
backend.word_timestamps_available = False
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
run = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="onnx", model="some/raw-model", require_word_timestamps=True
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="пословные таймкоды"):
|
||||
run.transcribe(Path("a.mp3"))
|
||||
|
||||
backend.transcribe.assert_not_called()
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_reports_actual_openvino_device(mock_get_backend):
|
||||
"""openvino-gpu, выбранный до загрузки, уточняется по фактическому устройству OpenVINO."""
|
||||
backend = _make_run_backend()
|
||||
backend.actual_ov_device = "CPU"
|
||||
backend.actual_compute_type = "int8"
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
with patch(
|
||||
"local_transcriber.transcriber.detect_device", return_value="openvino-gpu"
|
||||
):
|
||||
info = Transcriber(
|
||||
ExecutionRequest(device="openvino", model="medium")
|
||||
).prepare()
|
||||
|
||||
assert info.requested_device == "openvino"
|
||||
assert info.resolved_device == "openvino-gpu"
|
||||
assert info.device == "openvino-cpu"
|
||||
assert info.engine == "openvino"
|
||||
assert info.compute_type == "int8"
|
||||
assert info.description == "OpenVINO (CPU)"
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_gpu_name", return_value="RTX 3060")
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_exposes_runtime_and_gpu_name_from_module_data(mock_get_backend, _gpu):
|
||||
"""Сведения о runtime берутся из adapter'а, имя GPU — из драйвера, потоки — из запроса."""
|
||||
backend = _make_run_backend()
|
||||
backend.runtime_info.return_value = {"ctranslate2": "4.5.0"}
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
info = Transcriber(
|
||||
ExecutionRequest(
|
||||
device="cuda", model="tiny", compute_type="float16", cpu_threads=4
|
||||
)
|
||||
).prepare()
|
||||
|
||||
assert info.description == "CUDA (RTX 3060)"
|
||||
assert info.runtime == {"ctranslate2": "4.5.0"}
|
||||
assert info.cpu_threads == 4
|
||||
backend.create_model.assert_called_once_with(
|
||||
"/mock/model", "cuda", "float16", cpu_threads=4
|
||||
)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_run_passes_auto_language_as_none(mock_get_backend):
|
||||
backend = _make_run_backend()
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
Transcriber(
|
||||
ExecutionRequest(device="cpu", model="tiny", language="auto")
|
||||
).transcribe(Path("a.mp3"))
|
||||
|
||||
assert backend.transcribe.call_args[0][2] is None
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.get_backend")
|
||||
def test_public_transcribe_uses_the_same_execution_module(mock_get_backend):
|
||||
"""transcribe() без параметров идёт тем же путём, что CLI по умолчанию: ONNX CPU."""
|
||||
backend = _make_run_backend(transcribe_result=_make_result(count=1))
|
||||
mock_get_backend.return_value = backend
|
||||
|
||||
result = transcribe(Path("a.mp3"))
|
||||
|
||||
mock_get_backend.assert_called_once_with("onnx", compute_type_explicit=False)
|
||||
backend.ensure_model_available.assert_called_once_with(
|
||||
"gigaam-v3-e2e-rnnt", "int8", None
|
||||
)
|
||||
assert result.device_used == "onnx"
|
||||
assert len(result.segments) == 1
|
||||
Reference in new issue
Block a user