diff --git a/src/local_transcriber/backends/base.py b/src/local_transcriber/backends/base.py index 86036b8..bc4ca1c 100644 --- a/src/local_transcriber/backends/base.py +++ b/src/local_transcriber/backends/base.py @@ -53,3 +53,7 @@ class Backend(Protocol): ) -> TranscribeResult: """Транскрибирует файл, возвращает результат.""" ... + + def runtime_info(self) -> dict[str, str]: + """Версии runtime и фактическая конфигурация для удалённой диагностики.""" + ... diff --git a/src/local_transcriber/backends/faster_whisper.py b/src/local_transcriber/backends/faster_whisper.py index deddc81..abf4d41 100644 --- a/src/local_transcriber/backends/faster_whisper.py +++ b/src/local_transcriber/backends/faster_whisper.py @@ -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: diff --git a/src/local_transcriber/backends/onnx_asr.py b/src/local_transcriber/backends/onnx_asr.py index 5f4a668..198442b 100644 --- a/src/local_transcriber/backends/onnx_asr.py +++ b/src/local_transcriber/backends/onnx_asr.py @@ -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, diff --git a/src/local_transcriber/backends/openvino.py b/src/local_transcriber/backends/openvino.py index e6ce106..4048128 100644 --- a/src/local_transcriber/backends/openvino.py +++ b/src/local_transcriber/backends/openvino.py @@ -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. diff --git a/src/local_transcriber/transcriber.py b/src/local_transcriber/transcriber.py index a516485..1de3a27 100644 --- a/src/local_transcriber/transcriber.py +++ b/src/local_transcriber/transcriber.py @@ -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( diff --git a/src/local_transcriber/utils.py b/src/local_transcriber/utils.py index 84e8a22..3996e87 100644 --- a/src/local_transcriber/utils.py +++ b/src/local_transcriber/utils.py @@ -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 "не установлен" diff --git a/tests/test_backend_faster_whisper.py b/tests/test_backend_faster_whisper.py index f889c0e..ed27204 100644 --- a/tests/test_backend_faster_whisper.py +++ b/tests/test_backend_faster_whisper.py @@ -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" diff --git a/tests/test_backend_openvino.py b/tests/test_backend_openvino.py index 8d44dbb..973416f 100644 --- a/tests/test_backend_openvino.py +++ b/tests/test_backend_openvino.py @@ -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" diff --git a/tests/test_onnx_asr.py b/tests/test_onnx_asr.py index 118d7b6..8f2fa9d 100644 --- a/tests/test_onnx_asr.py +++ b/tests/test_onnx_asr.py @@ -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" diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index 4212e6a..31a7a45 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -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