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:
Dmitry DementievandClaude Opus 5 committed 2026-09-21 23:46:54 +03:00
1 parent 343a31b35b
commit 4dea7f4bc6
10 files changed
+620 -29

No files matched your search

+4
View File
@@ -53,3 +53,7 @@ class Backend(Protocol):
) -> TranscribeResult: ) -> TranscribeResult:
"""Транскрибирует файл, возвращает результат.""" """Транскрибирует файл, возвращает результат."""
... ...
def runtime_info(self) -> dict[str, str]:
"""Версии runtime и фактическая конфигурация для удалённой диагностики."""
...
@@ -16,6 +16,7 @@ from local_transcriber.types import (
Word, Word,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import package_version
MODEL_REPOS = { MODEL_REPOS = {
"tiny": "Systran/faster-whisper-tiny", "tiny": "Systran/faster-whisper-tiny",
@@ -157,6 +158,22 @@ class FasterWhisperBackend:
words=words, 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: def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
if on_status is not None: if on_status is not None:
+27 -2
View File
@@ -15,6 +15,7 @@ from local_transcriber.types import (
Word, Word,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import package_version
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -138,6 +139,8 @@ class OnnxAsrBackend:
self._model_name: str | None = None self._model_name: str | None = None
self._model_spec: OnnxModelSpec | None = None self._model_spec: OnnxModelSpec | None = None
self._vad: Any = None self._vad: Any = None
self._providers: list[str] = []
self._quantization: str | None = None
@property @property
def word_timestamps_available(self) -> bool: def word_timestamps_available(self) -> bool:
@@ -197,15 +200,37 @@ class OnnxAsrBackend:
actual_compute_type = self.actual_compute_type or compute_type actual_compute_type = self.actual_compute_type or compute_type
quantization = _normalize_quantization(actual_compute_type) quantization = _normalize_quantization(actual_compute_type)
providers = ["CPUExecutionProvider"]
model = onnx_asr.load_model( model = onnx_asr.load_model(
model=model_path, model=model_path,
quantization=quantization, 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._vad = vad
self._providers = providers
self._quantization = quantization
return model.with_vad(vad).with_timestamps() 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( def transcribe(
self, self,
model: Any, model: Any,
@@ -19,6 +19,7 @@ from local_transcriber.types import (
Word, Word,
WordTimestampsUnavailableError, WordTimestampsUnavailableError,
) )
from local_transcriber.utils import package_version
# (model_alias, compute_type) → HF repo # (model_alias, compute_type) → HF repo
MODEL_REPOS: dict[tuple[str, str], str] = { MODEL_REPOS: dict[tuple[str, str], str] = {
@@ -203,6 +204,18 @@ class OpenVINOBackend:
words=words, 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]: def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
"""Находит HF repo для пары (model, compute_type) с fallback. """Находит HF repo для пары (model, compute_type) с fallback.
+229 -27
View File
@@ -2,18 +2,230 @@
import warnings import warnings
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from local_transcriber.backends import get_backend from local_transcriber.backends import get_backend
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
# Re-export из types.py для обратной совместимости # Re-export из types.py для обратной совместимости
from local_transcriber.types import ( # noqa: F401 from local_transcriber.types import (
Segment, Segment,
TranscribeFileResult, TranscribeFileResult,
TranscribeResult, TranscribeResult,
WordTimestampsUnavailableError, 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( def load_model(
@@ -132,40 +344,30 @@ def _transcribe_file(
def transcribe( def transcribe(
file_path: Path, file_path: Path,
model_name: str = "large-v3", model_name: str | None = None,
device: str = "auto", device: str = "auto",
compute_type: str = "int8", compute_type: str | None = None,
language: str | None = None, language: str | None = None,
on_segment: Callable[[Segment], None] | None = None, on_segment: Callable[[Segment], None] | None = None,
on_status: Callable[[str], None] | None = None, on_status: Callable[[str], None] | None = None,
strict_device: bool = False, strict_device: bool = False,
cpu_threads: int = 0, cpu_threads: int = 0,
) -> TranscribeResult: ) -> TranscribeResult:
"""High-level API: загрузка модели + транскрипция за один вызов.""" """Публичный путь одного файла через тот же module выполнения, что и CLI.
model, actual_device, backend, model_path = load_model(
model_name, ``None`` в model_name/compute_type берёт умолчания выбранного исполнения.
device, """
compute_type, run = Transcriber(
on_status, ExecutionRequest(
strict_device, device=device,
compute_type_explicit=True, # Python API — caller explicitly chose compute_type model=model_name,
cpu_threads=cpu_threads, compute_type=compute_type,
language=language,
cpu_threads=cpu_threads,
strict_device=strict_device,
)
) )
tfr = _transcribe_file( return run.transcribe(file_path, on_segment, on_status)
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
def ensure_model_available( def ensure_model_available(
+9
View File
@@ -4,6 +4,7 @@ import glob
import platform import platform
import subprocess import subprocess
import warnings import warnings
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
SUPPORTED_EXTENSIONS = { SUPPORTED_EXTENSIONS = {
@@ -141,3 +142,11 @@ def expand_globs(paths: list[Path]) -> list[Path]:
def has_existing_transcript(input_path: Path) -> bool: def has_existing_transcript(input_path: Path) -> bool:
"""Проверяет наличие транскрипта для skip-логики батч-режима.""" """Проверяет наличие транскрипта для skip-логики батч-режима."""
return build_output_path(input_path).exists() return build_output_path(input_path).exists()
def package_version(name: str) -> str:
"""Версия установленного пакета для диагностики; отсутствие не считается ошибкой."""
try:
return version(name)
except PackageNotFoundError:
return "не установлен"
+15
View File
@@ -83,3 +83,18 @@ def test_transcribe_rejects_one_nonempty_segment_without_word_timestamps(tmp_pat
with pytest.raises(RuntimeError, match="пословные таймкоды"): with pytest.raises(RuntimeError, match="пословные таймкоды"):
FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru") 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"
+18
View File
@@ -463,3 +463,21 @@ def test_validate_model_dir_requires_alignment_heads_for_word_timestamps(tmp_pat
with pytest.raises(ValueError, match="alignment_heads"): with pytest.raises(ValueError, match="alignment_heads"):
_validate_model_dir(tmp_path) _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"
+24
View File
@@ -659,3 +659,27 @@ class TestModelAliases:
assert "--device cpu --model medium" in message assert "--device cpu --model medium" in message
assert "--device cuda --model large-v3-turbo" not in message assert "--device cuda --model large-v3-turbo" not in message
assert "--device cpu --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"
+264
View File
@@ -4,7 +4,9 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from local_transcriber.transcriber import ( from local_transcriber.transcriber import (
ExecutionRequest,
Segment, Segment,
Transcriber,
TranscribeResult, TranscribeResult,
_transcribe_file, _transcribe_file,
cuda_error_hint, cuda_error_hint,
@@ -697,3 +699,265 @@ def test_ensure_model_available_openvino_default_compute_type():
repo, ct = backend._resolve_repo("medium", "int8") repo, ct = backend._resolve_repo("medium", "int8")
assert repo == "OpenVINO/whisper-medium-int8-ov" assert repo == "OpenVINO/whisper-medium-int8-ov"
assert ct == "int8" 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